From bfff10e25e5306f855edfdea953f59b33104ed9b Mon Sep 17 00:00:00 2001 From: pengx17 Date: Fri, 2 Aug 2024 02:02:03 +0000 Subject: [PATCH] feat(electron): app tabs dnd (#7684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit
🎥 Video uploaded on Graphite:
fix AF-1149 fix PD-1513 fix PD-1515 --- .../frontend/component/src/theme/global.css | 26 -- .../services/app-tabs-header-service.ts | 64 ++++- .../app-tabs-header/views/app-tabs-header.tsx | 254 ++++++++++++++---- .../app-tabs-header/views/styles.css.ts | 57 +++- .../modules/workbench/view/workbench-link.tsx | 1 + packages/frontend/core/src/types/dnd.ts | 7 + .../frontend/electron/renderer/global.css | 25 ++ packages/frontend/electron/renderer/index.tsx | 7 + .../electron/renderer/shell/index.tsx | 1 + .../frontend/electron/src/main/ui/handlers.ts | 4 + .../src/main/windows-manager/tab-views.ts | 68 ++++- tests/affine-desktop/e2e/tabs.spec.ts | 194 ++++++++----- tests/kit/utils/page-logic.ts | 47 +++- 13 files changed, 567 insertions(+), 188 deletions(-) create mode 100644 packages/frontend/electron/renderer/global.css diff --git a/packages/frontend/component/src/theme/global.css b/packages/frontend/component/src/theme/global.css index f9ab1fee08f87..ab05eda9a290c 100644 --- a/packages/frontend/component/src/theme/global.css +++ b/packages/frontend/component/src/theme/global.css @@ -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, body { height: 100%; diff --git a/packages/frontend/core/src/modules/app-tabs-header/services/app-tabs-header-service.ts b/packages/frontend/core/src/modules/app-tabs-header/services/app-tabs-header-service.ts index cf3ddd8508bd8..176a467016210 100644 --- a/packages/frontend/core/src/modules/app-tabs-header/services/app-tabs-header-service.ts +++ b/packages/frontend/core/src/modules/app-tabs-header/services/app-tabs-header-service.ts @@ -31,23 +31,63 @@ export class AppTabsHeaderService extends Service { [] ); - showContextMenu = async (workbenchId: string, viewIdx: number) => { - await apis?.ui.showTabContextMenu(workbenchId, viewIdx); - }; + showContextMenu = apis?.ui.showTabContextMenu; - activateView = async (workbenchId: string, viewIdx: number) => { - await apis?.ui.activateView(workbenchId, viewIdx); - }; + activateView = apis?.ui.activateView; + + closeTab = apis?.ui.closeTab; - closeTab = async (workbenchId: string) => { - await apis?.ui.closeTab(workbenchId); + onAddTab = apis?.ui.addTab; + + onAddDocTab = async ( + docId: string, + targetTabId?: string, + edge?: 'left' | 'right' + ) => { + await apis?.ui.addTab({ + view: { + path: { + pathname: '/' + docId, + }, + }, + target: targetTabId, + edge, + }); }; - onAddTab = async () => { - await apis?.ui.addTab(); + onAddTagTab = async ( + tagId: string, + targetTabId?: string, + edge?: 'left' | 'right' + ) => { + await apis?.ui.addTab({ + view: { + path: { + pathname: '/tag/' + tagId, + }, + }, + target: targetTabId, + edge, + }); }; - onToggleRightSidebar = async () => { - await apis?.ui.toggleRightSidebar(); + onAddCollectionTab = async ( + collectionId: string, + targetTabId?: string, + edge?: 'left' | 'right' + ) => { + await apis?.ui.addTab({ + view: { + path: { + pathname: '/collection/' + collectionId, + }, + }, + target: targetTabId, + edge, + }); }; + + onToggleRightSidebar = apis?.ui.toggleRightSidebar; + + moveTab = apis?.ui.moveTab; } diff --git a/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx b/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx index 2fe119a52d613..f2477b2f1c8fd 100644 --- a/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx +++ b/packages/frontend/core/src/modules/app-tabs-header/views/app-tabs-header.tsx @@ -1,4 +1,11 @@ -import { IconButton, Loading } from '@affine/component'; +import { + type DropTargetDropEvent, + type DropTargetOptions, + IconButton, + Loading, + useDraggable, + useDropTarget, +} from '@affine/component'; import { appSidebarFloatingAtom, appSidebarOpenAtom, @@ -7,6 +14,7 @@ import { import { appSidebarWidthAtom } from '@affine/core/components/app-sidebar/index.jotai'; import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls'; 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 { CloseIcon, PlusIcon, RightSidebarIcon } from '@blocksuite/icons/rc'; import { @@ -33,27 +41,53 @@ import { } from '../services/app-tabs-header-service'; import * as styles from './styles.css'; +const TabSupportType = ['collection', 'tag', 'doc']; + +const tabCanDrop = + (tab?: TabStatus): NonNullable['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 = ({ workbench, active: tabActive, tabsLength, + dnd, + onDrop, }: { workbench: TabStatus; active: boolean; tabsLength: number; + dnd?: boolean; + onDrop?: (data: DropTargetDropEvent) => void; }) => { useServiceOptional(DesktopStateSynchronizer); const tabsHeaderService = useService(AppTabsHeaderService); const activeViewIndex = workbench.activeViewIndex ?? 0; const onContextMenu = useAsyncCallback( async (viewIdx: number) => { - await tabsHeaderService.showContextMenu(workbench.id, viewIdx); + await tabsHeaderService.showContextMenu?.(workbench.id, viewIdx); }, [tabsHeaderService, workbench.id] ); const onActivateView = useAsyncCallback( async (viewIdx: number) => { - await tabsHeaderService.activateView(workbench.id, viewIdx); + await tabsHeaderService.activateView?.(workbench.id, viewIdx); }, [tabsHeaderService, workbench.id] ); @@ -61,66 +95,104 @@ const WorkbenchTab = ({ async e => { e.stopPropagation(); - await tabsHeaderService.closeTab(workbench.id); + await tabsHeaderService.closeTab?.(workbench.id); }, [tabsHeaderService, workbench.id] ); + const { dropTargetRef, closestEdge } = useDropTarget( + () => ({ + closestEdge: { + allowedEdges: ['left', 'right'], + }, + onDrop, + dropEffect: 'move', + canDrop: tabCanDrop(workbench), + isSticky: true, + }), + [onDrop, workbench] + ); + + const { dragRef } = useDraggable( + () => ({ + canDrag: dnd, + data: { + from: { + at: 'app-header:tabs', + tabId: workbench.id, + }, + }, + dragPreviewPosition: 'pointer-outside', + }), + [dnd, workbench.id] + ); + return (
{ + dropTargetRef.current = node; + dragRef.current = node; + }} > - {workbench.views.map((view, viewIdx) => { - return ( - - + {workbench.pinned || !view.title ? null : ( +
+ {view.title} +
+ )} + - {viewIdx !== workbench.views.length - 1 ? ( -
+ {viewIdx !== workbench.views.length - 1 ? ( +
+ ) : null} + + ); + })} + {!workbench.pinned ? ( +
+ {tabsLength > 1 ? ( + ) : null} - - ); - })} -
- {!workbench.pinned && tabsLength > 1 ? ( - +
) : null}
+
); }; @@ -164,11 +236,11 @@ export const AppTabsHeader = ({ const [pinned, unpinned] = partition(tabs, tab => tab.pinned); const onAddTab = useAsyncCallback(async () => { - await tabsHeaderService.onAddTab(); + await tabsHeaderService.onAddTab?.(); }, [tabsHeaderService]); const onToggleRightSidebar = useAsyncCallback(async () => { - await tabsHeaderService.onToggleRightSidebar(); + await tabsHeaderService.onToggleRightSidebar?.(); }, [tabsHeaderService]); useEffect(() => { @@ -177,6 +249,63 @@ export const AppTabsHeader = ({ } }, [mode]); + const onDrop = useAsyncCallback( + async (data: DropTargetDropEvent, 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( + () => ({ + onDrop, + dropEffect: 'move', + canDrop: tabCanDrop(), + }), + [onDrop] + ); + return (
{ return ( onDrop(data, tab.id)} active={tab.active} /> ); @@ -213,21 +344,28 @@ export const AppTabsHeader = ({ {pinned.length > 0 && unpinned.length > 0 && (
)} - {unpinned.map(workbench => { + {unpinned.map(tab => { return ( onDrop(data, tab.id)} + active={tab.active} /> ); })} +
+
-
diff --git a/packages/frontend/core/src/modules/app-tabs-header/views/styles.css.ts b/packages/frontend/core/src/modules/app-tabs-header/views/styles.css.ts index aed2ee786abcb..93a42e8d40dc3 100644 --- a/packages/frontend/core/src/modules/app-tabs-header/views/styles.css.ts +++ b/packages/frontend/core/src/modules/app-tabs-header/views/styles.css.ts @@ -37,7 +37,6 @@ export const tabs = style({ flexDirection: 'row', alignItems: 'center', padding: '0 8px', - gap: '8px', overflow: 'clip', height: '100%', selectors: { @@ -52,6 +51,7 @@ export const pinSeparator = style({ width: 1, height: 16, flexShrink: 0, + marginRight: 8, }); export const splitViewSeparator = style({ @@ -61,6 +61,16 @@ export const splitViewSeparator = style({ 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({ height: 32, minWidth: 32, @@ -75,6 +85,9 @@ export const tab = style({ position: 'relative', ['WebkitAppRegion' as string]: 'no-drag', selectors: { + [`${tabWrapper} &`]: { + marginRight: 8, + }, '&[data-active="true"]': { background: cssVar('backgroundPrimaryColor'), boxShadow: cssVar('shadow1'), @@ -85,6 +98,9 @@ export const tab = style({ '&[data-pinned="true"]': { 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({ 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)', + }, + }, }); diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx index 8c63c449d7daa..9caf953881db2 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx @@ -40,6 +40,7 @@ export const WorkbenchLink = forwardRef< await apis?.ui.addTab({ basename, view: { path }, + show: false, }); } } else if (!environment.isDesktop) { diff --git a/packages/frontend/core/src/types/dnd.ts b/packages/frontend/core/src/types/dnd.ts index 7da3e9eb4c170..e0d258efe3c0a 100644 --- a/packages/frontend/core/src/types/dnd.ts +++ b/packages/frontend/core/src/types/dnd.ts @@ -58,6 +58,10 @@ export interface AffineDNDData extends DNDData { } | { at: 'explorer:tags:docs'; + } + | { + at: 'app-header:tabs'; + tabId: string; }; }; dropTarget: @@ -85,5 +89,8 @@ export interface AffineDNDData extends DNDData { | { at: 'explorer:tag'; } + | { + at: 'app-header:tabs'; + } | Record; } diff --git a/packages/frontend/electron/renderer/global.css b/packages/frontend/electron/renderer/global.css new file mode 100644 index 0000000000000..bf27b863f4a78 --- /dev/null +++ b/packages/frontend/electron/renderer/global.css @@ -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; +} diff --git a/packages/frontend/electron/renderer/index.tsx b/packages/frontend/electron/renderer/index.tsx index d9abb899bf35f..2ca5c81f5b3fd 100644 --- a/packages/frontend/electron/renderer/index.tsx +++ b/packages/frontend/electron/renderer/index.tsx @@ -1,5 +1,6 @@ import './polyfill/dispose'; import '@affine/core/bootstrap/preload'; +import './global.css'; import { appConfigProxy } from '@affine/core/hooks/use-app-config-storage'; import { performanceLogger } from '@affine/core/shared'; @@ -96,6 +97,12 @@ function main() { }, 50); window.addEventListener('resize', handleResize); performanceMainLogger.info('setup done'); + window.addEventListener('dragstart', () => { + document.documentElement.dataset.dragging = 'true'; + }); + window.addEventListener('dragend', () => { + document.documentElement.dataset.dragging = 'false'; + }); } mountApp(); diff --git a/packages/frontend/electron/renderer/shell/index.tsx b/packages/frontend/electron/renderer/shell/index.tsx index 6bda38d779d85..59de48d576117 100644 --- a/packages/frontend/electron/renderer/shell/index.tsx +++ b/packages/frontend/electron/renderer/shell/index.tsx @@ -2,6 +2,7 @@ import 'setimmediate'; import '@affine/component/theme/global.css'; import '@affine/component/theme/theme.css'; import '@affine/core/bootstrap/preload'; +import '../global.css'; import { ThemeProvider } from '@affine/component/theme-provider'; import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header'; diff --git a/packages/frontend/electron/src/main/ui/handlers.ts b/packages/frontend/electron/src/main/ui/handlers.ts index aa8ec78cd3824..a38c8686805d8 100644 --- a/packages/frontend/electron/src/main/ui/handlers.ts +++ b/packages/frontend/electron/src/main/ui/handlers.ts @@ -18,6 +18,7 @@ import { initAndShowMainWindow, isActiveTab, launchStage, + moveTab, pingAppLayoutReady, showDevTools, showTab, @@ -193,6 +194,9 @@ export const uiHandlers = { activateView: async (_, ...args: Parameters) => { await activateView(...args); }, + moveTab: async (_, ...args: Parameters) => { + moveTab(...args); + }, toggleRightSidebar: async (_, tabId?: string) => { tabId ??= getTabViewsMeta().activeWorkbenchId; if (tabId) { diff --git a/packages/frontend/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/electron/src/main/windows-manager/tab-views.ts index 72e379f402284..3ab5c3277dc90 100644 --- a/packages/frontend/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/electron/src/main/windows-manager/tab-views.ts @@ -115,8 +115,14 @@ type TabAction = | OpenInSplitViewAction; type AddTabOption = { - basename: string; + basename?: string; view?: Omit | Array>; + target?: string; + edge?: 'left' | 'right'; + /** + * Whether to show the tab after adding. + */ + show?: boolean; }; export class WebContentViewsManager { @@ -405,27 +411,32 @@ export class WebContentViewsManager { 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 = { - basename: option.basename, + basename: option.basename ?? this.activeWorkbenchMeta?.basename ?? '/', activeViewIndex: 0, views: views, id: newKey, - pinned: false, + pinned: targetItem?.pinned ?? false, }; this.patchTabViewsMeta({ - activeWorkbenchId: newKey, - workbenches: [...workbenches, workbench], + workbenches: workbenches.toSpliced(newIndex, 0, workbench), + activeWorkbenchId: this.activeWorkbenchId ?? newKey, }); - await this.showTab(newKey); + await (option.show !== false ? this.showTab(newKey) : this.loadTab(newKey)); this.tabAction$.next({ type: 'add-tab', payload: workbench, }); - return { - ...option, - key: newKey, - }; + return workbench; }; loadTab = async (id: string): Promise => { @@ -521,6 +532,42 @@ export class WebContentViewsManager { 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) => { const tabMeta = this.tabViewsMeta.workbenches.find(w => w.id === tabId); if (!tabMeta) { @@ -906,6 +953,7 @@ export const showTab = WebContentViewsManager.instance.showTab; export const closeTab = WebContentViewsManager.instance.closeTab; export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab; export const activateView = WebContentViewsManager.instance.activateView; +export const moveTab = WebContentViewsManager.instance.moveTab; export const reloadView = async () => { const id = WebContentViewsManager.instance.activeWorkbenchId; diff --git a/tests/affine-desktop/e2e/tabs.spec.ts b/tests/affine-desktop/e2e/tabs.spec.ts index fba462a85598f..527d144beffa2 100644 --- a/tests/affine-desktop/e2e/tabs.spec.ts +++ b/tests/affine-desktop/e2e/tabs.spec.ts @@ -2,18 +2,65 @@ import { test } from '@affine-test/kit/electron'; import { clickNewPageButton, createLinkedPage, + dragTo, } from '@affine-test/kit/utils/page-logic'; +import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; 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 }) => { let page = await views.getActive(); 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 - await expect(page.getByTestId('workbench-tab').nth(1)).toContainText( - 'All docs' - ); + await expectTabTitle(page, 1, 'All docs'); + await expectActiveTab(page, 1); page = await views.getActive(); // page content should be at all docs page 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 }) => { await page.getByTestId('add-tab-view-button').click(); - await expect(page.getByTestId('workbench-tab').nth(1)).toHaveAttribute( - 'data-active', - 'true' - ); + await expectActiveTab(page, 1); + // switch to the previous tab by clicking on it await page.getByTestId('workbench-tab').nth(0).click(); - await expect(page.getByTestId('workbench-tab').nth(0)).toHaveAttribute( - 'data-active', - 'true' - ); + await expectActiveTab(page, 0); // switch to the next tab by clicking on it await page.getByTestId('workbench-tab').nth(1).click(); - await expect(page.getByTestId('workbench-tab').nth(1)).toHaveAttribute( - 'data-active', - 'true' - ); + await expectActiveTab(page, 1); // close the current tab - await page - .getByTestId('workbench-tab') - .nth(1) - .getByTestId('close-tab-button') - .click(); + await closeTab(page, 1); // the first tab should be active - await expect(page.getByTestId('workbench-tab').nth(0)).toHaveAttribute( - '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' - ); + await expectActiveTab(page, 0); }); test('Collapse Sidebar', async ({ page }) => { @@ -100,17 +110,15 @@ test('Expand Sidebar', async ({ page }) => { }); test('tab title will change when navigating', async ({ page }) => { - await expect(page.getByTestId('workbench-tab')).toContainText( - 'Write, Draw, Plan all at Once' - ); + await expectTabTitle(page, 0, 'Write, Draw, Plan all at Once'); // create new page await clickNewPageButton(page); - await expect(page.getByTestId('workbench-tab')).toContainText('Untitled'); + await expectTabTitle(page, 0, 'Untitled'); // go to all page 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 await page.getByTestId('slider-bar-journals-button').click(); @@ -120,7 +128,7 @@ test('tab title will change when navigating', async ({ page }) => { .textContent(); 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(); } +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 }) => { await enableSplitView(page); await clickNewPageButton(page); @@ -164,27 +189,62 @@ test('open split view', async ({ page }) => { // check tab title await expect(page.getByTestId('split-view-label')).toHaveCount(2); - await expect(page.getByTestId('split-view-label').nth(0)).toContainText( - 'Untitled' - ); - await expect(page.getByTestId('split-view-label').nth(1)).toContainText( - 'hi from another page' - ); + await expectTabTitle(page, 0, ['Untitled', 'hi from another page']); // the second split view should be active - await expect(page.getByTestId('split-view-label').nth(1)).toHaveAttribute( - 'data-active', - 'true' - ); + await expectActiveTab(page, 0, 1); // by clicking the first split view label, the first split view should be active await page.getByTestId('split-view-label').nth(0).click(); - await expect(page.getByTestId('split-view-label').nth(0)).toHaveAttribute( - 'data-active', - 'true' - ); + await expectActiveTab(page, 0, 0); await expect(page.getByTestId('split-view-indicator').nth(0)).toHaveAttribute( 'data-active', '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]); +}); diff --git a/tests/kit/utils/page-logic.ts b/tests/kit/utils/page-logic.ts index 6ff38f9fbe5b8..7208e09b89c99 100644 --- a/tests/kit/utils/page-logic.ts +++ b/tests/kit/utils/page-logic.ts @@ -14,8 +14,8 @@ export async function waitForAllPagesLoad(page: Page) { }); } -export async function clickNewPageButton(page: Page) { - //FiXME: when the page is in edgeless mode, clickNewPageButton will create a new edgeless page +export async function clickNewPageButton(page: Page, title?: string) { + // FiXME: when the page is in edgeless mode, clickNewPageButton will create a new edgeless page const edgelessPage = page.locator('edgeless-editor'); if (await edgelessPage.isVisible()) { await page.getByTestId('switch-page-mode-button').click({ @@ -27,6 +27,9 @@ export async function clickNewPageButton(page: Page) { delay: 100, }); await waitForEmptyEditor(page); + if (title) { + await getBlockSuiteEditorTitle(page).fill(title); + } } export async function waitForEmptyEditor(page: Page) { @@ -75,7 +78,13 @@ export const dragTo = async ( page: Page, locator: Locator, target: Locator, - location: 'top-left' | 'top' | 'bottom' | 'center' = 'center' + location: + | 'top-left' + | 'top' + | 'bottom' + | 'center' + | 'left' + | 'right' = 'center' ) => { await locator.hover(); await page.mouse.down(); @@ -85,19 +94,29 @@ export const dragTo = async ( if (!targetElement) { throw new Error('target element not found'); } - const position = - location === 'center' - ? { + const position = (() => { + switch (location) { + case 'center': + return { x: targetElement.width / 2, y: targetElement.height / 2, - } - : 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 }; + }; + 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 }; + } + })(); await target.hover({ position: position, });