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
@@ -96,7 +96,6 @@ export const showTabContextMenu = async (
]; ];
const menu = Menu.buildFromTemplate(template); const menu = Menu.buildFromTemplate(template);
menu.popup(); menu.popup();
let unsub: (() => void) | undefined; let unsub: (() => void) | undefined;
const subscription = WebContentViewsManager.instance.tabAction$.subscribe( const subscription = WebContentViewsManager.instance.tabAction$.subscribe(
action => { action => {
@@ -1,8 +1,9 @@
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react'; import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
import { useTransition } from 'react-transition-state'; import { useTransitionState } from 'react-transition-state';
import { useDropTarget } from '../../ui/dnd';
import { Tooltip, type TooltipProps } from '../../ui/tooltip'; import { Tooltip, type TooltipProps } from '../../ui/tooltip';
import * as styles from './resize-panel.css'; import * as styles from './resize-panel.css';
@@ -22,6 +23,7 @@ export interface ResizeHandleProps
tooltipShortcut?: TooltipProps['shortcut']; tooltipShortcut?: TooltipProps['shortcut'];
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>; tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
tooltipShortcutClassName?: string; tooltipShortcutClassName?: string;
dropTargetOptions?: Parameters<typeof useDropTarget>[0];
} }
export interface ResizePanelProps export interface ResizePanelProps
@@ -40,6 +42,7 @@ export interface ResizePanelProps
resizeHandleTooltipOptions?: Partial< resizeHandleTooltipOptions?: Partial<
Omit<TooltipProps, 'content' | 'shortcut'> Omit<TooltipProps, 'content' | 'shortcut'>
>; >;
resizeHandleDropTargetOptions?: Parameters<typeof useDropTarget>[0];
enableAnimation?: boolean; enableAnimation?: boolean;
width: number; width: number;
unmountOnExit?: boolean; unmountOnExit?: boolean;
@@ -56,6 +59,7 @@ const ResizeHandle = ({
resizeHandlePos, resizeHandlePos,
resizeHandleOffset, resizeHandleOffset,
resizeHandleVerticalPadding, resizeHandleVerticalPadding,
dropTargetOptions,
open, open,
onOpen, onOpen,
onResizing, onResizing,
@@ -115,6 +119,10 @@ const ResizeHandle = ({
[maxWidth, resizeHandlePos, minWidth, onWidthChange, onResizing, onOpen] [maxWidth, resizeHandlePos, minWidth, onWidthChange, onResizing, onOpen]
); );
const { dropTargetRef } = useDropTarget(dropTargetOptions, [
dropTargetOptions,
]);
return ( return (
<Tooltip <Tooltip
content={tooltip} content={tooltip}
@@ -125,7 +133,10 @@ const ResizeHandle = ({
<div <div
{...rest} {...rest}
data-testid="resize-handle" data-testid="resize-handle"
ref={ref} ref={node => {
ref.current = node;
dropTargetRef.current = node;
}}
style={assignInlineVars({ style={assignInlineVars({
[styles.resizeHandleOffsetVar]: `${resizeHandleOffset ?? 0}px`, [styles.resizeHandleOffsetVar]: `${resizeHandleOffset ?? 0}px`,
[styles.resizeHandleVerticalPadding]: `${ [styles.resizeHandleVerticalPadding]: `${
@@ -169,17 +180,18 @@ export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
resizeHandleTooltipShortcut, resizeHandleTooltipShortcut,
resizeHandleTooltipShortcutClassName, resizeHandleTooltipShortcutClassName,
resizeHandleTooltipOptions, resizeHandleTooltipOptions,
resizeHandleDropTargetOptions,
...rest ...rest
}, },
ref ref
) { ) {
const safeWidth = Math.min(maxWidth, Math.max(minWidth, width)); const safeWidth = Math.min(maxWidth, Math.max(minWidth, width));
const [{ status }, toggle] = useTransition({ const [{ status }, toggle] = useTransitionState({
timeout: animationTimeout, timeout: animationTimeout,
}); });
useLayoutEffect(() => { useLayoutEffect(() => {
toggle(open); toggle(open);
}, [open]); }, [open, toggle]);
return ( return (
<div <div
{...rest} {...rest}
@@ -213,6 +225,7 @@ export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
onWidthChange={onWidthChange} onWidthChange={onWidthChange}
open={open} open={open}
resizing={resizing} resizing={resizing}
dropTargetOptions={resizeHandleDropTargetOptions}
/> />
</div> </div>
); );
@@ -0,0 +1,33 @@
import type { DNDData, fromExternalData } from './types';
export const isExternalDrag = <D extends DNDData>(args: {
source: { data: D['draggable'] };
}) => {
return !args.source['data'];
};
export const getAdaptedEventArgs = <
D extends DNDData,
Args extends { source: { data: D['draggable'] } },
>(
args: Args,
fromExternalData?: fromExternalData<D>,
isDropEvent = false
): Args => {
const data =
isExternalDrag(args) && fromExternalData
? fromExternalData(
// @ts-expect-error hack for external data adapter (source has no data field)
args as ExternalGetDataFeedbackArgs,
isDropEvent
)
: args.source['data'];
return {
...args,
source: {
...args.source,
data,
},
};
};
@@ -71,7 +71,6 @@ export const useDraggable = <D extends DNDData = DNDData>(
const context = useContext(DNDContext); const context = useContext(DNDContext);
// eslint-disable-next-line react-hooks/exhaustive-deps
const options = useMemo(() => { const options = useMemo(() => {
const opts = getOptions(); const opts = getOptions();
@@ -19,6 +19,8 @@ import {
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { shallowUpdater } from '../../utils';
import { getAdaptedEventArgs, isExternalDrag } from './common';
import { DNDContext } from './context'; import { DNDContext } from './context';
import type { DNDData, fromExternalData } from './types'; import type { DNDData, fromExternalData } from './types';
@@ -44,7 +46,7 @@ export type DropTargetTreeInstruction = Instruction;
export type ExternalDragPayload = ExternalDragType['payload']; export type ExternalDragPayload = ExternalDragType['payload'];
type DropTargetGetFeedback<D extends DNDData> = Parameters< export type DropTargetGetFeedback<D extends DNDData> = Parameters<
NonNullable<Parameters<typeof dropTargetForElements>[0]['canDrop']> NonNullable<Parameters<typeof dropTargetForElements>[0]['canDrop']>
>[0] & { >[0] & {
source: { source: {
@@ -59,38 +61,6 @@ type DropTargetGet<T, D extends DNDData> =
| T | T
| ((data: DropTargetGetFeedback<D>) => T); | ((data: DropTargetGetFeedback<D>) => T);
const isExternalDrag = <D extends DNDData>(
args: Pick<DropTargetGetFeedback<D>, 'source'>
) => {
return !args.source['data'];
};
const getAdaptedEventArgs = <
D extends DNDData,
Args extends Pick<DropTargetGetFeedback<D>, 'source'>,
>(
options: DropTargetOptions<D>,
args: Args,
isDropEvent = false
): Args => {
const data =
isExternalDrag(args) && options.fromExternalData
? options.fromExternalData(
// @ts-expect-error hack for external data adapter (source has no data field)
args as ExternalGetDataFeedbackArgs,
isDropEvent
)
: args.source['data'];
return {
...args,
source: {
...args.source,
data,
},
};
};
function dropTargetGet<T, D extends DNDData>( function dropTargetGet<T, D extends DNDData>(
get: T, get: T,
options: DropTargetOptions<D> options: DropTargetOptions<D>
@@ -110,7 +80,7 @@ function dropTargetGet<T, D extends DNDData>(
) => { ) => {
if (typeof get === 'function') { if (typeof get === 'function') {
return (get as any)({ return (get as any)({
...getAdaptedEventArgs(options, args), ...getAdaptedEventArgs(args, options.fromExternalData),
get treeInstruction() { get treeInstruction() {
return options.treeInstruction return options.treeInstruction
? extractInstruction( ? extractInstruction(
@@ -145,8 +115,8 @@ function dropTargetGet<T, D extends DNDData>(
}); });
} else { } else {
return { return {
...getAdaptedEventArgs(args, options.fromExternalData),
...get, ...get,
...getAdaptedEventArgs(options, args),
}; };
} }
}) as any; }) as any;
@@ -168,6 +138,8 @@ export interface DropTargetOptions<D extends DNDData = DNDData> {
}; };
onDrop?: (data: DropTargetDropEvent<D>) => void; onDrop?: (data: DropTargetDropEvent<D>) => void;
onDrag?: (data: DropTargetDragEvent<D>) => void; onDrag?: (data: DropTargetDragEvent<D>) => void;
onDragEnter?: (data: DropTargetDragEvent<D>) => void;
onDragLeave?: (data: DropTargetDragEvent<D>) => void;
/** /**
* external data adapter. * external data adapter.
* Will use the external data adapter from the context if not provided. * Will use the external data adapter from the context if not provided.
@@ -232,6 +204,55 @@ export const useDropTarget = <D extends DNDData = DNDData>(
const dropTargetOptions = useMemo(() => { const dropTargetOptions = useMemo(() => {
const wrappedCanDrop = dropTargetGet(options.canDrop, options); const wrappedCanDrop = dropTargetGet(options.canDrop, options);
let _element: HTMLElement | null = null; let _element: HTMLElement | null = null;
const updateDragOver = (
args: DropTargetDragEvent<D>,
handler?: (data: DropTargetDragEvent<D>) => void
) => {
args = getAdaptedEventArgs(args, options.fromExternalData);
if (
args.location.current.dropTargets[0]?.element === dropTargetRef.current
) {
if (enableDraggedOverDraggable.current) {
setDraggedOverDraggable(shallowUpdater(args.source));
}
let instruction = null;
let closestEdge = null;
if (options.treeInstruction) {
instruction = extractInstruction(args.self.data);
setTreeInstruction(shallowUpdater(instruction));
if (dropTargetRef.current) {
dropTargetRef.current.dataset['treeInstruction'] =
instruction?.type;
}
}
if (options.closestEdge) {
closestEdge = extractClosestEdge(args.self.data);
setClosestEdge(shallowUpdater(closestEdge));
}
if (enableDropEffect.current) {
setDropEffect(shallowUpdater(args.self.dropEffect));
}
if (enableDraggedOverPosition.current) {
const rect = args.self.element.getBoundingClientRect();
const { clientX, clientY } = args.location.current.input;
setDraggedOverPosition(
shallowUpdater({
relativeX: clientX - rect.x,
relativeY: clientY - rect.y,
clientX: clientX,
clientY: clientY,
})
);
}
handler?.({
...args,
treeInstruction: instruction,
closestEdge,
} as DropTargetDropEvent<D>);
}
};
return { return {
get element() { get element() {
if (!_element) { if (!_element) {
@@ -285,7 +306,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
// external data is only available in drop event thus // external data is only available in drop event thus
// this is the only case for getAdaptedEventArgs // this is the only case for getAdaptedEventArgs
const args = getAdaptedEventArgs(options, _args, true); const args = getAdaptedEventArgs(_args, options.fromExternalData, true);
if ( if (
isExternalDrag(_args) && isExternalDrag(_args) &&
options.fromExternalData && options.fromExternalData &&
@@ -309,7 +330,7 @@ export const useDropTarget = <D extends DNDData = DNDData>(
} }
}, },
getData: (args: DropTargetGetFeedback<D>) => { getData: (args: DropTargetGetFeedback<D>) => {
args = getAdaptedEventArgs(options, args); args = getAdaptedEventArgs(args, options.fromExternalData);
const originData = dropTargetGet(options.data ?? {}, options)(args); const originData = dropTargetGet(options.data ?? {}, options)(args);
const { input, element } = args; const { input, element } = args;
const withInstruction = options.treeInstruction const withInstruction = options.treeInstruction
@@ -332,50 +353,29 @@ export const useDropTarget = <D extends DNDData = DNDData>(
return withClosestEdge; return withClosestEdge;
}, },
onDrag: (args: DropTargetDragEvent<D>) => { onDrag: (args: DropTargetDragEvent<D>) => {
args = getAdaptedEventArgs(options, args); updateDragOver(args, options.onDrag);
if ( },
args.location.current.dropTargets[0]?.element === onDragEnter: (args: DropTargetDragEvent<D>) => {
dropTargetRef.current updateDragOver(args, options.onDragEnter);
) { },
if (enableDraggedOverDraggable.current) { onDragLeave: (args: DropTargetDragEvent<D>) => {
setDraggedOverDraggable(args.source); args = getAdaptedEventArgs(args, options.fromExternalData);
}
let instruction = null; const withClosestEdge = options.closestEdge
let closestEdge = null; ? attachClosestEdge(args.self.data, {
if (options.treeInstruction) { element: args.self.element,
instruction = extractInstruction(args.self.data); input: args.location.current.input,
setTreeInstruction(instruction); allowedEdges: options.closestEdge.allowedEdges,
if (dropTargetRef.current) { })
dropTargetRef.current.dataset['treeInstruction'] = : args.self.data;
instruction?.type;
} options.onDragLeave?.({
} ...args,
if (options.closestEdge) { self: { ...args.self, data: withClosestEdge },
closestEdge = extractClosestEdge(args.self.data); });
setClosestEdge(closestEdge);
}
if (enableDropEffect.current) {
setDropEffect(args.self.dropEffect);
}
if (enableDraggedOverPosition.current) {
const rect = args.self.element.getBoundingClientRect();
const { clientX, clientY } = args.location.current.input;
setDraggedOverPosition({
relativeX: clientX - rect.x,
relativeY: clientY - rect.y,
clientX: clientX,
clientY: clientY,
});
}
options.onDrag?.({
...args,
treeInstruction: instruction,
closestEdge,
} as DropTargetDropEvent<D>);
}
}, },
onDropTargetChange: (args: DropTargetDropEvent<D>) => { onDropTargetChange: (args: DropTargetDropEvent<D>) => {
args = getAdaptedEventArgs(options, args); args = getAdaptedEventArgs(args, options.fromExternalData);
if ( if (
args.location.current.dropTargets[0]?.element === args.location.current.dropTargets[0]?.element ===
dropTargetRef.current dropTargetRef.current
@@ -2,4 +2,5 @@ export * from './context';
export * from './draggable'; export * from './draggable';
export * from './drop-indicator'; export * from './drop-indicator';
export * from './drop-target'; export * from './drop-target';
export * from './monitor';
export * from './types'; export * from './types';
@@ -0,0 +1,121 @@
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
import type {
DragLocationHistory,
ElementDragType,
} from '@atlaskit/pragmatic-drag-and-drop/types';
import { useContext, useEffect, useMemo } from 'react';
import { getAdaptedEventArgs } from './common';
import { DNDContext } from './context';
import type { DNDData, fromExternalData } from './types';
type MonitorGetFeedback<D extends DNDData = DNDData> = Parameters<
NonNullable<Parameters<typeof monitorForElements>[0]['canMonitor']>
>[0] & {
source: {
data: D['draggable'];
};
};
type MonitorGet<T, D extends DNDData = DNDData> =
| T
| ((data: MonitorGetFeedback<D>) => T);
type MonitorDragEvent<D extends DNDData = DNDData> = {
/**
* Location history for the drag operation
*/
location: DragLocationHistory;
/**
* Data associated with the entity that is being dragged
*/
source: Exclude<ElementDragType['payload'], 'data'> & {
data: D['draggable'];
};
};
export interface MonitorOptions<D extends DNDData = DNDData> {
canMonitor?: MonitorGet<boolean, D>;
onDragStart?: (data: MonitorDragEvent<D>) => void;
onDrag?: (data: MonitorDragEvent<D>) => void;
onDrop?: (data: MonitorDragEvent<D>) => void;
onDropTargetChange?: (data: MonitorDragEvent<D>) => void;
/**
* external data adapter.
* Will use the external data adapter from the context if not provided.
*/
fromExternalData?: fromExternalData<D>;
/**
* Make the drop target allow external data.
* If this is undefined, it will be set to true if fromExternalData is provided.
*
* @default undefined
*/
allowExternal?: boolean;
}
function monitorGet<D extends DNDData, T>(
get: T,
options: MonitorOptions<D>
): T extends undefined
? undefined
: T extends MonitorGet<infer I>
? (args: MonitorGetFeedback<D>) => I
: never {
if (get === undefined) {
return undefined as any;
}
return ((args: MonitorGetFeedback<D>) => {
const adaptedArgs = getAdaptedEventArgs(args, options.fromExternalData);
return typeof get === 'function'
? (get as any)(adaptedArgs)
: {
...adaptedArgs,
...get,
};
}) as any;
}
export const useDndMonitor = <D extends DNDData = DNDData>(
getOptions: () => MonitorOptions<D> = () => ({}),
deps: any[] = []
) => {
const dropTargetContext = useContext(DNDContext);
const options = useMemo(() => {
const opts = getOptions();
const allowExternal = opts.allowExternal ?? !!opts.fromExternalData;
return {
...opts,
allowExternal,
fromExternalData: allowExternal
? (opts.fromExternalData ??
(dropTargetContext.fromExternalData as fromExternalData<D>))
: undefined,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, getOptions]);
const monitorOptions = useMemo(() => {
return {
canMonitor: monitorGet(options.canMonitor, options),
onDragStart: monitorGet(options.onDragStart, options),
onDrag: monitorGet(options.onDrag, options),
onDrop: monitorGet(options.onDrop, options),
onDropTargetChange: monitorGet(options.onDropTargetChange, options),
};
}, [options]);
useEffect(() => {
return monitorForElements(monitorOptions);
}, [monitorOptions]);
useEffect(() => {
if (!options.fromExternalData) {
return;
}
// @ts-expect-error external & element adapter types have some subtle differences
return monitorForExternal(monitorOptions);
}, [monitorOptions, options.fromExternalData]);
};
@@ -1,6 +1,8 @@
import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter'; import type { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter';
export type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
export interface DNDData< export interface DNDData<
Draggable extends Record<string, unknown> = Record<string, unknown>, Draggable extends Record<string, unknown> = Record<string, unknown>,
DropTarget extends Record<string, unknown> = Record<string, unknown>, DropTarget extends Record<string, unknown> = Record<string, unknown>,
@@ -1,4 +1,5 @@
export * from './observe-intersection'; export * from './observe-intersection';
export * from './observe-resize'; export * from './observe-resize';
export * from './shallow-equal';
export { startScopedViewTransition } from './view-transition'; export { startScopedViewTransition } from './view-transition';
export * from './with-unit'; export * from './with-unit';
@@ -0,0 +1,43 @@
// 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;
}
export const shallowUpdater =
<T>(newState: T) =>
(state: T | null): T => {
if (state && shallowEqual(state, newState)) {
return state;
}
return newState;
};
-3
View File
@@ -20,9 +20,6 @@
"@blocksuite/icons": "2.2.1", "@blocksuite/icons": "2.2.1",
"@capacitor/app": "^6.0.2", "@capacitor/app": "^6.0.2",
"@capacitor/browser": "^6.0.4", "@capacitor/browser": "^6.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dotlottie/player-component": "^2.7.12", "@dotlottie/player-component": "^2.7.12",
"@emotion/cache": "^11.14.0", "@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -23,7 +23,9 @@ export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Blocks }) => {
<div className="doc-title-container" data-testid="journal-title"> <div className="doc-title-container" data-testid="journal-title">
<span data-testid="date">{localizedJournalDate}</span> <span data-testid="date">{localizedJournalDate}</span>
{isTodayJournal ? ( {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> <span className={styles.titleDayTag}>{day}</span>
)} )}
@@ -8,7 +8,9 @@ export const JournalTodayButton = () => {
const journalHelper = useJournalRouteHelper(); const journalHelper = useJournalRouteHelper();
const onToday = useCallback(() => { const onToday = useCallback(() => {
journalHelper.openToday(); journalHelper.openToday({
replaceHistory: true,
});
}, [journalHelper]); }, [journalHelper]);
return ( return (
@@ -5,6 +5,7 @@ import {
JournalService, JournalService,
type MaybeDate, type MaybeDate,
} from '@affine/core/modules/journal'; } from '@affine/core/modules/journal';
import type { WorkbenchOpenOptions } from '@affine/core/modules/workbench/entities/workbench';
import { i18nTime } from '@affine/i18n'; import { i18nTime } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { useService, useServices } from '@toeverything/infra'; import { useService, useServices } from '@toeverything/infra';
@@ -60,9 +61,9 @@ export const useJournalRouteHelper = () => {
* open journal by date, create one if not exist * open journal by date, create one if not exist
*/ */
const openJournal = useCallback( const openJournal = useCallback(
(maybeDate: MaybeDate, newTab?: boolean) => { (maybeDate: MaybeDate, options?: WorkbenchOpenOptions) => {
const page = getJournalByDate(maybeDate); const page = getJournalByDate(maybeDate);
workbench.openDoc(page.id, { at: newTab ? 'new-tab' : 'active' }); workbench.openDoc(page.id, options);
track.$.navigationPanel.journal.navigate({ track.$.navigationPanel.journal.navigate({
to: 'journal', to: 'journal',
}); });
@@ -75,9 +76,9 @@ export const useJournalRouteHelper = () => {
* open today's journal * open today's journal
*/ */
const openToday = useCallback( const openToday = useCallback(
(newTab?: boolean) => { (options: WorkbenchOpenOptions) => {
const date = dayjs().format(JOURNAL_DATE_FORMAT); const date = dayjs().format(JOURNAL_DATE_FORMAT);
return openJournal(date, newTab); return openJournal(date, options);
}, },
[openJournal] [openJournal]
); );
@@ -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 ( return (
@@ -14,7 +14,6 @@ import {
CompatibleFavoriteItemsAdapter, CompatibleFavoriteItemsAdapter,
FavoriteService, FavoriteService,
} from '@affine/core/modules/favorite'; } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
@@ -65,19 +64,15 @@ export const PageOperationCell = ({
}: PageOperationCellProps) => { }: PageOperationCellProps) => {
const t = useI18n(); const t = useI18n();
const { const {
featureFlagService,
workspaceService, workspaceService,
compatibleFavoriteItemsAdapter: favAdapter, compatibleFavoriteItemsAdapter: favAdapter,
workbenchService, workbenchService,
} = useServices({ } = useServices({
FeatureFlagService,
WorkspaceService, WorkspaceService,
CompatibleFavoriteItemsAdapter, CompatibleFavoriteItemsAdapter,
WorkbenchService, WorkbenchService,
}); });
const enableSplitView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const currentWorkspace = workspaceService.workspace; const currentWorkspace = workspaceService.workspace;
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc')); const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const workbench = workbenchService.workbench; const workbench = workbenchService.workbench;
@@ -194,7 +189,7 @@ export const PageOperationCell = ({
<MenuItem onClick={onOpenInNewTab} prefixIcon={<OpenInNewIcon />}> <MenuItem onClick={onOpenInNewTab} prefixIcon={<OpenInNewIcon />}>
{t['com.affine.workbench.tab.page-menu-open']()} {t['com.affine.workbench.tab.page-menu-open']()}
</MenuItem> </MenuItem>
{BUILD_CONFIG.isElectron && enableSplitView ? ( {BUILD_CONFIG.isElectron ? (
<MenuItem onClick={onOpenInSplitView} prefixIcon={<SplitViewIcon />}> <MenuItem onClick={onOpenInSplitView} prefixIcon={<SplitViewIcon />}>
{t['com.affine.workbench.split-view.page-menu-open']()} {t['com.affine.workbench.split-view.page-menu-open']()}
</MenuItem> </MenuItem>
@@ -1,3 +1,4 @@
import { shallowEqual } from '@affine/component';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import type { Tag } from '@affine/env/filter'; import type { Tag } from '@affine/env/filter';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
@@ -34,7 +35,6 @@ import type {
TagListItemProps, TagListItemProps,
TagMeta, TagMeta,
} from './types'; } from './types';
import { shallowEqual } from './utils';
export const ItemGroupHeader = memo(function ItemGroupHeader< export const ItemGroupHeader = memo(function ItemGroupHeader<
T extends ListItem, T extends ListItem,
@@ -1,3 +1,4 @@
import { shallowEqual } from '@affine/component';
import { DEFAULT_SORT_KEY } from '@affine/env/constant'; import { DEFAULT_SORT_KEY } from '@affine/env/constant';
import { atom } from 'jotai'; import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils'; import { selectAtom } from 'jotai/utils';
@@ -10,7 +11,6 @@ import type {
MetaRecord, MetaRecord,
VirtualizedListProps, VirtualizedListProps,
} from './types'; } from './types';
import { shallowEqual } from './utils';
// for ease of use in the component tree // for ease of use in the component tree
// note: must use selectAtom to access this atom for efficiency // note: must use selectAtom to access this atom for efficiency
@@ -56,38 +56,3 @@ export const betweenDaysAgo = (
): boolean => { ): boolean => {
return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1); 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;
}
@@ -3,7 +3,6 @@ import { Menu, MenuItem, usePromptModal } from '@affine/component';
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info'; import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import type { Collection } from '@affine/env/filter'; import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
@@ -36,21 +35,16 @@ export const CollectionOperations = ({
const { const {
collectionService: service, collectionService: service,
workbenchService, workbenchService,
featureFlagService,
workspaceDialogService, workspaceDialogService,
} = useServices({ } = useServices({
CollectionService, CollectionService,
WorkbenchService, WorkbenchService,
FeatureFlagService,
WorkspaceDialogService, WorkspaceDialogService,
}); });
const deleteInfo = useDeleteCollectionInfo(); const deleteInfo = useDeleteCollectionInfo();
const workbench = workbenchService.workbench; const workbench = workbenchService.workbench;
const t = useI18n(); const t = useI18n();
const { openPromptModal } = usePromptModal(); const { openPromptModal } = usePromptModal();
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const showEditName = useCallback(() => { const showEditName = useCallback(() => {
// use openRenameModal if it is in the sidebar collection list // 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'](), name: t['com.affine.workbench.tab.page-menu-open'](),
click: openCollectionNewTab, click: openCollectionNewTab,
}, },
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron
? [ ? [
{ {
icon: <SplitViewIcon />, icon: <SplitViewIcon />,
@@ -172,7 +166,6 @@ export const CollectionOperations = ({
}, },
], ],
[ [
enableMultiView,
t, t,
showEditName, showEditName,
showEdit, showEdit,
@@ -1,32 +1,19 @@
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook'; import { MenuLinkItem } from '@affine/core/modules/app-sidebar/views';
import {
useJournalInfoHelper,
useJournalRouteHelper,
} from '@affine/core/components/hooks/use-journal';
import { MenuItem } from '@affine/core/modules/app-sidebar/views';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { isNewTabTrigger } from '@affine/core/utils';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { TodayIcon } from '@blocksuite/icons/rc'; import { TodayIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { type MouseEvent } from 'react';
export const AppSidebarJournalButton = () => { export const AppSidebarJournalButton = () => {
const t = useI18n(); const t = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService); const docDisplayMetaService = useService(DocDisplayMetaService);
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$); const location = useLiveData(workbench.location$);
const { openToday } = useJournalRouteHelper();
const maybeDocId = location.pathname.split('/')[1]; const maybeDocId = location.pathname.split('/')[1];
const { isJournal } = useJournalInfoHelper(maybeDocId); const isJournal = !!useLiveData(journalService.journalDate$(maybeDocId));
const handleOpenToday = useCatchEventCallback(
(e: MouseEvent) => {
openToday(isNewTabTrigger(e));
},
[openToday]
);
const JournalIcon = useLiveData( const JournalIcon = useLiveData(
docDisplayMetaService.icon$(maybeDocId, { docDisplayMetaService.icon$(maybeDocId, {
@@ -36,14 +23,13 @@ export const AppSidebarJournalButton = () => {
const Icon = isJournal ? JournalIcon : TodayIcon; const Icon = isJournal ? JournalIcon : TodayIcon;
return ( return (
<MenuItem <MenuLinkItem
data-testid="slider-bar-journals-button" data-testid="slider-bar-journals-button"
active={isJournal} active={isJournal}
onClick={handleOpenToday} to={'/journals'}
onAuxClick={handleOpenToday}
icon={<Icon />} icon={<Icon />}
> >
{t['com.affine.journal.app-sidebar-title']()} {t['com.affine.journal.app-sidebar-title']()}
</MenuItem> </MenuLinkItem>
); );
}; };
@@ -78,27 +78,25 @@ export const mainContainerStyle = style({
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flex: 1, flex: 1,
overflow: 'clip',
maxWidth: '100%', maxWidth: '100%',
selectors: { selectors: {
'&[data-client-border="true"]': { '&[data-client-border="true"]': {
borderRadius: 6, borderRadius: 6,
margin: '8px', padding: '8px',
overflow: 'clip',
'@media': { '@media': {
print: { print: {
overflow: 'visible', overflow: 'visible',
margin: '0px', padding: '0px',
borderRadius: '0px', borderRadius: '0px',
}, },
}, },
}, },
'&[data-client-border="true"][data-side-bar-open="true"]': { '&[data-client-border="true"][data-side-bar-open="true"]': {
marginLeft: 0, paddingLeft: 0,
}, },
'&[data-client-border="true"][data-is-desktop="true"]': { '&[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"]': '&[data-client-border="false"][data-is-desktop="true"][data-side-bar-open="true"]':
{ {
@@ -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;
};
@@ -33,6 +33,10 @@ export const workbenchRoutes = [
path: '/:pageId/attachments/:attachmentId', path: '/:pageId/attachments/:attachmentId',
lazy: () => import('./pages/workspace/attachment/index'), lazy: () => import('./pages/workspace/attachment/index'),
}, },
{
path: '/journals',
lazy: () => import('./pages/journals'),
},
{ {
path: '*', path: '*',
lazy: () => import('./pages/404'), lazy: () => import('./pages/404'),
@@ -11,7 +11,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CollectionService } from '@affine/core/modules/collection'; import { CollectionService } from '@affine/core/modules/collection';
import type { NodeOperation } from '@affine/core/modules/explorer'; import type { NodeOperation } from '@affine/core/modules/explorer';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
@@ -161,10 +160,6 @@ export const useExplorerCollectionNodeOperationsMenu = (
onOpenEdit: () => void onOpenEdit: () => void
): NodeOperation[] => { ): NodeOperation[] => {
const t = useI18n(); const t = useI18n();
const { featureFlagService } = useServices({ FeatureFlagService });
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const { const {
favorite, favorite,
@@ -246,7 +241,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
</MenuItem> </MenuItem>
), ),
}, },
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron
? [ ? [
{ {
index: 99, index: 99,
@@ -279,7 +274,6 @@ export const useExplorerCollectionNodeOperationsMenu = (
}, },
], ],
[ [
enableMultiView,
favorite, favorite,
handleAddDocToCollection, handleAddDocToCollection,
handleDeleteCollection, handleDeleteCollection,
@@ -13,7 +13,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { DocsService } from '@affine/core/modules/doc'; import { DocsService } from '@affine/core/modules/doc';
import type { NodeOperation } from '@affine/core/modules/explorer'; import type { NodeOperation } from '@affine/core/modules/explorer';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { preventDefault } from '@affine/core/utils'; import { preventDefault } from '@affine/core/utils';
@@ -171,7 +170,6 @@ export const useExplorerDocNodeOperationsMenu = (
} }
): NodeOperation[] => { ): NodeOperation[] => {
const t = useI18n(); const t = useI18n();
const featureFlagService = useService(FeatureFlagService);
const { const {
favorite, favorite,
handleAddLinkedPage, handleAddLinkedPage,
@@ -186,9 +184,6 @@ export const useExplorerDocNodeOperationsMenu = (
const docService = useService(DocsService); const docService = useService(DocsService);
const docRecord = useLiveData(docService.list.doc$(docId)); const docRecord = useLiveData(docService.list.doc$(docId));
const title = useLiveData(docRecord?.title$); const title = useLiveData(docRecord?.title$);
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
return useMemo( return useMemo(
() => [ () => [
@@ -258,7 +253,7 @@ export const useExplorerDocNodeOperationsMenu = (
</MenuItem> </MenuItem>
), ),
}, },
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron
? [ ? [
{ {
index: 100, index: 100,
@@ -305,7 +300,6 @@ export const useExplorerDocNodeOperationsMenu = (
], ],
[ [
docId, docId,
enableMultiView,
favorite, favorite,
handleAddLinkedPage, handleAddLinkedPage,
handleDuplicate, handleDuplicate,
@@ -11,7 +11,6 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc'; import { DocsService } from '@affine/core/modules/doc';
import type { NodeOperation } from '@affine/core/modules/explorer'; import type { NodeOperation } from '@affine/core/modules/explorer';
import { FavoriteService } from '@affine/core/modules/favorite'; import { FavoriteService } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalCacheService } from '@affine/core/modules/storage'; import { GlobalCacheService } from '@affine/core/modules/storage';
import { TagService } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
@@ -24,7 +23,7 @@ import {
PlusIcon, PlusIcon,
SplitViewIcon, SplitViewIcon,
} from '@blocksuite/icons/rc'; } from '@blocksuite/icons/rc';
import { useLiveData, useService, useServices } from '@toeverything/infra'; import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { TagRenameSubMenu } from './dialog'; import { TagRenameSubMenu } from './dialog';
@@ -219,10 +218,6 @@ export const useExplorerTagNodeOperationsMenu = (
} }
): NodeOperation[] => { ): NodeOperation[] => {
const t = useI18n(); const t = useI18n();
const featureFlagService = useService(FeatureFlagService);
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const { const {
favorite, favorite,
handleNewDoc, handleNewDoc,
@@ -266,7 +261,7 @@ export const useExplorerTagNodeOperationsMenu = (
</MenuItem> </MenuItem>
), ),
}, },
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron
? [ ? [
{ {
index: 100, index: 100,
@@ -312,7 +307,6 @@ export const useExplorerTagNodeOperationsMenu = (
}, },
], ],
[ [
enableMultiView,
favorite, favorite,
handleChangeNameOrColor, handleChangeNameOrColor,
handleMoveToTrash, handleMoveToTrash,
@@ -1,8 +1,9 @@
import { Skeleton } from '@affine/component'; import { type DropTargetGetFeedback, Skeleton } from '@affine/component';
import { ResizePanel } from '@affine/component/resize-panel'; import { ResizePanel } from '@affine/component/resize-panel';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper'; import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper'; import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper';
import { WorkspaceNavigator } from '@affine/core/components/workspace-selector'; import { WorkspaceNavigator } from '@affine/core/components/workspace-selector';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { import {
useLiveData, useLiveData,
@@ -14,6 +15,8 @@ import { debounce } from 'lodash-es';
import type { PropsWithChildren, ReactElement } from 'react'; import type { PropsWithChildren, ReactElement } from 'react';
import { useCallback, useContext, useEffect, useMemo, useState } 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 { WorkspaceService } from '../../workspace';
import { AppSidebarService } from '../services/app-sidebar'; import { AppSidebarService } from '../services/app-sidebar';
import * as styles from './fallback.css'; import * as styles from './fallback.css';
@@ -44,6 +47,7 @@ export function AppSidebar({ children }: PropsWithChildren) {
const clientBorder = appSettings.clientBorder; const clientBorder = appSettings.clientBorder;
const appSidebarService = useService(AppSidebarService).sidebar; const appSidebarService = useService(AppSidebarService).sidebar;
const workbenchService = useService(WorkbenchService).workbench;
const open = useLiveData(appSidebarService.open$); const open = useLiveData(appSidebarService.open$);
const width = useLiveData(appSidebarService.width$); const width = useLiveData(appSidebarService.width$);
@@ -147,6 +151,31 @@ export function AppSidebar({ children }: PropsWithChildren) {
}; };
}, [appSidebarService, resizing, sidebarState, width]); }, [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) { if (!initialized) {
return null; return null;
} }
@@ -154,6 +183,7 @@ export function AppSidebar({ children }: PropsWithChildren) {
return ( return (
<> <>
<ResizePanel <ResizePanel
resizeHandleDropTargetOptions={resizeHandleDropTargetOptions}
floating={ floating={
sidebarState === 'floating' || sidebarState === 'floating-with-mask' sidebarState === 'floating' || sidebarState === 'floating-with-mask'
} }
@@ -26,6 +26,7 @@ import {
type MouseEventHandler, type MouseEventHandler,
type ReactNode, type ReactNode,
useEffect, useEffect,
useMemo,
useState, useState,
} from 'react'; } from 'react';
@@ -62,71 +63,24 @@ const tabCanDrop =
return false; return false;
}; };
const WorkbenchTab = ({ const WorkbenchView = ({
workbench, workbench,
active: tabActive, view,
activeViewIndex,
tabsLength, tabsLength,
dnd, viewIdx,
onDrop, tabActive,
}: { }: {
workbench: TabStatus; workbench: TabStatus;
active: boolean; view: TabStatus['views'][number];
activeViewIndex: number;
tabsLength: number; tabsLength: number;
viewIdx: number;
tabActive: boolean;
dnd?: boolean; dnd?: boolean;
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
}) => { }) => {
useServiceOptional(DesktopStateSynchronizer);
const tabsHeaderService = useService(AppTabsHeaderService); 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( const onActivateView = useAsyncCallback(
async (viewIdx: number) => { async (viewIdx: number) => {
if (viewIdx === activeViewIndex && tabActive) { if (viewIdx === activeViewIndex && tabActive) {
@@ -147,6 +101,15 @@ const WorkbenchTab = ({
}, },
[activeViewIndex, tabActive, tabsHeaderService, workbench.id] [activeViewIndex, tabActive, tabsHeaderService, workbench.id]
); );
const handleClick: MouseEventHandler = useCatchEventCallback(
async e => {
e.stopPropagation();
onActivateView(viewIdx);
},
[onActivateView, viewIdx]
);
const handleAuxClick: MouseEventHandler = useCatchEventCallback( const handleAuxClick: MouseEventHandler = useCatchEventCallback(
async e => { async e => {
if (e.button === 1) { if (e.button === 1) {
@@ -160,6 +123,106 @@ const WorkbenchTab = ({
[tabsHeaderService, workbench.id] [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 () => { const handleCloseTab = useCatchEventCallback(async () => {
await tabsHeaderService.closeTab?.(workbench.id); await tabsHeaderService.closeTab?.(workbench.id);
track.$.appTabsHeader.$.tabAction({ track.$.appTabsHeader.$.tabAction({
@@ -175,11 +238,11 @@ const WorkbenchTab = ({
}, },
onDrop, onDrop,
dropEffect: 'move', dropEffect: 'move',
canDrop: tabCanDrop(workbench), canDrop: dnd ? tabCanDrop(workbench) : false,
isSticky: true, isSticky: true,
allowExternal: true, allowExternal: true,
}), }),
[onDrop, workbench] [dnd, onDrop, workbench]
); );
const { dragRef } = useDraggable<AffineDNDData>(() => { const { dragRef } = useDraggable<AffineDNDData>(() => {
@@ -251,37 +314,15 @@ const WorkbenchTab = ({
{workbench.views.map((view, viewIdx) => { {workbench.views.map((view, viewIdx) => {
return ( return (
<Fragment key={view.id}> <Fragment key={view.id}>
<button <WorkbenchView
key={view.id} workbench={workbench}
data-testid="split-view-label" view={view}
className={styles.splitViewLabel} activeViewIndex={activeViewIndex}
data-active={activeViewIndex === viewIdx && tabActive} tabsLength={workbench.views.length}
onContextMenu={() => { viewIdx={viewIdx}
onContextMenu(viewIdx); tabActive={tabActive}
}} dnd={dnd}
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>
{viewIdx !== workbench.views.length - 1 ? ( {viewIdx !== workbench.views.length - 1 ? (
<div className={styles.splitViewSeparator} /> <div className={styles.splitViewSeparator} />
@@ -81,7 +81,7 @@ export class DndService extends Service {
isDropEvent?: boolean isDropEvent?: boolean
) => { ) => {
if (!isDropEvent) { if (!isDropEvent) {
return {}; return this.resolveBlocksuiteExternalData(args.source) || {};
} }
let resolved: AffineDNDData['draggable'] | null = null; let resolved: AffineDNDData['draggable'] | null = null;
@@ -168,24 +168,30 @@ export class DndService extends Service {
if (!dndAPI) { if (!dndAPI) {
return null; return null;
} }
const encoded = source.getStringData(dndAPI.mimeType);
if (!encoded) { if (source.types.includes(dndAPI.mimeType)) {
return null; const from = {
}
const snapshot = dndAPI.decodeSnapshot(encoded);
if (!snapshot) {
return null;
}
const entity = this.resolveBlockSnapshot(snapshot);
if (!entity) {
return null;
}
return {
entity,
from: {
at: 'blocksuite-editor', 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 => { private readonly resolveHTML: EntityResolver = html => {
@@ -9,7 +9,6 @@ import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/us
import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CollectionService } from '@affine/core/modules/collection'; import { CollectionService } from '@affine/core/modules/collection';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
@@ -37,13 +36,11 @@ export const useExplorerCollectionNodeOperations = (
workspaceService, workspaceService,
collectionService, collectionService,
compatibleFavoriteItemsAdapter, compatibleFavoriteItemsAdapter,
featureFlagService,
} = useServices({ } = useServices({
WorkbenchService, WorkbenchService,
WorkspaceService, WorkspaceService,
CollectionService, CollectionService,
CompatibleFavoriteItemsAdapter, CompatibleFavoriteItemsAdapter,
FeatureFlagService,
}); });
const deleteInfo = useDeleteCollectionInfo(); const deleteInfo = useDeleteCollectionInfo();
@@ -51,9 +48,6 @@ export const useExplorerCollectionNodeOperations = (
workspaceService.workspace.docCollection workspaceService.workspace.docCollection
); );
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const favorite = useLiveData( const favorite = useLiveData(
useMemo( useMemo(
() => () =>
@@ -173,7 +167,7 @@ export const useExplorerCollectionNodeOperations = (
</MenuItem> </MenuItem>
), ),
}, },
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron
? [ ? [
{ {
index: 99, index: 99,
@@ -206,7 +200,6 @@ export const useExplorerCollectionNodeOperations = (
}, },
], ],
[ [
enableMultiView,
favorite, favorite,
handleAddDocToCollection, handleAddDocToCollection,
handleDeleteCollection, handleDeleteCollection,
@@ -11,7 +11,6 @@ import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hoo
import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { DocsService } from '@affine/core/modules/doc'; import { DocsService } from '@affine/core/modules/doc';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
@@ -43,17 +42,12 @@ export const useExplorerDocNodeOperations = (
workspaceService, workspaceService,
docsService, docsService,
compatibleFavoriteItemsAdapter, compatibleFavoriteItemsAdapter,
featureFlagService,
} = useServices({ } = useServices({
DocsService, DocsService,
WorkbenchService, WorkbenchService,
WorkspaceService, WorkspaceService,
CompatibleFavoriteItemsAdapter, CompatibleFavoriteItemsAdapter,
FeatureFlagService,
}); });
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const docRecord = useLiveData(docsService.list.doc$(docId)); const docRecord = useLiveData(docsService.list.doc$(docId));
@@ -188,7 +182,7 @@ export const useExplorerDocNodeOperations = (
</MenuItem> </MenuItem>
), ),
}, },
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron
? [ ? [
{ {
index: 100, index: 100,
@@ -234,7 +228,6 @@ export const useExplorerDocNodeOperations = (
}, },
], ],
[ [
enableMultiView,
favorite, favorite,
handleAddLinkedPage, handleAddLinkedPage,
handleDuplicate, handleDuplicate,
@@ -3,7 +3,6 @@ import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-pa
import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { DocsService } from '@affine/core/modules/doc'; import { DocsService } from '@affine/core/modules/doc';
import { FavoriteService } from '@affine/core/modules/favorite'; import { FavoriteService } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { TagService } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace'; import { WorkspaceService } from '@affine/core/modules/workspace';
@@ -29,28 +28,19 @@ export const useExplorerTagNodeOperations = (
} }
): NodeOperation[] => { ): NodeOperation[] => {
const t = useI18n(); const t = useI18n();
const { const { workbenchService, workspaceService, tagService, favoriteService } =
workbenchService, useServices({
workspaceService, WorkbenchService,
tagService, WorkspaceService,
favoriteService, TagService,
featureFlagService, DocsService,
} = useServices({ FavoriteService,
WorkbenchService, });
WorkspaceService,
TagService,
DocsService,
FavoriteService,
FeatureFlagService,
});
const favorite = useLiveData( const favorite = useLiveData(
favoriteService.favoriteList.favorite$('tag', tagId) favoriteService.favoriteList.favorite$('tag', tagId)
); );
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId)); const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const { createPage } = usePageHelper( const { createPage } = usePageHelper(
workspaceService.workspace.docCollection workspaceService.workspace.docCollection
@@ -115,7 +105,7 @@ export const useExplorerTagNodeOperations = (
</MenuItem> </MenuItem>
), ),
}, },
...(BUILD_CONFIG.isElectron && enableMultiView ...(BUILD_CONFIG.isElectron
? [ ? [
{ {
index: 100, index: 100,
@@ -161,7 +151,6 @@ export const useExplorerTagNodeOperations = (
}, },
], ],
[ [
enableMultiView,
favorite, favorite,
handleMoveToTrash, handleMoveToTrash,
handleNewDoc, handleNewDoc,
@@ -103,18 +103,6 @@ export const AFFINE_FLAGS = {
configurable: false, configurable: false,
defaultState: true, 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: { enable_emoji_folder_icon: {
category: 'affine', category: 'affine',
displayName: displayName:
@@ -12,7 +12,7 @@ import { View } from './view';
export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number; export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number;
type WorkbenchOpenOptions = { export type WorkbenchOpenOptions = {
at?: WorkbenchPosition | 'new-tab'; at?: WorkbenchPosition | 'new-tab';
replaceHistory?: boolean; replaceHistory?: boolean;
show?: boolean; // only for new tab show?: boolean; // only for new tab
@@ -52,16 +52,24 @@ export class Workbench extends Entity {
}); });
sidebarOpen$ = new LiveData(false); sidebarOpen$ = new LiveData(false);
active(index: number) { active(index: number | View) {
index = Math.max(0, Math.min(index, this.views$.value.length - 1)); if (typeof index === 'number') {
this.activeViewIndex$.next(index); 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) { updateBasename(basename: string) {
this.basename$.next(basename); this.basename$.next(basename);
} }
createView(at: WorkbenchPosition = 'beside', defaultLocation: To) { createView(
at: WorkbenchPosition = 'beside',
defaultLocation: To,
active = true
) {
const view = this.framework.createEntity(View, { const view = this.framework.createEntity(View, {
id: nanoid(), id: nanoid(),
defaultLocation, defaultLocation,
@@ -70,7 +78,9 @@ export class Workbench extends Entity {
newViews.splice(this.indexAt(at), 0, view); newViews.splice(this.indexAt(at), 0, view);
this.views$.next(newViews); this.views$.next(newViews);
const index = newViews.indexOf(view); const index = newViews.indexOf(view);
this.active(index); if (active) {
this.active(index);
}
return index; return index;
} }
@@ -95,7 +105,7 @@ export class Workbench extends Entity {
const { at = 'active', replaceHistory = false } = option; const { at = 'active', replaceHistory = false } = option;
let view = this.viewAt(at); let view = this.viewAt(at);
if (!view) { if (!view) {
const newIndex = this.createView(at, to); const newIndex = this.createView(at, to, option.show);
view = this.viewAt(newIndex); view = this.viewAt(newIndex);
if (!view) { if (!view) {
throw new Unreachable(); throw new Unreachable();
@@ -28,12 +28,9 @@ export class DesktopStateSynchronizer extends Service {
event.type === 'open-in-split-view' && event.type === 'open-in-split-view' &&
event.payload.tabId === appInfo?.viewId event.payload.tabId === appInfo?.viewId
) { ) {
const to = workbench.openAll({
event.payload.view?.path ??
workbench.activeView$.value?.location$.value;
workbench.open(to, {
at: 'beside', at: 'beside',
show: false,
}); });
} }
@@ -23,34 +23,57 @@ export const menuTrigger = style({
height: 0, height: 0,
pointerEvents: 'none', pointerEvents: 'none',
}); });
export const indicator = style({ export const indicator = style({
width: 29, padding: '6px 20px',
height: 15, height: 15,
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
gap: 3,
cursor: 'grab', cursor: 'grab',
['WebkitAppRegion' as string]: 'no-drag', ['WebkitAppRegion' as string]: 'no-drag',
color: cssVar('placeholderColor'), color: cssVar('placeholderColor'),
transition: 'all 0.2s',
selectors: { selectors: {
'&:hover, &:active, &[data-active="true"]': { '&:hover, &[data-active="true"], &[data-dragging="true"]': {
color: cssVar('brandColor'), color: cssVar('brandColor'),
}, },
}, '&[data-dragging="true"]': {
}); gap: 4,
},
export const indicatorInner = style({ },
width: 16, });
height: 3,
borderRadius: 10, export const indicatorDot = style({
backgroundColor: 'currentColor', width: 4,
transition: 'all 0.5s cubic-bezier(0.16, 1, 0.3, 1)', height: 4,
borderRadius: 2,
selectors: { backgroundColor: 'currentColor',
'[data-is-dragging="true"] &': { transition: 'all 0.2s',
width: 24, selectors: {
height: 2, [`${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,
}, },
}, },
}); });
@@ -4,41 +4,23 @@ import clsx from 'clsx';
import type { HTMLAttributes, MouseEventHandler } from 'react'; import type { HTMLAttributes, MouseEventHandler } from 'react';
import { forwardRef, memo, useCallback, useMemo, useState } from 'react'; import { forwardRef, memo, useCallback, useMemo, useState } from 'react';
import type { View } from '../../entities/view';
import * as styles from './indicator.css'; import * as styles from './indicator.css';
export interface SplitViewMenuProps extends HTMLAttributes<HTMLDivElement> { export interface SplitViewDragHandleProps
extends HTMLAttributes<HTMLDivElement> {
active?: boolean; active?: boolean;
dragging?: boolean;
open?: boolean; open?: boolean;
onOpenMenu?: () => void; onOpenMenu?: () => void;
setPressed: (v: boolean) => void;
} }
export const SplitViewMenuIndicator = memo( export const SplitViewDragHandle = memo(
forwardRef<HTMLDivElement, SplitViewMenuProps>( forwardRef<HTMLDivElement, SplitViewDragHandleProps>(
function SplitViewMenuIndicator( function SplitViewDragHandle(
{ { className, active, open, onOpenMenu, dragging, ...attrs },
className,
active,
open,
setPressed,
onOpenMenu,
...attrs
}: SplitViewMenuProps,
ref 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(() => { const onClick: MouseEventHandler = useCallback(() => {
!open && onOpenMenu?.(); !open && onOpenMenu?.();
}, [onOpenMenu, open]); }, [onOpenMenu, open]);
@@ -47,13 +29,16 @@ export const SplitViewMenuIndicator = memo(
<div <div
ref={ref} ref={ref}
data-active={active} data-active={active}
data-dragging={dragging}
data-testid="split-view-indicator" data-testid="split-view-indicator"
className={clsx(className, styles.indicator)} className={clsx(className, styles.indicator)}
onClick={onClick} onClick={onClick}
onMouseDown={onMouseDown}
{...attrs} {...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> </div>
); );
} }
@@ -61,64 +46,69 @@ export const SplitViewMenuIndicator = memo(
); );
interface SplitViewIndicatorProps extends HTMLAttributes<HTMLDivElement> { interface SplitViewIndicatorProps extends HTMLAttributes<HTMLDivElement> {
isDragging?: boolean; view: View;
isActive?: boolean; isActive?: boolean;
isDragging?: boolean;
menuItems?: React.ReactNode; menuItems?: React.ReactNode;
// import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities' is not allowed
listeners?: any;
setPressed?: (pressed: boolean) => void; setPressed?: (pressed: boolean) => void;
dragHandleRef?: React.RefObject<HTMLDivElement>;
} }
export const SplitViewIndicator = ({ export const SplitViewIndicator = memo(
isDragging, forwardRef<HTMLDivElement, SplitViewIndicatorProps>(
isActive, function SplitViewIndicator(
menuItems, { isActive, menuItems, isDragging, dragHandleRef },
listeners, ref
setPressed, ) {
}: SplitViewIndicatorProps) => { const [menuOpen, setMenuOpen] = useState(false);
const active = isActive || isDragging;
const [menuOpen, setMenuOpen] = useState(false);
// prevent menu from opening when dragging // prevent menu from opening when dragging
const setOpenMenuManually = useCallback((open: boolean) => { const setOpenMenuManually = useCallback((open: boolean) => {
if (open) return; if (open) return;
setMenuOpen(open); setMenuOpen(open);
}, []); }, []);
const openMenu = useCallback(() => {
setMenuOpen(true);
}, []);
const menuRootOptions = useMemo( const openMenu = useCallback(() => {
() => setMenuOpen(true);
({ }, []);
open: menuOpen,
onOpenChange: setOpenMenuManually,
}) satisfies MenuProps['rootOptions'],
[menuOpen, setOpenMenuManually]
);
const menuContentOptions = useMemo(
() =>
({
align: 'center',
}) satisfies MenuProps['contentOptions'],
[]
);
return ( const menuRootOptions = useMemo(
<div data-is-dragging={isDragging} className={styles.indicatorWrapper}> () =>
<Menu ({
contentOptions={menuContentOptions} open: menuOpen,
items={menuItems} onOpenChange: setOpenMenuManually,
rootOptions={menuRootOptions} }) satisfies MenuProps['rootOptions'],
> [menuOpen, setOpenMenuManually]
<div className={styles.menuTrigger} /> );
</Menu> const menuContentOptions = useMemo(
<SplitViewMenuIndicator () =>
open={menuOpen} ({
onOpenMenu={openMenu} align: 'center',
active={active} }) satisfies MenuProps['contentOptions'],
setPressed={setPressed} []
{...listeners} );
/>
</div> 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>
);
}
)
);
@@ -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 { useI18n } from '@affine/i18n';
import { import {
ExpandCloseIcon, CloseIcon,
MoveToLeftDuotoneIcon, ExpandFullIcon,
MoveToRightDuotoneIcon, InsertLeftIcon,
SoloViewIcon, InsertRightIcon,
} from '@blocksuite/icons/rc'; } from '@blocksuite/icons/rc';
import { useSortable } from '@dnd-kit/sortable';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import type { import { useAtom } from 'jotai';
Dispatch, import type { HTMLAttributes, PropsWithChildren } from 'react';
HTMLAttributes, import { memo, useCallback, useMemo } from 'react';
PropsWithChildren,
RefObject,
SetStateAction,
} from 'react';
import {
memo,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { View } from '../../entities/view'; import type { View } from '../../entities/view';
import { WorkbenchService } from '../../services/workbench'; import { WorkbenchService } from '../../services/workbench';
import { SplitViewIndicator } from './indicator'; import { SplitViewIndicator } from './indicator';
import { ResizeHandle } from './resize-handle';
import * as styles from './split-view.css'; import * as styles from './split-view.css';
import {
draggingOverViewAtom,
draggingViewAtom,
resizingViewAtom,
} from './state';
import { allowedSplitViewEntityTypes } from './types';
export interface SplitViewPanelProps export interface SplitViewPanelProps
extends PropsWithChildren<HTMLAttributes<HTMLDivElement>> { extends PropsWithChildren<HTMLAttributes<HTMLDivElement>> {
view: View; view: View;
index: number;
resizeHandle?: React.ReactNode; resizeHandle?: React.ReactNode;
setSlots?: Dispatch< onMove: (from: number, to: number) => void;
SetStateAction<Record<string, RefObject<HTMLDivElement | null>>> onResizing: (dxy: { x: number; y: number }) => void;
>; draggingEntity: boolean;
} }
export const SplitViewPanelContainer = ({ 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({ export const SplitViewPanel = memo(function SplitViewPanel({
children, children,
view, view,
setSlots, onMove,
onResizing,
draggingEntity,
index,
}: SplitViewPanelProps) { }: SplitViewPanelProps) {
const [indicatorPressed, setIndicatorPressed] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const size = useLiveData(view.size$); const size = useLiveData(view.size$);
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const activeView = useLiveData(workbench.activeView$); const activeView = useLiveData(workbench.activeView$);
const views = useLiveData(workbench.views$); 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; const isActive = activeView === view;
useLayoutEffect(() => { const [draggingView, setDraggingView] = useAtom(draggingViewAtom);
if (ref.current) { const [draggingOverView, setDraggingOverView] = useAtom(draggingOverViewAtom);
setSlots?.(slots => ({ ...slots, [view.id]: ref })); const [resizingView, setResizingView] = useAtom(resizingViewAtom);
}
}, [setSlots, view.id]);
const style = useMemo( const order = useMemo(
() => ({ () =>
...assignInlineVars({ '--size': size.toString() }), calculateOrder(
}), index,
[size] draggingView?.index ?? -1,
); draggingOverView?.index ?? -1
const dragStyle = useMemo( ),
() => ({ [index, draggingView, draggingOverView]
transform: `translate3d(${transform?.x ?? 0}px, 0, 0)`,
transition,
}),
[transform, transition]
); );
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 ( return (
<SplitViewPanelContainer <SplitViewPanelContainer
style={style} style={style}
data-is-dragging={isDragging} data-is-dragging={dragging}
data-is-active={isActive && views.length > 1} data-is-active={isActive && views.length > 1}
data-is-first={isFirst}
data-is-last={isLast} 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 <div
ref={setNodeRef} ref={node => {
style={dragStyle} dropTargetRef.current = node;
dragRef.current = node;
}}
className={styles.splitViewPanelDrag} className={styles.splitViewPanelDrag}
{...attributes}
> >
<div className={styles.splitViewPanelContent} ref={ref} /> <div draggable={false} className={styles.splitViewPanelContent}>
{views.length > 1 ? ( {children}
</div>
{views.length > 1 && onMove ? (
<SplitViewIndicator <SplitViewIndicator
listeners={listeners} view={view}
isDragging={isDragging}
isActive={isActive} isActive={isActive}
menuItems={<SplitViewMenu view={view} />} isDragging={dragging}
setPressed={setIndicatorPressed} dragHandleRef={dragHandleRef}
menuItems={<SplitViewMenu view={view} onMove={onMove} />}
/> />
) : null} ) : null}
</div> </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> </SplitViewPanelContainer>
); );
}); });
const SplitViewMenu = ({ view }: { view: View }) => { const SplitViewMenu = ({
view,
onMove,
}: {
view: View;
onMove: (from: number, to: number) => void;
}) => {
const t = useI18n(); const t = useI18n();
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const views = useLiveData(workbench.views$); const views = useLiveData(workbench.views$);
@@ -136,42 +293,39 @@ const SplitViewMenu = ({ view }: { view: View }) => {
[view, workbench] [view, workbench]
); );
const handleMoveLeft = useCallback(() => { const handleMoveLeft = useCallback(() => {
workbench.moveView(viewIndex, viewIndex - 1); onMove(viewIndex, viewIndex - 1);
}, [viewIndex, workbench]); }, [onMove, viewIndex]);
const handleMoveRight = useCallback(() => { const handleMoveRight = useCallback(() => {
workbench.moveView(viewIndex, viewIndex + 1); onMove(viewIndex, viewIndex + 1);
}, [viewIndex, workbench]); }, [onMove, viewIndex]);
const handleCloseOthers = useCallback(() => { const handleCloseOthers = useCallback(() => {
workbench.closeOthers(view); workbench.closeOthers(view);
}, [view, workbench]); }, [view, workbench]);
const CloseItem = const CloseItem =
views.length > 1 ? ( views.length > 1 ? (
<MenuItem prefixIcon={<ExpandCloseIcon />} onClick={handleClose}> <MenuItem prefixIcon={<CloseIcon />} onClick={handleClose}>
{t['com.affine.workbench.split-view-menu.close']()} {t['com.affine.workbench.split-view-menu.close']()}
</MenuItem> </MenuItem>
) : null; ) : null;
const MoveLeftItem = const MoveLeftItem =
viewIndex > 0 && views.length > 1 ? ( viewIndex > 0 && views.length > 1 ? (
<MenuItem onClick={handleMoveLeft} prefixIcon={<MoveToLeftDuotoneIcon />}> <MenuItem onClick={handleMoveLeft} prefixIcon={<InsertRightIcon />}>
{t['com.affine.workbench.split-view-menu.move-left']()} {t['com.affine.workbench.split-view-menu.move-left']()}
</MenuItem> </MenuItem>
) : null; ) : null;
const FullScreenItem = const FullScreenItem =
views.length > 1 ? ( views.length > 1 ? (
<MenuItem onClick={handleCloseOthers} prefixIcon={<SoloViewIcon />}> <MenuItem onClick={handleCloseOthers} prefixIcon={<ExpandFullIcon />}>
{t['com.affine.workbench.split-view-menu.keep-this-one']()} {t['com.affine.workbench.split-view-menu.keep-this-one']()}
</MenuItem> </MenuItem>
) : null; ) : null;
const MoveRightItem = const MoveRightItem =
viewIndex < views.length - 1 ? ( viewIndex < views.length - 1 ? (
<MenuItem <MenuItem onClick={handleMoveRight} prefixIcon={<InsertLeftIcon />}>
onClick={handleMoveRight}
prefixIcon={<MoveToRightDuotoneIcon />}
>
{t['com.affine.workbench.split-view-menu.move-right']()} {t['com.affine.workbench.split-view-menu.move-right']()}
</MenuItem> </MenuItem>
) : null; ) : null;
@@ -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 type { HTMLAttributes } from 'react';
import { useCallback } 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 * as styles from './split-view.css';
import { draggingOverResizeHandleAtom } from './state';
import { allowedSplitViewEntityTypes } from './types';
interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> { interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {
resizing: boolean; state: 'resizing' | 'drop-indicator' | 'idle';
onResizeStart: () => void; edge: 'left' | 'right';
onResizeEnd: () => void; view: View;
onResizing: (offset: { x: number; y: number }) => void; onResizeStart?: () => void;
onResizeEnd?: () => void;
onResizing?: (offset: { x: number; y: number }) => void;
} }
export const ResizeHandle = ({ export const ResizeHandle = ({
resizing, state,
view,
edge,
onResizing, onResizing,
onResizeStart, onResizeStart,
onResizeEnd, onResizeEnd,
}: ResizeHandleProps) => { }: 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 // TODO(@catsjuice): touch support
const onMouseDown = useCallback( const onMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!onResizing || !onResizeStart || !onResizeEnd) {
return;
}
e.preventDefault(); e.preventDefault();
onResizeStart(); onResizeStart?.();
const prevPos = { x: e.clientX, y: e.clientY }; const prevPos = { x: e.clientX, y: e.clientY };
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
const dx = e.clientX - prevPos.x; const dx = e.clientX - prevPos.x;
const dy = e.clientY - prevPos.y; const dy = e.clientY - prevPos.y;
onResizing({ x: dx, y: dy }); onResizing?.({ x: dx, y: dy });
prevPos.x = e.clientX; prevPos.x = e.clientX;
prevPos.y = e.clientY; prevPos.y = e.clientY;
} }
function onMouseUp(e: MouseEvent) { function onMouseUp(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
onResizeEnd(); onResizeEnd?.();
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
} }
@@ -44,10 +90,21 @@ export const ResizeHandle = ({
[onResizeEnd, onResizeStart, onResizing] [onResizeEnd, onResizeStart, onResizing]
); );
const canResize =
state === 'idle' &&
!(isLast && edge === 'right') &&
!(isFirst && edge === 'left');
return ( return (
<div <div
ref={dropTargetRef}
data-edge={edge}
onMouseDown={onMouseDown} 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} className={styles.resizeHandle}
/> />
); );
@@ -1,55 +1,55 @@
import { cssVar } from '@toeverything/theme'; import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css'; import { createVar, fallbackVar, keyframes, style } from '@vanilla-extract/css';
const gap = createVar(); const gap = createVar();
const borderRadius = 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({ const expandDropIndicator = keyframes({
vars: { from: {
[gap]: '0px', vars: {
[borderRadius]: '0px', [resizeHandleWidth]: '30px',
}, [dropIndicatorWidth]: '3px',
display: 'flex', [dropIndicatorOpacity]: '1',
flexDirection: 'row', [dropIndicatorRadius]: '10px',
position: 'relative',
borderRadius,
gap,
selectors: {
'&[data-client-border="true"]': {
vars: {
[gap]: '8px',
[borderRadius]: '6px',
},
}, },
'&[data-orientation="vertical"]': { },
flexDirection: 'column', to: {
vars: {
[resizeHandleWidth]: '300px',
[dropIndicatorWidth]: '100%',
[dropIndicatorOpacity]: '0.15',
[dropIndicatorRadius]: '4px',
}, },
}, },
}); });
export const splitViewPanel = style({ export const splitViewPanel = style({
flexShrink: 0, flexShrink: 0,
flexGrow: 'var(--size, 1)', flexGrow: fallbackVar(size, '1'),
position: 'relative', position: 'relative',
borderRadius: 'inherit', order: panelOrder,
display: 'flex',
selectors: { selectors: {
'[data-orientation="vertical"] &': { '[data-client-border="false"] &[data-is-first="true"]': {
height: 0, borderTopLeftRadius: borderRadius,
},
'[data-orientation="horizontal"] &': {
width: 0,
}, },
'[data-client-border="false"] &:not([data-is-last="true"]):not([data-is-dragging="true"])': '[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"] &': { '[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%', width: '100%',
height: '100%', height: '100%',
borderRadius: 'inherit', borderRadius: 'inherit',
transition: 'opacity 0.2s',
selectors: { selectors: {
'&::after': { '&::after': {
@@ -73,8 +74,12 @@ export const splitViewPanelDrag = style({
transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)', transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
}, },
'[data-is-dragging="true"] &::after': { '[data-is-active="true"] &::after': {
boxShadow: `inset 0 0 0 2px ${cssVar('brandColor')}`, 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', position: 'absolute',
top: 0, top: 0,
bottom: 0, bottom: 0,
right: -5, width: resizeHandleWidth,
width: 10,
// to make sure it's above all-pages's header // to make sure it's above all-pages's header
zIndex: 3, zIndex: 5,
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'stretch', alignItems: 'stretch',
cursor: 'col-resize',
selectors: { selectors: {
'[data-client-border="true"] &': { '&[data-can-resize="false"]:not([data-state="drop-indicator"])': {
right: `calc(-5px - ${gap} / 2)`, 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"] &`]: { [`.${splitViewPanel}[data-is-dragging="true"] &`]: {
display: 'none', display: 'none',
}, },
// horizontal '&[data-state="drop-indicator"][data-dragging-over="true"]': {
'[data-orientation="horizontal"] &::before, [data-orientation="horizontal"] &::after': animationName: expandDropIndicator,
{ animationDuration: '0.5s',
content: '""', animationDelay: '1s',
width: 2, animationFillMode: 'forwards',
position: 'absolute', vars: {
height: '100%', [dropIndicatorOpacity]: '1',
background: 'transparent', [dropIndicatorWidth]: '3px',
transition: 'background 0.1s',
borderRadius: 10,
}, },
'[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, 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',
},
}, },
}); });
@@ -1,27 +1,18 @@
import { useDndMonitor } from '@affine/component';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper'; import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import type { DragEndEvent } from '@dnd-kit/core'; import type { AffineDNDData } from '@affine/core/types/dnd';
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import type { HTMLAttributes, RefObject } from 'react'; import { useSetAtom } from 'jotai';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { HTMLAttributes } from 'react';
import { createPortal } from 'react-dom'; import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { View } from '../../entities/view'; import type { View } from '../../entities/view';
import { WorkbenchService } from '../../services/workbench'; import { WorkbenchService } from '../../services/workbench';
import { SplitViewPanel } from './panel'; import { SplitViewPanel } from './panel';
import { ResizeHandle } from './resize-handle';
import * as styles from './split-view.css'; import * as styles from './split-view.css';
import { draggingOverResizeHandleAtom } from './state';
import { allowedSplitViewEntityTypes, inferToFromEntity } from './types';
export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> { export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
/** /**
@@ -30,12 +21,10 @@ export interface SplitViewProps extends HTMLAttributes<HTMLDivElement> {
*/ */
orientation?: 'horizontal' | 'vertical'; orientation?: 'horizontal' | 'vertical';
views: View[]; views: View[];
renderer: (item: View, index: number) => React.ReactNode; renderer: (item: View) => React.ReactNode;
onMove?: (from: number, to: number) => void; onMove?: (from: number, to: number) => void;
} }
type SlotsMap = Record<View['id'], RefObject<HTMLDivElement | null>>;
// TODO(@catsjuice): vertical orientation support
export const SplitView = ({ export const SplitView = ({
orientation = 'horizontal', orientation = 'horizontal',
className, className,
@@ -45,29 +34,27 @@ export const SplitView = ({
...attrs ...attrs
}: SplitViewProps) => { }: SplitViewProps) => {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const [slots, setSlots] = useState<SlotsMap>({});
const [resizingViewId, setResizingViewId] = useState<View['id'] | null>(null);
const { appSettings } = useAppSettingHelper(); const { appSettings } = useAppSettingHelper();
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
// blocksuite's lit host element has an issue on remounting. // workaround: blocksuite's lit host element has an issue on remounting.
// Add a workaround here to force remounting after dropping. // we do not want the view to change its render ordering here after reordering
const [visible, setVisibility] = useState(true); // instead we use a local state to store the views + its order to avoid remounting
const [localViewsState, setLocalViewsState] = useState<View[]>(views);
const sensors = useSensors( useLayoutEffect(() => {
useSensor( setLocalViewsState(oldViews => {
PointerSensor, let newViews = oldViews.filter(v => views.includes(v));
useMemo(
/* avoid re-rendering */ for (const view of views) {
() => ({ if (!newViews.includes(view)) {
activationConstraint: { newViews.push(view);
distance: 0, }
}, }
}),
[] return newViews;
) });
) }, [views]);
);
const onResizing = useCallback( const onResizing = useCallback(
(index: number, { x, y }: { x: number; y: number }) => { (index: number, { x, y }: { x: number; y: number }) => {
@@ -85,45 +72,101 @@ export const SplitView = ({
[orientation, workbench] [orientation, workbench]
); );
const resizeHandleRenderer = useCallback( const handleOnMove = useCallback(
(view: View, index: number) => (from: number, to: number) => {
index < views.length - 1 ? ( onMove?.(from, to);
<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);
}
}, },
[onMove, views] [onMove]
); );
useEffect(() => { const [draggingEntity, setDraggingEntity] = useState(false);
if (!visible) {
const timeoutId = setTimeout(() => {
setVisibility(true);
}, 0);
return () => { const setDraggingOverResizeHandle = useSetAtom(draggingOverResizeHandleAtom);
clearTimeout(timeoutId);
}; useDndMonitor<AffineDNDData>(() => {
} return {
return; // todo(@pengx17): external data for monitor is not supported yet
}, [visible]); // 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 ( return (
<div <div
@@ -133,29 +176,19 @@ export const SplitView = ({
data-client-border={appSettings.clientBorder} data-client-border={appSettings.clientBorder}
{...attrs} {...attrs}
> >
<DndContext {localViewsState.map(view => {
sensors={sensors} const order = views.indexOf(view);
collisionDetection={closestCenter} return (
onDragEnd={handleDragEnd} <SplitViewPanel
> view={view}
<SortableContext items={views} strategy={horizontalListSortingStrategy}> index={order}
{views.map((view, index) => key={view.id}
visible ? ( onMove={handleOnMove}
<SplitViewPanel view={view} key={view.id} setSlots={setSlots}> onResizing={dxy => onResizing(order, dxy)}
{resizeHandleRenderer(view, index)} draggingEntity={draggingEntity}
</SplitViewPanel> >
) : null {renderer(view)}
)} </SplitViewPanel>
</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}`
); );
})} })}
</div> </div>
@@ -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);
@@ -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;
};
@@ -1,10 +1,12 @@
import { useDraggable } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; 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 { isNewTabTrigger } from '@affine/core/utils';
import { useLiveData, useServices } from '@toeverything/infra'; import { useLiveData, useServices } from '@toeverything/infra';
import { type To } from 'history'; import { type To } from 'history';
import { forwardRef, type MouseEvent } from 'react'; import { forwardRef, type MouseEvent } from 'react';
import { FeatureFlagService } from '../../feature-flag'; import { resolveRouteLinkMeta } from '../../navigation/utils';
import { WorkbenchService } from '../services/workbench'; import { WorkbenchService } from '../services/workbench';
export type WorkbenchLinkProps = React.PropsWithChildren< export type WorkbenchLinkProps = React.PropsWithChildren<
@@ -15,20 +17,45 @@ export type WorkbenchLinkProps = React.PropsWithChildren<
} & React.HTMLProps<HTMLAnchorElement> } & 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>( export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
function WorkbenchLink({ to, onClick, replaceHistory, ...other }, ref) { function WorkbenchLink({ to, onClick, replaceHistory, ...other }, ref) {
const { featureFlagService, workbenchService } = useServices({ const { workbenchService } = useServices({
FeatureFlagService,
WorkbenchService, WorkbenchService,
}); });
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const workbench = workbenchService.workbench; const workbench = workbenchService.workbench;
const basename = useLiveData(workbench.basename$); const basename = useLiveData(workbench.basename$);
const link = const stringTo =
basename + typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`;
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`); const link = basename + stringTo;
const handleClick = useAsyncCallback( const handleClick = useAsyncCallback(
async (event: React.MouseEvent<HTMLAnchorElement>) => { async (event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event); onClick?.(event);
@@ -37,9 +64,7 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
} }
const at = (() => { const at = (() => {
if (isNewTabTrigger(event)) { if (isNewTabTrigger(event)) {
return BUILD_CONFIG.isElectron && event.altKey && enableMultiView return BUILD_CONFIG.isElectron && event.altKey ? 'tail' : 'new-tab';
? 'tail'
: 'new-tab';
} }
return 'active'; return 'active';
})(); })();
@@ -47,15 +72,32 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}, },
[enableMultiView, onClick, replaceHistory, to, workbench] [onClick, replaceHistory, to, workbench]
); );
// eslint suspicious runtime error const { dragRef } = useDraggable<AffineDNDData>(() => {
// eslint-disable-next-line react/no-danger-with-children return {
data: {
entity: resolveToEntity(to, basename),
from: {
at: 'workbench:link',
to: stringTo,
},
},
};
}, [to, basename, stringTo]);
return ( return (
<a <a
{...other} {...other}
ref={ref} ref={node => {
dragRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
href={link} href={link}
onClick={handleClick} onClick={handleClick}
onAuxClick={handleClick} onAuxClick={handleClick}
@@ -5,7 +5,6 @@ export const workbenchRootContainer = style({
display: 'flex', display: 'flex',
height: '100%', height: '100%',
flex: 1, flex: 1,
overflow: 'hidden',
}); });
export const workbenchViewContainer = style({ export const workbenchViewContainer = style({
@@ -48,8 +48,8 @@ export const WorkbenchRoot = memo(() => {
useAdapter(workbench, basename); useAdapter(workbench, basename);
const panelRenderer = useCallback((view: View, index: number) => { const panelRenderer = useCallback((view: View) => {
return <WorkbenchView key={view.id} view={view} index={index} />; return <WorkbenchView view={view} />;
}, []); }, []);
const onMove = useCallback( const onMove = useCallback(
@@ -78,12 +78,12 @@ export const WorkbenchRoot = memo(() => {
WorkbenchRoot.displayName = 'memo(WorkbenchRoot)'; WorkbenchRoot.displayName = 'memo(WorkbenchRoot)';
const WorkbenchView = ({ view, index }: { view: View; index: number }) => { const WorkbenchView = ({ view }: { view: View }) => {
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const handleOnFocus = useCallback(() => { const handleOnFocus = useCallback(() => {
workbench.active(index); workbench.active(view);
}, [workbench, index]); }, [workbench, view]);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -149,7 +149,7 @@ const WorkbenchSidebar = () => {
<ResizePanel <ResizePanel
floating={floating} floating={floating}
resizeHandlePos="left" resizeHandlePos="left"
resizeHandleOffset={0} resizeHandleOffset={clientBorder && sidebarOpen ? 3 : 0}
width={width} width={width}
resizing={resizing} resizing={resizing}
onResizing={setResizing} onResizing={setResizing}
+40 -21
View File
@@ -1,28 +1,30 @@
import type { DNDData } from '@affine/component'; 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 { export interface AffineDNDData extends DNDData {
draggable: { draggable: {
entity?: entity?: AffineDNDEntity;
| {
type: 'doc';
id: string;
}
| {
type: 'folder';
id: string;
}
| {
type: 'collection';
id: string;
}
| {
type: 'tag';
id: string;
}
| {
type: 'custom-property';
id: string;
};
from?: from?:
| { | {
at: 'explorer:organize:folder-node'; at: 'explorer:organize:folder-node';
@@ -79,6 +81,19 @@ export interface AffineDNDData extends DNDData {
at: 'doc-detail:header'; at: 'doc-detail:header';
docId: string; docId: string;
} }
| {
at: 'workbench:view';
viewId: string;
}
| {
at: 'workbench:link';
to: string;
}
| {
at: 'workbench:resize-handle';
viewId: string;
edge: 'left' | 'right';
}
| { | {
at: 'blocksuite-editor'; at: 'blocksuite-editor';
} }
@@ -114,5 +129,9 @@ export interface AffineDNDData extends DNDData {
| { | {
at: 'app-header:tabs'; at: 'app-header:tabs';
} }
| {
at: 'workbench:view';
viewId: string;
}
| Record<string, unknown>; | Record<string, unknown>;
} }
+115
View File
@@ -0,0 +1,115 @@
import { test } from '@affine-test/kit/electron';
import {
expectActiveTab,
expectTabTitle,
} from '@affine-test/kit/utils/app-tabs';
import {
clickNewPageButton,
createLinkedPage,
dragTo,
waitForAllPagesLoad,
} from '@affine-test/kit/utils/page-logic';
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
import { expect } from '@playwright/test';
test('open split view', async ({ 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', 'Alt'],
});
await expect(page.locator('.doc-title-container')).toHaveCount(2);
// check tab title
await expect(page.getByTestId('split-view-label')).toHaveCount(2);
await expectTabTitle(page, 0, ['Untitled', 'hi from another page']);
// the second split view should be active
await expectActiveTab(page, 0, 1);
// by clicking the first split view label, the first split view should be active
await page.getByTestId('split-view-label').nth(0).click();
await expectActiveTab(page, 0, 0);
await expect(page.getByTestId('split-view-indicator').nth(0)).toHaveAttribute(
'data-active',
'true'
);
const firstDragHandel = page
.getByTestId('split-view-panel')
.first()
.getByTestId('split-view-indicator');
await dragTo(
page,
firstDragHandel,
page.getByTestId('split-view-panel').last(),
'center',
true
);
await expectTabTitle(page, 0, ['hi from another page', 'Untitled']);
});
test('open split view in all docs (operations button)', async ({ page }) => {
const testTitle = 'test-page';
await clickNewPageButton(page, testTitle);
await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page);
await page
.getByTestId('page-list-item')
.filter({
hasText: testTitle,
})
.getByTestId('page-list-operation-button')
.click();
await page.getByRole('menuitem', { name: 'Open in Split View' }).click();
await expect(page.getByTestId('split-view-panel')).toHaveCount(2);
const targetPage = page.getByTestId('split-view-panel').last();
await expect(targetPage).toHaveAttribute('data-is-active', 'true');
await expect(targetPage.locator('.doc-title-container')).toBeVisible();
await expect(targetPage.locator('.doc-title-container')).toContainText(
testTitle
);
});
test('open split view in all docs (drag to resize handle)', async ({
page,
}) => {
const testTitle = 'test-page';
await clickNewPageButton(page, testTitle);
await clickSideBarAllPageButton(page);
await waitForAllPagesLoad(page);
// case for AF-2061. toggle selection checkbox
await page.getByTestId('page-list-header-selection-checkbox').click();
const pageItem = page.getByTestId('page-list-item').filter({
hasText: testTitle,
});
const leftResizeHandle = page.getByTestId('resize-handle').first();
await dragTo(page, pageItem, leftResizeHandle, 'center');
await expectTabTitle(page, 0, ['test-page', 'All docs']);
});
test('creating split view by dragging sidebar journals', async ({ page }) => {
const journalButton = page.getByTestId('slider-bar-journals-button');
const leftResizeHandle = page.getByTestId('resize-handle').first();
await dragTo(page, journalButton, leftResizeHandle, 'center');
await expect(page.getByTestId('split-view-panel')).toHaveCount(2);
await expect(
page
.getByTestId('split-view-panel')
.filter({
has: page.locator('[data-is-first="true"]'),
})
.getByTestId('date-today-label')
).toBeVisible();
});
+7 -75
View File
@@ -1,57 +1,17 @@
import { test } from '@affine-test/kit/electron'; import { test } from '@affine-test/kit/electron';
import {
closeTab,
expectActiveTab,
expectTabCount,
expectTabTitle,
} from '@affine-test/kit/utils/app-tabs';
import { import {
clickNewPageButton, clickNewPageButton,
createLinkedPage, createLinkedPage,
dragTo, dragTo,
} from '@affine-test/kit/utils/page-logic'; } from '@affine-test/kit/utils/page-logic';
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
import { expect, type Page } from '@playwright/test'; import { expect } 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();
@@ -148,34 +108,6 @@ test('open new tab via cmd+click page link', async ({ page }) => {
await expectActiveTab(page, 0); await expectActiveTab(page, 0);
}); });
test('open split view', async ({ 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', 'Alt'],
});
await expect(page.locator('.doc-title-container')).toHaveCount(2);
// check tab title
await expect(page.getByTestId('split-view-label')).toHaveCount(2);
await expectTabTitle(page, 0, ['Untitled', 'hi from another page']);
// the second split view should be active
await expectActiveTab(page, 0, 1);
// by clicking the first split view label, the first split view should be active
await page.getByTestId('split-view-label').nth(0).click();
await expectActiveTab(page, 0, 0);
await expect(page.getByTestId('split-view-indicator').nth(0)).toHaveAttribute(
'data-active',
'true'
);
});
test('drag a page from "All pages" list to tabs header', async ({ page }) => { test('drag a page from "All pages" list to tabs header', async ({ page }) => {
const title = 'this is a new page to drag'; const title = 'this is a new page to drag';
await clickNewPageButton(page, title); await clickNewPageButton(page, title);
+52
View File
@@ -0,0 +1,52 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export 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');
}
export 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]);
}
}
}
export async function expectTabCount(page: Page, count: number) {
await expect(page.getByTestId('workbench-tab')).toHaveCount(count);
}
export 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();
}
+15 -12
View File
@@ -139,7 +139,8 @@ export const dragTo = async (
page: Page, page: Page,
locator: Locator, locator: Locator,
target: Locator, target: Locator,
location: DragLocation = 'center' location: DragLocation = 'center',
willMoveOnDrag = false
) => { ) => {
await locator.hover(); await locator.hover();
const locatorElement = await locator.boundingBox(); const locatorElement = await locator.boundingBox();
@@ -164,18 +165,20 @@ export const dragTo = async (
await target.hover(); await target.hover();
const targetElement = await target.boundingBox(); if (!willMoveOnDrag) {
if (!targetElement) { const targetElement = await target.boundingBox();
throw new Error('target element not found'); if (!targetElement) {
} throw new Error('target element not found');
const targetPosition = toPosition(targetElement, location);
await page.mouse.move(
targetElement.x + targetPosition.x,
targetElement.y + targetPosition.y,
{
steps: 10,
} }
); const targetPosition = toPosition(targetElement, location);
await page.mouse.move(
targetElement.x + targetPosition.x,
targetElement.y + targetPosition.y,
{
steps: 10,
}
);
}
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.up(); await page.mouse.up();
}; };
-65
View File
@@ -361,9 +361,6 @@ __metadata:
"@blocksuite/icons": "npm:2.2.1" "@blocksuite/icons": "npm:2.2.1"
"@capacitor/app": "npm:^6.0.2" "@capacitor/app": "npm:^6.0.2"
"@capacitor/browser": "npm:^6.0.4" "@capacitor/browser": "npm:^6.0.4"
"@dnd-kit/core": "npm:^6.3.1"
"@dnd-kit/modifiers": "npm:^9.0.0"
"@dnd-kit/sortable": "npm:^10.0.0"
"@dotlottie/player-component": "npm:^2.7.12" "@dotlottie/player-component": "npm:^2.7.12"
"@emotion/cache": "npm:^11.14.0" "@emotion/cache": "npm:^11.14.0"
"@emotion/react": "npm:^11.14.0" "@emotion/react": "npm:^11.14.0"
@@ -4534,68 +4531,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@dnd-kit/accessibility@npm:^3.1.1":
version: 3.1.1
resolution: "@dnd-kit/accessibility@npm:3.1.1"
dependencies:
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
checksum: 10/961000456a36700a9cd13be51147a818bc100f7dfabb332b80438d02e06f3b556aa0ff46ddf13bdff3b70bc8f9b63dd5a392cc285597ab1f7026e672660c54b6
languageName: node
linkType: hard
"@dnd-kit/core@npm:^6.3.1":
version: 6.3.1
resolution: "@dnd-kit/core@npm:6.3.1"
dependencies:
"@dnd-kit/accessibility": "npm:^3.1.1"
"@dnd-kit/utilities": "npm:^3.2.2"
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 10/a5ae6fa8404765712aa80e308f58cb79bac9a306c274ec8272c405c2a59dd277d24b966348fe8ca6340bb3f0d75f90b8a021fa781edcf65255114d3cf2bef891
languageName: node
linkType: hard
"@dnd-kit/modifiers@npm:^9.0.0":
version: 9.0.0
resolution: "@dnd-kit/modifiers@npm:9.0.0"
dependencies:
"@dnd-kit/utilities": "npm:^3.2.2"
tslib: "npm:^2.0.0"
peerDependencies:
"@dnd-kit/core": ^6.3.0
react: ">=16.8.0"
checksum: 10/2ae238a1b787029e95d92319d7e4a0e2ffba8fceed56c4b58dfee7ed6890df207bf89ce522d4126411051121954222bd8e1444fae321485b594ae518c7c4397d
languageName: node
linkType: hard
"@dnd-kit/sortable@npm:^10.0.0":
version: 10.0.0
resolution: "@dnd-kit/sortable@npm:10.0.0"
dependencies:
"@dnd-kit/utilities": "npm:^3.2.2"
tslib: "npm:^2.0.0"
peerDependencies:
"@dnd-kit/core": ^6.3.0
react: ">=16.8.0"
checksum: 10/bc61c25e76905204a53f91294b8116bf106fa27247eebca2c66478450b2051d7177115a384054e7e5639e6c4430083ade63056f79ee45f549da537cf05bc5288
languageName: node
linkType: hard
"@dnd-kit/utilities@npm:^3.2.2":
version: 3.2.2
resolution: "@dnd-kit/utilities@npm:3.2.2"
dependencies:
tslib: "npm:^2.0.0"
peerDependencies:
react: ">=16.8.0"
checksum: 10/6cfe46a5fcdaced943982e7ae66b08b89235493e106eb5bc833737c25905e13375c6ecc3aa0c357d136cb21dae3966213dba063f19b7a60b1235a29a7b05ff84
languageName: node
linkType: hard
"@dotlottie/common@npm:0.7.11": "@dotlottie/common@npm:0.7.11":
version: 0.7.11 version: 0.7.11
resolution: "@dotlottie/common@npm:0.7.11" resolution: "@dotlottie/common@npm:0.7.11"