feat(core): drop doc onto split view (#9487)

fix AF-2068, AF-2069, AF-1175, AF-2061, AF-2079, AF-2034, AF-2080, AF-1960, AF-2081

1. replace `dnd-kit` with `@atlaskit/pragmatic-drag-and-drop`
2. allow creating split views by drag & drop the following
   a. WorkbenchLinks (route links), like journals, trash, all docs
   b. doc refs
   c. tags/collection
3. style adjustments to split view
4. remove split view's feature flag and make it GA for electron

https://github.com/user-attachments/assets/6a3e4a25-faa2-4215-8eb0-983f44db6e8c
This commit is contained in:
pengx17
2025-01-08 05:05:33 +00:00
parent c0ed74dfed
commit a4841bbfa3
53 changed files with 1574 additions and 905 deletions

View File

@@ -23,7 +23,9 @@ export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Blocks }) => {
<div className="doc-title-container" data-testid="journal-title">
<span data-testid="date">{localizedJournalDate}</span>
{isTodayJournal ? (
<span className={styles.titleTodayTag}>{t['com.affine.today']()}</span>
<span className={styles.titleTodayTag} data-testid="date-today-label">
{t['com.affine.today']()}
</span>
) : (
<span className={styles.titleDayTag}>{day}</span>
)}

View File

@@ -8,7 +8,9 @@ export const JournalTodayButton = () => {
const journalHelper = useJournalRouteHelper();
const onToday = useCallback(() => {
journalHelper.openToday();
journalHelper.openToday({
replaceHistory: true,
});
}, [journalHelper]);
return (

View File

@@ -5,6 +5,7 @@ import {
JournalService,
type MaybeDate,
} from '@affine/core/modules/journal';
import type { WorkbenchOpenOptions } from '@affine/core/modules/workbench/entities/workbench';
import { i18nTime } from '@affine/i18n';
import { track } from '@affine/track';
import { useService, useServices } from '@toeverything/infra';
@@ -60,9 +61,9 @@ export const useJournalRouteHelper = () => {
* open journal by date, create one if not exist
*/
const openJournal = useCallback(
(maybeDate: MaybeDate, newTab?: boolean) => {
(maybeDate: MaybeDate, options?: WorkbenchOpenOptions) => {
const page = getJournalByDate(maybeDate);
workbench.openDoc(page.id, { at: newTab ? 'new-tab' : 'active' });
workbench.openDoc(page.id, options);
track.$.navigationPanel.journal.navigate({
to: 'journal',
});
@@ -75,9 +76,9 @@ export const useJournalRouteHelper = () => {
* open today's journal
*/
const openToday = useCallback(
(newTab?: boolean) => {
(options: WorkbenchOpenOptions) => {
const date = dayjs().format(JOURNAL_DATE_FORMAT);
return openJournal(date, newTab);
return openJournal(date, options);
},
[openJournal]
);

View File

@@ -180,7 +180,8 @@ export const PageListItem = (props: PageListItemProps) => {
},
},
}),
[props.draggable, props.pageId]
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.draggable, props.pageId, props.selectable]
);
return (

View File

@@ -14,7 +14,6 @@ import {
CompatibleFavoriteItemsAdapter,
FavoriteService,
} from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
@@ -65,19 +64,15 @@ export const PageOperationCell = ({
}: PageOperationCellProps) => {
const t = useI18n();
const {
featureFlagService,
workspaceService,
compatibleFavoriteItemsAdapter: favAdapter,
workbenchService,
} = useServices({
FeatureFlagService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
WorkbenchService,
});
const enableSplitView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const currentWorkspace = workspaceService.workspace;
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const workbench = workbenchService.workbench;
@@ -194,7 +189,7 @@ export const PageOperationCell = ({
<MenuItem onClick={onOpenInNewTab} prefixIcon={<OpenInNewIcon />}>
{t['com.affine.workbench.tab.page-menu-open']()}
</MenuItem>
{BUILD_CONFIG.isElectron && enableSplitView ? (
{BUILD_CONFIG.isElectron ? (
<MenuItem onClick={onOpenInSplitView} prefixIcon={<SplitViewIcon />}>
{t['com.affine.workbench.split-view.page-menu-open']()}
</MenuItem>

View File

@@ -1,3 +1,4 @@
import { shallowEqual } from '@affine/component';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import type { Tag } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
@@ -34,7 +35,6 @@ import type {
TagListItemProps,
TagMeta,
} from './types';
import { shallowEqual } from './utils';
export const ItemGroupHeader = memo(function ItemGroupHeader<
T extends ListItem,

View File

@@ -1,3 +1,4 @@
import { shallowEqual } from '@affine/component';
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
@@ -10,7 +11,6 @@ import type {
MetaRecord,
VirtualizedListProps,
} from './types';
import { shallowEqual } from './utils';
// for ease of use in the component tree
// note: must use selectAtom to access this atom for efficiency

View File

@@ -56,38 +56,3 @@ export const betweenDaysAgo = (
): boolean => {
return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1);
};
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
export function shallowEqual(objA: any, objB: any) {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (const key of keysA) {
if (
!Object.prototype.hasOwnProperty.call(objB, key) ||
!Object.is(objA[key], objB[key])
) {
return false;
}
}
return true;
}

View File

@@ -3,7 +3,6 @@ import { Menu, MenuItem, usePromptModal } from '@affine/component';
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
@@ -36,21 +35,16 @@ export const CollectionOperations = ({
const {
collectionService: service,
workbenchService,
featureFlagService,
workspaceDialogService,
} = useServices({
CollectionService,
WorkbenchService,
FeatureFlagService,
WorkspaceDialogService,
});
const deleteInfo = useDeleteCollectionInfo();
const workbench = workbenchService.workbench;
const t = useI18n();
const { openPromptModal } = usePromptModal();
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const showEditName = useCallback(() => {
// use openRenameModal if it is in the sidebar collection list
@@ -150,7 +144,7 @@ export const CollectionOperations = ({
name: t['com.affine.workbench.tab.page-menu-open'](),
click: openCollectionNewTab,
},
...(BUILD_CONFIG.isElectron && enableMultiView
...(BUILD_CONFIG.isElectron
? [
{
icon: <SplitViewIcon />,
@@ -172,7 +166,6 @@ export const CollectionOperations = ({
},
],
[
enableMultiView,
t,
showEditName,
showEdit,

View File

@@ -1,32 +1,19 @@
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
import {
useJournalInfoHelper,
useJournalRouteHelper,
} from '@affine/core/components/hooks/use-journal';
import { MenuItem } from '@affine/core/modules/app-sidebar/views';
import { MenuLinkItem } from '@affine/core/modules/app-sidebar/views';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { isNewTabTrigger } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { TodayIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { type MouseEvent } from 'react';
export const AppSidebarJournalButton = () => {
const t = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService);
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$);
const { openToday } = useJournalRouteHelper();
const maybeDocId = location.pathname.split('/')[1];
const { isJournal } = useJournalInfoHelper(maybeDocId);
const handleOpenToday = useCatchEventCallback(
(e: MouseEvent) => {
openToday(isNewTabTrigger(e));
},
[openToday]
);
const isJournal = !!useLiveData(journalService.journalDate$(maybeDocId));
const JournalIcon = useLiveData(
docDisplayMetaService.icon$(maybeDocId, {
@@ -36,14 +23,13 @@ export const AppSidebarJournalButton = () => {
const Icon = isJournal ? JournalIcon : TodayIcon;
return (
<MenuItem
<MenuLinkItem
data-testid="slider-bar-journals-button"
active={isJournal}
onClick={handleOpenToday}
onAuxClick={handleOpenToday}
to={'/journals'}
icon={<Icon />}
>
{t['com.affine.journal.app-sidebar-title']()}
</MenuItem>
</MenuLinkItem>
);
};

View File

@@ -78,27 +78,25 @@ export const mainContainerStyle = style({
width: '100%',
display: 'flex',
flex: 1,
overflow: 'clip',
maxWidth: '100%',
selectors: {
'&[data-client-border="true"]': {
borderRadius: 6,
margin: '8px',
overflow: 'clip',
padding: '8px',
'@media': {
print: {
overflow: 'visible',
margin: '0px',
padding: '0px',
borderRadius: '0px',
},
},
},
'&[data-client-border="true"][data-side-bar-open="true"]': {
marginLeft: 0,
paddingLeft: 0,
},
'&[data-client-border="true"][data-is-desktop="true"]': {
marginTop: 0,
paddingTop: 0,
},
'&[data-client-border="false"][data-is-desktop="true"][data-side-bar-open="true"]':
{

View File

@@ -0,0 +1,11 @@
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { useEffect } from 'react';
// this route page acts as a redirector to today's journal
export const Component = () => {
const { openToday } = useJournalRouteHelper();
useEffect(() => {
openToday({ replaceHistory: true });
}, [openToday]);
return null;
};

View File

@@ -33,6 +33,10 @@ export const workbenchRoutes = [
path: '/:pageId/attachments/:attachmentId',
lazy: () => import('./pages/workspace/attachment/index'),
},
{
path: '/journals',
lazy: () => import('./pages/journals'),
},
{
path: '*',
lazy: () => import('./pages/404'),

View File

@@ -11,7 +11,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CollectionService } from '@affine/core/modules/collection';
import type { NodeOperation } from '@affine/core/modules/explorer';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
@@ -161,10 +160,6 @@ export const useExplorerCollectionNodeOperationsMenu = (
onOpenEdit: () => void
): NodeOperation[] => {
const t = useI18n();
const { featureFlagService } = useServices({ FeatureFlagService });
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const {
favorite,
@@ -246,7 +241,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
</MenuItem>
),
},
...(BUILD_CONFIG.isElectron && enableMultiView
...(BUILD_CONFIG.isElectron
? [
{
index: 99,
@@ -279,7 +274,6 @@ export const useExplorerCollectionNodeOperationsMenu = (
},
],
[
enableMultiView,
favorite,
handleAddDocToCollection,
handleDeleteCollection,

View File

@@ -13,7 +13,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { DocsService } from '@affine/core/modules/doc';
import type { NodeOperation } from '@affine/core/modules/explorer';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { preventDefault } from '@affine/core/utils';
@@ -171,7 +170,6 @@ export const useExplorerDocNodeOperationsMenu = (
}
): NodeOperation[] => {
const t = useI18n();
const featureFlagService = useService(FeatureFlagService);
const {
favorite,
handleAddLinkedPage,
@@ -186,9 +184,6 @@ export const useExplorerDocNodeOperationsMenu = (
const docService = useService(DocsService);
const docRecord = useLiveData(docService.list.doc$(docId));
const title = useLiveData(docRecord?.title$);
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
return useMemo(
() => [
@@ -258,7 +253,7 @@ export const useExplorerDocNodeOperationsMenu = (
</MenuItem>
),
},
...(BUILD_CONFIG.isElectron && enableMultiView
...(BUILD_CONFIG.isElectron
? [
{
index: 100,
@@ -305,7 +300,6 @@ export const useExplorerDocNodeOperationsMenu = (
],
[
docId,
enableMultiView,
favorite,
handleAddLinkedPage,
handleDuplicate,

View File

@@ -11,7 +11,6 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc';
import type { NodeOperation } from '@affine/core/modules/explorer';
import { FavoriteService } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalCacheService } from '@affine/core/modules/storage';
import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench';
@@ -24,7 +23,7 @@ import {
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { TagRenameSubMenu } from './dialog';
@@ -219,10 +218,6 @@ export const useExplorerTagNodeOperationsMenu = (
}
): NodeOperation[] => {
const t = useI18n();
const featureFlagService = useService(FeatureFlagService);
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const {
favorite,
handleNewDoc,
@@ -266,7 +261,7 @@ export const useExplorerTagNodeOperationsMenu = (
</MenuItem>
),
},
...(BUILD_CONFIG.isElectron && enableMultiView
...(BUILD_CONFIG.isElectron
? [
{
index: 100,
@@ -312,7 +307,6 @@ export const useExplorerTagNodeOperationsMenu = (
},
],
[
enableMultiView,
favorite,
handleChangeNameOrColor,
handleMoveToTrash,

View File

@@ -1,8 +1,9 @@
import { Skeleton } from '@affine/component';
import { type DropTargetGetFeedback, Skeleton } from '@affine/component';
import { ResizePanel } from '@affine/component/resize-panel';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper';
import { WorkspaceNavigator } from '@affine/core/components/workspace-selector';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import {
useLiveData,
@@ -14,6 +15,8 @@ import { debounce } from 'lodash-es';
import type { PropsWithChildren, ReactElement } from 'react';
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { WorkbenchService } from '../../workbench';
import { allowedSplitViewEntityTypes } from '../../workbench/view/split-view/types';
import { WorkspaceService } from '../../workspace';
import { AppSidebarService } from '../services/app-sidebar';
import * as styles from './fallback.css';
@@ -44,6 +47,7 @@ export function AppSidebar({ children }: PropsWithChildren) {
const clientBorder = appSettings.clientBorder;
const appSidebarService = useService(AppSidebarService).sidebar;
const workbenchService = useService(WorkbenchService).workbench;
const open = useLiveData(appSidebarService.open$);
const width = useLiveData(appSidebarService.width$);
@@ -147,6 +151,31 @@ export function AppSidebar({ children }: PropsWithChildren) {
};
}, [appSidebarService, resizing, sidebarState, width]);
const resizeHandleDropTargetOptions = useMemo(() => {
return () => ({
data: () => {
const firstView = workbenchService.views$.value.at(0);
if (!firstView) {
return {};
}
return {
at: 'workbench:resize-handle',
edge: 'left', // left of the first view
viewId: firstView.id,
};
},
canDrop: (data: DropTargetGetFeedback<AffineDNDData>) => {
return (
(!!data.source.data.entity?.type &&
allowedSplitViewEntityTypes.has(data.source.data.entity?.type)) ||
data.source.data.from?.at === 'workbench:link'
);
},
});
}, [workbenchService.views$.value]);
if (!initialized) {
return null;
}
@@ -154,6 +183,7 @@ export function AppSidebar({ children }: PropsWithChildren) {
return (
<>
<ResizePanel
resizeHandleDropTargetOptions={resizeHandleDropTargetOptions}
floating={
sidebarState === 'floating' || sidebarState === 'floating-with-mask'
}

View File

@@ -26,6 +26,7 @@ import {
type MouseEventHandler,
type ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
@@ -62,71 +63,24 @@ const tabCanDrop =
return false;
};
const WorkbenchTab = ({
const WorkbenchView = ({
workbench,
active: tabActive,
view,
activeViewIndex,
tabsLength,
dnd,
onDrop,
viewIdx,
tabActive,
}: {
workbench: TabStatus;
active: boolean;
view: TabStatus['views'][number];
activeViewIndex: number;
tabsLength: number;
viewIdx: number;
tabActive: boolean;
dnd?: boolean;
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
}) => {
useServiceOptional(DesktopStateSynchronizer);
const tabsHeaderService = useService(AppTabsHeaderService);
const activeViewIndex = workbench.activeViewIndex ?? 0;
const onContextMenu = useAsyncCallback(
async (viewIdx: number) => {
const action = await tabsHeaderService.showContextMenu?.(
workbench.id,
viewIdx
);
switch (action?.type) {
case 'open-in-split-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'openInSplitView',
});
break;
}
case 'separate-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'separateTabs',
});
break;
}
case 'pin-tab': {
if (action.payload.shouldPin) {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'pin',
});
} else {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'unpin',
});
}
break;
}
// fixme: when close tab the view may already be gc'ed
case 'close-tab': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'close',
});
break;
}
default:
break;
}
},
[tabsHeaderService, workbench.id]
);
const onActivateView = useAsyncCallback(
async (viewIdx: number) => {
if (viewIdx === activeViewIndex && tabActive) {
@@ -147,6 +101,15 @@ const WorkbenchTab = ({
},
[activeViewIndex, tabActive, tabsHeaderService, workbench.id]
);
const handleClick: MouseEventHandler = useCatchEventCallback(
async e => {
e.stopPropagation();
onActivateView(viewIdx);
},
[onActivateView, viewIdx]
);
const handleAuxClick: MouseEventHandler = useCatchEventCallback(
async e => {
if (e.button === 1) {
@@ -160,6 +123,106 @@ const WorkbenchTab = ({
[tabsHeaderService, workbench.id]
);
const onContextMenu = useAsyncCallback(async () => {
const action = await tabsHeaderService.showContextMenu?.(
workbench.id,
viewIdx
);
switch (action?.type) {
case 'open-in-split-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'openInSplitView',
});
break;
}
case 'separate-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'separateTabs',
});
break;
}
case 'pin-tab': {
if (action.payload.shouldPin) {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'pin',
});
} else {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'unpin',
});
}
break;
}
// fixme: when close tab the view may already be gc'ed
case 'close-tab': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'close',
});
break;
}
default:
break;
}
}, [tabsHeaderService, viewIdx, workbench.id]);
const contentNode = useMemo(() => {
return (
<>
<div className={styles.labelIcon}>
{workbench.ready || !workbench.loaded ? (
iconNameToIcon[view.iconName ?? 'allDocs']
) : (
<Loading />
)}
</div>
{!view.title ? null : (
<div
title={view.title}
className={styles.splitViewLabelText}
data-padding-right={tabsLength > 1 && !workbench.pinned}
>
{view.title}
</div>
)}
</>
);
}, [workbench, view, tabsLength]);
return (
<button
data-testid="split-view-label"
className={styles.splitViewLabel}
data-active={activeViewIndex === viewIdx && tabActive}
onContextMenu={onContextMenu}
onAuxClick={handleAuxClick}
onClick={handleClick}
>
{contentNode}
</button>
);
};
const WorkbenchTab = ({
workbench,
active: tabActive,
tabsLength,
dnd,
onDrop,
}: {
workbench: TabStatus;
active: boolean;
tabsLength: number;
dnd?: boolean;
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
}) => {
useServiceOptional(DesktopStateSynchronizer);
const tabsHeaderService = useService(AppTabsHeaderService);
const activeViewIndex = workbench.activeViewIndex ?? 0;
const handleCloseTab = useCatchEventCallback(async () => {
await tabsHeaderService.closeTab?.(workbench.id);
track.$.appTabsHeader.$.tabAction({
@@ -175,11 +238,11 @@ const WorkbenchTab = ({
},
onDrop,
dropEffect: 'move',
canDrop: tabCanDrop(workbench),
canDrop: dnd ? tabCanDrop(workbench) : false,
isSticky: true,
allowExternal: true,
}),
[onDrop, workbench]
[dnd, onDrop, workbench]
);
const { dragRef } = useDraggable<AffineDNDData>(() => {
@@ -251,37 +314,15 @@ const WorkbenchTab = ({
{workbench.views.map((view, viewIdx) => {
return (
<Fragment key={view.id}>
<button
key={view.id}
data-testid="split-view-label"
className={styles.splitViewLabel}
data-active={activeViewIndex === viewIdx && tabActive}
onContextMenu={() => {
onContextMenu(viewIdx);
}}
onAuxClick={handleAuxClick}
onClick={e => {
e.stopPropagation();
onActivateView(viewIdx);
}}
>
<div className={styles.labelIcon}>
{workbench.ready || !workbench.loaded ? (
iconNameToIcon[view.iconName ?? 'allDocs']
) : (
<Loading />
)}
</div>
{!view.title ? null : (
<div
title={view.title}
className={styles.splitViewLabelText}
data-padding-right={tabsLength > 1 && !workbench.pinned}
>
{view.title}
</div>
)}
</button>
<WorkbenchView
workbench={workbench}
view={view}
activeViewIndex={activeViewIndex}
tabsLength={workbench.views.length}
viewIdx={viewIdx}
tabActive={tabActive}
dnd={dnd}
/>
{viewIdx !== workbench.views.length - 1 ? (
<div className={styles.splitViewSeparator} />

View File

@@ -81,7 +81,7 @@ export class DndService extends Service {
isDropEvent?: boolean
) => {
if (!isDropEvent) {
return {};
return this.resolveBlocksuiteExternalData(args.source) || {};
}
let resolved: AffineDNDData['draggable'] | null = null;
@@ -168,24 +168,30 @@ export class DndService extends Service {
if (!dndAPI) {
return null;
}
const encoded = source.getStringData(dndAPI.mimeType);
if (!encoded) {
return null;
}
const snapshot = dndAPI.decodeSnapshot(encoded);
if (!snapshot) {
return null;
}
const entity = this.resolveBlockSnapshot(snapshot);
if (!entity) {
return null;
}
return {
entity,
from: {
if (source.types.includes(dndAPI.mimeType)) {
const from = {
at: 'blocksuite-editor',
},
};
} as const;
let entity: Entity | null = null;
const encoded = source.getStringData(dndAPI.mimeType);
const snapshot = encoded ? dndAPI.decodeSnapshot(encoded) : null;
entity = snapshot ? this.resolveBlockSnapshot(snapshot) : null;
if (!entity) {
return {
from,
};
} else {
return {
entity,
from,
};
}
}
return null;
};
private readonly resolveHTML: EntityResolver = html => {

View File

@@ -9,7 +9,6 @@ import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/us
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CollectionService } from '@affine/core/modules/collection';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
@@ -37,13 +36,11 @@ export const useExplorerCollectionNodeOperations = (
workspaceService,
collectionService,
compatibleFavoriteItemsAdapter,
featureFlagService,
} = useServices({
WorkbenchService,
WorkspaceService,
CollectionService,
CompatibleFavoriteItemsAdapter,
FeatureFlagService,
});
const deleteInfo = useDeleteCollectionInfo();
@@ -51,9 +48,6 @@ export const useExplorerCollectionNodeOperations = (
workspaceService.workspace.docCollection
);
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const favorite = useLiveData(
useMemo(
() =>
@@ -173,7 +167,7 @@ export const useExplorerCollectionNodeOperations = (
</MenuItem>
),
},
...(BUILD_CONFIG.isElectron && enableMultiView
...(BUILD_CONFIG.isElectron
? [
{
index: 99,
@@ -206,7 +200,6 @@ export const useExplorerCollectionNodeOperations = (
},
],
[
enableMultiView,
favorite,
handleAddDocToCollection,
handleDeleteCollection,

View File

@@ -11,7 +11,6 @@ import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hoo
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { DocsService } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
@@ -43,17 +42,12 @@ export const useExplorerDocNodeOperations = (
workspaceService,
docsService,
compatibleFavoriteItemsAdapter,
featureFlagService,
} = useServices({
DocsService,
WorkbenchService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
FeatureFlagService,
});
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const { openConfirmModal } = useConfirmModal();
const docRecord = useLiveData(docsService.list.doc$(docId));
@@ -188,7 +182,7 @@ export const useExplorerDocNodeOperations = (
</MenuItem>
),
},
...(BUILD_CONFIG.isElectron && enableMultiView
...(BUILD_CONFIG.isElectron
? [
{
index: 100,
@@ -234,7 +228,6 @@ export const useExplorerDocNodeOperations = (
},
],
[
enableMultiView,
favorite,
handleAddLinkedPage,
handleDuplicate,

View File

@@ -3,7 +3,6 @@ import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-pa
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { DocsService } from '@affine/core/modules/doc';
import { FavoriteService } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
@@ -29,28 +28,19 @@ export const useExplorerTagNodeOperations = (
}
): NodeOperation[] => {
const t = useI18n();
const {
workbenchService,
workspaceService,
tagService,
favoriteService,
featureFlagService,
} = useServices({
WorkbenchService,
WorkspaceService,
TagService,
DocsService,
FavoriteService,
FeatureFlagService,
});
const { workbenchService, workspaceService, tagService, favoriteService } =
useServices({
WorkbenchService,
WorkspaceService,
TagService,
DocsService,
FavoriteService,
});
const favorite = useLiveData(
favoriteService.favoriteList.favorite$('tag', tagId)
);
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
@@ -115,7 +105,7 @@ export const useExplorerTagNodeOperations = (
</MenuItem>
),
},
...(BUILD_CONFIG.isElectron && enableMultiView
...(BUILD_CONFIG.isElectron
? [
{
index: 100,
@@ -161,7 +151,6 @@ export const useExplorerTagNodeOperations = (
},
],
[
enableMultiView,
favorite,
handleMoveToTrash,
handleNewDoc,

View File

@@ -103,18 +103,6 @@ export const AFFINE_FLAGS = {
configurable: false,
defaultState: true,
},
enable_multi_view: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-multi-view.name',
description:
'com.affine.settings.workspace.experimental-features.enable-multi-view.description',
feedbackType: 'discord',
feedbackLink:
'https://discord.com/channels/959027316334407691/1280009690004324405',
configurable: isDesktopEnvironment,
defaultState: isCanaryBuild,
},
enable_emoji_folder_icon: {
category: 'affine',
displayName:

View File

@@ -12,7 +12,7 @@ import { View } from './view';
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
type WorkbenchOpenOptions = {
export type WorkbenchOpenOptions = {
at?: WorkbenchPosition | 'new-tab';
replaceHistory?: boolean;
show?: boolean; // only for new tab
@@ -52,16 +52,24 @@ export class Workbench extends Entity {
});
sidebarOpen$ = new LiveData(false);
active(index: number) {
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
this.activeViewIndex$.next(index);
active(index: number | View) {
if (typeof index === 'number') {
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
this.activeViewIndex$.next(index);
} else {
this.activeViewIndex$.next(this.views$.value.indexOf(index));
}
}
updateBasename(basename: string) {
this.basename$.next(basename);
}
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) {
createView(
at: WorkbenchPosition = 'beside',
defaultLocation: To,
active = true
) {
const view = this.framework.createEntity(View, {
id: nanoid(),
defaultLocation,
@@ -70,7 +78,9 @@ export class Workbench extends Entity {
newViews.splice(this.indexAt(at), 0, view);
this.views$.next(newViews);
const index = newViews.indexOf(view);
this.active(index);
if (active) {
this.active(index);
}
return index;
}
@@ -95,7 +105,7 @@ export class Workbench extends Entity {
const { at = 'active', replaceHistory = false } = option;
let view = this.viewAt(at);
if (!view) {
const newIndex = this.createView(at, to);
const newIndex = this.createView(at, to, option.show);
view = this.viewAt(newIndex);
if (!view) {
throw new Unreachable();

View File

@@ -28,12 +28,9 @@ export class DesktopStateSynchronizer extends Service {
event.type === 'open-in-split-view' &&
event.payload.tabId === appInfo?.viewId
) {
const to =
event.payload.view?.path ??
workbench.activeView$.value?.location$.value;
workbench.open(to, {
workbench.openAll({
at: 'beside',
show: false,
});
}

View File

@@ -23,34 +23,57 @@ export const menuTrigger = style({
height: 0,
pointerEvents: 'none',
});
export const indicator = style({
width: 29,
padding: '6px 20px',
height: 15,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 3,
cursor: 'grab',
['WebkitAppRegion' as string]: 'no-drag',
color: cssVar('placeholderColor'),
transition: 'all 0.2s',
selectors: {
'&:hover, &:active, &[data-active="true"]': {
'&:hover, &[data-active="true"], &[data-dragging="true"]': {
color: cssVar('brandColor'),
},
},
});
export const indicatorInner = style({
width: 16,
height: 3,
borderRadius: 10,
backgroundColor: 'currentColor',
transition: 'all 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
selectors: {
'[data-is-dragging="true"] &': {
width: 24,
height: 2,
'&[data-dragging="true"]': {
gap: 4,
},
},
});
export const indicatorDot = style({
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: 'currentColor',
transition: 'all 0.2s',
selectors: {
[`${indicator}[data-dragging="true"] &:is([data-idx="0"], [data-idx="2"])`]:
{
width: 7,
},
[`${indicator}[data-dragging="true"] &[data-idx="1"]`]: {
width: 6,
},
},
});
export const indicatorGradient = style({
position: 'absolute',
inset: 0,
height: '2px',
background:
'linear-gradient(to right, transparent, currentColor, transparent)',
transition: 'opacity 0.2s',
opacity: 0,
selectors: {
[`${indicator}[data-dragging="true"] &, ${indicator}:hover &`]: {
opacity: 1,
},
},
});

View File

@@ -4,41 +4,23 @@ import clsx from 'clsx';
import type { HTMLAttributes, MouseEventHandler } from 'react';
import { forwardRef, memo, useCallback, useMemo, useState } from 'react';
import type { View } from '../../entities/view';
import * as styles from './indicator.css';
export interface SplitViewMenuProps extends HTMLAttributes<HTMLDivElement> {
export interface SplitViewDragHandleProps
extends HTMLAttributes<HTMLDivElement> {
active?: boolean;
dragging?: boolean;
open?: boolean;
onOpenMenu?: () => void;
setPressed: (v: boolean) => void;
}
export const SplitViewMenuIndicator = memo(
forwardRef<HTMLDivElement, SplitViewMenuProps>(
function SplitViewMenuIndicator(
{
className,
active,
open,
setPressed,
onOpenMenu,
...attrs
}: SplitViewMenuProps,
export const SplitViewDragHandle = memo(
forwardRef<HTMLDivElement, SplitViewDragHandleProps>(
function SplitViewDragHandle(
{ className, active, open, onOpenMenu, dragging, ...attrs },
ref
) {
// dnd's `isDragging` changes after mouseDown and mouseMoved
const onMouseDown = useCallback(() => {
const t = setTimeout(() => setPressed(true), 100);
window.addEventListener(
'mouseup',
() => {
clearTimeout(t);
setPressed(false);
},
{ once: true }
);
}, [setPressed]);
const onClick: MouseEventHandler = useCallback(() => {
!open && onOpenMenu?.();
}, [onOpenMenu, open]);
@@ -47,13 +29,16 @@ export const SplitViewMenuIndicator = memo(
<div
ref={ref}
data-active={active}
data-dragging={dragging}
data-testid="split-view-indicator"
className={clsx(className, styles.indicator)}
onClick={onClick}
onMouseDown={onMouseDown}
{...attrs}
>
<div className={styles.indicatorInner} />
<div className={styles.indicatorGradient} />
<div data-idx={0} className={styles.indicatorDot} />
<div data-idx={1} className={styles.indicatorDot} />
<div data-idx={2} className={styles.indicatorDot} />
</div>
);
}
@@ -61,64 +46,69 @@ export const SplitViewMenuIndicator = memo(
);
interface SplitViewIndicatorProps extends HTMLAttributes<HTMLDivElement> {
isDragging?: boolean;
view: View;
isActive?: boolean;
isDragging?: boolean;
menuItems?: React.ReactNode;
// import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities' is not allowed
listeners?: any;
setPressed?: (pressed: boolean) => void;
dragHandleRef?: React.RefObject<HTMLDivElement>;
}
export const SplitViewIndicator = ({
isDragging,
isActive,
menuItems,
listeners,
setPressed,
}: SplitViewIndicatorProps) => {
const active = isActive || isDragging;
const [menuOpen, setMenuOpen] = useState(false);
export const SplitViewIndicator = memo(
forwardRef<HTMLDivElement, SplitViewIndicatorProps>(
function SplitViewIndicator(
{ isActive, menuItems, isDragging, dragHandleRef },
ref
) {
const [menuOpen, setMenuOpen] = useState(false);
// prevent menu from opening when dragging
const setOpenMenuManually = useCallback((open: boolean) => {
if (open) return;
setMenuOpen(open);
}, []);
const openMenu = useCallback(() => {
setMenuOpen(true);
}, []);
// prevent menu from opening when dragging
const setOpenMenuManually = useCallback((open: boolean) => {
if (open) return;
setMenuOpen(open);
}, []);
const menuRootOptions = useMemo(
() =>
({
open: menuOpen,
onOpenChange: setOpenMenuManually,
}) satisfies MenuProps['rootOptions'],
[menuOpen, setOpenMenuManually]
);
const menuContentOptions = useMemo(
() =>
({
align: 'center',
}) satisfies MenuProps['contentOptions'],
[]
);
const openMenu = useCallback(() => {
setMenuOpen(true);
}, []);
return (
<div data-is-dragging={isDragging} className={styles.indicatorWrapper}>
<Menu
contentOptions={menuContentOptions}
items={menuItems}
rootOptions={menuRootOptions}
>
<div className={styles.menuTrigger} />
</Menu>
<SplitViewMenuIndicator
open={menuOpen}
onOpenMenu={openMenu}
active={active}
setPressed={setPressed}
{...listeners}
/>
</div>
);
};
const menuRootOptions = useMemo(
() =>
({
open: menuOpen,
onOpenChange: setOpenMenuManually,
}) satisfies MenuProps['rootOptions'],
[menuOpen, setOpenMenuManually]
);
const menuContentOptions = useMemo(
() =>
({
align: 'center',
}) satisfies MenuProps['contentOptions'],
[]
);
return (
<div
ref={ref}
data-is-dragging={isDragging}
className={styles.indicatorWrapper}
>
<Menu
contentOptions={menuContentOptions}
items={menuItems}
rootOptions={menuRootOptions}
>
<div className={styles.menuTrigger} />
</Menu>
<SplitViewDragHandle
ref={dragHandleRef}
open={menuOpen}
onOpenMenu={openMenu}
active={isActive}
dragging={isDragging}
/>
</div>
);
}
)
);

View File

@@ -1,42 +1,44 @@
import { MenuItem } from '@affine/component';
import {
type DropTargetDragEvent,
MenuItem,
shallowUpdater,
useDraggable,
useDropTarget,
} from '@affine/component';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import {
ExpandCloseIcon,
MoveToLeftDuotoneIcon,
MoveToRightDuotoneIcon,
SoloViewIcon,
CloseIcon,
ExpandFullIcon,
InsertLeftIcon,
InsertRightIcon,
} from '@blocksuite/icons/rc';
import { useSortable } from '@dnd-kit/sortable';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import type {
Dispatch,
HTMLAttributes,
PropsWithChildren,
RefObject,
SetStateAction,
} from 'react';
import {
memo,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useAtom } from 'jotai';
import type { HTMLAttributes, PropsWithChildren } from 'react';
import { memo, useCallback, useMemo } from 'react';
import type { View } from '../../entities/view';
import { WorkbenchService } from '../../services/workbench';
import { SplitViewIndicator } from './indicator';
import { ResizeHandle } from './resize-handle';
import * as styles from './split-view.css';
import {
draggingOverViewAtom,
draggingViewAtom,
resizingViewAtom,
} from './state';
import { allowedSplitViewEntityTypes } from './types';
export interface SplitViewPanelProps
extends PropsWithChildren<HTMLAttributes<HTMLDivElement>> {
view: View;
index: number;
resizeHandle?: React.ReactNode;
setSlots?: Dispatch<
SetStateAction<Record<string, RefObject<HTMLDivElement | null>>>
>;
onMove: (from: number, to: number) => void;
onResizing: (dxy: { x: number; y: number }) => void;
draggingEntity: boolean;
}
export const SplitViewPanelContainer = ({
@@ -50,81 +52,236 @@ export const SplitViewPanelContainer = ({
);
};
/**
* Calculate the order of the panel
*/
function calculateOrder(
index: number,
draggingIndex: number,
droppingIndex: number
) {
// If not dragging or invalid indices, return original index
if (draggingIndex === -1 || draggingIndex < 0 || droppingIndex < 0) {
return index;
}
// If this is the dragging item, move it to the dropping position
if (index === draggingIndex) {
return droppingIndex;
}
// If dropping before the dragging item
if (droppingIndex < draggingIndex) {
// Items between drop and drag positions shift right
if (index >= droppingIndex && index < draggingIndex) {
return index + 1;
}
}
// If dropping after the dragging item
else if (
droppingIndex > draggingIndex &&
index > draggingIndex &&
index <= droppingIndex
) {
// Items between drag and drop positions shift left
return index - 1;
}
// For all other items, keep their original position
return index;
}
export const SplitViewPanel = memo(function SplitViewPanel({
children,
view,
setSlots,
onMove,
onResizing,
draggingEntity,
index,
}: SplitViewPanelProps) {
const [indicatorPressed, setIndicatorPressed] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const size = useLiveData(view.size$);
const workbench = useService(WorkbenchService).workbench;
const activeView = useLiveData(workbench.activeView$);
const views = useLiveData(workbench.views$);
const isLast = views[views.length - 1] === view;
const {
attributes,
listeners,
transform,
transition,
isDragging: dndIsDragging,
setNodeRef,
} = useSortable({ id: view.id, attributes: { role: 'group' } });
const isDragging = dndIsDragging || indicatorPressed;
const isActive = activeView === view;
useLayoutEffect(() => {
if (ref.current) {
setSlots?.(slots => ({ ...slots, [view.id]: ref }));
}
}, [setSlots, view.id]);
const [draggingView, setDraggingView] = useAtom(draggingViewAtom);
const [draggingOverView, setDraggingOverView] = useAtom(draggingOverViewAtom);
const [resizingView, setResizingView] = useAtom(resizingViewAtom);
const style = useMemo(
() => ({
...assignInlineVars({ '--size': size.toString() }),
}),
[size]
);
const dragStyle = useMemo(
() => ({
transform: `translate3d(${transform?.x ?? 0}px, 0, 0)`,
transition,
}),
[transform, transition]
const order = useMemo(
() =>
calculateOrder(
index,
draggingView?.index ?? -1,
draggingOverView?.index ?? -1
),
[index, draggingView, draggingOverView]
);
const isFirst = order === 0;
const isLast = views.length - 1 === order;
const style = useMemo(() => {
return {
...assignInlineVars({
[styles.size]: size.toString(),
[styles.panelOrder]: order.toString(),
}),
};
}, [size, order]);
const { dropTargetRef } = useDropTarget<AffineDNDData>(() => {
const handleDrag = (data: DropTargetDragEvent<AffineDNDData>) => {
// only the first view has left edge
const edge = data.closestEdge as 'left' | 'right';
const switchEdge = edge === 'left' && !isFirst;
const newDraggingOver = {
view: switchEdge ? views[index - 1] : view,
index: order,
edge: switchEdge ? 'right' : edge,
};
setDraggingOverView(shallowUpdater(newDraggingOver));
};
return {
closestEdge: {
allowedEdges: ['left', 'right'],
},
isSticky: true,
canDrop(data) {
const entityType = data.source.data.entity?.type;
return (
data.source.data.from?.at === 'workbench:view' ||
data.source.data.from?.at === 'workbench:link' ||
(!!entityType && allowedSplitViewEntityTypes.has(entityType))
);
},
onDragEnter: handleDrag,
onDrag: handleDrag,
};
}, [index, isFirst, order, setDraggingOverView, view, views]);
const { dragRef, dragHandleRef } = useDraggable<AffineDNDData>(() => {
return {
data: () => {
return {
from: {
at: 'workbench:view',
viewId: view.id,
},
};
},
onDrop() {
if (order !== index && draggingOverView) {
onMove?.(index, draggingOverView.index);
}
setDraggingView(null);
setDraggingOverView(null);
},
onDragStart() {
setDraggingView({
view,
index: order,
});
},
disableDragPreview: true,
};
}, [
draggingOverView,
index,
onMove,
order,
setDraggingOverView,
setDraggingView,
view,
]);
const dragging = draggingView?.view.id === view.id;
const onResizeStart = useCallback(() => {
setResizingView({ view, index });
}, [setResizingView, view, index]);
const onResizeEnd = useCallback(() => {
setResizingView(null);
}, [setResizingView]);
const indicatingEdge =
draggingOverView?.view === view ? draggingOverView.edge : null;
return (
<SplitViewPanelContainer
style={style}
data-is-dragging={isDragging}
data-is-dragging={dragging}
data-is-active={isActive && views.length > 1}
data-is-first={isFirst}
data-is-last={isLast}
data-testid="split-view-panel"
draggable={false} // only drag via drag handle
>
{isFirst ? (
<ResizeHandle
edge="left"
view={view}
state={
draggingEntity && indicatingEdge === 'left'
? 'drop-indicator'
: 'idle'
}
/>
) : null}
<div
ref={setNodeRef}
style={dragStyle}
ref={node => {
dropTargetRef.current = node;
dragRef.current = node;
}}
className={styles.splitViewPanelDrag}
{...attributes}
>
<div className={styles.splitViewPanelContent} ref={ref} />
{views.length > 1 ? (
<div draggable={false} className={styles.splitViewPanelContent}>
{children}
</div>
{views.length > 1 && onMove ? (
<SplitViewIndicator
listeners={listeners}
isDragging={isDragging}
view={view}
isActive={isActive}
menuItems={<SplitViewMenu view={view} />}
setPressed={setIndicatorPressed}
isDragging={dragging}
dragHandleRef={dragHandleRef}
menuItems={<SplitViewMenu view={view} onMove={onMove} />}
/>
) : null}
</div>
{children}
{!draggingView ? (
<ResizeHandle
edge="right"
view={view}
state={
resizingView?.view.id === view.id
? 'resizing'
: draggingEntity && indicatingEdge === 'right'
? 'drop-indicator'
: 'idle'
}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
onResizing={onResizing}
/>
) : null}
</SplitViewPanelContainer>
);
});
const SplitViewMenu = ({ view }: { view: View }) => {
const SplitViewMenu = ({
view,
onMove,
}: {
view: View;
onMove: (from: number, to: number) => void;
}) => {
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const views = useLiveData(workbench.views$);
@@ -136,42 +293,39 @@ const SplitViewMenu = ({ view }: { view: View }) => {
[view, workbench]
);
const handleMoveLeft = useCallback(() => {
workbench.moveView(viewIndex, viewIndex - 1);
}, [viewIndex, workbench]);
onMove(viewIndex, viewIndex - 1);
}, [onMove, viewIndex]);
const handleMoveRight = useCallback(() => {
workbench.moveView(viewIndex, viewIndex + 1);
}, [viewIndex, workbench]);
onMove(viewIndex, viewIndex + 1);
}, [onMove, viewIndex]);
const handleCloseOthers = useCallback(() => {
workbench.closeOthers(view);
}, [view, workbench]);
const CloseItem =
views.length > 1 ? (
<MenuItem prefixIcon={<ExpandCloseIcon />} onClick={handleClose}>
<MenuItem prefixIcon={<CloseIcon />} onClick={handleClose}>
{t['com.affine.workbench.split-view-menu.close']()}
</MenuItem>
) : null;
const MoveLeftItem =
viewIndex > 0 && views.length > 1 ? (
<MenuItem onClick={handleMoveLeft} prefixIcon={<MoveToLeftDuotoneIcon />}>
<MenuItem onClick={handleMoveLeft} prefixIcon={<InsertRightIcon />}>
{t['com.affine.workbench.split-view-menu.move-left']()}
</MenuItem>
) : null;
const FullScreenItem =
views.length > 1 ? (
<MenuItem onClick={handleCloseOthers} prefixIcon={<SoloViewIcon />}>
<MenuItem onClick={handleCloseOthers} prefixIcon={<ExpandFullIcon />}>
{t['com.affine.workbench.split-view-menu.keep-this-one']()}
</MenuItem>
) : null;
const MoveRightItem =
viewIndex < views.length - 1 ? (
<MenuItem
onClick={handleMoveRight}
prefixIcon={<MoveToRightDuotoneIcon />}
>
<MenuItem onClick={handleMoveRight} prefixIcon={<InsertLeftIcon />}>
{t['com.affine.workbench.split-view-menu.move-right']()}
</MenuItem>
) : null;

View File

@@ -1,40 +1,86 @@
import { useDropTarget } from '@affine/component';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useLiveData, useService } from '@toeverything/infra';
import { useAtomValue } from 'jotai';
import type { HTMLAttributes } from 'react';
import { useCallback } from 'react';
import type { View } from '../../entities/view';
import { WorkbenchService } from '../../services/workbench';
import * as styles from './split-view.css';
import { draggingOverResizeHandleAtom } from './state';
import { allowedSplitViewEntityTypes } from './types';
interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {
resizing: boolean;
onResizeStart: () => void;
onResizeEnd: () => void;
onResizing: (offset: { x: number; y: number }) => void;
state: 'resizing' | 'drop-indicator' | 'idle';
edge: 'left' | 'right';
view: View;
onResizeStart?: () => void;
onResizeEnd?: () => void;
onResizing?: (offset: { x: number; y: number }) => void;
}
export const ResizeHandle = ({
resizing,
state,
view,
edge,
onResizing,
onResizeStart,
onResizeEnd,
}: ResizeHandleProps) => {
const workbench = useService(WorkbenchService).workbench;
const views = useLiveData(workbench.views$);
const draggingOverHandle = useAtomValue(draggingOverResizeHandleAtom);
const draggingOver =
draggingOverHandle?.edge === edge && draggingOverHandle.viewId === view.id;
const index = views.findIndex(v => v.id === view.id);
const isLast = index === views.length - 1;
const isFirst = index === 0;
const { dropTargetRef } = useDropTarget<AffineDNDData>(() => {
return {
data: {
at: 'workbench:resize-handle',
edge,
viewId: view.id,
},
canDrop: data => {
return (
(!!data.source.data.entity?.type &&
allowedSplitViewEntityTypes.has(data.source.data.entity?.type)) ||
data.source.data.from?.at === 'workbench:link'
);
},
};
}, [edge, view.id]);
// TODO(@catsjuice): touch support
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!onResizing || !onResizeStart || !onResizeEnd) {
return;
}
e.preventDefault();
onResizeStart();
onResizeStart?.();
const prevPos = { x: e.clientX, y: e.clientY };
function onMouseMove(e: MouseEvent) {
e.preventDefault();
const dx = e.clientX - prevPos.x;
const dy = e.clientY - prevPos.y;
onResizing({ x: dx, y: dy });
onResizing?.({ x: dx, y: dy });
prevPos.x = e.clientX;
prevPos.y = e.clientY;
}
function onMouseUp(e: MouseEvent) {
e.preventDefault();
onResizeEnd();
onResizeEnd?.();
document.removeEventListener('mousemove', onMouseMove);
}
@@ -44,10 +90,21 @@ export const ResizeHandle = ({
[onResizeEnd, onResizeStart, onResizing]
);
const canResize =
state === 'idle' &&
!(isLast && edge === 'right') &&
!(isFirst && edge === 'left');
return (
<div
ref={dropTargetRef}
data-edge={edge}
onMouseDown={onMouseDown}
data-resizing={resizing || null}
data-is-last={isLast}
data-is-first={isFirst}
data-state={state}
data-dragging-over={state === 'drop-indicator' ? draggingOver : false}
data-can-resize={canResize}
className={styles.resizeHandle}
/>
);

View File

@@ -1,55 +1,55 @@
import { cssVar } from '@toeverything/theme';
import { createVar, style } from '@vanilla-extract/css';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, fallbackVar, keyframes, style } from '@vanilla-extract/css';
const gap = createVar();
const borderRadius = createVar();
const resizeHandleWidth = createVar();
export const size = createVar();
export const panelOrder = createVar();
const dropIndicatorWidth = createVar();
const dropIndicatorOpacity = createVar();
const dropIndicatorRadius = createVar();
export const splitViewRoot = style({
vars: {
[gap]: '0px',
[borderRadius]: '0px',
},
display: 'flex',
flexDirection: 'row',
position: 'relative',
borderRadius,
gap,
selectors: {
'&[data-client-border="true"]': {
vars: {
[gap]: '8px',
[borderRadius]: '6px',
},
const expandDropIndicator = keyframes({
from: {
vars: {
[resizeHandleWidth]: '30px',
[dropIndicatorWidth]: '3px',
[dropIndicatorOpacity]: '1',
[dropIndicatorRadius]: '10px',
},
'&[data-orientation="vertical"]': {
flexDirection: 'column',
},
to: {
vars: {
[resizeHandleWidth]: '300px',
[dropIndicatorWidth]: '100%',
[dropIndicatorOpacity]: '0.15',
[dropIndicatorRadius]: '4px',
},
},
});
export const splitViewPanel = style({
flexShrink: 0,
flexGrow: 'var(--size, 1)',
flexGrow: fallbackVar(size, '1'),
position: 'relative',
borderRadius: 'inherit',
order: panelOrder,
display: 'flex',
selectors: {
'[data-orientation="vertical"] &': {
height: 0,
},
'[data-orientation="horizontal"] &': {
width: 0,
'[data-client-border="false"] &[data-is-first="true"]': {
borderTopLeftRadius: borderRadius,
},
'[data-client-border="false"] &:not([data-is-last="true"]):not([data-is-dragging="true"])':
{
borderRight: `0.5px solid ${cssVar('borderColor')}`,
borderRight: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
},
'&[data-is-dragging="true"]': {
zIndex: 1,
},
'[data-client-border="true"] &': {
border: `0.5px solid ${cssVar('borderColor')}`,
border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
borderTopLeftRadius: borderRadius,
borderBottomLeftRadius: borderRadius,
borderTopRightRadius: borderRadius,
borderBottomRightRadius: borderRadius,
},
},
});
@@ -58,6 +58,7 @@ export const splitViewPanelDrag = style({
width: '100%',
height: '100%',
borderRadius: 'inherit',
transition: 'opacity 0.2s',
selectors: {
'&::after': {
@@ -73,8 +74,12 @@ export const splitViewPanelDrag = style({
transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
},
'[data-is-dragging="true"] &::after': {
boxShadow: `inset 0 0 0 2px ${cssVar('brandColor')}`,
'[data-is-active="true"] &::after': {
boxShadow: `inset 0 0 0 1px ${cssVarV2('button/primary')}`,
},
'[data-is-dragging="true"] &': {
opacity: 0.5,
},
},
});
@@ -90,49 +95,123 @@ export const resizeHandle = style({
position: 'absolute',
top: 0,
bottom: 0,
right: -5,
width: 10,
width: resizeHandleWidth,
// to make sure it's above all-pages's header
zIndex: 3,
zIndex: 5,
display: 'flex',
justifyContent: 'center',
alignItems: 'stretch',
cursor: 'col-resize',
selectors: {
'[data-client-border="true"] &': {
right: `calc(-5px - ${gap} / 2)`,
'&[data-can-resize="false"]:not([data-state="drop-indicator"])': {
pointerEvents: 'none',
},
'&[data-state="drop-indicator"]': {
vars: {
[resizeHandleWidth]: '20px',
},
},
'&[data-edge="left"]': {
left: `calc(${resizeHandleWidth} * -0.5)`,
right: 'auto',
},
'&[data-edge="right"]': {
left: 'auto',
right: `calc(${resizeHandleWidth} * -0.5)`,
},
'&[data-edge="right"][data-is-last="true"]': {
right: 0,
left: 'auto',
},
'[data-client-border="false"] &[data-is-last="true"][data-edge="right"]::before, [data-client-border="false"] &[data-is-last="true"][data-edge="right"]::after':
{
transform: `translateX(calc(0.5 * ${resizeHandleWidth} - 1px))`,
},
'&[data-can-resize="true"]': {
cursor: 'col-resize',
},
'[data-client-border="true"] &[data-edge="right"]': {
right: `calc(${resizeHandleWidth} * -0.5 - 0.5px - ${gap} / 2)`,
},
[`.${splitViewPanel}[data-is-dragging="true"] &`]: {
display: 'none',
},
// horizontal
'[data-orientation="horizontal"] &::before, [data-orientation="horizontal"] &::after':
{
content: '""',
width: 2,
position: 'absolute',
height: '100%',
background: 'transparent',
transition: 'background 0.1s',
borderRadius: 10,
'&[data-state="drop-indicator"][data-dragging-over="true"]': {
animationName: expandDropIndicator,
animationDuration: '0.5s',
animationDelay: '1s',
animationFillMode: 'forwards',
vars: {
[dropIndicatorOpacity]: '1',
[dropIndicatorWidth]: '3px',
},
'[data-orientation="horizontal"] &[data-resizing]::before, [data-orientation="horizontal"] &[data-resizing]::after':
{
width: 3,
},
'&:hover::before, &[data-resizing]::before': {
background: cssVar('brandColor'),
},
'&:hover::after, &[data-resizing]::after': {
boxShadow: `0px 12px 21px 4px ${cssVar('brandColor')}`,
'&::before, &::after': {
content: '""',
width: dropIndicatorWidth,
position: 'absolute',
height: '100%',
transition: 'all 0.2s, transform 0s',
borderRadius: dropIndicatorRadius,
},
'&::before': {
background: cssVarV2('button/primary'),
opacity: dropIndicatorOpacity,
},
'&[data-state="resizing"]::before, &[data-state="resizing"]::after': {
vars: {
[dropIndicatorWidth]: '3px',
[dropIndicatorOpacity]: '1',
},
},
'&[data-state="drop-indicator"][data-dragging-over="false"]::before': {
vars: {
[dropIndicatorOpacity]: '0.5',
},
},
'&:is(:hover[data-can-resize="true"], [data-state="resizing"])::before': {
vars: {
[dropIndicatorWidth]: '3px',
[dropIndicatorOpacity]: '1',
},
},
'&:is(:hover[data-can-resize="true"], [data-state="resizing"])::after': {
boxShadow: `0px 12px 21px 4px ${cssVarV2('button/primary')}`,
opacity: 0.15,
},
// vertical
// TODO
},
});
export const splitViewRoot = style({
vars: {
[gap]: '0px',
[borderRadius]: '6px',
[resizeHandleWidth]: '10px',
[dropIndicatorWidth]: '2px',
[dropIndicatorOpacity]: '0',
[dropIndicatorRadius]: '10px',
},
display: 'flex',
flexDirection: 'row',
position: 'relative',
borderRadius,
gap,
padding: '0 10px',
margin: '0 -10px',
selectors: {
'&[data-client-border="true"]': {
vars: {
[gap]: '8px',
},
},
[`&:has(${resizeHandle}[data-dragging-over="true"])`]: {
overflow: 'clip',
},
},
});

View File

@@ -1,27 +1,18 @@
import { useDndMonitor } from '@affine/component';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import type { DragEndEvent } from '@dnd-kit/core';
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import type { HTMLAttributes, RefObject } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useSetAtom } from 'jotai';
import type { HTMLAttributes } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { View } from '../../entities/view';
import { WorkbenchService } from '../../services/workbench';
import { SplitViewPanel } from './panel';
import { ResizeHandle } from './resize-handle';
import * as styles from './split-view.css';
import { draggingOverResizeHandleAtom } from './state';
import { allowedSplitViewEntityTypes, inferToFromEntity } from './types';
export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
/**
@@ -30,12 +21,10 @@ export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
*/
orientation?: 'horizontal' | 'vertical';
views: View[];
renderer: (item: View, index: number) => React.ReactNode;
renderer: (item: View) => React.ReactNode;
onMove?: (from: number, to: number) => void;
}
type SlotsMap = Record<View['id'], RefObject<HTMLDivElement | null>>;
// TODO(@catsjuice): vertical orientation support
export const SplitView = ({
orientation = 'horizontal',
className,
@@ -45,29 +34,27 @@ export const SplitView = ({
...attrs
}: SplitViewProps) => {
const rootRef = useRef<HTMLDivElement>(null);
const [slots, setSlots] = useState<SlotsMap>({});
const [resizingViewId, setResizingViewId] = useState<View['id'] | null>(null);
const { appSettings } = useAppSettingHelper();
const workbench = useService(WorkbenchService).workbench;
// blocksuite's lit host element has an issue on remounting.
// Add a workaround here to force remounting after dropping.
const [visible, setVisibility] = useState(true);
// workaround: blocksuite's lit host element has an issue on remounting.
// we do not want the view to change its render ordering here after reordering
// instead we use a local state to store the views + its order to avoid remounting
const [localViewsState, setLocalViewsState] = useState<View[]>(views);
const sensors = useSensors(
useSensor(
PointerSensor,
useMemo(
/* avoid re-rendering */
() => ({
activationConstraint: {
distance: 0,
},
}),
[]
)
)
);
useLayoutEffect(() => {
setLocalViewsState(oldViews => {
let newViews = oldViews.filter(v => views.includes(v));
for (const view of views) {
if (!newViews.includes(view)) {
newViews.push(view);
}
}
return newViews;
});
}, [views]);
const onResizing = useCallback(
(index: number, { x, y }: { x: number; y: number }) => {
@@ -85,45 +72,101 @@ export const SplitView = ({
[orientation, workbench]
);
const resizeHandleRenderer = useCallback(
(view: View, index: number) =>
index < views.length - 1 ? (
<ResizeHandle
resizing={resizingViewId === view.id}
onResizeStart={() => setResizingViewId(view.id)}
onResizeEnd={() => setResizingViewId(null)}
onResizing={dxy => onResizing(index, dxy)}
/>
) : null,
[onResizing, resizingViewId, views.length]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
// update order
const fromIndex = views.findIndex(v => v.id === active.id);
const toIndex = views.findIndex(v => v.id === over?.id);
onMove?.(fromIndex, toIndex);
setVisibility(false);
}
const handleOnMove = useCallback(
(from: number, to: number) => {
onMove?.(from, to);
},
[onMove, views]
[onMove]
);
useEffect(() => {
if (!visible) {
const timeoutId = setTimeout(() => {
setVisibility(true);
}, 0);
const [draggingEntity, setDraggingEntity] = useState(false);
return () => {
clearTimeout(timeoutId);
};
}
return;
}, [visible]);
const setDraggingOverResizeHandle = useSetAtom(draggingOverResizeHandleAtom);
useDndMonitor<AffineDNDData>(() => {
return {
// todo(@pengx17): external data for monitor is not supported yet
// allowExternal: true,
canMonitor(data) {
// allow dropping doc && tab view to split view panel
const from = data.source.data.from;
const entity = data.source.data.entity;
if (from?.at === 'app-header:tabs') {
return false;
} else if (
entity?.type &&
allowedSplitViewEntityTypes.has(entity?.type)
) {
return true;
} else if (from?.at === 'workbench:link') {
return true;
}
return false;
},
onDragStart() {
setDraggingEntity(true);
},
onDrop(data) {
setDraggingEntity(false);
const candidate = data.location.current.dropTargets.find(
target => target.data.at === 'workbench:resize-handle'
);
if (!candidate) {
return;
}
const dropTarget = candidate.data as AffineDNDData['draggable']['from'];
const entity = data.source.data.entity;
const from = data.source.data.from;
if (dropTarget?.at === 'workbench:resize-handle') {
const { edge, viewId } = dropTarget;
const index = views.findIndex(v => v.id === viewId);
const at = (() => {
if (edge === 'left') {
if (index === 0) {
return 'head';
}
return index - 1;
} else if (edge === 'right') {
if (index === views.length - 1) {
return 'tail';
}
return index + 1;
} else {
return 'tail';
}
})();
const to = entity
? inferToFromEntity(entity)
: from?.at === 'workbench:link'
? from.to
: null;
if (to) {
workbench.createView(at, to);
}
}
},
onDropTargetChange(data) {
const candidate = data.location.current.dropTargets.find(
target => target.data.at === 'workbench:resize-handle'
);
if (!candidate) {
setDraggingOverResizeHandle(null);
return;
}
setDraggingOverResizeHandle({
viewId: candidate.data.viewId as string,
edge: candidate.data.edge as 'left' | 'right',
});
},
};
}, []);
return (
<div
@@ -133,29 +176,19 @@ export const SplitView = ({
data-client-border={appSettings.clientBorder}
{...attrs}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={views} strategy={horizontalListSortingStrategy}>
{views.map((view, index) =>
visible ? (
<SplitViewPanel view={view} key={view.id} setSlots={setSlots}>
{resizeHandleRenderer(view, index)}
</SplitViewPanel>
) : null
)}
</SortableContext>
</DndContext>
{views.map((view, index) => {
const slot = slots[view.id]?.current;
if (!slot) return null;
return createPortal(
renderer(view, index),
slot,
`portalToSplitViewPanel_${view.id}`
{localViewsState.map(view => {
const order = views.indexOf(view);
return (
<SplitViewPanel
view={view}
index={order}
key={view.id}
onMove={handleOnMove}
onResizing={dxy => onResizing(order, dxy)}
draggingEntity={draggingEntity}
>
{renderer(view)}
</SplitViewPanel>
);
})}
</div>

View File

@@ -0,0 +1,25 @@
import { atom } from 'jotai';
import type { View } from '../../entities/view';
// global split view ui state
export const draggingOverViewAtom = atom<{
view: View;
index: number;
edge: 'left' | 'right';
} | null>(null);
export const draggingViewAtom = atom<{
view: View;
index: number;
} | null>(null);
export const resizingViewAtom = atom<{
view: View;
index: number;
} | null>(null);
export const draggingOverResizeHandleAtom = atom<{
viewId: string;
edge: 'left' | 'right';
} | null>(null);

View File

@@ -0,0 +1,15 @@
import type { AffineDNDEntity } from '@affine/core/types/dnd';
export const allowedSplitViewEntityTypes: Set<AffineDNDEntity['type']> =
new Set(['doc', 'collection', 'tag']);
export const inferToFromEntity = (entity: AffineDNDEntity) => {
if (entity.type === 'doc') {
return `/${entity.id}`;
} else if (entity.type === 'collection') {
return `/collection/${entity.id}`;
} else if (entity.type === 'tag') {
return `/tag/${entity.id}`;
}
return null;
};

View File

@@ -1,10 +1,12 @@
import { useDraggable } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import type { AffineDNDData, AffineDNDEntity } from '@affine/core/types/dnd';
import { isNewTabTrigger } from '@affine/core/utils';
import { useLiveData, useServices } from '@toeverything/infra';
import { type To } from 'history';
import { forwardRef, type MouseEvent } from 'react';
import { FeatureFlagService } from '../../feature-flag';
import { resolveRouteLinkMeta } from '../../navigation/utils';
import { WorkbenchService } from '../services/workbench';
export type WorkbenchLinkProps = React.PropsWithChildren<
@@ -15,20 +17,45 @@ export type WorkbenchLinkProps = React.PropsWithChildren<
} & React.HTMLProps<HTMLAnchorElement>
>;
function resolveToEntity(
to: To,
basename: string
): AffineDNDEntity | undefined {
const link =
basename +
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
const info = resolveRouteLinkMeta(link);
if (info?.moduleName === 'doc') {
return {
type: 'doc',
id: info.docId,
};
} else if (info?.moduleName === 'collection') {
return {
type: 'collection',
id: info.subModuleName,
};
} else if (info?.moduleName === 'tag') {
return {
type: 'tag',
id: info.subModuleName,
};
}
return undefined;
}
export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
function WorkbenchLink({ to, onClick, replaceHistory, ...other }, ref) {
const { featureFlagService, workbenchService } = useServices({
FeatureFlagService,
const { workbenchService } = useServices({
WorkbenchService,
});
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const workbench = workbenchService.workbench;
const basename = useLiveData(workbench.basename$);
const link =
basename +
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
const stringTo =
typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`;
const link = basename + stringTo;
const handleClick = useAsyncCallback(
async (event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
@@ -37,9 +64,7 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
}
const at = (() => {
if (isNewTabTrigger(event)) {
return BUILD_CONFIG.isElectron && event.altKey && enableMultiView
? 'tail'
: 'new-tab';
return BUILD_CONFIG.isElectron && event.altKey ? 'tail' : 'new-tab';
}
return 'active';
})();
@@ -47,15 +72,32 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
event.preventDefault();
event.stopPropagation();
},
[enableMultiView, onClick, replaceHistory, to, workbench]
[onClick, replaceHistory, to, workbench]
);
// eslint suspicious runtime error
// eslint-disable-next-line react/no-danger-with-children
const { dragRef } = useDraggable<AffineDNDData>(() => {
return {
data: {
entity: resolveToEntity(to, basename),
from: {
at: 'workbench:link',
to: stringTo,
},
},
};
}, [to, basename, stringTo]);
return (
<a
{...other}
ref={ref}
ref={node => {
dragRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
href={link}
onClick={handleClick}
onAuxClick={handleClick}

View File

@@ -5,7 +5,6 @@ export const workbenchRootContainer = style({
display: 'flex',
height: '100%',
flex: 1,
overflow: 'hidden',
});
export const workbenchViewContainer = style({

View File

@@ -48,8 +48,8 @@ export const WorkbenchRoot = memo(() => {
useAdapter(workbench, basename);
const panelRenderer = useCallback((view: View, index: number) => {
return <WorkbenchView key={view.id} view={view} index={index} />;
const panelRenderer = useCallback((view: View) => {
return <WorkbenchView view={view} />;
}, []);
const onMove = useCallback(
@@ -78,12 +78,12 @@ export const WorkbenchRoot = memo(() => {
WorkbenchRoot.displayName = 'memo(WorkbenchRoot)';
const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
const WorkbenchView = ({ view }: { view: View }) => {
const workbench = useService(WorkbenchService).workbench;
const handleOnFocus = useCallback(() => {
workbench.active(index);
}, [workbench, index]);
workbench.active(view);
}, [workbench, view]);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -149,7 +149,7 @@ const WorkbenchSidebar = () => {
<ResizePanel
floating={floating}
resizeHandlePos="left"
resizeHandleOffset={0}
resizeHandleOffset={clientBorder && sidebarOpen ? 3 : 0}
width={width}
resizing={resizing}
onResizing={setResizing}

View File

@@ -1,28 +1,30 @@
import type { DNDData } from '@affine/component';
export type AffineDNDEntity =
| {
type: 'doc';
id: string;
}
| {
type: 'folder';
id: string;
}
| {
type: 'collection';
id: string;
}
| {
type: 'tag';
id: string;
}
| {
type: 'custom-property';
id: string;
};
export interface AffineDNDData extends DNDData {
draggable: {
entity?:
| {
type: 'doc';
id: string;
}
| {
type: 'folder';
id: string;
}
| {
type: 'collection';
id: string;
}
| {
type: 'tag';
id: string;
}
| {
type: 'custom-property';
id: string;
};
entity?: AffineDNDEntity;
from?:
| {
at: 'explorer:organize:folder-node';
@@ -79,6 +81,19 @@ export interface AffineDNDData extends DNDData {
at: 'doc-detail:header';
docId: string;
}
| {
at: 'workbench:view';
viewId: string;
}
| {
at: 'workbench:link';
to: string;
}
| {
at: 'workbench:resize-handle';
viewId: string;
edge: 'left' | 'right';
}
| {
at: 'blocksuite-editor';
}
@@ -114,5 +129,9 @@ export interface AffineDNDData extends DNDData {
| {
at: 'app-header:tabs';
}
| {
at: 'workbench:view';
viewId: string;
}
| Record<string, unknown>;
}