diff --git a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx index 61fa64f673..2af73d846c 100644 --- a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx +++ b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx @@ -2,6 +2,7 @@ import { toast } from '@affine/component'; import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper'; import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; +import { Workbench } from '@affine/core/modules/workbench'; import type { Collection, Filter } from '@affine/env/filter'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -33,6 +34,7 @@ const usePageOperationsRenderer = () => { currentWorkspace.docCollection ); const t = useAFFiNEI18N(); + const workbench = useService(Workbench); const pageOperationsRenderer = useCallback( (page: DocMeta) => { @@ -48,6 +50,7 @@ const usePageOperationsRenderer = () => { isPublic={!!page.isPublic} onDisablePublicSharing={onDisablePublicSharing} link={`/workspace/${currentWorkspace.id}/${page.id}`} + onOpenInSplitView={() => workbench.openPage(page.id, { at: 'tail' })} onDuplicate={() => { duplicate(page.id, false); }} @@ -70,7 +73,14 @@ const usePageOperationsRenderer = () => { /> ); }, - [currentWorkspace.id, setTrashModal, t, toggleFavorite, duplicate] + [ + currentWorkspace.id, + workbench, + duplicate, + setTrashModal, + toggleFavorite, + t, + ] ); return pageOperationsRenderer; 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 3602ada676..9fd9328c66 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -7,6 +7,7 @@ import { toast, Tooltip, } from '@affine/component'; +import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { @@ -20,6 +21,7 @@ import { MoreVerticalIcon, OpenInNewIcon, ResetIcon, + SplitViewIcon, } from '@blocksuite/icons'; import { useCallback, useState } from 'react'; import { Link } from 'react-router-dom'; @@ -45,6 +47,7 @@ export interface PageOperationCellProps { onRemoveToTrash: () => void; onDuplicate: () => void; onDisablePublicSharing: () => void; + onOpenInSplitView: () => void; } export const PageOperationCell = ({ @@ -55,8 +58,10 @@ export const PageOperationCell = ({ onRemoveToTrash, onDuplicate, onDisablePublicSharing, + onOpenInSplitView, }: PageOperationCellProps) => { const t = useAFFiNEI18N(); + const { appSettings } = useAppSettingHelper(); const [openDisableShared, setOpenDisableShared] = useState(false); const OperationMenu = ( <> @@ -84,6 +89,20 @@ export const PageOperationCell = ({ ? t['com.affine.favoritePageOperation.remove']() : t['com.affine.favoritePageOperation.add']()} + + {environment.isDesktop && appSettings.enableMultiView ? ( + + + + } + > + {t['com.affine.workbench.split-view.page-menu-open']()} + + ) : null} + {!environment.isDesktop && ( void; }>) => { + const { appSettings } = useAppSettingHelper(); const service = useService(CollectionService); + const workbench = useService(Workbench); const { open: openEditCollectionModal, node: editModal } = useEditCollection(config); const t = useAFFiNEI18N(); @@ -71,6 +80,10 @@ export const CollectionOperations = ({ }); }, [openEditCollectionModal, collection, service]); + const openCollectionSplitView = useCallback(() => { + workbench.openCollection(collection.id, { at: 'tail' }); + }, [collection.id, workbench]); + const actions = useMemo< Array< | { @@ -104,6 +117,19 @@ export const CollectionOperations = ({ name: t['com.affine.collection.menu.edit'](), click: showEdit, }, + ...(appSettings.enableMultiView + ? [ + { + icon: ( + + + + ), + name: t['com.affine.workbench.split-view.page-menu-open'](), + click: openCollectionSplitView, + }, + ] + : []), { element:
, }, @@ -120,7 +146,16 @@ export const CollectionOperations = ({ type: 'danger', }, ], - [t, showEditName, showEdit, service, info, collection.id] + [ + t, + showEditName, + showEdit, + appSettings.enableMultiView, + openCollectionSplitView, + service, + info, + collection.id, + ] ); return ( <> diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx index 324cc6d06c..5ad1710420 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx @@ -4,6 +4,7 @@ import { type MenuItemProps, MenuSeparator, } from '@affine/component'; +import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { DeleteIcon, @@ -11,6 +12,7 @@ import { FavoriteIcon, FilterMinusIcon, LinkedPageIcon, + SplitViewIcon, } from '@blocksuite/icons'; import { type ReactElement, useMemo } from 'react'; @@ -24,6 +26,7 @@ type OperationItemsProps = { onAddLinkedPage: () => void; onRemoveFromFavourites?: () => void; onDelete: () => void; + onOpenInSplitView: () => void; }; export const OperationItems = ({ @@ -35,7 +38,9 @@ export const OperationItems = ({ onAddLinkedPage, onRemoveFromFavourites, onDelete, + onOpenInSplitView, }: OperationItemsProps) => { + const { appSettings } = useAppSettingHelper(); const t = useAFFiNEI18N(); const actions = useMemo< Array< @@ -81,9 +86,6 @@ export const OperationItems = ({ name: t['Remove from favorites'](), click: onRemoveFromFavourites, }, - { - element: , - }, ] : []), ...(inAllowList && onRemoveFromAllowList @@ -97,18 +99,27 @@ export const OperationItems = ({ name: t['Remove special filter'](), click: onRemoveFromAllowList, }, - { - element: , - }, ] : []), - ...(isReferencePage + + ...(appSettings.enableMultiView ? [ + // open split view { - element: , + icon: ( + + + + ), + name: t['com.affine.workbench.split-view.page-menu-open'](), + click: onOpenInSplitView, }, ] : []), + + { + element: , + }, { icon: ( @@ -121,14 +132,16 @@ export const OperationItems = ({ }, ], [ + t, onRename, onAddLinkedPage, inFavorites, onRemoveFromFavourites, isReferencePage, - t, inAllowList, onRemoveFromAllowList, + appSettings.enableMultiView, + onOpenInSplitView, onDelete, ] ); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx index a604567ae4..fc81fb7988 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx @@ -1,9 +1,11 @@ import { toast } from '@affine/component'; import { IconButton } from '@affine/component/ui/button'; import { Menu } from '@affine/component/ui/menu'; +import { Workbench } from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { MoreHorizontalIcon } from '@blocksuite/icons'; import type { DocCollection } from '@blocksuite/store'; +import { useService } from '@toeverything/infra/di'; import { useCallback } from 'react'; import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper'; @@ -37,6 +39,7 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { const { createLinkedPage } = usePageHelper(docCollection); const { setTrashModal } = useTrashModalHelper(docCollection); const { removeFromFavorite } = useBlockSuiteMetaHelper(docCollection); + const workbench = useService(Workbench); const handleRename = useCallback(() => { setRenameModalOpen?.(); @@ -64,6 +67,10 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { removeFromAllowList?.(pageId); }, [pageId, removeFromAllowList]); + const handleOpenInSplitView = useCallback(() => { + workbench.openPage(pageId, { at: 'tail' }); + }, [pageId, workbench]); + return ( { onRemoveFromAllowList={handleRemoveFromAllowList} onRemoveFromFavourites={handleRemoveFromFavourites} onRename={handleRename} + onOpenInSplitView={handleOpenInSplitView} inAllowList={inAllowList} inFavorites={inFavorites} isReferencePage={isReferencePage} diff --git a/packages/frontend/core/src/modules/right-sidebar/view/container.css.ts b/packages/frontend/core/src/modules/right-sidebar/view/container.css.ts index 72918888ac..a7be1f52e2 100644 --- a/packages/frontend/core/src/modules/right-sidebar/view/container.css.ts +++ b/packages/frontend/core/src/modules/right-sidebar/view/container.css.ts @@ -8,15 +8,24 @@ export const sidebarContainerInner = style({ overflow: 'hidden', height: '100%', width: '100%', + borderRadius: 'inherit', + selectors: { + ['[data-client-border=true][data-is-floating="true"] &']: { + boxShadow: cssVar('shadow3'), + border: `1px solid ${cssVar('borderColor')}`, + }, + }, }); export const sidebarContainer = style({ display: 'flex', flexShrink: 0, height: '100%', + right: 0, selectors: { [`&[data-client-border=true]`]: { - paddingLeft: 9, + paddingLeft: 8, + borderRadius: 6, }, [`&[data-client-border=false]`]: { borderLeft: `1px solid ${cssVar('borderColor')}`, diff --git a/packages/frontend/core/src/modules/right-sidebar/view/container.tsx b/packages/frontend/core/src/modules/right-sidebar/view/container.tsx index 1946a7fb4e..f27d5e7109 100644 --- a/packages/frontend/core/src/modules/right-sidebar/view/container.tsx +++ b/packages/frontend/core/src/modules/right-sidebar/view/container.tsx @@ -1,9 +1,10 @@ import { ResizePanel } from '@affine/component/resize-panel'; +import { appSidebarOpenAtom } from '@affine/core/components/app-sidebar'; import { appSettingAtom } from '@toeverything/infra/atom'; import { useService } from '@toeverything/infra/di'; import { useLiveData } from '@toeverything/infra/livedata'; import { useAtomValue } from 'jotai'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { RightSidebar } from '../entities/right-sidebar'; import * as styles from './container.css'; @@ -20,6 +21,18 @@ export const RightSidebarContainer = () => { const frontView = useLiveData(rightSidebar.front); const open = useLiveData(rightSidebar.isOpen) && frontView !== undefined; + const [floating, setFloating] = useState(false); + const appSidebarOpened = useAtomValue(appSidebarOpenAtom); + + useEffect(() => { + const onResize = () => + setFloating(!!(window.innerWidth < 1200 && appSidebarOpened)); + onResize(); + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + }; + }, [appSidebarOpened]); const handleOpenChange = useCallback( (open: boolean) => { @@ -38,8 +51,9 @@ export const RightSidebarContainer = () => { return ( { active?: boolean; + open?: boolean; + onOpenMenu?: () => void; + setPressed: (v: boolean) => void; } export const SplitViewMenuIndicator = memo( forwardRef( function SplitViewMenuIndicator( - { className, active, ...attrs }: SplitViewMenuProps, + { + className, + active, + open, + setPressed, + onOpenMenu, + ...attrs + }: SplitViewMenuProps, 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]); + return (
@@ -26,3 +64,66 @@ export const SplitViewMenuIndicator = memo( } ) ); + +interface SplitViewIndicatorProps extends HTMLAttributes { + isDragging?: boolean; + isActive?: boolean; + menuItems?: React.ReactNode; + // import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities' is not allowed + listeners?: any; + setPressed?: (pressed: boolean) => void; +} +export const SplitViewIndicator = ({ + isDragging, + isActive, + menuItems, + listeners, + setPressed, +}: SplitViewIndicatorProps) => { + const active = isActive || isDragging; + 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); + }, []); + + 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 bbe7787c45..3ab80455b9 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,10 +1,10 @@ -import { Menu, MenuIcon, MenuItem, type MenuProps } from '@affine/component'; +import { MenuIcon, MenuItem } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { - CloseIcon, - ExpandFullIcon, - InsertLeftIcon, - InsertRightIcon, + ExpandCloseIcon, + KeepThisOneIcon, + MoveToLeftIcon, + MoveToRightIcon, } from '@blocksuite/icons'; import { useSortable } from '@dnd-kit/sortable'; import { useService } from '@toeverything/infra/di'; @@ -26,7 +26,7 @@ import { import type { View } from '../../entities/view'; import { Workbench } from '../../entities/workbench'; -import { SplitViewMenuIndicator } from './indicator'; +import { SplitViewIndicator } from './indicator'; import * as styles from './split-view.css'; export interface SplitViewPanelProps @@ -43,22 +43,24 @@ export const SplitViewPanel = memo(function SplitViewPanel({ view, setSlots, }: SplitViewPanelProps) { + const [indicatorPressed, setIndicatorPressed] = useState(false); const ref = useRef(null); const size = useLiveData(view.size); - const [menuOpen, setMenuOpen] = useState(false); const workbench = useService(Workbench); const activeView = useLiveData(workbench.activeView); const views = useLiveData(workbench.views); + const isLast = views[views.length - 1] === view; + const { attributes, listeners, transform, transition, - isDragging, + isDragging: dndIsDragging, setNodeRef, - setActivatorNodeRef, } = useSortable({ id: view.id, attributes: { role: 'group' } }); + const isDragging = dndIsDragging || indicatorPressed; const isActive = activeView === view; useEffect(() => { @@ -67,12 +69,6 @@ export const SplitViewPanel = memo(function SplitViewPanel({ } }, [setSlots, view.id]); - useEffect(() => { - if (isDragging) { - setMenuOpen(false); - } - }, [isDragging]); - const style = useMemo( () => ({ ...assignInlineVars({ '--size': size.toString() }), @@ -86,27 +82,14 @@ export const SplitViewPanel = memo(function SplitViewPanel({ }), [transform, transition] ); - const menuRootOptions = useMemo( - () => - ({ - open: menuOpen, - onOpenChange: setMenuOpen, - }) satisfies MenuProps['rootOptions'], - [menuOpen] - ); - const menuContentOptions = useMemo( - () => - ({ - align: 'center', - }) satisfies MenuProps['contentOptions'], - [] - ); return (
1} + data-is-last={isLast} >
{views.length > 1 ? ( - } - rootOptions={menuRootOptions} - > - - + } + setPressed={setIndicatorPressed} + /> ) : null}
{children} @@ -135,10 +113,7 @@ export const SplitViewPanel = memo(function SplitViewPanel({ ); }); -interface SplitViewMenuProps { - view: View; -} -const SplitViewMenu = ({ view }: SplitViewMenuProps) => { +const SplitViewMenu = ({ view }: { view: View }) => { const t = useAFFiNEI18N(); const workbench = useService(Workbench); const views = useLiveData(workbench.views); @@ -155,14 +130,14 @@ const SplitViewMenu = ({ view }: SplitViewMenuProps) => { const handleMoveRight = useCallback(() => { workbench.moveView(viewIndex, viewIndex + 1); }, [viewIndex, workbench]); - const handleFullScreen = useCallback(() => { + const handleCloseOthers = useCallback(() => { workbench.closeOthers(view); }, [view, workbench]); const CloseItem = views.length > 1 ? ( } />} + preFix={} />} onClick={handleClose} > {t['com.affine.workbench.split-view-menu.close']()} @@ -173,7 +148,7 @@ const SplitViewMenu = ({ view }: SplitViewMenuProps) => { viewIndex > 0 && views.length > 1 ? ( } />} + preFix={} />} > {t['com.affine.workbench.split-view-menu.move-left']()} @@ -182,10 +157,10 @@ const SplitViewMenu = ({ view }: SplitViewMenuProps) => { const FullScreenItem = views.length > 1 ? ( } />} + onClick={handleCloseOthers} + preFix={} />} > - {t['com.affine.workbench.split-view-menu.full-screen']()} + {t['com.affine.workbench.split-view-menu.keep-this-one']()} ) : null; @@ -193,7 +168,7 @@ const SplitViewMenu = ({ view }: SplitViewMenuProps) => { viewIndex < views.length - 1 ? ( } />} + preFix={} />} > {t['com.affine.workbench.split-view-menu.move-right']()} 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 a7b579b60a..5aff5907e5 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 @@ -17,7 +17,7 @@ export const splitViewRoot = style({ selectors: { '&[data-client-border="true"]': { vars: { - [gap]: '6px', + [gap]: '8px', [borderRadius]: '6px', }, }, @@ -40,7 +40,7 @@ export const splitViewPanel = style({ '[data-orientation="horizontal"] &': { width: 0, }, - '[data-client-border="false"] &:not(:last-child):not([data-is-dragging="true"])': + '[data-client-border="false"] &:not([data-is-last="true"]):not([data-is-dragging="true"])': { borderRight: `1px solid ${cssVar('borderColor')}`, }, @@ -63,6 +63,10 @@ export const splitViewPanelDrag = style({ borderRadius: 'inherit', pointerEvents: 'none', zIndex: 10, + + // animate border in/out + boxShadow: `inset 0 0 0 0 transparent`, + transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)', }, '[data-is-dragging="true"] &::after': { @@ -125,11 +129,3 @@ export const resizeHandle = style({ // TODO }, }); - -export const menuTrigger = style({ - position: 'absolute', - left: '50%', - top: 3, - transform: 'translateX(-50%)', - zIndex: 10, -}); 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 0b68aa0b41..3638026da3 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 @@ -58,7 +58,7 @@ export const SplitView = ({ const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { - distance: 2, + distance: 0, }, }) ); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index af2b9c366d..8a818aa5dc 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1163,5 +1163,7 @@ "com.affine.delete-tags.count": "{{count}} tag deleted", "com.affine.delete-tags.count_one": "{{count}} tag deleted", "com.affine.delete-tags.count_other": "{{count}} tags deleted", + "com.affine.workbench.split-view-menu.keep-this-one": "Keep this one", + "com.affine.workbench.split-view.page-menu-open": "Split View", "com.affine.search-tags.placeholder": "Type here ..." }