From a4841bbfa3562300a70388bc5fca63b7fe8d20fc Mon Sep 17 00:00:00 2001 From: pengx17 Date: Wed, 8 Jan 2025 05:05:33 +0000 Subject: [PATCH] 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 --- .../src/main/windows-manager/context-menu.ts | 1 - .../components/resize-panel/resize-panel.tsx | 21 +- .../frontend/component/src/ui/dnd/common.ts | 33 ++ .../component/src/ui/dnd/draggable.ts | 1 - .../component/src/ui/dnd/drop-target.ts | 158 ++++----- .../frontend/component/src/ui/dnd/index.ts | 1 + .../frontend/component/src/ui/dnd/monitor.tsx | 121 +++++++ .../frontend/component/src/ui/dnd/types.ts | 2 + .../frontend/component/src/utils/index.ts | 1 + .../component/src/utils/shallow-equal.ts | 43 +++ packages/frontend/core/package.json | 3 - .../block-suite-editor/journal-doc-title.tsx | 4 +- .../journal/today-button.tsx | 4 +- .../core/src/components/hooks/use-journal.ts | 9 +- .../page-list/docs/page-list-item.tsx | 3 +- .../components/page-list/operation-cell.tsx | 9 +- .../src/components/page-list/page-group.tsx | 2 +- .../src/components/page-list/scoped-atoms.tsx | 2 +- .../core/src/components/page-list/utils.tsx | 35 -- .../page-list/view/collection-operations.tsx | 9 +- .../root-app-sidebar/journal-button.tsx | 28 +- .../components/app-container/styles.css.ts | 10 +- .../core/src/desktop/pages/journals/index.tsx | 11 + .../core/src/desktop/workbench-router.ts | 4 + .../explorer/nodes/collection/operations.tsx | 8 +- .../explorer/nodes/doc/operations.tsx | 8 +- .../explorer/nodes/tag/operations.tsx | 10 +- .../src/modules/app-sidebar/views/index.tsx | 32 +- .../app-tabs-header/views/app-tabs-header.tsx | 221 ++++++++----- .../core/src/modules/dnd/services/index.ts | 42 ++- .../views/nodes/collection/operations.tsx | 9 +- .../explorer/views/nodes/doc/operations.tsx | 9 +- .../explorer/views/nodes/tag/operations.tsx | 29 +- .../core/src/modules/feature-flag/constant.ts | 12 - .../modules/workbench/entities/workbench.ts | 24 +- .../services/desktop-state-synchronizer.ts | 7 +- .../view/split-view/indicator.css.ts | 55 +++- .../workbench/view/split-view/indicator.tsx | 154 ++++----- .../workbench/view/split-view/panel.tsx | 310 +++++++++++++----- .../view/split-view/resize-handle.tsx | 75 ++++- .../view/split-view/split-view.css.ts | 205 ++++++++---- .../workbench/view/split-view/split-view.tsx | 225 +++++++------ .../workbench/view/split-view/state.ts | 25 ++ .../workbench/view/split-view/types.ts | 15 + .../modules/workbench/view/workbench-link.tsx | 74 ++++- .../workbench/view/workbench-root.css.ts | 1 - .../modules/workbench/view/workbench-root.tsx | 12 +- packages/frontend/core/src/types/dnd.ts | 61 ++-- tests/affine-desktop/e2e/split-view.spec.ts | 115 +++++++ tests/affine-desktop/e2e/tabs.spec.ts | 82 +---- tests/kit/src/utils/app-tabs.ts | 52 +++ tests/kit/src/utils/page-logic.ts | 27 +- yarn.lock | 65 ---- 53 files changed, 1574 insertions(+), 905 deletions(-) create mode 100644 packages/frontend/component/src/ui/dnd/common.ts create mode 100644 packages/frontend/component/src/ui/dnd/monitor.tsx create mode 100644 packages/frontend/component/src/utils/shallow-equal.ts create mode 100644 packages/frontend/core/src/desktop/pages/journals/index.tsx create mode 100644 packages/frontend/core/src/modules/workbench/view/split-view/state.ts create mode 100644 packages/frontend/core/src/modules/workbench/view/split-view/types.ts create mode 100644 tests/affine-desktop/e2e/split-view.spec.ts create mode 100644 tests/kit/src/utils/app-tabs.ts diff --git a/packages/frontend/apps/electron/src/main/windows-manager/context-menu.ts b/packages/frontend/apps/electron/src/main/windows-manager/context-menu.ts index 9a6ce1fcd..2407e65a0 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/context-menu.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/context-menu.ts @@ -96,7 +96,6 @@ export const showTabContextMenu = async ( ]; const menu = Menu.buildFromTemplate(template); menu.popup(); - let unsub: (() => void) | undefined; const subscription = WebContentViewsManager.instance.tabAction$.subscribe( action => { diff --git a/packages/frontend/component/src/components/resize-panel/resize-panel.tsx b/packages/frontend/component/src/components/resize-panel/resize-panel.tsx index 8d3194ca7..ebc6d6aac 100644 --- a/packages/frontend/component/src/components/resize-panel/resize-panel.tsx +++ b/packages/frontend/component/src/components/resize-panel/resize-panel.tsx @@ -1,8 +1,9 @@ import { assignInlineVars } from '@vanilla-extract/dynamic'; import clsx from 'clsx'; 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 * as styles from './resize-panel.css'; @@ -22,6 +23,7 @@ export interface ResizeHandleProps tooltipShortcut?: TooltipProps['shortcut']; tooltipOptions?: Partial>; tooltipShortcutClassName?: string; + dropTargetOptions?: Parameters[0]; } export interface ResizePanelProps @@ -40,6 +42,7 @@ export interface ResizePanelProps resizeHandleTooltipOptions?: Partial< Omit >; + resizeHandleDropTargetOptions?: Parameters[0]; enableAnimation?: boolean; width: number; unmountOnExit?: boolean; @@ -56,6 +59,7 @@ const ResizeHandle = ({ resizeHandlePos, resizeHandleOffset, resizeHandleVerticalPadding, + dropTargetOptions, open, onOpen, onResizing, @@ -115,6 +119,10 @@ const ResizeHandle = ({ [maxWidth, resizeHandlePos, minWidth, onWidthChange, onResizing, onOpen] ); + const { dropTargetRef } = useDropTarget(dropTargetOptions, [ + dropTargetOptions, + ]); + return ( { + ref.current = node; + dropTargetRef.current = node; + }} style={assignInlineVars({ [styles.resizeHandleOffsetVar]: `${resizeHandleOffset ?? 0}px`, [styles.resizeHandleVerticalPadding]: `${ @@ -169,17 +180,18 @@ export const ResizePanel = forwardRef( resizeHandleTooltipShortcut, resizeHandleTooltipShortcutClassName, resizeHandleTooltipOptions, + resizeHandleDropTargetOptions, ...rest }, ref ) { const safeWidth = Math.min(maxWidth, Math.max(minWidth, width)); - const [{ status }, toggle] = useTransition({ + const [{ status }, toggle] = useTransitionState({ timeout: animationTimeout, }); useLayoutEffect(() => { toggle(open); - }, [open]); + }, [open, toggle]); return (
( onWidthChange={onWidthChange} open={open} resizing={resizing} + dropTargetOptions={resizeHandleDropTargetOptions} />
); diff --git a/packages/frontend/component/src/ui/dnd/common.ts b/packages/frontend/component/src/ui/dnd/common.ts new file mode 100644 index 000000000..4fb014311 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/common.ts @@ -0,0 +1,33 @@ +import type { DNDData, fromExternalData } from './types'; + +export const isExternalDrag = (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, + 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, + }, + }; +}; diff --git a/packages/frontend/component/src/ui/dnd/draggable.ts b/packages/frontend/component/src/ui/dnd/draggable.ts index 0d98cc0a0..964548e7b 100644 --- a/packages/frontend/component/src/ui/dnd/draggable.ts +++ b/packages/frontend/component/src/ui/dnd/draggable.ts @@ -71,7 +71,6 @@ export const useDraggable = ( const context = useContext(DNDContext); - // eslint-disable-next-line react-hooks/exhaustive-deps const options = useMemo(() => { const opts = getOptions(); diff --git a/packages/frontend/component/src/ui/dnd/drop-target.ts b/packages/frontend/component/src/ui/dnd/drop-target.ts index 6b758ab11..ae3bd083a 100644 --- a/packages/frontend/component/src/ui/dnd/drop-target.ts +++ b/packages/frontend/component/src/ui/dnd/drop-target.ts @@ -19,6 +19,8 @@ import { } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { shallowUpdater } from '../../utils'; +import { getAdaptedEventArgs, isExternalDrag } from './common'; import { DNDContext } from './context'; import type { DNDData, fromExternalData } from './types'; @@ -44,7 +46,7 @@ export type DropTargetTreeInstruction = Instruction; export type ExternalDragPayload = ExternalDragType['payload']; -type DropTargetGetFeedback = Parameters< +export type DropTargetGetFeedback = Parameters< NonNullable[0]['canDrop']> >[0] & { source: { @@ -59,38 +61,6 @@ type DropTargetGet = | T | ((data: DropTargetGetFeedback) => T); -const isExternalDrag = ( - args: Pick, 'source'> -) => { - return !args.source['data']; -}; - -const getAdaptedEventArgs = < - D extends DNDData, - Args extends Pick, 'source'>, ->( - options: DropTargetOptions, - 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( get: T, options: DropTargetOptions @@ -110,7 +80,7 @@ function dropTargetGet( ) => { if (typeof get === 'function') { return (get as any)({ - ...getAdaptedEventArgs(options, args), + ...getAdaptedEventArgs(args, options.fromExternalData), get treeInstruction() { return options.treeInstruction ? extractInstruction( @@ -145,8 +115,8 @@ function dropTargetGet( }); } else { return { + ...getAdaptedEventArgs(args, options.fromExternalData), ...get, - ...getAdaptedEventArgs(options, args), }; } }) as any; @@ -168,6 +138,8 @@ export interface DropTargetOptions { }; onDrop?: (data: DropTargetDropEvent) => void; onDrag?: (data: DropTargetDragEvent) => void; + onDragEnter?: (data: DropTargetDragEvent) => void; + onDragLeave?: (data: DropTargetDragEvent) => void; /** * external data adapter. * Will use the external data adapter from the context if not provided. @@ -232,6 +204,55 @@ export const useDropTarget = ( const dropTargetOptions = useMemo(() => { const wrappedCanDrop = dropTargetGet(options.canDrop, options); let _element: HTMLElement | null = null; + + const updateDragOver = ( + args: DropTargetDragEvent, + handler?: (data: DropTargetDragEvent) => 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); + } + }; + return { get element() { if (!_element) { @@ -285,7 +306,7 @@ export const useDropTarget = ( // external data is only available in drop event thus // this is the only case for getAdaptedEventArgs - const args = getAdaptedEventArgs(options, _args, true); + const args = getAdaptedEventArgs(_args, options.fromExternalData, true); if ( isExternalDrag(_args) && options.fromExternalData && @@ -309,7 +330,7 @@ export const useDropTarget = ( } }, getData: (args: DropTargetGetFeedback) => { - args = getAdaptedEventArgs(options, args); + args = getAdaptedEventArgs(args, options.fromExternalData); const originData = dropTargetGet(options.data ?? {}, options)(args); const { input, element } = args; const withInstruction = options.treeInstruction @@ -332,50 +353,29 @@ export const useDropTarget = ( return withClosestEdge; }, onDrag: (args: DropTargetDragEvent) => { - args = getAdaptedEventArgs(options, args); - if ( - args.location.current.dropTargets[0]?.element === - dropTargetRef.current - ) { - if (enableDraggedOverDraggable.current) { - setDraggedOverDraggable(args.source); - } - let instruction = null; - let closestEdge = null; - if (options.treeInstruction) { - instruction = extractInstruction(args.self.data); - setTreeInstruction(instruction); - if (dropTargetRef.current) { - dropTargetRef.current.dataset['treeInstruction'] = - instruction?.type; - } - } - if (options.closestEdge) { - 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); - } + updateDragOver(args, options.onDrag); + }, + onDragEnter: (args: DropTargetDragEvent) => { + updateDragOver(args, options.onDragEnter); + }, + onDragLeave: (args: DropTargetDragEvent) => { + args = getAdaptedEventArgs(args, options.fromExternalData); + + const withClosestEdge = options.closestEdge + ? attachClosestEdge(args.self.data, { + element: args.self.element, + input: args.location.current.input, + allowedEdges: options.closestEdge.allowedEdges, + }) + : args.self.data; + + options.onDragLeave?.({ + ...args, + self: { ...args.self, data: withClosestEdge }, + }); }, onDropTargetChange: (args: DropTargetDropEvent) => { - args = getAdaptedEventArgs(options, args); + args = getAdaptedEventArgs(args, options.fromExternalData); if ( args.location.current.dropTargets[0]?.element === dropTargetRef.current diff --git a/packages/frontend/component/src/ui/dnd/index.ts b/packages/frontend/component/src/ui/dnd/index.ts index 59aea6d5b..49b234760 100644 --- a/packages/frontend/component/src/ui/dnd/index.ts +++ b/packages/frontend/component/src/ui/dnd/index.ts @@ -2,4 +2,5 @@ export * from './context'; export * from './draggable'; export * from './drop-indicator'; export * from './drop-target'; +export * from './monitor'; export * from './types'; diff --git a/packages/frontend/component/src/ui/dnd/monitor.tsx b/packages/frontend/component/src/ui/dnd/monitor.tsx new file mode 100644 index 000000000..fd837b7e7 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/monitor.tsx @@ -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 = Parameters< + NonNullable[0]['canMonitor']> +>[0] & { + source: { + data: D['draggable']; + }; +}; + +type MonitorGet = + | T + | ((data: MonitorGetFeedback) => T); + +type MonitorDragEvent = { + /** + * Location history for the drag operation + */ + location: DragLocationHistory; + /** + * Data associated with the entity that is being dragged + */ + source: Exclude & { + data: D['draggable']; + }; +}; + +export interface MonitorOptions { + canMonitor?: MonitorGet; + onDragStart?: (data: MonitorDragEvent) => void; + onDrag?: (data: MonitorDragEvent) => void; + onDrop?: (data: MonitorDragEvent) => void; + onDropTargetChange?: (data: MonitorDragEvent) => void; + /** + * external data adapter. + * Will use the external data adapter from the context if not provided. + */ + fromExternalData?: fromExternalData; + /** + * 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( + get: T, + options: MonitorOptions +): T extends undefined + ? undefined + : T extends MonitorGet + ? (args: MonitorGetFeedback) => I + : never { + if (get === undefined) { + return undefined as any; + } + return ((args: MonitorGetFeedback) => { + const adaptedArgs = getAdaptedEventArgs(args, options.fromExternalData); + return typeof get === 'function' + ? (get as any)(adaptedArgs) + : { + ...adaptedArgs, + ...get, + }; + }) as any; +} + +export const useDndMonitor = ( + getOptions: () => MonitorOptions = () => ({}), + 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)) + : 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]); +}; diff --git a/packages/frontend/component/src/ui/dnd/types.ts b/packages/frontend/component/src/ui/dnd/types.ts index 9c6c26b93..ae588d0e3 100644 --- a/packages/frontend/component/src/ui/dnd/types.ts +++ b/packages/frontend/component/src/ui/dnd/types.ts @@ -1,6 +1,8 @@ import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/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< Draggable extends Record = Record, DropTarget extends Record = Record, diff --git a/packages/frontend/component/src/utils/index.ts b/packages/frontend/component/src/utils/index.ts index 8aab883c1..861f83c63 100644 --- a/packages/frontend/component/src/utils/index.ts +++ b/packages/frontend/component/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './observe-intersection'; export * from './observe-resize'; +export * from './shallow-equal'; export { startScopedViewTransition } from './view-transition'; export * from './with-unit'; diff --git a/packages/frontend/component/src/utils/shallow-equal.ts b/packages/frontend/component/src/utils/shallow-equal.ts new file mode 100644 index 000000000..9018b345e --- /dev/null +++ b/packages/frontend/component/src/utils/shallow-equal.ts @@ -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 = + (newState: T) => + (state: T | null): T => { + if (state && shallowEqual(state, newState)) { + return state; + } + return newState; + }; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index ece80570e..3b6fe9222 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -20,9 +20,6 @@ "@blocksuite/icons": "2.2.1", "@capacitor/app": "^6.0.2", "@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", "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx index 0c8a86449..c94305992 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx @@ -23,7 +23,9 @@ export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Blocks }) => {
{localizedJournalDate} {isTodayJournal ? ( - {t['com.affine.today']()} + + {t['com.affine.today']()} + ) : ( {day} )} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/today-button.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/today-button.tsx index e72aa6393..79854bc8e 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/today-button.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/today-button.tsx @@ -8,7 +8,9 @@ export const JournalTodayButton = () => { const journalHelper = useJournalRouteHelper(); const onToday = useCallback(() => { - journalHelper.openToday(); + journalHelper.openToday({ + replaceHistory: true, + }); }, [journalHelper]); return ( diff --git a/packages/frontend/core/src/components/hooks/use-journal.ts b/packages/frontend/core/src/components/hooks/use-journal.ts index 241f95307..2ae83e1ea 100644 --- a/packages/frontend/core/src/components/hooks/use-journal.ts +++ b/packages/frontend/core/src/components/hooks/use-journal.ts @@ -5,6 +5,7 @@ import { JournalService, type MaybeDate, } from '@affine/core/modules/journal'; +import type { WorkbenchOpenOptions } from '@affine/core/modules/workbench/entities/workbench'; import { i18nTime } from '@affine/i18n'; import { track } from '@affine/track'; import { useService, useServices } from '@toeverything/infra'; @@ -60,9 +61,9 @@ export const useJournalRouteHelper = () => { * open journal by date, create one if not exist */ const openJournal = useCallback( - (maybeDate: MaybeDate, newTab?: boolean) => { + (maybeDate: MaybeDate, options?: WorkbenchOpenOptions) => { const page = getJournalByDate(maybeDate); - workbench.openDoc(page.id, { at: newTab ? 'new-tab' : 'active' }); + workbench.openDoc(page.id, options); track.$.navigationPanel.journal.navigate({ to: 'journal', }); @@ -75,9 +76,9 @@ export const useJournalRouteHelper = () => { * open today's journal */ const openToday = useCallback( - (newTab?: boolean) => { + (options: WorkbenchOpenOptions) => { const date = dayjs().format(JOURNAL_DATE_FORMAT); - return openJournal(date, newTab); + return openJournal(date, options); }, [openJournal] ); diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx index 646ee66de..6016ed737 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx @@ -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 ( diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index 1f84d797f..8ae1b596d 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -14,7 +14,6 @@ import { CompatibleFavoriteItemsAdapter, FavoriteService, } from '@affine/core/modules/favorite'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkspaceService } from '@affine/core/modules/workspace'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; @@ -65,19 +64,15 @@ export const PageOperationCell = ({ }: PageOperationCellProps) => { const t = useI18n(); const { - featureFlagService, workspaceService, compatibleFavoriteItemsAdapter: favAdapter, workbenchService, } = useServices({ - FeatureFlagService, WorkspaceService, CompatibleFavoriteItemsAdapter, WorkbenchService, }); - const enableSplitView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); + const currentWorkspace = workspaceService.workspace; const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc')); const workbench = workbenchService.workbench; @@ -194,7 +189,7 @@ export const PageOperationCell = ({ }> {t['com.affine.workbench.tab.page-menu-open']()} - {BUILD_CONFIG.isElectron && enableSplitView ? ( + {BUILD_CONFIG.isElectron ? ( }> {t['com.affine.workbench.split-view.page-menu-open']()} diff --git a/packages/frontend/core/src/components/page-list/page-group.tsx b/packages/frontend/core/src/components/page-list/page-group.tsx index b9ff06842..bd8b65fd4 100644 --- a/packages/frontend/core/src/components/page-list/page-group.tsx +++ b/packages/frontend/core/src/components/page-list/page-group.tsx @@ -1,3 +1,4 @@ +import { shallowEqual } from '@affine/component'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import type { Tag } from '@affine/env/filter'; import { useI18n } from '@affine/i18n'; @@ -34,7 +35,6 @@ import type { TagListItemProps, TagMeta, } from './types'; -import { shallowEqual } from './utils'; export const ItemGroupHeader = memo(function ItemGroupHeader< T extends ListItem, diff --git a/packages/frontend/core/src/components/page-list/scoped-atoms.tsx b/packages/frontend/core/src/components/page-list/scoped-atoms.tsx index 226994695..dfb10282c 100644 --- a/packages/frontend/core/src/components/page-list/scoped-atoms.tsx +++ b/packages/frontend/core/src/components/page-list/scoped-atoms.tsx @@ -1,3 +1,4 @@ +import { shallowEqual } from '@affine/component'; import { DEFAULT_SORT_KEY } from '@affine/env/constant'; import { atom } from 'jotai'; import { selectAtom } from 'jotai/utils'; @@ -10,7 +11,6 @@ import type { MetaRecord, VirtualizedListProps, } from './types'; -import { shallowEqual } from './utils'; // for ease of use in the component tree // note: must use selectAtom to access this atom for efficiency diff --git a/packages/frontend/core/src/components/page-list/utils.tsx b/packages/frontend/core/src/components/page-list/utils.tsx index 1a63f2fe3..7cdc2553d 100644 --- a/packages/frontend/core/src/components/page-list/utils.tsx +++ b/packages/frontend/core/src/components/page-list/utils.tsx @@ -56,38 +56,3 @@ export const betweenDaysAgo = ( ): boolean => { return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1); }; - -// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js -export function shallowEqual(objA: any, objB: any) { - if (Object.is(objA, objB)) { - return true; - } - - if ( - typeof objA !== 'object' || - objA === null || - typeof objB !== 'object' || - objB === null - ) { - return false; - } - - const keysA = Object.keys(objA); - const keysB = Object.keys(objB); - - if (keysA.length !== keysB.length) { - return false; - } - - // Test for A's keys different from B. - for (const key of keysA) { - if ( - !Object.prototype.hasOwnProperty.call(objB, key) || - !Object.is(objA[key], objB[key]) - ) { - return false; - } - } - - return true; -} diff --git a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx index 4ff464388..5c14cc0e8 100644 --- a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx +++ b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx @@ -3,7 +3,6 @@ import { Menu, MenuItem, usePromptModal } from '@affine/component'; import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info'; import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { WorkbenchService } from '@affine/core/modules/workbench'; import type { Collection } from '@affine/env/filter'; import { useI18n } from '@affine/i18n'; @@ -36,21 +35,16 @@ export const CollectionOperations = ({ const { collectionService: service, workbenchService, - featureFlagService, workspaceDialogService, } = useServices({ CollectionService, WorkbenchService, - FeatureFlagService, WorkspaceDialogService, }); const deleteInfo = useDeleteCollectionInfo(); const workbench = workbenchService.workbench; const t = useI18n(); const { openPromptModal } = usePromptModal(); - const enableMultiView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); const showEditName = useCallback(() => { // use openRenameModal if it is in the sidebar collection list @@ -150,7 +144,7 @@ export const CollectionOperations = ({ name: t['com.affine.workbench.tab.page-menu-open'](), click: openCollectionNewTab, }, - ...(BUILD_CONFIG.isElectron && enableMultiView + ...(BUILD_CONFIG.isElectron ? [ { icon: , @@ -172,7 +166,6 @@ export const CollectionOperations = ({ }, ], [ - enableMultiView, t, showEditName, showEdit, diff --git a/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx b/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx index 544197f74..4edd07210 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx @@ -1,32 +1,19 @@ -import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook'; -import { - useJournalInfoHelper, - useJournalRouteHelper, -} from '@affine/core/components/hooks/use-journal'; -import { MenuItem } from '@affine/core/modules/app-sidebar/views'; +import { MenuLinkItem } from '@affine/core/modules/app-sidebar/views'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; +import { JournalService } from '@affine/core/modules/journal'; import { WorkbenchService } from '@affine/core/modules/workbench'; -import { isNewTabTrigger } from '@affine/core/utils'; import { useI18n } from '@affine/i18n'; import { TodayIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; -import { type MouseEvent } from 'react'; export const AppSidebarJournalButton = () => { const t = useI18n(); const docDisplayMetaService = useService(DocDisplayMetaService); + const journalService = useService(JournalService); const workbench = useService(WorkbenchService).workbench; const location = useLiveData(workbench.location$); - const { openToday } = useJournalRouteHelper(); const maybeDocId = location.pathname.split('/')[1]; - const { isJournal } = useJournalInfoHelper(maybeDocId); - - const handleOpenToday = useCatchEventCallback( - (e: MouseEvent) => { - openToday(isNewTabTrigger(e)); - }, - [openToday] - ); + const isJournal = !!useLiveData(journalService.journalDate$(maybeDocId)); const JournalIcon = useLiveData( docDisplayMetaService.icon$(maybeDocId, { @@ -36,14 +23,13 @@ export const AppSidebarJournalButton = () => { const Icon = isJournal ? JournalIcon : TodayIcon; return ( - } > {t['com.affine.journal.app-sidebar-title']()} - + ); }; diff --git a/packages/frontend/core/src/desktop/components/app-container/styles.css.ts b/packages/frontend/core/src/desktop/components/app-container/styles.css.ts index 634914218..e3b0a6148 100644 --- a/packages/frontend/core/src/desktop/components/app-container/styles.css.ts +++ b/packages/frontend/core/src/desktop/components/app-container/styles.css.ts @@ -78,27 +78,25 @@ export const mainContainerStyle = style({ width: '100%', display: 'flex', flex: 1, - overflow: 'clip', maxWidth: '100%', selectors: { '&[data-client-border="true"]': { borderRadius: 6, - margin: '8px', - overflow: 'clip', + padding: '8px', '@media': { print: { overflow: 'visible', - margin: '0px', + padding: '0px', borderRadius: '0px', }, }, }, '&[data-client-border="true"][data-side-bar-open="true"]': { - marginLeft: 0, + paddingLeft: 0, }, '&[data-client-border="true"][data-is-desktop="true"]': { - marginTop: 0, + paddingTop: 0, }, '&[data-client-border="false"][data-is-desktop="true"][data-side-bar-open="true"]': { diff --git a/packages/frontend/core/src/desktop/pages/journals/index.tsx b/packages/frontend/core/src/desktop/pages/journals/index.tsx new file mode 100644 index 000000000..4860ac287 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/journals/index.tsx @@ -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; +}; diff --git a/packages/frontend/core/src/desktop/workbench-router.ts b/packages/frontend/core/src/desktop/workbench-router.ts index 8c4754797..42e477f06 100644 --- a/packages/frontend/core/src/desktop/workbench-router.ts +++ b/packages/frontend/core/src/desktop/workbench-router.ts @@ -33,6 +33,10 @@ export const workbenchRoutes = [ path: '/:pageId/attachments/:attachmentId', lazy: () => import('./pages/workspace/attachment/index'), }, + { + path: '/journals', + lazy: () => import('./pages/journals'), + }, { path: '*', lazy: () => import('./pages/404'), diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx index 468b0475b..46919d55a 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx @@ -11,7 +11,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { CollectionService } from '@affine/core/modules/collection'; import type { NodeOperation } from '@affine/core/modules/explorer'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; @@ -161,10 +160,6 @@ export const useExplorerCollectionNodeOperationsMenu = ( onOpenEdit: () => void ): NodeOperation[] => { const t = useI18n(); - const { featureFlagService } = useServices({ FeatureFlagService }); - const enableMultiView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); const { favorite, @@ -246,7 +241,7 @@ export const useExplorerCollectionNodeOperationsMenu = ( ), }, - ...(BUILD_CONFIG.isElectron && enableMultiView + ...(BUILD_CONFIG.isElectron ? [ { index: 99, @@ -279,7 +274,6 @@ export const useExplorerCollectionNodeOperationsMenu = ( }, ], [ - enableMultiView, favorite, handleAddDocToCollection, handleDeleteCollection, diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx index 3f8f38150..079b6c889 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx @@ -13,7 +13,6 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { DocsService } from '@affine/core/modules/doc'; import type { NodeOperation } from '@affine/core/modules/explorer'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { preventDefault } from '@affine/core/utils'; @@ -171,7 +170,6 @@ export const useExplorerDocNodeOperationsMenu = ( } ): NodeOperation[] => { const t = useI18n(); - const featureFlagService = useService(FeatureFlagService); const { favorite, handleAddLinkedPage, @@ -186,9 +184,6 @@ export const useExplorerDocNodeOperationsMenu = ( const docService = useService(DocsService); const docRecord = useLiveData(docService.list.doc$(docId)); const title = useLiveData(docRecord?.title$); - const enableMultiView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); return useMemo( () => [ @@ -258,7 +253,7 @@ export const useExplorerDocNodeOperationsMenu = ( ), }, - ...(BUILD_CONFIG.isElectron && enableMultiView + ...(BUILD_CONFIG.isElectron ? [ { index: 100, @@ -305,7 +300,6 @@ export const useExplorerDocNodeOperationsMenu = ( ], [ docId, - enableMultiView, favorite, handleAddLinkedPage, handleDuplicate, diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx index cc53c9d53..0a3e91dfa 100644 --- a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx @@ -11,7 +11,6 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import { DocsService } from '@affine/core/modules/doc'; import type { NodeOperation } from '@affine/core/modules/explorer'; import { FavoriteService } from '@affine/core/modules/favorite'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { GlobalCacheService } from '@affine/core/modules/storage'; import { TagService } from '@affine/core/modules/tag'; import { WorkbenchService } from '@affine/core/modules/workbench'; @@ -24,7 +23,7 @@ import { PlusIcon, SplitViewIcon, } from '@blocksuite/icons/rc'; -import { useLiveData, useService, useServices } from '@toeverything/infra'; +import { useLiveData, useServices } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; import { TagRenameSubMenu } from './dialog'; @@ -219,10 +218,6 @@ export const useExplorerTagNodeOperationsMenu = ( } ): NodeOperation[] => { const t = useI18n(); - const featureFlagService = useService(FeatureFlagService); - const enableMultiView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); const { favorite, handleNewDoc, @@ -266,7 +261,7 @@ export const useExplorerTagNodeOperationsMenu = ( ), }, - ...(BUILD_CONFIG.isElectron && enableMultiView + ...(BUILD_CONFIG.isElectron ? [ { index: 100, @@ -312,7 +307,6 @@ export const useExplorerTagNodeOperationsMenu = ( }, ], [ - enableMultiView, favorite, handleChangeNameOrColor, handleMoveToTrash, diff --git a/packages/frontend/core/src/modules/app-sidebar/views/index.tsx b/packages/frontend/core/src/modules/app-sidebar/views/index.tsx index 9c254d744..f7953a994 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/index.tsx +++ b/packages/frontend/core/src/modules/app-sidebar/views/index.tsx @@ -1,8 +1,9 @@ -import { Skeleton } from '@affine/component'; +import { type DropTargetGetFeedback, Skeleton } from '@affine/component'; import { ResizePanel } from '@affine/component/resize-panel'; import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper'; import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper'; import { WorkspaceNavigator } from '@affine/core/components/workspace-selector'; +import type { AffineDNDData } from '@affine/core/types/dnd'; import { useI18n } from '@affine/i18n'; import { useLiveData, @@ -14,6 +15,8 @@ import { debounce } from 'lodash-es'; import type { PropsWithChildren, ReactElement } from 'react'; import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { WorkbenchService } from '../../workbench'; +import { allowedSplitViewEntityTypes } from '../../workbench/view/split-view/types'; import { WorkspaceService } from '../../workspace'; import { AppSidebarService } from '../services/app-sidebar'; import * as styles from './fallback.css'; @@ -44,6 +47,7 @@ export function AppSidebar({ children }: PropsWithChildren) { const clientBorder = appSettings.clientBorder; const appSidebarService = useService(AppSidebarService).sidebar; + const workbenchService = useService(WorkbenchService).workbench; const open = useLiveData(appSidebarService.open$); const width = useLiveData(appSidebarService.width$); @@ -147,6 +151,31 @@ export function AppSidebar({ children }: PropsWithChildren) { }; }, [appSidebarService, resizing, sidebarState, width]); + const resizeHandleDropTargetOptions = useMemo(() => { + return () => ({ + data: () => { + const firstView = workbenchService.views$.value.at(0); + + if (!firstView) { + return {}; + } + + return { + at: 'workbench:resize-handle', + edge: 'left', // left of the first view + viewId: firstView.id, + }; + }, + canDrop: (data: DropTargetGetFeedback) => { + return ( + (!!data.source.data.entity?.type && + allowedSplitViewEntityTypes.has(data.source.data.entity?.type)) || + data.source.data.from?.at === 'workbench:link' + ); + }, + }); + }, [workbenchService.views$.value]); + if (!initialized) { return null; } @@ -154,6 +183,7 @@ export function AppSidebar({ children }: PropsWithChildren) { return ( <> ) => void; }) => { - useServiceOptional(DesktopStateSynchronizer); const tabsHeaderService = useService(AppTabsHeaderService); - const activeViewIndex = workbench.activeViewIndex ?? 0; - const onContextMenu = useAsyncCallback( - async (viewIdx: number) => { - const action = await tabsHeaderService.showContextMenu?.( - workbench.id, - viewIdx - ); - switch (action?.type) { - case 'open-in-split-view': { - track.$.appTabsHeader.$.tabAction({ - control: 'contextMenu', - action: 'openInSplitView', - }); - break; - } - case 'separate-view': { - track.$.appTabsHeader.$.tabAction({ - control: 'contextMenu', - action: 'separateTabs', - }); - break; - } - case 'pin-tab': { - if (action.payload.shouldPin) { - track.$.appTabsHeader.$.tabAction({ - control: 'contextMenu', - action: 'pin', - }); - } else { - track.$.appTabsHeader.$.tabAction({ - control: 'contextMenu', - action: 'unpin', - }); - } - break; - } - // fixme: when close tab the view may already be gc'ed - case 'close-tab': { - track.$.appTabsHeader.$.tabAction({ - control: 'contextMenu', - action: 'close', - }); - break; - } - default: - break; - } - }, - [tabsHeaderService, workbench.id] - ); + const onActivateView = useAsyncCallback( async (viewIdx: number) => { if (viewIdx === activeViewIndex && tabActive) { @@ -147,6 +101,15 @@ const WorkbenchTab = ({ }, [activeViewIndex, tabActive, tabsHeaderService, workbench.id] ); + + const handleClick: MouseEventHandler = useCatchEventCallback( + async e => { + e.stopPropagation(); + onActivateView(viewIdx); + }, + [onActivateView, viewIdx] + ); + const handleAuxClick: MouseEventHandler = useCatchEventCallback( async e => { if (e.button === 1) { @@ -160,6 +123,106 @@ const WorkbenchTab = ({ [tabsHeaderService, workbench.id] ); + const onContextMenu = useAsyncCallback(async () => { + const action = await tabsHeaderService.showContextMenu?.( + workbench.id, + viewIdx + ); + switch (action?.type) { + case 'open-in-split-view': { + track.$.appTabsHeader.$.tabAction({ + control: 'contextMenu', + action: 'openInSplitView', + }); + break; + } + case 'separate-view': { + track.$.appTabsHeader.$.tabAction({ + control: 'contextMenu', + action: 'separateTabs', + }); + break; + } + case 'pin-tab': { + if (action.payload.shouldPin) { + track.$.appTabsHeader.$.tabAction({ + control: 'contextMenu', + action: 'pin', + }); + } else { + track.$.appTabsHeader.$.tabAction({ + control: 'contextMenu', + action: 'unpin', + }); + } + break; + } + // fixme: when close tab the view may already be gc'ed + case 'close-tab': { + track.$.appTabsHeader.$.tabAction({ + control: 'contextMenu', + action: 'close', + }); + break; + } + default: + break; + } + }, [tabsHeaderService, viewIdx, workbench.id]); + + const contentNode = useMemo(() => { + return ( + <> +
+ {workbench.ready || !workbench.loaded ? ( + iconNameToIcon[view.iconName ?? 'allDocs'] + ) : ( + + )} +
+ {!view.title ? null : ( +
1 && !workbench.pinned} + > + {view.title} +
+ )} + + ); + }, [workbench, view, tabsLength]); + return ( + + ); +}; + +const WorkbenchTab = ({ + workbench, + active: tabActive, + tabsLength, + dnd, + onDrop, +}: { + workbench: TabStatus; + active: boolean; + tabsLength: number; + dnd?: boolean; + onDrop?: (data: DropTargetDropEvent) => void; +}) => { + useServiceOptional(DesktopStateSynchronizer); + const tabsHeaderService = useService(AppTabsHeaderService); + const activeViewIndex = workbench.activeViewIndex ?? 0; + const handleCloseTab = useCatchEventCallback(async () => { await tabsHeaderService.closeTab?.(workbench.id); track.$.appTabsHeader.$.tabAction({ @@ -175,11 +238,11 @@ const WorkbenchTab = ({ }, onDrop, dropEffect: 'move', - canDrop: tabCanDrop(workbench), + canDrop: dnd ? tabCanDrop(workbench) : false, isSticky: true, allowExternal: true, }), - [onDrop, workbench] + [dnd, onDrop, workbench] ); const { dragRef } = useDraggable(() => { @@ -251,37 +314,15 @@ const WorkbenchTab = ({ {workbench.views.map((view, viewIdx) => { return ( - + {viewIdx !== workbench.views.length - 1 ? (
diff --git a/packages/frontend/core/src/modules/dnd/services/index.ts b/packages/frontend/core/src/modules/dnd/services/index.ts index f02c7fdfa..0e5d94289 100644 --- a/packages/frontend/core/src/modules/dnd/services/index.ts +++ b/packages/frontend/core/src/modules/dnd/services/index.ts @@ -81,7 +81,7 @@ export class DndService extends Service { isDropEvent?: boolean ) => { if (!isDropEvent) { - return {}; + return this.resolveBlocksuiteExternalData(args.source) || {}; } let resolved: AffineDNDData['draggable'] | null = null; @@ -168,24 +168,30 @@ export class DndService extends Service { if (!dndAPI) { return null; } - const encoded = source.getStringData(dndAPI.mimeType); - if (!encoded) { - return null; - } - const snapshot = dndAPI.decodeSnapshot(encoded); - if (!snapshot) { - return null; - } - const entity = this.resolveBlockSnapshot(snapshot); - if (!entity) { - return null; - } - return { - entity, - from: { + + if (source.types.includes(dndAPI.mimeType)) { + const from = { at: 'blocksuite-editor', - }, - }; + } as const; + + let entity: Entity | null = null; + + const encoded = source.getStringData(dndAPI.mimeType); + const snapshot = encoded ? dndAPI.decodeSnapshot(encoded) : null; + entity = snapshot ? this.resolveBlockSnapshot(snapshot) : null; + + if (!entity) { + return { + from, + }; + } else { + return { + entity, + from, + }; + } + } + return null; }; private readonly resolveHTML: EntityResolver = html => { diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx index d5537d4ee..6160641ce 100644 --- a/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx +++ b/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx @@ -9,7 +9,6 @@ import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/us import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { CollectionService } from '@affine/core/modules/collection'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; @@ -37,13 +36,11 @@ export const useExplorerCollectionNodeOperations = ( workspaceService, collectionService, compatibleFavoriteItemsAdapter, - featureFlagService, } = useServices({ WorkbenchService, WorkspaceService, CollectionService, CompatibleFavoriteItemsAdapter, - FeatureFlagService, }); const deleteInfo = useDeleteCollectionInfo(); @@ -51,9 +48,6 @@ export const useExplorerCollectionNodeOperations = ( workspaceService.workspace.docCollection ); - const enableMultiView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); const favorite = useLiveData( useMemo( () => @@ -173,7 +167,7 @@ export const useExplorerCollectionNodeOperations = ( ), }, - ...(BUILD_CONFIG.isElectron && enableMultiView + ...(BUILD_CONFIG.isElectron ? [ { index: 99, @@ -206,7 +200,6 @@ export const useExplorerCollectionNodeOperations = ( }, ], [ - enableMultiView, favorite, handleAddDocToCollection, handleDeleteCollection, diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx index c25df37c7..281cd8c38 100644 --- a/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx +++ b/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx @@ -11,7 +11,6 @@ import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hoo import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { DocsService } from '@affine/core/modules/doc'; import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkspaceService } from '@affine/core/modules/workspace'; import { useI18n } from '@affine/i18n'; @@ -43,17 +42,12 @@ export const useExplorerDocNodeOperations = ( workspaceService, docsService, compatibleFavoriteItemsAdapter, - featureFlagService, } = useServices({ DocsService, WorkbenchService, WorkspaceService, CompatibleFavoriteItemsAdapter, - FeatureFlagService, }); - const enableMultiView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); const { openConfirmModal } = useConfirmModal(); const docRecord = useLiveData(docsService.list.doc$(docId)); @@ -188,7 +182,7 @@ export const useExplorerDocNodeOperations = ( ), }, - ...(BUILD_CONFIG.isElectron && enableMultiView + ...(BUILD_CONFIG.isElectron ? [ { index: 100, @@ -234,7 +228,6 @@ export const useExplorerDocNodeOperations = ( }, ], [ - enableMultiView, favorite, handleAddLinkedPage, handleDuplicate, diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx index e99afb41d..eb48b8c66 100644 --- a/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx +++ b/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx @@ -3,7 +3,6 @@ import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-pa import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; import { DocsService } from '@affine/core/modules/doc'; import { FavoriteService } from '@affine/core/modules/favorite'; -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { TagService } from '@affine/core/modules/tag'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkspaceService } from '@affine/core/modules/workspace'; @@ -29,28 +28,19 @@ export const useExplorerTagNodeOperations = ( } ): NodeOperation[] => { const t = useI18n(); - const { - workbenchService, - workspaceService, - tagService, - favoriteService, - featureFlagService, - } = useServices({ - WorkbenchService, - WorkspaceService, - TagService, - DocsService, - FavoriteService, - FeatureFlagService, - }); + const { workbenchService, workspaceService, tagService, favoriteService } = + useServices({ + WorkbenchService, + WorkspaceService, + TagService, + DocsService, + FavoriteService, + }); const favorite = useLiveData( favoriteService.favoriteList.favorite$('tag', tagId) ); const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId)); - const enableMultiView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); const { createPage } = usePageHelper( workspaceService.workspace.docCollection @@ -115,7 +105,7 @@ export const useExplorerTagNodeOperations = ( ), }, - ...(BUILD_CONFIG.isElectron && enableMultiView + ...(BUILD_CONFIG.isElectron ? [ { index: 100, @@ -161,7 +151,6 @@ export const useExplorerTagNodeOperations = ( }, ], [ - enableMultiView, favorite, handleMoveToTrash, handleNewDoc, diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index b25cd6706..60bac041e 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -103,18 +103,6 @@ export const AFFINE_FLAGS = { configurable: false, defaultState: true, }, - enable_multi_view: { - category: 'affine', - displayName: - 'com.affine.settings.workspace.experimental-features.enable-multi-view.name', - description: - 'com.affine.settings.workspace.experimental-features.enable-multi-view.description', - feedbackType: 'discord', - feedbackLink: - 'https://discord.com/channels/959027316334407691/1280009690004324405', - configurable: isDesktopEnvironment, - defaultState: isCanaryBuild, - }, enable_emoji_folder_icon: { category: 'affine', displayName: diff --git a/packages/frontend/core/src/modules/workbench/entities/workbench.ts b/packages/frontend/core/src/modules/workbench/entities/workbench.ts index c6c761b0a..05d2be99c 100644 --- a/packages/frontend/core/src/modules/workbench/entities/workbench.ts +++ b/packages/frontend/core/src/modules/workbench/entities/workbench.ts @@ -12,7 +12,7 @@ import { View } from './view'; export type WorkbenchPosition = 'beside' | 'active' | 'head' | 'tail' | number; -type WorkbenchOpenOptions = { +export type WorkbenchOpenOptions = { at?: WorkbenchPosition | 'new-tab'; replaceHistory?: boolean; show?: boolean; // only for new tab @@ -52,16 +52,24 @@ export class Workbench extends Entity { }); sidebarOpen$ = new LiveData(false); - active(index: number) { - index = Math.max(0, Math.min(index, this.views$.value.length - 1)); - this.activeViewIndex$.next(index); + active(index: number | View) { + if (typeof index === 'number') { + index = Math.max(0, Math.min(index, this.views$.value.length - 1)); + this.activeViewIndex$.next(index); + } else { + this.activeViewIndex$.next(this.views$.value.indexOf(index)); + } } updateBasename(basename: string) { this.basename$.next(basename); } - createView(at: WorkbenchPosition = 'beside', defaultLocation: To) { + createView( + at: WorkbenchPosition = 'beside', + defaultLocation: To, + active = true + ) { const view = this.framework.createEntity(View, { id: nanoid(), defaultLocation, @@ -70,7 +78,9 @@ export class Workbench extends Entity { newViews.splice(this.indexAt(at), 0, view); this.views$.next(newViews); const index = newViews.indexOf(view); - this.active(index); + if (active) { + this.active(index); + } return index; } @@ -95,7 +105,7 @@ export class Workbench extends Entity { const { at = 'active', replaceHistory = false } = option; let view = this.viewAt(at); if (!view) { - const newIndex = this.createView(at, to); + const newIndex = this.createView(at, to, option.show); view = this.viewAt(newIndex); if (!view) { throw new Unreachable(); diff --git a/packages/frontend/core/src/modules/workbench/services/desktop-state-synchronizer.ts b/packages/frontend/core/src/modules/workbench/services/desktop-state-synchronizer.ts index 581d9a62e..b71f34173 100644 --- a/packages/frontend/core/src/modules/workbench/services/desktop-state-synchronizer.ts +++ b/packages/frontend/core/src/modules/workbench/services/desktop-state-synchronizer.ts @@ -28,12 +28,9 @@ export class DesktopStateSynchronizer extends Service { event.type === 'open-in-split-view' && event.payload.tabId === appInfo?.viewId ) { - const to = - event.payload.view?.path ?? - workbench.activeView$.value?.location$.value; - - workbench.open(to, { + workbench.openAll({ at: 'beside', + show: false, }); } diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts index 9a2755ea9..2b80e207d 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts +++ b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts @@ -23,34 +23,57 @@ export const menuTrigger = style({ height: 0, pointerEvents: 'none', }); + export const indicator = style({ - width: 29, + padding: '6px 20px', height: 15, display: 'flex', justifyContent: 'center', alignItems: 'center', + gap: 3, cursor: 'grab', ['WebkitAppRegion' as string]: 'no-drag', color: cssVar('placeholderColor'), + transition: 'all 0.2s', selectors: { - '&:hover, &:active, &[data-active="true"]': { + '&:hover, &[data-active="true"], &[data-dragging="true"]': { color: cssVar('brandColor'), }, - }, -}); - -export const indicatorInner = style({ - width: 16, - height: 3, - borderRadius: 10, - backgroundColor: 'currentColor', - transition: 'all 0.5s cubic-bezier(0.16, 1, 0.3, 1)', - - selectors: { - '[data-is-dragging="true"] &': { - width: 24, - height: 2, + '&[data-dragging="true"]': { + gap: 4, + }, + }, +}); + +export const indicatorDot = style({ + width: 4, + height: 4, + borderRadius: 2, + backgroundColor: 'currentColor', + transition: 'all 0.2s', + selectors: { + [`${indicator}[data-dragging="true"] &:is([data-idx="0"], [data-idx="2"])`]: + { + width: 7, + }, + [`${indicator}[data-dragging="true"] &[data-idx="1"]`]: { + width: 6, + }, + }, +}); + +export const indicatorGradient = style({ + position: 'absolute', + inset: 0, + height: '2px', + background: + 'linear-gradient(to right, transparent, currentColor, transparent)', + transition: 'opacity 0.2s', + opacity: 0, + selectors: { + [`${indicator}[data-dragging="true"] &, ${indicator}:hover &`]: { + opacity: 1, }, }, }); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx index 29425e878..d3141689c 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx @@ -4,41 +4,23 @@ import clsx from 'clsx'; import type { HTMLAttributes, MouseEventHandler } from 'react'; import { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import type { View } from '../../entities/view'; import * as styles from './indicator.css'; -export interface SplitViewMenuProps extends HTMLAttributes { +export interface SplitViewDragHandleProps + extends HTMLAttributes { active?: boolean; + dragging?: boolean; open?: boolean; onOpenMenu?: () => void; - setPressed: (v: boolean) => void; } -export const SplitViewMenuIndicator = memo( - forwardRef( - function SplitViewMenuIndicator( - { - className, - active, - open, - setPressed, - onOpenMenu, - ...attrs - }: SplitViewMenuProps, +export const SplitViewDragHandle = memo( + forwardRef( + function SplitViewDragHandle( + { className, active, open, onOpenMenu, dragging, ...attrs }, ref ) { - // dnd's `isDragging` changes after mouseDown and mouseMoved - const onMouseDown = useCallback(() => { - const t = setTimeout(() => setPressed(true), 100); - window.addEventListener( - 'mouseup', - () => { - clearTimeout(t); - setPressed(false); - }, - { once: true } - ); - }, [setPressed]); - const onClick: MouseEventHandler = useCallback(() => { !open && onOpenMenu?.(); }, [onOpenMenu, open]); @@ -47,13 +29,16 @@ export const SplitViewMenuIndicator = memo(
-
+
+
+
+
); } @@ -61,64 +46,69 @@ export const SplitViewMenuIndicator = memo( ); interface SplitViewIndicatorProps extends HTMLAttributes { - isDragging?: boolean; + view: View; isActive?: boolean; + isDragging?: boolean; menuItems?: React.ReactNode; - // import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities' is not allowed - listeners?: any; setPressed?: (pressed: boolean) => void; + dragHandleRef?: React.RefObject; } -export const SplitViewIndicator = ({ - isDragging, - isActive, - menuItems, - listeners, - setPressed, -}: SplitViewIndicatorProps) => { - const active = isActive || isDragging; - const [menuOpen, setMenuOpen] = useState(false); +export const SplitViewIndicator = memo( + forwardRef( + function SplitViewIndicator( + { isActive, menuItems, isDragging, dragHandleRef }, + ref + ) { + const [menuOpen, setMenuOpen] = useState(false); - // prevent menu from opening when dragging - const setOpenMenuManually = useCallback((open: boolean) => { - if (open) return; - setMenuOpen(open); - }, []); - const openMenu = useCallback(() => { - setMenuOpen(true); - }, []); + // prevent menu from opening when dragging + const setOpenMenuManually = useCallback((open: boolean) => { + if (open) return; + setMenuOpen(open); + }, []); - const menuRootOptions = useMemo( - () => - ({ - open: menuOpen, - onOpenChange: setOpenMenuManually, - }) satisfies MenuProps['rootOptions'], - [menuOpen, setOpenMenuManually] - ); - const menuContentOptions = useMemo( - () => - ({ - align: 'center', - }) satisfies MenuProps['contentOptions'], - [] - ); + const openMenu = useCallback(() => { + setMenuOpen(true); + }, []); - return ( -
- -
-
- -
- ); -}; + const menuRootOptions = useMemo( + () => + ({ + open: menuOpen, + onOpenChange: setOpenMenuManually, + }) satisfies MenuProps['rootOptions'], + [menuOpen, setOpenMenuManually] + ); + const menuContentOptions = useMemo( + () => + ({ + align: 'center', + }) satisfies MenuProps['contentOptions'], + [] + ); + + return ( +
+ +
+
+ +
+ ); + } + ) +); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx index 65f3aaeab..b27cb0ed4 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx @@ -1,42 +1,44 @@ -import { MenuItem } from '@affine/component'; +import { + type DropTargetDragEvent, + MenuItem, + shallowUpdater, + useDraggable, + useDropTarget, +} from '@affine/component'; +import type { AffineDNDData } from '@affine/core/types/dnd'; import { useI18n } from '@affine/i18n'; import { - ExpandCloseIcon, - MoveToLeftDuotoneIcon, - MoveToRightDuotoneIcon, - SoloViewIcon, + CloseIcon, + ExpandFullIcon, + InsertLeftIcon, + InsertRightIcon, } from '@blocksuite/icons/rc'; -import { useSortable } from '@dnd-kit/sortable'; import { useLiveData, useService } from '@toeverything/infra'; import { assignInlineVars } from '@vanilla-extract/dynamic'; -import type { - Dispatch, - HTMLAttributes, - PropsWithChildren, - RefObject, - SetStateAction, -} from 'react'; -import { - memo, - useCallback, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useAtom } from 'jotai'; +import type { HTMLAttributes, PropsWithChildren } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import type { View } from '../../entities/view'; import { WorkbenchService } from '../../services/workbench'; import { SplitViewIndicator } from './indicator'; +import { ResizeHandle } from './resize-handle'; import * as styles from './split-view.css'; +import { + draggingOverViewAtom, + draggingViewAtom, + resizingViewAtom, +} from './state'; +import { allowedSplitViewEntityTypes } from './types'; export interface SplitViewPanelProps extends PropsWithChildren> { view: View; + index: number; resizeHandle?: React.ReactNode; - setSlots?: Dispatch< - SetStateAction>> - >; + onMove: (from: number, to: number) => void; + onResizing: (dxy: { x: number; y: number }) => void; + draggingEntity: boolean; } export const SplitViewPanelContainer = ({ @@ -50,81 +52,236 @@ export const SplitViewPanelContainer = ({ ); }; +/** + * Calculate the order of the panel + */ +function calculateOrder( + index: number, + draggingIndex: number, + droppingIndex: number +) { + // If not dragging or invalid indices, return original index + if (draggingIndex === -1 || draggingIndex < 0 || droppingIndex < 0) { + return index; + } + + // If this is the dragging item, move it to the dropping position + if (index === draggingIndex) { + return droppingIndex; + } + + // If dropping before the dragging item + if (droppingIndex < draggingIndex) { + // Items between drop and drag positions shift right + if (index >= droppingIndex && index < draggingIndex) { + return index + 1; + } + } + // If dropping after the dragging item + else if ( + droppingIndex > draggingIndex && + index > draggingIndex && + index <= droppingIndex + ) { + // Items between drag and drop positions shift left + return index - 1; + } + + // For all other items, keep their original position + return index; +} + export const SplitViewPanel = memo(function SplitViewPanel({ children, view, - setSlots, + onMove, + onResizing, + draggingEntity, + index, }: SplitViewPanelProps) { - const [indicatorPressed, setIndicatorPressed] = useState(false); - const ref = useRef(null); const size = useLiveData(view.size$); const workbench = useService(WorkbenchService).workbench; + const activeView = useLiveData(workbench.activeView$); const views = useLiveData(workbench.views$); - const isLast = views[views.length - 1] === view; - const { - attributes, - listeners, - transform, - transition, - isDragging: dndIsDragging, - setNodeRef, - } = useSortable({ id: view.id, attributes: { role: 'group' } }); - - const isDragging = dndIsDragging || indicatorPressed; const isActive = activeView === view; - useLayoutEffect(() => { - if (ref.current) { - setSlots?.(slots => ({ ...slots, [view.id]: ref })); - } - }, [setSlots, view.id]); + const [draggingView, setDraggingView] = useAtom(draggingViewAtom); + const [draggingOverView, setDraggingOverView] = useAtom(draggingOverViewAtom); + const [resizingView, setResizingView] = useAtom(resizingViewAtom); - const style = useMemo( - () => ({ - ...assignInlineVars({ '--size': size.toString() }), - }), - [size] - ); - const dragStyle = useMemo( - () => ({ - transform: `translate3d(${transform?.x ?? 0}px, 0, 0)`, - transition, - }), - [transform, transition] + const order = useMemo( + () => + calculateOrder( + index, + draggingView?.index ?? -1, + draggingOverView?.index ?? -1 + ), + [index, draggingView, draggingOverView] ); + const isFirst = order === 0; + const isLast = views.length - 1 === order; + + const style = useMemo(() => { + return { + ...assignInlineVars({ + [styles.size]: size.toString(), + [styles.panelOrder]: order.toString(), + }), + }; + }, [size, order]); + + const { dropTargetRef } = useDropTarget(() => { + const handleDrag = (data: DropTargetDragEvent) => { + // 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(() => { + 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 ( 1} + data-is-first={isFirst} data-is-last={isLast} + data-testid="split-view-panel" + draggable={false} // only drag via drag handle > + {isFirst ? ( + + ) : null}
{ + dropTargetRef.current = node; + dragRef.current = node; + }} className={styles.splitViewPanelDrag} - {...attributes} > -
- {views.length > 1 ? ( +
+ {children} +
+ {views.length > 1 && onMove ? ( } - setPressed={setIndicatorPressed} + isDragging={dragging} + dragHandleRef={dragHandleRef} + menuItems={} /> ) : null}
- {children} + {!draggingView ? ( + + ) : null} ); }); -const SplitViewMenu = ({ view }: { view: View }) => { +const SplitViewMenu = ({ + view, + onMove, +}: { + view: View; + onMove: (from: number, to: number) => void; +}) => { const t = useI18n(); const workbench = useService(WorkbenchService).workbench; const views = useLiveData(workbench.views$); @@ -136,42 +293,39 @@ const SplitViewMenu = ({ view }: { view: View }) => { [view, workbench] ); const handleMoveLeft = useCallback(() => { - workbench.moveView(viewIndex, viewIndex - 1); - }, [viewIndex, workbench]); + onMove(viewIndex, viewIndex - 1); + }, [onMove, viewIndex]); const handleMoveRight = useCallback(() => { - workbench.moveView(viewIndex, viewIndex + 1); - }, [viewIndex, workbench]); + onMove(viewIndex, viewIndex + 1); + }, [onMove, viewIndex]); const handleCloseOthers = useCallback(() => { workbench.closeOthers(view); }, [view, workbench]); const CloseItem = views.length > 1 ? ( - } onClick={handleClose}> + } onClick={handleClose}> {t['com.affine.workbench.split-view-menu.close']()} ) : null; const MoveLeftItem = viewIndex > 0 && views.length > 1 ? ( - }> + }> {t['com.affine.workbench.split-view-menu.move-left']()} ) : null; const FullScreenItem = views.length > 1 ? ( - }> + }> {t['com.affine.workbench.split-view-menu.keep-this-one']()} ) : null; const MoveRightItem = viewIndex < views.length - 1 ? ( - } - > + }> {t['com.affine.workbench.split-view-menu.move-right']()} ) : null; diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/resize-handle.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/resize-handle.tsx index 72032a344..095720280 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/resize-handle.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/resize-handle.tsx @@ -1,40 +1,86 @@ +import { useDropTarget } from '@affine/component'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useAtomValue } from 'jotai'; import type { HTMLAttributes } from 'react'; import { useCallback } from 'react'; +import type { View } from '../../entities/view'; +import { WorkbenchService } from '../../services/workbench'; import * as styles from './split-view.css'; +import { draggingOverResizeHandleAtom } from './state'; +import { allowedSplitViewEntityTypes } from './types'; interface ResizeHandleProps extends HTMLAttributes { - resizing: boolean; - onResizeStart: () => void; - onResizeEnd: () => void; - onResizing: (offset: { x: number; y: number }) => void; + state: 'resizing' | 'drop-indicator' | 'idle'; + edge: 'left' | 'right'; + view: View; + onResizeStart?: () => void; + onResizeEnd?: () => void; + onResizing?: (offset: { x: number; y: number }) => void; } export const ResizeHandle = ({ - resizing, + state, + view, + edge, onResizing, onResizeStart, onResizeEnd, }: ResizeHandleProps) => { + const workbench = useService(WorkbenchService).workbench; + const views = useLiveData(workbench.views$); + + const draggingOverHandle = useAtomValue(draggingOverResizeHandleAtom); + + const draggingOver = + draggingOverHandle?.edge === edge && draggingOverHandle.viewId === view.id; + + const index = views.findIndex(v => v.id === view.id); + + const isLast = index === views.length - 1; + const isFirst = index === 0; + + const { dropTargetRef } = useDropTarget(() => { + return { + data: { + at: 'workbench:resize-handle', + edge, + viewId: view.id, + }, + canDrop: data => { + return ( + (!!data.source.data.entity?.type && + allowedSplitViewEntityTypes.has(data.source.data.entity?.type)) || + data.source.data.from?.at === 'workbench:link' + ); + }, + }; + }, [edge, view.id]); + // TODO(@catsjuice): touch support const onMouseDown = useCallback( (e: React.MouseEvent) => { + if (!onResizing || !onResizeStart || !onResizeEnd) { + return; + } + e.preventDefault(); - onResizeStart(); + onResizeStart?.(); const prevPos = { x: e.clientX, y: e.clientY }; function onMouseMove(e: MouseEvent) { e.preventDefault(); const dx = e.clientX - prevPos.x; const dy = e.clientY - prevPos.y; - onResizing({ x: dx, y: dy }); + onResizing?.({ x: dx, y: dy }); prevPos.x = e.clientX; prevPos.y = e.clientY; } function onMouseUp(e: MouseEvent) { e.preventDefault(); - onResizeEnd(); + onResizeEnd?.(); document.removeEventListener('mousemove', onMouseMove); } @@ -44,10 +90,21 @@ export const ResizeHandle = ({ [onResizeEnd, onResizeStart, onResizing] ); + const canResize = + state === 'idle' && + !(isLast && edge === 'right') && + !(isFirst && edge === 'left'); + return (
); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts index 344117079..6034b31e5 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts +++ b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts @@ -1,55 +1,55 @@ -import { cssVar } from '@toeverything/theme'; -import { createVar, style } from '@vanilla-extract/css'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { createVar, fallbackVar, keyframes, style } from '@vanilla-extract/css'; const gap = createVar(); const borderRadius = createVar(); +const resizeHandleWidth = createVar(); +export const size = createVar(); +export const panelOrder = createVar(); +const dropIndicatorWidth = createVar(); +const dropIndicatorOpacity = createVar(); +const dropIndicatorRadius = createVar(); -export const splitViewRoot = style({ - vars: { - [gap]: '0px', - [borderRadius]: '0px', - }, - display: 'flex', - flexDirection: 'row', - position: 'relative', - borderRadius, - gap, - - selectors: { - '&[data-client-border="true"]': { - vars: { - [gap]: '8px', - [borderRadius]: '6px', - }, +const expandDropIndicator = keyframes({ + from: { + vars: { + [resizeHandleWidth]: '30px', + [dropIndicatorWidth]: '3px', + [dropIndicatorOpacity]: '1', + [dropIndicatorRadius]: '10px', }, - '&[data-orientation="vertical"]': { - flexDirection: 'column', + }, + to: { + vars: { + [resizeHandleWidth]: '300px', + [dropIndicatorWidth]: '100%', + [dropIndicatorOpacity]: '0.15', + [dropIndicatorRadius]: '4px', }, }, }); export const splitViewPanel = style({ flexShrink: 0, - flexGrow: 'var(--size, 1)', + flexGrow: fallbackVar(size, '1'), position: 'relative', - borderRadius: 'inherit', + order: panelOrder, + display: 'flex', selectors: { - '[data-orientation="vertical"] &': { - height: 0, - }, - '[data-orientation="horizontal"] &': { - width: 0, + '[data-client-border="false"] &[data-is-first="true"]': { + borderTopLeftRadius: borderRadius, }, '[data-client-border="false"] &:not([data-is-last="true"]):not([data-is-dragging="true"])': { - borderRight: `0.5px solid ${cssVar('borderColor')}`, + borderRight: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`, }, - '&[data-is-dragging="true"]': { - zIndex: 1, - }, '[data-client-border="true"] &': { - border: `0.5px solid ${cssVar('borderColor')}`, + border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`, + borderTopLeftRadius: borderRadius, + borderBottomLeftRadius: borderRadius, + borderTopRightRadius: borderRadius, + borderBottomRightRadius: borderRadius, }, }, }); @@ -58,6 +58,7 @@ export const splitViewPanelDrag = style({ width: '100%', height: '100%', borderRadius: 'inherit', + transition: 'opacity 0.2s', selectors: { '&::after': { @@ -73,8 +74,12 @@ export const splitViewPanelDrag = style({ transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)', }, - '[data-is-dragging="true"] &::after': { - boxShadow: `inset 0 0 0 2px ${cssVar('brandColor')}`, + '[data-is-active="true"] &::after': { + boxShadow: `inset 0 0 0 1px ${cssVarV2('button/primary')}`, + }, + + '[data-is-dragging="true"] &': { + opacity: 0.5, }, }, }); @@ -90,49 +95,123 @@ export const resizeHandle = style({ position: 'absolute', top: 0, bottom: 0, - right: -5, - width: 10, + width: resizeHandleWidth, // to make sure it's above all-pages's header - zIndex: 3, + zIndex: 5, display: 'flex', justifyContent: 'center', alignItems: 'stretch', - cursor: 'col-resize', selectors: { - '[data-client-border="true"] &': { - right: `calc(-5px - ${gap} / 2)`, + '&[data-can-resize="false"]:not([data-state="drop-indicator"])': { + pointerEvents: 'none', + }, + + '&[data-state="drop-indicator"]': { + vars: { + [resizeHandleWidth]: '20px', + }, + }, + '&[data-edge="left"]': { + left: `calc(${resizeHandleWidth} * -0.5)`, + right: 'auto', + }, + '&[data-edge="right"]': { + left: 'auto', + right: `calc(${resizeHandleWidth} * -0.5)`, + }, + '&[data-edge="right"][data-is-last="true"]': { + right: 0, + left: 'auto', + }, + '[data-client-border="false"] &[data-is-last="true"][data-edge="right"]::before, [data-client-border="false"] &[data-is-last="true"][data-edge="right"]::after': + { + transform: `translateX(calc(0.5 * ${resizeHandleWidth} - 1px))`, + }, + + '&[data-can-resize="true"]': { + cursor: 'col-resize', + }, + '[data-client-border="true"] &[data-edge="right"]': { + right: `calc(${resizeHandleWidth} * -0.5 - 0.5px - ${gap} / 2)`, }, [`.${splitViewPanel}[data-is-dragging="true"] &`]: { display: 'none', }, - // horizontal - '[data-orientation="horizontal"] &::before, [data-orientation="horizontal"] &::after': - { - content: '""', - width: 2, - position: 'absolute', - height: '100%', - background: 'transparent', - transition: 'background 0.1s', - borderRadius: 10, + '&[data-state="drop-indicator"][data-dragging-over="true"]': { + animationName: expandDropIndicator, + animationDuration: '0.5s', + animationDelay: '1s', + animationFillMode: 'forwards', + vars: { + [dropIndicatorOpacity]: '1', + [dropIndicatorWidth]: '3px', }, - '[data-orientation="horizontal"] &[data-resizing]::before, [data-orientation="horizontal"] &[data-resizing]::after': - { - width: 3, - }, - - '&:hover::before, &[data-resizing]::before': { - background: cssVar('brandColor'), }, - '&:hover::after, &[data-resizing]::after': { - boxShadow: `0px 12px 21px 4px ${cssVar('brandColor')}`, + + '&::before, &::after': { + content: '""', + width: dropIndicatorWidth, + position: 'absolute', + height: '100%', + transition: 'all 0.2s, transform 0s', + borderRadius: dropIndicatorRadius, + }, + '&::before': { + background: cssVarV2('button/primary'), + opacity: dropIndicatorOpacity, + }, + '&[data-state="resizing"]::before, &[data-state="resizing"]::after': { + vars: { + [dropIndicatorWidth]: '3px', + [dropIndicatorOpacity]: '1', + }, + }, + '&[data-state="drop-indicator"][data-dragging-over="false"]::before': { + vars: { + [dropIndicatorOpacity]: '0.5', + }, + }, + '&:is(:hover[data-can-resize="true"], [data-state="resizing"])::before': { + vars: { + [dropIndicatorWidth]: '3px', + [dropIndicatorOpacity]: '1', + }, + }, + '&:is(:hover[data-can-resize="true"], [data-state="resizing"])::after': { + boxShadow: `0px 12px 21px 4px ${cssVarV2('button/primary')}`, opacity: 0.15, }, - - // vertical - // TODO + }, +}); + +export const splitViewRoot = style({ + vars: { + [gap]: '0px', + [borderRadius]: '6px', + [resizeHandleWidth]: '10px', + [dropIndicatorWidth]: '2px', + [dropIndicatorOpacity]: '0', + [dropIndicatorRadius]: '10px', + }, + display: 'flex', + flexDirection: 'row', + position: 'relative', + borderRadius, + gap, + padding: '0 10px', + margin: '0 -10px', + + selectors: { + '&[data-client-border="true"]': { + vars: { + [gap]: '8px', + }, + }, + [`&:has(${resizeHandle}[data-dragging-over="true"])`]: { + overflow: 'clip', + }, }, }); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx index 14fcdcc88..8b59e9426 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx @@ -1,27 +1,18 @@ +import { useDndMonitor } from '@affine/component'; import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper'; -import type { DragEndEvent } from '@dnd-kit/core'; -import { - closestCenter, - DndContext, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - horizontalListSortingStrategy, - SortableContext, -} from '@dnd-kit/sortable'; +import type { AffineDNDData } from '@affine/core/types/dnd'; import { useService } from '@toeverything/infra'; import clsx from 'clsx'; -import type { HTMLAttributes, RefObject } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; +import { useSetAtom } from 'jotai'; +import type { HTMLAttributes } from 'react'; +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import type { View } from '../../entities/view'; import { WorkbenchService } from '../../services/workbench'; import { SplitViewPanel } from './panel'; -import { ResizeHandle } from './resize-handle'; import * as styles from './split-view.css'; +import { draggingOverResizeHandleAtom } from './state'; +import { allowedSplitViewEntityTypes, inferToFromEntity } from './types'; export interface SplitViewProps extends HTMLAttributes { /** @@ -30,12 +21,10 @@ export interface SplitViewProps extends HTMLAttributes { */ orientation?: 'horizontal' | 'vertical'; views: View[]; - renderer: (item: View, index: number) => React.ReactNode; + renderer: (item: View) => React.ReactNode; onMove?: (from: number, to: number) => void; } -type SlotsMap = Record>; -// TODO(@catsjuice): vertical orientation support export const SplitView = ({ orientation = 'horizontal', className, @@ -45,29 +34,27 @@ export const SplitView = ({ ...attrs }: SplitViewProps) => { const rootRef = useRef(null); - const [slots, setSlots] = useState({}); - const [resizingViewId, setResizingViewId] = useState(null); const { appSettings } = useAppSettingHelper(); const workbench = useService(WorkbenchService).workbench; - // blocksuite's lit host element has an issue on remounting. - // Add a workaround here to force remounting after dropping. - const [visible, setVisibility] = useState(true); + // workaround: blocksuite's lit host element has an issue on remounting. + // we do not want the view to change its render ordering here after reordering + // instead we use a local state to store the views + its order to avoid remounting + const [localViewsState, setLocalViewsState] = useState(views); - const sensors = useSensors( - useSensor( - PointerSensor, - useMemo( - /* avoid re-rendering */ - () => ({ - activationConstraint: { - distance: 0, - }, - }), - [] - ) - ) - ); + useLayoutEffect(() => { + setLocalViewsState(oldViews => { + let newViews = oldViews.filter(v => views.includes(v)); + + for (const view of views) { + if (!newViews.includes(view)) { + newViews.push(view); + } + } + + return newViews; + }); + }, [views]); const onResizing = useCallback( (index: number, { x, y }: { x: number; y: number }) => { @@ -85,45 +72,101 @@ export const SplitView = ({ [orientation, workbench] ); - const resizeHandleRenderer = useCallback( - (view: View, index: number) => - index < views.length - 1 ? ( - setResizingViewId(view.id)} - onResizeEnd={() => setResizingViewId(null)} - onResizing={dxy => onResizing(index, dxy)} - /> - ) : null, - [onResizing, resizingViewId, views.length] - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (active.id !== over?.id) { - // update order - const fromIndex = views.findIndex(v => v.id === active.id); - const toIndex = views.findIndex(v => v.id === over?.id); - onMove?.(fromIndex, toIndex); - setVisibility(false); - } + const handleOnMove = useCallback( + (from: number, to: number) => { + onMove?.(from, to); }, - [onMove, views] + [onMove] ); - useEffect(() => { - if (!visible) { - const timeoutId = setTimeout(() => { - setVisibility(true); - }, 0); + const [draggingEntity, setDraggingEntity] = useState(false); - return () => { - clearTimeout(timeoutId); - }; - } - return; - }, [visible]); + const setDraggingOverResizeHandle = useSetAtom(draggingOverResizeHandleAtom); + + useDndMonitor(() => { + return { + // todo(@pengx17): external data for monitor is not supported yet + // allowExternal: true, + canMonitor(data) { + // allow dropping doc && tab view to split view panel + const from = data.source.data.from; + const entity = data.source.data.entity; + if (from?.at === 'app-header:tabs') { + return false; + } else if ( + entity?.type && + allowedSplitViewEntityTypes.has(entity?.type) + ) { + return true; + } else if (from?.at === 'workbench:link') { + return true; + } + return false; + }, + onDragStart() { + setDraggingEntity(true); + }, + onDrop(data) { + setDraggingEntity(false); + const candidate = data.location.current.dropTargets.find( + target => target.data.at === 'workbench:resize-handle' + ); + + if (!candidate) { + return; + } + + const dropTarget = candidate.data as AffineDNDData['draggable']['from']; + const entity = data.source.data.entity; + const from = data.source.data.from; + + if (dropTarget?.at === 'workbench:resize-handle') { + const { edge, viewId } = dropTarget; + const index = views.findIndex(v => v.id === viewId); + const at = (() => { + if (edge === 'left') { + if (index === 0) { + return 'head'; + } + return index - 1; + } else if (edge === 'right') { + if (index === views.length - 1) { + return 'tail'; + } + return index + 1; + } else { + return 'tail'; + } + })(); + + const to = entity + ? inferToFromEntity(entity) + : from?.at === 'workbench:link' + ? from.to + : null; + + if (to) { + workbench.createView(at, to); + } + } + }, + onDropTargetChange(data) { + const candidate = data.location.current.dropTargets.find( + target => target.data.at === 'workbench:resize-handle' + ); + + if (!candidate) { + setDraggingOverResizeHandle(null); + return; + } + + setDraggingOverResizeHandle({ + viewId: candidate.data.viewId as string, + edge: candidate.data.edge as 'left' | 'right', + }); + }, + }; + }, []); return (
- - - {views.map((view, index) => - visible ? ( - - {resizeHandleRenderer(view, index)} - - ) : null - )} - - - - {views.map((view, index) => { - const slot = slots[view.id]?.current; - if (!slot) return null; - return createPortal( - renderer(view, index), - slot, - `portalToSplitViewPanel_${view.id}` + {localViewsState.map(view => { + const order = views.indexOf(view); + return ( + onResizing(order, dxy)} + draggingEntity={draggingEntity} + > + {renderer(view)} + ); })}
diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/state.ts b/packages/frontend/core/src/modules/workbench/view/split-view/state.ts new file mode 100644 index 000000000..8d6e0f269 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/split-view/state.ts @@ -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); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/types.ts b/packages/frontend/core/src/modules/workbench/view/split-view/types.ts new file mode 100644 index 000000000..c9525b134 --- /dev/null +++ b/packages/frontend/core/src/modules/workbench/view/split-view/types.ts @@ -0,0 +1,15 @@ +import type { AffineDNDEntity } from '@affine/core/types/dnd'; + +export const allowedSplitViewEntityTypes: Set = + 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; +}; diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx index 98c6a5b15..94b35b3d1 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx @@ -1,10 +1,12 @@ +import { useDraggable } from '@affine/component'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import type { AffineDNDData, AffineDNDEntity } from '@affine/core/types/dnd'; import { isNewTabTrigger } from '@affine/core/utils'; import { useLiveData, useServices } from '@toeverything/infra'; import { type To } from 'history'; import { forwardRef, type MouseEvent } from 'react'; -import { FeatureFlagService } from '../../feature-flag'; +import { resolveRouteLinkMeta } from '../../navigation/utils'; import { WorkbenchService } from '../services/workbench'; export type WorkbenchLinkProps = React.PropsWithChildren< @@ -15,20 +17,45 @@ export type WorkbenchLinkProps = React.PropsWithChildren< } & React.HTMLProps >; +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( function WorkbenchLink({ to, onClick, replaceHistory, ...other }, ref) { - const { featureFlagService, workbenchService } = useServices({ - FeatureFlagService, + const { workbenchService } = useServices({ WorkbenchService, }); - const enableMultiView = useLiveData( - featureFlagService.flags.enable_multi_view.$ - ); const workbench = workbenchService.workbench; const basename = useLiveData(workbench.basename$); - const link = - basename + - (typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`); + const stringTo = + typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`; + const link = basename + stringTo; const handleClick = useAsyncCallback( async (event: React.MouseEvent) => { onClick?.(event); @@ -37,9 +64,7 @@ export const WorkbenchLink = forwardRef( } const at = (() => { if (isNewTabTrigger(event)) { - return BUILD_CONFIG.isElectron && event.altKey && enableMultiView - ? 'tail' - : 'new-tab'; + return BUILD_CONFIG.isElectron && event.altKey ? 'tail' : 'new-tab'; } return 'active'; })(); @@ -47,15 +72,32 @@ export const WorkbenchLink = forwardRef( event.preventDefault(); event.stopPropagation(); }, - [enableMultiView, onClick, replaceHistory, to, workbench] + [onClick, replaceHistory, to, workbench] ); - // eslint suspicious runtime error - // eslint-disable-next-line react/no-danger-with-children + const { dragRef } = useDraggable(() => { + return { + data: { + entity: resolveToEntity(to, basename), + from: { + at: 'workbench:link', + to: stringTo, + }, + }, + }; + }, [to, basename, stringTo]); + return ( { + dragRef.current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }} href={link} onClick={handleClick} onAuxClick={handleClick} diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.css.ts b/packages/frontend/core/src/modules/workbench/view/workbench-root.css.ts index 336b7d601..943fb8ecf 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-root.css.ts +++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.css.ts @@ -5,7 +5,6 @@ export const workbenchRootContainer = style({ display: 'flex', height: '100%', flex: 1, - overflow: 'hidden', }); export const workbenchViewContainer = style({ diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx index 5a2ea7fac..1e5d3e66b 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx @@ -48,8 +48,8 @@ export const WorkbenchRoot = memo(() => { useAdapter(workbench, basename); - const panelRenderer = useCallback((view: View, index: number) => { - return ; + const panelRenderer = useCallback((view: View) => { + return ; }, []); const onMove = useCallback( @@ -78,12 +78,12 @@ export const WorkbenchRoot = memo(() => { WorkbenchRoot.displayName = 'memo(WorkbenchRoot)'; -const WorkbenchView = ({ view, index }: { view: View; index: number }) => { +const WorkbenchView = ({ view }: { view: View }) => { const workbench = useService(WorkbenchService).workbench; const handleOnFocus = useCallback(() => { - workbench.active(index); - }, [workbench, index]); + workbench.active(view); + }, [workbench, view]); const containerRef = useRef(null); @@ -149,7 +149,7 @@ const WorkbenchSidebar = () => { ; } diff --git a/tests/affine-desktop/e2e/split-view.spec.ts b/tests/affine-desktop/e2e/split-view.spec.ts new file mode 100644 index 000000000..cd0941762 --- /dev/null +++ b/tests/affine-desktop/e2e/split-view.spec.ts @@ -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(); +}); diff --git a/tests/affine-desktop/e2e/tabs.spec.ts b/tests/affine-desktop/e2e/tabs.spec.ts index 9b7a2b58f..b4bfb2ae6 100644 --- a/tests/affine-desktop/e2e/tabs.spec.ts +++ b/tests/affine-desktop/e2e/tabs.spec.ts @@ -1,57 +1,17 @@ import { test } from '@affine-test/kit/electron'; +import { + closeTab, + expectActiveTab, + expectTabCount, + expectTabTitle, +} from '@affine-test/kit/utils/app-tabs'; import { clickNewPageButton, createLinkedPage, dragTo, } from '@affine-test/kit/utils/page-logic'; import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; -import { expect, type Page } from '@playwright/test'; - -async function expectActiveTab(page: Page, index: number, activeViewIndex = 0) { - await expect( - page - .getByTestId('workbench-tab') - .nth(index) - .getByTestId('split-view-label') - .nth(activeViewIndex) - ).toHaveAttribute('data-active', 'true'); -} - -async function expectTabTitle( - page: Page, - index: number, - title: string | string[] -) { - if (typeof title === 'string') { - await expect(page.getByTestId('workbench-tab').nth(index)).toContainText( - title - ); - } else { - for (let i = 0; i < title.length; i++) { - await expect( - page - .getByTestId('workbench-tab') - .nth(index) - .getByTestId('split-view-label') - .nth(i) - ).toContainText(title[i]); - } - } -} - -async function expectTabCount(page: Page, count: number) { - await expect(page.getByTestId('workbench-tab')).toHaveCount(count); -} - -async function closeTab(page: Page, index: number) { - await page.getByTestId('workbench-tab').nth(index).hover(); - - await page - .getByTestId('workbench-tab') - .nth(index) - .getByTestId('close-tab-button') - .click(); -} +import { expect } from '@playwright/test'; test('create new tab', async ({ views }) => { let page = await views.getActive(); @@ -148,34 +108,6 @@ test('open new tab via cmd+click page link', async ({ page }) => { 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 }) => { const title = 'this is a new page to drag'; await clickNewPageButton(page, title); diff --git a/tests/kit/src/utils/app-tabs.ts b/tests/kit/src/utils/app-tabs.ts new file mode 100644 index 000000000..32c42b734 --- /dev/null +++ b/tests/kit/src/utils/app-tabs.ts @@ -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(); +} diff --git a/tests/kit/src/utils/page-logic.ts b/tests/kit/src/utils/page-logic.ts index aebf04523..2c0515ae6 100644 --- a/tests/kit/src/utils/page-logic.ts +++ b/tests/kit/src/utils/page-logic.ts @@ -139,7 +139,8 @@ export const dragTo = async ( page: Page, locator: Locator, target: Locator, - location: DragLocation = 'center' + location: DragLocation = 'center', + willMoveOnDrag = false ) => { await locator.hover(); const locatorElement = await locator.boundingBox(); @@ -164,18 +165,20 @@ export const dragTo = async ( await target.hover(); - const targetElement = await target.boundingBox(); - 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, + if (!willMoveOnDrag) { + const targetElement = await target.boundingBox(); + 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, + } + ); + } await page.waitForTimeout(100); await page.mouse.up(); }; diff --git a/yarn.lock b/yarn.lock index aecbb0903..d674af820 100644 --- a/yarn.lock +++ b/yarn.lock @@ -361,9 +361,6 @@ __metadata: "@blocksuite/icons": "npm:2.2.1" "@capacitor/app": "npm:^6.0.2" "@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" "@emotion/cache": "npm:^11.14.0" "@emotion/react": "npm:^11.14.0" @@ -4534,68 +4531,6 @@ __metadata: languageName: node 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": version: 0.7.11 resolution: "@dotlottie/common@npm:0.7.11"