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:
@@ -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%;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,66 +95,104 @@ 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
|
<div
|
||||||
key={workbench.id}
|
className={styles.tabWrapper}
|
||||||
data-testid="workbench-tab"
|
ref={node => {
|
||||||
data-active={tabActive}
|
dropTargetRef.current = node;
|
||||||
data-pinned={workbench.pinned}
|
dragRef.current = node;
|
||||||
className={styles.tab}
|
}}
|
||||||
>
|
>
|
||||||
{workbench.views.map((view, viewIdx) => {
|
<div
|
||||||
return (
|
key={workbench.id}
|
||||||
<Fragment key={view.id}>
|
data-testid="workbench-tab"
|
||||||
<button
|
data-active={tabActive}
|
||||||
key={view.id}
|
data-pinned={workbench.pinned}
|
||||||
data-testid="split-view-label"
|
className={styles.tab}
|
||||||
className={styles.splitViewLabel}
|
>
|
||||||
data-active={activeViewIndex === viewIdx && tabActive}
|
{workbench.views.map((view, viewIdx) => {
|
||||||
onContextMenu={() => {
|
return (
|
||||||
onContextMenu(viewIdx);
|
<Fragment key={view.id}>
|
||||||
}}
|
<button
|
||||||
onClick={e => {
|
key={view.id}
|
||||||
e.stopPropagation();
|
data-testid="split-view-label"
|
||||||
onActivateView(viewIdx);
|
className={styles.splitViewLabel}
|
||||||
}}
|
data-active={activeViewIndex === viewIdx && tabActive}
|
||||||
>
|
onContextMenu={() => {
|
||||||
<div className={styles.labelIcon}>
|
onContextMenu(viewIdx);
|
||||||
{workbench.ready || !workbench.loaded ? (
|
}}
|
||||||
iconNameToIcon[view.iconName ?? 'allDocs']
|
onClick={e => {
|
||||||
) : (
|
e.stopPropagation();
|
||||||
<Loading />
|
onActivateView(viewIdx);
|
||||||
)}
|
}}
|
||||||
</div>
|
>
|
||||||
{workbench.pinned || !view.title ? null : (
|
<div className={styles.labelIcon}>
|
||||||
<div title={view.title} className={styles.splitViewLabelText}>
|
{workbench.ready || !workbench.loaded ? (
|
||||||
{view.title}
|
iconNameToIcon[view.iconName ?? 'allDocs']
|
||||||
|
) : (
|
||||||
|
<Loading />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{workbench.pinned || !view.title ? null : (
|
||||||
</button>
|
<div title={view.title} className={styles.splitViewLabelText}>
|
||||||
|
{view.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{viewIdx !== workbench.views.length - 1 ? (
|
{viewIdx !== workbench.views.length - 1 ? (
|
||||||
<div className={styles.splitViewSeparator} />
|
<div className={styles.splitViewSeparator} />
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!workbench.pinned ? (
|
||||||
|
<div className={styles.tabCloseButtonWrapper}>
|
||||||
|
{tabsLength > 1 ? (
|
||||||
|
<button
|
||||||
|
data-testid="close-tab-button"
|
||||||
|
className={styles.tabCloseButton}
|
||||||
|
onClick={onCloseTab}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</Fragment>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className={styles.tabCloseButtonWrapper}>
|
|
||||||
{!workbench.pinned && tabsLength > 1 ? (
|
|
||||||
<button
|
|
||||||
data-testid="close-tab-button"
|
|
||||||
className={styles.tabCloseButton}
|
|
||||||
onClick={onCloseTab}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
25
packages/frontend/electron/renderer/global.css
Normal file
25
packages/frontend/electron/renderer/global.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
: location === 'top-left'
|
case 'top':
|
||||||
? { x: 1, y: 1 }
|
return { x: targetElement.width / 2, y: 1 };
|
||||||
: location === 'top'
|
case 'bottom':
|
||||||
? { x: targetElement.width / 2, y: 1 }
|
return { x: targetElement.width / 2, y: targetElement.height - 1 };
|
||||||
: location === 'bottom'
|
|
||||||
? { x: targetElement.width / 2, y: targetElement.height - 1 }
|
case 'left':
|
||||||
: { x: 1, y: 1 };
|
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({
|
await target.hover({
|
||||||
position: position,
|
position: position,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user