diff --git a/apps/web/src/components/affine/operation-menu-items/CopyLink.tsx b/apps/web/src/components/affine/operation-menu-items/CopyLink.tsx new file mode 100644 index 0000000000..b8008bb58f --- /dev/null +++ b/apps/web/src/components/affine/operation-menu-items/CopyLink.tsx @@ -0,0 +1,24 @@ +import { MenuItem } from '@affine/component'; +import { useTranslation } from '@affine/i18n'; +import { CopyIcon } from '@blocksuite/icons'; +// import { useRouter } from "next/router"; +// import { useCallback } from "react"; +// +// import { toast } from "../../../utils"; + +export const CopyLink = () => { + const { t } = useTranslation(); + // const router = useRouter(); + // const copyUrl = useCallback(() => { + // const workspaceId = router.query.workspaceId; + // navigator.clipboard.writeText(window.location.href); + // toast(t("Copied link to clipboard")); + // }, [router.query.workspaceId, t]); + return ( + <> + {}} icon={} disabled={true}> + {t('Copy Link')} + + + ); +}; diff --git a/apps/web/src/components/affine/operation-menu-items/Export.tsx b/apps/web/src/components/affine/operation-menu-items/Export.tsx new file mode 100644 index 0000000000..6c2a876717 --- /dev/null +++ b/apps/web/src/components/affine/operation-menu-items/Export.tsx @@ -0,0 +1,50 @@ +import { Menu, MenuItem } from '@affine/component'; +import { useTranslation } from '@affine/i18n'; +import { + ArrowRightSmallIcon, + ExportIcon, + ExportToHtmlIcon, + ExportToMarkdownIcon, +} from '@blocksuite/icons'; + +export const Export = () => { + const { t } = useTranslation(); + + return ( + + { + // @ts-expect-error + globalThis.currentEditor.contentParser.onExportHtml(); + }} + icon={} + > + {t('Export to HTML')} + + { + // @ts-expect-error + globalThis.currentEditor.contentParser.onExportMarkdown(); + }} + icon={} + > + {t('Export to Markdown')} + + + } + > + } + endIcon={} + onClick={e => e.stopPropagation()} + > + {t('Export')} + + + ); +}; diff --git a/apps/web/src/components/affine/operation-menu-items/MoveTo.tsx b/apps/web/src/components/affine/operation-menu-items/MoveTo.tsx new file mode 100644 index 0000000000..deba4def80 --- /dev/null +++ b/apps/web/src/components/affine/operation-menu-items/MoveTo.tsx @@ -0,0 +1,46 @@ +import { MenuItem } from '@affine/component'; +import { useTranslation } from '@affine/i18n'; +import { ArrowRightSmallIcon, MoveToIcon } from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; +import { useRef, useState } from 'react'; + +import type { BlockSuiteWorkspace } from '../../../shared'; +import { PivotsMenu } from '../pivots'; + +export const MoveTo = ({ + metas, + currentMeta, + blockSuiteWorkspace, +}: { + metas: PageMeta[]; + currentMeta: PageMeta; + blockSuiteWorkspace: BlockSuiteWorkspace; +}) => { + const { t } = useTranslation(); + const ref = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + const open = anchorEl !== null; + return ( + <> + { + e.stopPropagation(); + setAnchorEl(ref.current); + }} + icon={} + endIcon={} + > + {t('Move to')} + + !meta.trash)} + currentMeta={currentMeta} + blockSuiteWorkspace={blockSuiteWorkspace} + /> + + ); +}; diff --git a/apps/web/src/components/affine/operation-menu-items/MoveToTrash.tsx b/apps/web/src/components/affine/operation-menu-items/MoveToTrash.tsx new file mode 100644 index 0000000000..3ea05448fc --- /dev/null +++ b/apps/web/src/components/affine/operation-menu-items/MoveToTrash.tsx @@ -0,0 +1,60 @@ +import { Confirm, MenuItem } from '@affine/component'; +import { useTranslation } from '@affine/i18n'; +import { DeleteTemporarilyIcon } from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; +import { useState } from 'react'; + +import { usePageMetaHelper } from '../../../hooks/use-page-meta'; +import type { BlockSuiteWorkspace } from '../../../shared'; +import { toast } from '../../../utils'; + +export const MoveToTrash = ({ + currentMeta, + blockSuiteWorkspace, + testId, +}: { + currentMeta: PageMeta; + blockSuiteWorkspace: BlockSuiteWorkspace; + testId?: string; +}) => { + const { t } = useTranslation(); + + const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace); + const [open, setOpen] = useState(false); + return ( + <> + { + setOpen(true); + }} + icon={} + > + {t('Move to Trash')} + + { + toast(t('Moved to Trash')); + setOpen(false); + setPageMeta(currentMeta.id, { + trash: true, + trashDate: +new Date(), + }); + }} + onClose={() => { + setOpen(false); + }} + onCancel={() => { + setOpen(false); + }} + /> + + ); +}; diff --git a/apps/web/src/components/affine/operation-menu-items/index.ts b/apps/web/src/components/affine/operation-menu-items/index.ts new file mode 100644 index 0000000000..612fefc606 --- /dev/null +++ b/apps/web/src/components/affine/operation-menu-items/index.ts @@ -0,0 +1,4 @@ +export * from './CopyLink'; +export * from './Export'; +export * from './MoveTo'; +export * from './MoveToTrash'; diff --git a/apps/web/src/components/affine/pivots/OperationButton.tsx b/apps/web/src/components/affine/pivots/OperationButton.tsx new file mode 100644 index 0000000000..e949aafeb8 --- /dev/null +++ b/apps/web/src/components/affine/pivots/OperationButton.tsx @@ -0,0 +1,103 @@ +import { MuiClickAwayListener } from '@affine/component'; +import { MoreVerticalIcon } from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; +import { useTheme } from '@mui/material'; +import { useMemo, useState } from 'react'; + +import type { BlockSuiteWorkspace } from '../../../shared'; +import { OperationMenu } from './OperationMenu'; +import { PivotsMenu } from './PivotsMenu/PivotsMenu'; +import { StyledOperationButton } from './styles'; + +export type OperationButtonProps = { + onAdd: () => void; + onDelete: () => void; + metas: PageMeta[]; + currentMeta: PageMeta; + blockSuiteWorkspace: BlockSuiteWorkspace; + isHover: boolean; + onMenuClose?: () => void; +}; +export const OperationButton = ({ + onAdd, + onDelete, + metas, + currentMeta, + blockSuiteWorkspace, + isHover, + onMenuClose, +}: OperationButtonProps) => { + const { + zIndex: { modal: modalIndex }, + } = useTheme(); + + const [anchorEl, setAnchorEl] = useState(null); + const [operationOpen, setOperationOpen] = useState(false); + const [pivotsMenuOpen, setPivotsMenuOpen] = useState(false); + + const menuIndex = useMemo(() => modalIndex + 1, [modalIndex]); + + return ( + { + setOperationOpen(false); + setPivotsMenuOpen(false); + }} + > +
{ + e.stopPropagation(); + }} + onMouseLeave={() => { + setOperationOpen(false); + setPivotsMenuOpen(false); + }} + > + setAnchorEl(ref)} + size="small" + onClick={() => { + setOperationOpen(!operationOpen); + }} + visible={isHover} + > + + + { + switch (type) { + case 'add': + onAdd(); + break; + case 'move': + setPivotsMenuOpen(true); + break; + case 'delete': + onDelete(); + break; + } + setOperationOpen(false); + onMenuClose?.(); + }} + currentMeta={currentMeta} + blockSuiteWorkspace={blockSuiteWorkspace} + /> + + +
+
+ ); +}; diff --git a/apps/web/src/components/affine/pivots/OperationMenu.tsx b/apps/web/src/components/affine/pivots/OperationMenu.tsx new file mode 100644 index 0000000000..137d75fedf --- /dev/null +++ b/apps/web/src/components/affine/pivots/OperationMenu.tsx @@ -0,0 +1,74 @@ +import type { PureMenuProps } from '@affine/component'; +import { MenuItem, PureMenu } from '@affine/component'; +import { useTranslation } from '@affine/i18n'; +import { MoveToIcon, PenIcon, PlusIcon } from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; +import type { ReactElement } from 'react'; + +import type { BlockSuiteWorkspace } from '../../../shared'; +import { CopyLink, MoveToTrash } from '../operation-menu-items'; + +export type OperationMenuProps = { + onSelect: (type: OperationMenuItems['type']) => void; + blockSuiteWorkspace: BlockSuiteWorkspace; + currentMeta: PageMeta; +} & PureMenuProps; + +export type OperationMenuItems = { + label: string; + icon: ReactElement; + type: 'add' | 'move' | 'rename' | 'delete' | 'copy'; + disabled?: boolean; +}; + +const menuItems: OperationMenuItems[] = [ + { + label: 'Add a subpage inside', + icon: , + type: 'add', + }, + { + label: 'Move to', + icon: , + type: 'move', + }, + { + label: 'Rename', + icon: , + type: 'rename', + disabled: true, + }, +]; + +export const OperationMenu = ({ + onSelect, + blockSuiteWorkspace, + currentMeta, + ...menuProps +}: OperationMenuProps) => { + const { t } = useTranslation(); + + return ( + + {menuItems.map((item, index) => { + return ( + { + onSelect(item.type); + }} + icon={item.icon} + disabled={!!item.disabled} + > + {t(item.label)} + + ); + })} + + + + ); +}; diff --git a/apps/web/src/components/affine/pivots/PivotRender.tsx b/apps/web/src/components/affine/pivots/PivotRender.tsx new file mode 100644 index 0000000000..8073ccdd03 --- /dev/null +++ b/apps/web/src/components/affine/pivots/PivotRender.tsx @@ -0,0 +1,65 @@ +import { ArrowDownSmallIcon, EdgelessIcon, PageIcon } from '@blocksuite/icons'; +import { useAtomValue } from 'jotai'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; + +import { workspacePreferredModeAtom } from '../../../atoms'; +import { OperationButton } from './OperationButton'; +import { StyledCollapsedButton, StyledPivot } from './styles'; +import type { TreeNode } from './types'; + +export const PivotRender: TreeNode['render'] = ( + node, + { isOver, onAdd, onDelete, collapsed, setCollapsed, isSelected }, + renderProps +) => { + const { + onClick, + showOperationButton = false, + currentMeta, + metas = [], + blockSuiteWorkspace, + } = renderProps!; + const record = useAtomValue(workspacePreferredModeAtom); + const router = useRouter(); + + const [isHover, setIsHover] = useState(false); + + const active = router.query.pageId === node.id; + + return ( + { + onClick?.(e, node); + }} + onMouseEnter={() => setIsHover(true)} + onMouseLeave={() => setIsHover(false)} + isOver={isOver || isSelected} + active={active} + > + { + e.stopPropagation(); + setCollapsed(node.id, !collapsed); + }} + > + + + {record[node.id] === 'edgeless' ? : } + {currentMeta?.title || 'Untitled'} + {showOperationButton && ( + setIsHover(false)} + /> + )} + + ); +}; diff --git a/apps/web/src/components/affine/pivots/PivotsMenu/EmptyItem.tsx b/apps/web/src/components/affine/pivots/PivotsMenu/EmptyItem.tsx new file mode 100644 index 0000000000..a53472c995 --- /dev/null +++ b/apps/web/src/components/affine/pivots/PivotsMenu/EmptyItem.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from '@affine/i18n'; + +import { StyledPivot } from '../styles'; + +export const EmptyItem = () => { + const { t } = useTranslation(); + return {t('No item')}; +}; + +export default EmptyItem; diff --git a/apps/web/src/components/affine/pivots/PivotsMenu/Pivots.tsx b/apps/web/src/components/affine/pivots/PivotsMenu/Pivots.tsx new file mode 100644 index 0000000000..9ac93ba7ab --- /dev/null +++ b/apps/web/src/components/affine/pivots/PivotsMenu/Pivots.tsx @@ -0,0 +1,85 @@ +import { MuiCollapse, TreeView } from '@affine/component'; +import { useTranslation } from '@affine/i18n'; +import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons'; +import type { MouseEvent } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import { usePageMetaHelper } from '../../../../hooks/use-page-meta'; +import { usePivotData } from '../hooks/usePivotData'; +import { usePivotHandler } from '../hooks/usePivotHandler'; +import { PivotRender } from '../PivotRender'; +import { StyledCollapsedButton, StyledPivot } from '../styles'; +import EmptyItem from './EmptyItem'; +import type { PivotsMenuProps } from './PivotsMenu'; + +export const Pivots = ({ + metas, + blockSuiteWorkspace, + currentMeta, +}: Pick) => { + const { t } = useTranslation(); + const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace); + const [showPivot, setShowPivot] = useState(true); + const { handleDrop } = usePivotHandler({ + blockSuiteWorkspace, + metas, + }); + const { data } = usePivotData({ + metas, + pivotRender: PivotRender, + blockSuiteWorkspace, + onClick: (e, node) => { + handleDrop(currentMeta.id, node.id, { + bottomLine: false, + topLine: false, + internal: true, + }); + }, + }); + + const isPivotEmpty = useMemo( + () => metas.filter(meta => !meta.trash).length === 0, + [metas] + ); + + return ( + <> + { + setPageMeta(currentMeta.id, { isPivots: true }); + }} + > + ) => { + e.stopPropagation(); + setShowPivot(!showPivot); + }, + [showPivot] + )} + collapse={showPivot} + > + + + + {t('Pivots')} + + + + {isPivotEmpty ? ( + + ) : ( + + )} + + + ); +}; +export default Pivots; diff --git a/apps/web/src/components/affine/pivots/PivotsMenu/PivotsMenu.tsx b/apps/web/src/components/affine/pivots/PivotsMenu/PivotsMenu.tsx new file mode 100644 index 0000000000..4153a47ad0 --- /dev/null +++ b/apps/web/src/components/affine/pivots/PivotsMenu/PivotsMenu.tsx @@ -0,0 +1,109 @@ +import type { PureMenuProps } from '@affine/component'; +import { Input, PureMenu } from '@affine/component'; +import { useTranslation } from '@affine/i18n'; +import { RemoveIcon, SearchIcon } from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; +import React, { useState } from 'react'; + +import { usePageMetaHelper } from '../../../../hooks/use-page-meta'; +import type { BlockSuiteWorkspace } from '../../../../shared'; +import { + StyledMenuContent, + StyledMenuFooter, + StyledMenuSubTitle, + StyledPivot, + StyledSearchContainer, +} from '../styles'; +import { Pivots } from './Pivots'; + +export type PivotsMenuProps = { + metas: PageMeta[]; + currentMeta: PageMeta; + blockSuiteWorkspace: BlockSuiteWorkspace; + showRemovePivots?: boolean; +} & PureMenuProps; + +export const PivotsMenu = ({ + metas, + currentMeta, + blockSuiteWorkspace, + showRemovePivots = false, + ...pureMenuProps +}: PivotsMenuProps) => { + const { t } = useTranslation(); + const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace); + const [query, setQuery] = useState(''); + const isSearching = query.length > 0; + + const searchResult = metas.filter( + meta => !meta.trash && meta.title.includes(query) + ); + + return ( + + + + e.stopPropagation()} + /> + + + + {isSearching && ( + <> + + {searchResult.length + ? t('Find results', { number: searchResult.length }) + : t('Find 0 result')} + + {searchResult.map(meta => { + return {meta.title}; + })} + + )} + + {!isSearching && ( + <> + Suggested + + + )} + + + {showRemovePivots && ( + + { + setPageMeta(currentMeta.id, { isPivots: false }); + const parentMeta = metas.find(m => + m.subpageIds.includes(currentMeta.id) + ); + if (!parentMeta) return; + const newSubpageIds = [...parentMeta.subpageIds]; + const deleteIndex = newSubpageIds.findIndex( + id => id === currentMeta.id + ); + newSubpageIds.splice(deleteIndex, 1); + setPageMeta(parentMeta.id, { subpageIds: newSubpageIds }); + }} + > + + {t('Remove from Pivots')} + +

{t('RFP')}

+
+ )} +
+ ); +}; diff --git a/apps/web/src/components/pure/workspace-slider-bar/pivot/utils.ts b/apps/web/src/components/affine/pivots/hooks/usePivotData.ts similarity index 53% rename from apps/web/src/components/pure/workspace-slider-bar/pivot/utils.ts rename to apps/web/src/components/affine/pivots/hooks/usePivotData.ts index be785620f8..01d2fea1aa 100644 --- a/apps/web/src/components/pure/workspace-slider-bar/pivot/utils.ts +++ b/apps/web/src/components/affine/pivots/hooks/usePivotData.ts @@ -1,13 +1,14 @@ import type { PageMeta } from '@blocksuite/store'; +import { useMemo } from 'react'; -import { TreeNodeRender } from './TreeNodeRender'; -import type { TreeNode } from './types'; -export const flattenToTree = ( - handleMetas: PageMeta[], - renderProps: { openPage: (pageId: string) => void } +import type { RenderProps, TreeNode } from '../types'; + +const flattenToTree = ( + metas: PageMeta[], + pivotRender: TreeNode['render'], + renderProps: RenderProps ): TreeNode[] => { // Compatibility process: the old data not has `subpageIds`, it is a root page - const metas = JSON.parse(JSON.stringify(handleMetas)) as PageMeta[]; const rootMetas = metas .filter(meta => { if (meta.subpageIds) { @@ -19,29 +20,55 @@ export const flattenToTree = ( } return true; }) - .filter(meta => !meta.trash); + .filter(meta => meta.isPivots === true); const helper = (internalMetas: PageMeta[]): TreeNode[] => { return internalMetas.reduce((returnedMetas, internalMeta) => { const { subpageIds = [] } = internalMeta; const childrenMetas = subpageIds .map(id => metas.find(m => m.id === id)!) - .filter(meta => !meta.trash); - // FIXME: remove ts-ignore after blocksuite update + .filter(m => m); // @ts-ignore const returnedMeta: TreeNode = { ...internalMeta, children: helper(childrenMetas), render: (node, props) => - TreeNodeRender!(node, props, { - pageMeta: internalMeta, + pivotRender(node, props, { ...renderProps, + currentMeta: internalMeta, + metas, }), }; - // @ts-ignore returnedMetas.push(returnedMeta); return returnedMetas; }, []); }; return helper(rootMetas); }; + +export const usePivotData = ({ + metas, + pivotRender, + blockSuiteWorkspace, + onClick, + showOperationButton, +}: { + metas: PageMeta[]; + pivotRender: TreeNode['render']; +} & RenderProps) => { + const data = useMemo( + () => + flattenToTree(metas, pivotRender, { + blockSuiteWorkspace, + onClick, + showOperationButton, + }), + [blockSuiteWorkspace, metas, onClick, pivotRender, showOperationButton] + ); + + return { + data, + }; +}; + +export default usePivotData; diff --git a/apps/web/src/components/affine/pivots/hooks/usePivotHandler.ts b/apps/web/src/components/affine/pivots/hooks/usePivotHandler.ts new file mode 100644 index 0000000000..e60220856e --- /dev/null +++ b/apps/web/src/components/affine/pivots/hooks/usePivotHandler.ts @@ -0,0 +1,198 @@ +import type { TreeViewProps } from '@affine/component'; +import { DebugLogger } from '@affine/debug'; +import type { PageMeta } from '@blocksuite/store'; +import { nanoid } from '@blocksuite/store'; +import { useCallback } from 'react'; + +import { useBlockSuiteWorkspaceHelper } from '../../../../hooks/use-blocksuite-workspace-helper'; +import { usePageMetaHelper } from '../../../../hooks/use-page-meta'; +import type { BlockSuiteWorkspace } from '../../../../shared'; +import type { NodeRenderProps, TreeNode } from '../types'; + +const logger = new DebugLogger('pivot'); + +const findRootIds = (metas: PageMeta[], id: string): string[] => { + const parentMeta = metas.find(m => m.subpageIds?.includes(id)); + if (!parentMeta) { + return [id]; + } + return [parentMeta.id, ...findRootIds(metas, parentMeta.id)]; +}; +export const usePivotHandler = ({ + blockSuiteWorkspace, + metas, + onAdd, + onDelete, + onDrop, +}: { + blockSuiteWorkspace: BlockSuiteWorkspace; + metas: PageMeta[]; + onAdd?: (addedId: string, parentId: string) => void; + onDelete?: TreeViewProps['onDelete']; + onDrop?: TreeViewProps['onDrop']; +}) => { + const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace); + const { getPageMeta, setPageMeta, shiftPageMeta } = + usePageMetaHelper(blockSuiteWorkspace); + + const handleAdd = useCallback( + (node: TreeNode) => { + const id = nanoid(); + createPage(id, node.id); + onAdd?.(id, node.id); + }, + [createPage, onAdd] + ); + + const handleDelete = useCallback( + (node: TreeNode) => { + const removeToTrash = (currentMeta: PageMeta) => { + const { subpageIds = [] } = currentMeta; + setPageMeta(currentMeta.id, { + trash: true, + trashDate: +new Date(), + }); + subpageIds.forEach(id => { + const subcurrentMeta = getPageMeta(id); + subcurrentMeta && removeToTrash(subcurrentMeta); + }); + }; + removeToTrash(metas.find(m => m.id === node.id)!); + onDelete?.(node); + }, + [metas, getPageMeta, onDelete, setPageMeta] + ); + + const handleDrop = useCallback( + ( + dragId: string, + dropId: string, + position: { + topLine: boolean; + bottomLine: boolean; + internal: boolean; + } + ) => { + if (dragId === dropId) { + return; + } + const dropRootIds = findRootIds(metas, dropId); + if (dropRootIds.includes(dragId)) { + return; + } + + const { topLine, bottomLine } = position; + logger.info('handleDrop', { + dragId, + dropId, + bottomLine, + metas, + }); + + const dragParentMeta = metas.find(meta => + meta.subpageIds?.includes(dragId) + ); + if (bottomLine || topLine) { + const insertOffset = bottomLine ? 1 : 0; + const dropParentMeta = metas.find(m => m.subpageIds?.includes(dropId)); + + if (!dropParentMeta) { + // drop into root + logger.info('drop into root and resort'); + + if (dragParentMeta) { + const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])]; + + const deleteIndex = dragParentMeta.subpageIds?.findIndex( + id => id === dragId + ); + newSubpageIds.splice(deleteIndex, 1); + setPageMeta(dragParentMeta.id, { + subpageIds: newSubpageIds, + }); + } + + logger.info('resort root meta'); + const insertIndex = + metas.findIndex(m => m.id === dropId) + insertOffset; + shiftPageMeta(dragId, insertIndex); + return onDrop?.(dragId, dropId, position); + } + + if ( + dragParentMeta && + (dragParentMeta.id === dropId || + dragParentMeta.id === dropParentMeta!.id) + ) { + logger.info('drop to resort'); + // need to resort + const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])]; + + const deleteIndex = newSubpageIds.findIndex(id => id === dragId); + newSubpageIds.splice(deleteIndex, 1); + + const insertIndex = + newSubpageIds.findIndex(id => id === dropId) + insertOffset; + newSubpageIds.splice(insertIndex, 0, dragId); + setPageMeta(dropParentMeta.id, { + subpageIds: newSubpageIds, + }); + + return onDrop?.(dragId, dropId, position); + } + + logger.info('drop into drop node parent and resort'); + + if (dragParentMeta) { + const metaIndex = dragParentMeta.subpageIds.findIndex( + id => id === dragId + ); + const newSubpageIds = [...dragParentMeta.subpageIds]; + newSubpageIds.splice(metaIndex, 1); + setPageMeta(dragParentMeta.id, { + subpageIds: newSubpageIds, + }); + } + const newSubpageIds = [...(dropParentMeta!.subpageIds ?? [])]; + const insertIndex = newSubpageIds.findIndex(id => id === dropId) + 1; + newSubpageIds.splice(insertIndex, 0, dragId); + setPageMeta(dropParentMeta.id, { + subpageIds: newSubpageIds, + }); + + return onDrop?.(dragId, dropId, position); + } + + logger.info('drop into the drop node'); + + // drop into the node + if (dragParentMeta && dragParentMeta.id === dropId) { + return; + } + if (dragParentMeta) { + const metaIndex = dragParentMeta.subpageIds.findIndex( + id => id === dragId + ); + const newSubpageIds = [...dragParentMeta.subpageIds]; + newSubpageIds.splice(metaIndex, 1); + setPageMeta(dragParentMeta.id, { + subpageIds: newSubpageIds, + }); + } + const dropMeta = metas.find(meta => meta.id === dropId)!; + const newSubpageIds = [dragId, ...(dropMeta.subpageIds ?? [])]; + setPageMeta(dropMeta.id, { + subpageIds: newSubpageIds, + }); + }, + [metas, onDrop, setPageMeta, shiftPageMeta] + ); + + return { + handleDrop, + handleAdd, + handleDelete, + }; +}; + +export default usePivotHandler; diff --git a/apps/web/src/components/affine/pivots/index.ts b/apps/web/src/components/affine/pivots/index.ts new file mode 100644 index 0000000000..eb887647ac --- /dev/null +++ b/apps/web/src/components/affine/pivots/index.ts @@ -0,0 +1,5 @@ +export * from './hooks/usePivotData'; +export * from './hooks/usePivotHandler'; +export * from './PivotRender'; +export * from './PivotsMenu/PivotsMenu'; +export * from './types'; diff --git a/apps/web/src/components/affine/pivots/styles.ts b/apps/web/src/components/affine/pivots/styles.ts new file mode 100644 index 0000000000..c824a2ac2f --- /dev/null +++ b/apps/web/src/components/affine/pivots/styles.ts @@ -0,0 +1,117 @@ +import { + alpha, + displayFlex, + IconButton, + styled, + textEllipsis, +} from '@affine/component'; + +export const StyledCollapsedButton = styled('button')<{ + collapse: boolean; + show?: boolean; +}>(({ collapse, show = true, theme }) => { + return { + width: '16px', + height: '16px', + fontSize: '16px', + position: 'absolute', + left: '0', + top: '0', + bottom: '0', + margin: 'auto', + color: theme.colors.iconColor, + opacity: '.6', + display: show ? 'block' : 'none', + svg: { + transform: `rotate(${collapse ? '0' : '-90'}deg)`, + }, + }; +}); + +export const StyledPivot = styled('div')<{ + disable?: boolean; + active?: boolean; + isOver?: boolean; +}>(({ disable = false, active = false, theme, isOver }) => { + return { + width: '100%', + height: '32px', + borderRadius: '8px', + ...displayFlex('flex-start', 'center'), + padding: '0 2px 0 16px', + position: 'relative', + color: disable + ? theme.colors.disableColor + : active + ? theme.colors.primaryColor + : theme.colors.textColor, + cursor: disable ? 'not-allowed' : 'pointer', + background: isOver ? alpha(theme.colors.primaryColor, 0.06) : '', + fontSize: theme.font.base, + span: { + flexGrow: '1', + textAlign: 'left', + ...textEllipsis(1), + }, + '> svg': { + fontSize: '20px', + marginRight: '8px', + flexShrink: '0', + color: active ? theme.colors.primaryColor : theme.colors.iconColor, + }, + + ':hover': { + backgroundColor: disable ? '' : theme.colors.hoverBackground, + }, + }; +}); + +export const StyledOperationButton = styled(IconButton)<{ visible: boolean }>( + ({ visible }) => { + return { + visibility: visible ? 'visible' : 'hidden', + }; + } +); + +export const StyledSearchContainer = styled('div')(({ theme }) => { + return { + width: 'calc(100% - 24px)', + margin: '0 auto', + ...displayFlex('flex-start', 'center'), + borderBottom: `1px solid ${theme.colors.borderColor}`, + label: { + color: theme.colors.iconColor, + fontSize: '20px', + height: '20px', + }, + }; +}); +export const StyledMenuContent = styled('div')(() => { + return { + height: '266px', + overflow: 'auto', + }; +}); +export const StyledMenuSubTitle = styled('div')(({ theme }) => { + return { + color: theme.colors.secondaryTextColor, + lineHeight: '36px', + padding: '0 12px', + }; +}); + +export const StyledMenuFooter = styled('div')(({ theme }) => { + return { + width: 'calc(100% - 24px)', + margin: '0 auto', + borderTop: `1px solid ${theme.colors.borderColor}`, + padding: '6px 0', + + p: { + paddingLeft: '44px', + color: theme.colors.secondaryTextColor, + fontSize: '14px', + }, + }; +}); diff --git a/apps/web/src/components/affine/pivots/types.ts b/apps/web/src/components/affine/pivots/types.ts new file mode 100644 index 0000000000..388b1064a4 --- /dev/null +++ b/apps/web/src/components/affine/pivots/types.ts @@ -0,0 +1,18 @@ +import type { Node } from '@affine/component'; +import type { PageMeta } from '@blocksuite/store'; +import type { MouseEvent } from 'react'; + +import type { BlockSuiteWorkspace } from '../../../shared'; + +export type RenderProps = { + blockSuiteWorkspace: BlockSuiteWorkspace; + onClick?: (e: MouseEvent, node: TreeNode) => void; + showOperationButton?: boolean; +}; + +export type NodeRenderProps = RenderProps & { + metas: PageMeta[]; + currentMeta: PageMeta; +}; + +export type TreeNode = Node; diff --git a/apps/web/src/components/affine/workspace-setting-detail/panel/collaboration/invite-member-modal/index.tsx b/apps/web/src/components/affine/workspace-setting-detail/panel/collaboration/invite-member-modal/index.tsx index 566b25eef4..4c39c1a646 100644 --- a/apps/web/src/components/affine/workspace-setting-detail/panel/collaboration/invite-member-modal/index.tsx +++ b/apps/web/src/components/affine/workspace-setting-detail/panel/collaboration/invite-member-modal/index.tsx @@ -1,8 +1,12 @@ -import { styled } from '@affine/component'; -import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component'; -import { Button } from '@affine/component'; -import { Input } from '@affine/component'; -import { MuiAvatar } from '@affine/component'; +import { + Button, + Input, + Modal, + ModalCloseButton, + ModalWrapper, + MuiAvatar, + styled, +} from '@affine/component'; import { useTranslation } from '@affine/i18n'; import { EmailIcon } from '@blocksuite/icons'; import type React from 'react'; @@ -87,7 +91,7 @@ export const InviteMemberModal = ({ setShowMemberPreview(false); }, [])} placeholder={t('Invite placeholder')} - > + /> {showMemberPreview && gmailReg.test(email) && ( diff --git a/apps/web/src/components/blocksuite/block-suite-page-list/page-list/OperationCell.tsx b/apps/web/src/components/blocksuite/block-suite-page-list/page-list/OperationCell.tsx index e5abfd7029..0fec338b62 100644 --- a/apps/web/src/components/blocksuite/block-suite-page-list/page-list/OperationCell.tsx +++ b/apps/web/src/components/blocksuite/block-suite-page-list/page-list/OperationCell.tsx @@ -20,10 +20,14 @@ import type { PageMeta } from '@blocksuite/store'; import type React from 'react'; import { useState } from 'react'; +import type { BlockSuiteWorkspace } from '../../../../shared'; import { toast } from '../../../../utils'; +import { MoveTo } from '../../../affine/operation-menu-items'; export type OperationCellProps = { pageMeta: PageMeta; + metas: PageMeta[]; + blockSuiteWorkspace: BlockSuiteWorkspace; onOpenPageInNewTab: (pageId: string) => void; onToggleFavoritePage: (pageId: string) => void; onToggleTrashPage: (pageId: string) => void; @@ -31,6 +35,8 @@ export type OperationCellProps = { export const OperationCell: React.FC = ({ pageMeta, + metas, + blockSuiteWorkspace, onOpenPageInNewTab, onToggleFavoritePage, onToggleTrashPage, @@ -59,6 +65,11 @@ export const OperationCell: React.FC = ({ > {t('Open in new tab')} + { diff --git a/apps/web/src/components/blocksuite/block-suite-page-list/page-list/index.tsx b/apps/web/src/components/blocksuite/block-suite-page-list/page-list/index.tsx index 90f4734f9b..afd9297e87 100644 --- a/apps/web/src/components/blocksuite/block-suite-page-list/page-list/index.tsx +++ b/apps/web/src/components/blocksuite/block-suite-page-list/page-list/index.tsx @@ -1,11 +1,13 @@ import { + Content, + IconButton, Table, TableBody, TableCell, TableHead, TableRow, + Tooltip, } from '@affine/component'; -import { Content, IconButton, Tooltip } from '@affine/component'; import { useTranslation } from '@affine/i18n'; import { EdgelessIcon, @@ -223,6 +225,8 @@ export const PageList: React.FC = ({ ) : ( { onClickPage(pageId, true); }} diff --git a/apps/web/src/components/blocksuite/header/header-right-items/EditorOptionMenu.tsx b/apps/web/src/components/blocksuite/header/header-right-items/EditorOptionMenu.tsx index e08975bfb0..a538fc83d6 100644 --- a/apps/web/src/components/blocksuite/header/header-right-items/EditorOptionMenu.tsx +++ b/apps/web/src/components/blocksuite/header/header-right-items/EditorOptionMenu.tsx @@ -1,21 +1,16 @@ // fixme(himself65): refactor this file -import { Confirm, FlexWrapper, Menu, MenuItem } from '@affine/component'; -import { IconButton } from '@affine/component'; +import { FlexWrapper, IconButton, Menu, MenuItem } from '@affine/component'; import { useTranslation } from '@affine/i18n'; import { - DeleteTemporarilyIcon, - ExportIcon, - ExportToHtmlIcon, - ExportToMarkdownIcon, + EdgelessIcon, FavoritedIcon, FavoriteIcon, MoreVerticalIcon, + PageIcon, } from '@blocksuite/icons'; -import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; import { assertExists } from '@blocksuite/store'; import { useTheme } from '@mui/material'; import { useAtom } from 'jotai'; -import { useState } from 'react'; import { workspacePreferredModeAtom } from '../../../../atoms'; import { useCurrentPageId } from '../../../../hooks/current/use-current-page-id'; @@ -25,6 +20,11 @@ import { usePageMetaHelper, } from '../../../../hooks/use-page-meta'; import { toast } from '../../../../utils'; +import { + Export, + MoveTo, + MoveToTrash, +} from '../../../affine/operation-menu-items'; export const EditorOptionMenu = () => { const { t } = useTranslation(); @@ -39,12 +39,12 @@ export const EditorOptionMenu = () => { const pageMeta = usePageMeta(blockSuiteWorkspace).find( meta => meta.id === pageId ); + const allMetas = usePageMeta(workspace?.blockSuiteWorkspace ?? null); const [record, set] = useAtom(workspacePreferredModeAtom); const mode = record[pageId] ?? 'page'; assertExists(pageMeta); - const { favorite, trash } = pageMeta; + const { favorite } = pageMeta; const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace); - const [open, setOpen] = useState(false); const EditMenu = ( <> @@ -56,7 +56,6 @@ export const EditorOptionMenu = () => { favorite ? t('Removed from Favorites') : t('Added to Favorites') ); }} - iconSize={[20, 20]} icon={ favorite ? ( @@ -69,7 +68,6 @@ export const EditorOptionMenu = () => { : } - iconSize={[20, 20]} data-testid="editor-option-menu-edgeless" onClick={() => { set(record => ({ @@ -81,48 +79,17 @@ export const EditorOptionMenu = () => { {t('Convert to ')} {mode === 'page' ? t('Edgeless') : t('Page')} - - { - // @ts-expect-error - globalThis.currentEditor.contentParser.onExportHtml(); - }} - icon={} - iconSize={[20, 20]} - > - {t('Export to HTML')} - - { - // @ts-expect-error - globalThis.currentEditor.contentParser.onExportMarkdown(); - }} - icon={} - iconSize={[20, 20]} - > - {t('Export to Markdown')} - - - } - > - } iconSize={[20, 20]} isDir={true}> - {t('Export')} - - - { - setOpen(true); - }} - icon={} - iconSize={[20, 20]} - > - {t('Delete')} - + + + ); @@ -141,26 +108,6 @@ export const EditorOptionMenu = () => { - { - toast(t('Moved to Trash')); - setOpen(false); - setPageMeta(pageId, { trash: !trash, trashDate: +new Date() }); - }} - onClose={() => { - setOpen(false); - }} - onCancel={() => { - setOpen(false); - }} - /> ); }; diff --git a/apps/web/src/components/pure/workspace-slider-bar/Pivots.tsx b/apps/web/src/components/pure/workspace-slider-bar/Pivots.tsx new file mode 100644 index 0000000000..07a020e35e --- /dev/null +++ b/apps/web/src/components/pure/workspace-slider-bar/Pivots.tsx @@ -0,0 +1,120 @@ +import { MuiCollapse, TreeView } from '@affine/component'; +import { useTranslation } from '@affine/i18n'; +import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons'; +import type { PageMeta } from '@blocksuite/store'; +import type { MouseEvent } from 'react'; +import { useCallback, useMemo, useState } from 'react'; + +import type { RemWorkspace } from '../../../shared'; +import type { TreeNode } from '../../affine/pivots'; +import { + PivotRender, + usePivotData, + usePivotHandler, +} from '../../affine/pivots'; +import EmptyItem from './favorite/empty-item'; +import { StyledCollapseButton, StyledListItem } from './shared-styles'; + +export const PivotInternal = ({ + currentWorkspace, + openPage, + allMetas, +}: { + currentWorkspace: RemWorkspace; + openPage: (pageId: string) => void; + allMetas: PageMeta[]; +}) => { + const handlePivotClick = useCallback( + (e: MouseEvent, node: TreeNode) => { + openPage(node.id); + }, + [openPage] + ); + const onAdd = useCallback( + (id: string) => { + openPage(id); + }, + [openPage] + ); + + const { data } = usePivotData({ + metas: allMetas.filter(meta => !meta.trash), + pivotRender: PivotRender, + blockSuiteWorkspace: currentWorkspace.blockSuiteWorkspace, + onClick: handlePivotClick, + showOperationButton: true, + }); + + const { handleAdd, handleDelete, handleDrop } = usePivotHandler({ + blockSuiteWorkspace: currentWorkspace.blockSuiteWorkspace, + + metas: allMetas, + onAdd, + }); + + return ( + + ); +}; + +export const Pivots = ({ + currentWorkspace, + openPage, + allMetas, +}: { + currentWorkspace: RemWorkspace; + openPage: (pageId: string) => void; + allMetas: PageMeta[]; +}) => { + const { t } = useTranslation(); + + const [showPivot, setShowPivot] = useState(true); + + const isPivotEmpty = useMemo( + () => allMetas.filter(meta => !meta.trash).length === 0, + [allMetas] + ); + + return ( + <> + + { + setShowPivot(!showPivot); + }, [showPivot])} + collapse={showPivot} + > + + + + {t('Pivots')} + + + + {isPivotEmpty ? ( + + ) : ( + + )} + + + ); +}; +export default Pivots; diff --git a/apps/web/src/components/pure/workspace-slider-bar/index.tsx b/apps/web/src/components/pure/workspace-slider-bar/index.tsx index a337fd9c95..c842d5f45f 100644 --- a/apps/web/src/components/pure/workspace-slider-bar/index.tsx +++ b/apps/web/src/components/pure/workspace-slider-bar/index.tsx @@ -16,7 +16,7 @@ import { usePageMeta } from '../../../hooks/use-page-meta'; import type { RemWorkspace } from '../../../shared'; import { SidebarSwitch } from '../../affine/sidebar-switch'; import Favorite from './favorite'; -import { Pivot } from './pivot'; +import { Pivots } from './Pivots'; import { StyledListItem } from './shared-styles'; import { StyledLink, @@ -142,7 +142,7 @@ export const WorkSpaceSliderBar: React.FC = ({ currentWorkspace={currentWorkspace} /> {config.enableSubpage && !!currentWorkspace && ( - void; - onDelete: () => void; -}) => { - const { t } = useTranslation(); - const router = useRouter(); - - const [anchorEl, setAnchorEl] = useState(null); - - const [open, setOpen] = useState(false); - const copyUrl = useCallback(() => { - const workspaceId = router.query.workspaceId; - navigator.clipboard.writeText(window.location.href); - toast(t('Copied link to clipboard')); - }, [router.query.workspaceId, t]); - - return ( - { - setOpen(false); - }} - > -
{ - e.stopPropagation(); - }} - onMouseLeave={() => { - setOpen(false); - }} - > - setAnchorEl(ref)} - size="small" - className="operation-button" - onClick={event => { - event.stopPropagation(); - setOpen(!open); - }} - > - - - - } - onClick={() => { - onAdd(); - setOpen(false); - }} - > - {t('Add a subpage inside')} - - } disabled={true}> - {t('Move to')} - - } disabled={true}> - {t('Rename')} - - } - onClick={() => { - onDelete(); - setOpen(false); - }} - > - {t('Move to Trash')} - - } - disabled={true} - // onClick={() => { - // const workspaceId = router.query.workspaceId; - // navigator.clipboard.writeText(window.location.href); - // toast(t('Copied link to clipboard')); - // }} - > - {t('Copy Link')} - - -
-
- ); -}; diff --git a/apps/web/src/components/pure/workspace-slider-bar/pivot/Pivot.tsx b/apps/web/src/components/pure/workspace-slider-bar/pivot/Pivot.tsx deleted file mode 100644 index d40b92f5a5..0000000000 --- a/apps/web/src/components/pure/workspace-slider-bar/pivot/Pivot.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { MuiCollapse, TreeView } from '@affine/component'; -import { DebugLogger } from '@affine/debug'; -import { useTranslation } from '@affine/i18n'; -import { ArrowDownSmallIcon, PivotsIcon } from '@blocksuite/icons'; -import type { PageMeta } from '@blocksuite/store'; -import { nanoid } from '@blocksuite/store'; -import { useCallback, useMemo, useState } from 'react'; - -import { useBlockSuiteWorkspaceHelper } from '../../../../hooks/use-blocksuite-workspace-helper'; -import { usePageMetaHelper } from '../../../../hooks/use-page-meta'; -import type { RemWorkspace } from '../../../../shared'; -import EmptyItem from '../favorite/empty-item'; -import { StyledCollapseButton, StyledListItem } from '../shared-styles'; -import type { TreeNode } from './types'; -import { flattenToTree } from './utils'; -const logger = new DebugLogger('pivot'); - -export const PivotInternal = ({ - currentWorkspace, - openPage, - allMetas, -}: { - currentWorkspace: RemWorkspace; - openPage: (pageId: string) => void; - allMetas: PageMeta[]; -}) => { - const { createPage } = useBlockSuiteWorkspaceHelper( - currentWorkspace.blockSuiteWorkspace - ); - const { getPageMeta, setPageMeta, shiftPageMeta } = usePageMetaHelper( - currentWorkspace.blockSuiteWorkspace - ); - - const treeData = useMemo( - () => flattenToTree(allMetas, { openPage }), - [allMetas, openPage] - ); - - const handleAdd = useCallback( - (node: TreeNode) => { - const id = nanoid(); - createPage(id, node.id); - openPage(id); - }, - [createPage, openPage] - ); - - const handleDelete = useCallback( - (node: TreeNode) => { - const removeToTrash = (pageMeta: PageMeta) => { - const { subpageIds = [] } = pageMeta; - setPageMeta(pageMeta.id, { trash: true, trashDate: +new Date() }); - subpageIds.forEach(id => { - const subpageMeta = getPageMeta(id); - subpageMeta && removeToTrash(subpageMeta); - }); - }; - removeToTrash(node as PageMeta); - }, - [getPageMeta, setPageMeta] - ); - - const handleDrop = useCallback( - ( - dragNode: TreeNode, - dropNode: TreeNode, - position: { - topLine: boolean; - bottomLine: boolean; - internal: boolean; - } - ) => { - const { topLine, bottomLine } = position; - logger.info('handleDrop', { dragNode, dropNode, bottomLine, allMetas }); - - const dragParentMeta = allMetas.find(meta => - meta.subpageIds?.includes(dragNode.id) - ); - if (bottomLine || topLine) { - const insertOffset = bottomLine ? 1 : 0; - const dropParentMeta = allMetas.find(m => - m.subpageIds?.includes(dropNode.id) - ); - - if (!dropParentMeta) { - // drop into root - logger.info('drop into root and resort'); - - if (dragParentMeta) { - const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])]; - - const deleteIndex = dragParentMeta.subpageIds?.findIndex( - id => id === dragNode.id - ); - newSubpageIds.splice(deleteIndex, 1); - setPageMeta(dragParentMeta.id, { - subpageIds: newSubpageIds, - }); - } - - logger.info('resort root meta'); - const insertIndex = - allMetas.findIndex(m => m.id === dropNode.id) + insertOffset; - shiftPageMeta(dragNode.id, insertIndex); - - return; - } - - if ( - dragParentMeta && - (dragParentMeta.id === dropNode.id || - dragParentMeta.id === dropParentMeta!.id) - ) { - logger.info('drop to resort'); - // need to resort - const newSubpageIds = [...(dragParentMeta.subpageIds ?? [])]; - - const deleteIndex = newSubpageIds.findIndex(id => id === dragNode.id); - newSubpageIds.splice(deleteIndex, 1); - - const insertIndex = - newSubpageIds.findIndex(id => id === dropNode.id) + insertOffset; - newSubpageIds.splice(insertIndex, 0, dragNode.id); - setPageMeta(dropParentMeta.id, { - subpageIds: newSubpageIds, - }); - return; - } - - logger.info('drop into drop node parent and resort'); - - if (dragParentMeta) { - const metaIndex = dragParentMeta.subpageIds.findIndex( - id => id === dragNode.id - ); - const newSubpageIds = [...dragParentMeta.subpageIds]; - newSubpageIds.splice(metaIndex, 1); - setPageMeta(dragParentMeta.id, { - subpageIds: newSubpageIds, - }); - } - const newSubpageIds = [...(dropParentMeta!.subpageIds ?? [])]; - const insertIndex = - newSubpageIds.findIndex(id => id === dropNode.id) + 1; - newSubpageIds.splice(insertIndex, 0, dragNode.id); - setPageMeta(dropParentMeta.id, { - subpageIds: newSubpageIds, - }); - return; - } - - logger.info('drop into the drop node'); - - // drop into the node - if (dragParentMeta && dragParentMeta.id === dropNode.id) { - return; - } - if (dragParentMeta) { - const metaIndex = dragParentMeta.subpageIds.findIndex( - id => id === dragNode.id - ); - const newSubpageIds = [...dragParentMeta.subpageIds]; - newSubpageIds.splice(metaIndex, 1); - setPageMeta(dragParentMeta.id, { - subpageIds: newSubpageIds, - }); - } - const dropMeta = allMetas.find(meta => meta.id === dropNode.id)!; - const newSubpageIds = [dragNode.id, ...(dropMeta.subpageIds ?? [])]; - setPageMeta(dropMeta.id, { - subpageIds: newSubpageIds, - }); - }, - [allMetas, setPageMeta, shiftPageMeta] - ); - - return ( - - ); -}; - -export const Pivot = ({ - currentWorkspace, - openPage, - allMetas, -}: { - currentWorkspace: RemWorkspace; - openPage: (pageId: string) => void; - allMetas: PageMeta[]; -}) => { - const { t } = useTranslation(); - - const [showPivot, setShowPivot] = useState(true); - - const isPivotEmpty = useMemo( - () => allMetas.filter(meta => !meta.trash).length === 0, - [allMetas] - ); - - return ( - <> - - { - setShowPivot(!showPivot); - }, [showPivot])} - collapse={showPivot} - > - - - - {t('Pivots')} - - - - {isPivotEmpty ? ( - - ) : ( - - )} - - - ); -}; -export default Pivot; diff --git a/apps/web/src/components/pure/workspace-slider-bar/pivot/TreeNodeRender.tsx b/apps/web/src/components/pure/workspace-slider-bar/pivot/TreeNodeRender.tsx deleted file mode 100644 index 68a1271241..0000000000 --- a/apps/web/src/components/pure/workspace-slider-bar/pivot/TreeNodeRender.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ArrowDownSmallIcon, EdgelessIcon, PageIcon } from '@blocksuite/icons'; -import type { PageMeta } from '@blocksuite/store'; -import { useAtomValue } from 'jotai'; -import { useRouter } from 'next/router'; - -import { workspacePreferredModeAtom } from '../../../../atoms'; -import { StyledCollapseButton, StyledCollapseItem } from '../shared-styles'; -import { OperationButton } from './OperationButton'; -import type { TreeNode } from './types'; - -export const TreeNodeRender: TreeNode['render'] = ( - node, - { isOver, onAdd, onDelete, collapsed, setCollapsed }, - extendProps -) => { - const { openPage, pageMeta } = extendProps as { - openPage: (pageId: string) => void; - pageMeta: PageMeta; - }; - const record = useAtomValue(workspacePreferredModeAtom); - - const router = useRouter(); - const active = router.query.pageId === node.id; - - return ( - { - if (active) { - return; - } - openPage(node.id); - }} - isOver={isOver} - active={active} - > - { - e.stopPropagation(); - setCollapsed(!collapsed); - }} - > - - - {record[pageMeta.id] === 'edgeless' ? : } - {node.title || 'Untitled'} - - - ); -}; diff --git a/apps/web/src/components/pure/workspace-slider-bar/pivot/index.tsx b/apps/web/src/components/pure/workspace-slider-bar/pivot/index.tsx deleted file mode 100644 index 3e9528faf1..0000000000 --- a/apps/web/src/components/pure/workspace-slider-bar/pivot/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from './Pivot'; -export * from './types'; diff --git a/apps/web/src/components/pure/workspace-slider-bar/pivot/styles.ts b/apps/web/src/components/pure/workspace-slider-bar/pivot/styles.ts deleted file mode 100644 index cbaabc9cba..0000000000 --- a/apps/web/src/components/pure/workspace-slider-bar/pivot/styles.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IconButton, styled } from '@affine/component'; - -export const StyledOperationButton = styled('button')(({ theme }) => { - return { - height: '20px', - width: '20px', - fontSize: '20px', - color: theme.colors.iconColor, - display: 'none', - ':hover': { - background: theme.colors.hoverBackground, - }, - }; -}); - -export const StyledCollapsedButton = styled(IconButton, { - shouldForwardProp: prop => { - return !['show'].includes(prop as string); - }, -})<{ show: boolean }>(({ show }) => { - return { - display: show ? 'block' : 'none', - position: 'absolute', - left: '0px', - top: '0px', - bottom: '0px', - margin: 'auto', - }; -}); diff --git a/apps/web/src/components/pure/workspace-slider-bar/pivot/types.ts b/apps/web/src/components/pure/workspace-slider-bar/pivot/types.ts deleted file mode 100644 index 8166153f30..0000000000 --- a/apps/web/src/components/pure/workspace-slider-bar/pivot/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { Node } from '@affine/component'; -import type { PageMeta } from '@blocksuite/store'; - -export type TreeNode = Node; diff --git a/apps/web/src/hooks/use-page-meta.ts b/apps/web/src/hooks/use-page-meta.ts index 211767f600..2172f49fb8 100644 --- a/apps/web/src/hooks/use-page-meta.ts +++ b/apps/web/src/hooks/use-page-meta.ts @@ -11,6 +11,8 @@ declare module '@blocksuite/store' { trashDate?: number; // whether to create the page with the default template init?: boolean; + // use for subpage + isPivots?: boolean; } } diff --git a/packages/component/src/ui/input/Input.tsx b/packages/component/src/ui/input/Input.tsx index 99376c2726..43e8e2d15e 100644 --- a/packages/component/src/ui/input/Input.tsx +++ b/packages/component/src/ui/input/Input.tsx @@ -1,12 +1,12 @@ import type { + CSSProperties, FocusEventHandler, ForwardedRef, HTMLAttributes, InputHTMLAttributes, KeyboardEventHandler, } from 'react'; -import { forwardRef } from 'react'; -import { useEffect, useState } from 'react'; +import { forwardRef, useEffect, useState } from 'react'; import { StyledInput } from './style'; @@ -14,13 +14,14 @@ type inputProps = { value?: string; placeholder?: string; disabled?: boolean; - width?: number; - height?: number; + width?: CSSProperties['width']; + height?: CSSProperties['height']; maxLength?: number; minLength?: number; onChange?: (value: string) => void; onBlur?: FocusEventHandler; onKeyDown?: KeyboardEventHandler; + noBorder?: boolean; } & Omit, 'onChange'>; export const Input = forwardRef(function Input( @@ -31,10 +32,11 @@ export const Input = forwardRef(function Input( maxLength, minLength, height, - width = 260, + width, onChange, onBlur, onKeyDown, + noBorder = false, ...otherProps }: inputProps, ref: ForwardedRef @@ -69,7 +71,8 @@ export const Input = forwardRef(function Input( onBlur={handleBlur} onKeyDown={handleKeyDown} height={height} + noBorder={noBorder} {...otherProps} - > + /> ); }); diff --git a/packages/component/src/ui/input/style.ts b/packages/component/src/ui/input/style.ts index eb5d4b663d..11f9d92be9 100644 --- a/packages/component/src/ui/input/style.ts +++ b/packages/component/src/ui/input/style.ts @@ -1,28 +1,25 @@ +import type { CSSProperties } from 'react'; + import { styled } from '../../styles'; export const StyledInput = styled('input')<{ disabled?: boolean; value?: string; - width: number; - height?: number; -}>(({ theme, width, disabled, height }) => { - const fontWeight = 400; - const fontSize = '16px'; + width?: CSSProperties['width']; + height?: CSSProperties['height']; + noBorder?: boolean; +}>(({ theme, width, disabled, height, noBorder }) => { return { - width: `${width}px`, + width: width || '100%', + height, lineHeight: '22px', padding: '8px 12px', - fontWeight, - fontSize, - height: height ? `${height}px` : 'auto', color: disabled ? theme.colors.disableColor : theme.colors.textColor, - border: `1px solid`, + border: noBorder ? 'unset' : `1px solid`, borderColor: theme.colors.borderColor, // TODO: check out disableColor, backgroundColor: theme.colors.popoverBackground, borderRadius: '10px', '&::placeholder': { - fontWeight, - fontSize, color: theme.colors.placeHolderColor, }, '&:focus': { diff --git a/packages/component/src/ui/menu/MenuItem.tsx b/packages/component/src/ui/menu/MenuItem.tsx index b23bf60481..6a73686ad0 100644 --- a/packages/component/src/ui/menu/MenuItem.tsx +++ b/packages/component/src/ui/menu/MenuItem.tsx @@ -1,31 +1,28 @@ import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react'; -import { cloneElement, forwardRef } from 'react'; +import { forwardRef } from 'react'; + +import { + StyledContent, + StyledEndIconWrapper, + StyledMenuItem, + StyledStartIconWrapper, +} from './styles'; -import { StyledArrow, StyledMenuItem } from './styles'; export type IconMenuProps = PropsWithChildren<{ - isDir?: boolean; icon?: ReactElement; + endIcon?: ReactElement; iconSize?: [number, number]; disabled?: boolean; }> & HTMLAttributes; export const MenuItem = forwardRef( - ({ isDir = false, icon, iconSize, children, ...props }, ref) => { - const [iconWidth, iconHeight] = iconSize || [20, 20]; + ({ endIcon, icon, iconSize, children, ...props }, ref) => { return ( - {icon && - cloneElement(icon, { - width: iconWidth, - height: iconHeight, - style: { - marginRight: 12, - ...icon.props?.style, - }, - })} - {children} - {isDir ? : null} + {icon && {icon}} + {children} + {endIcon && {endIcon}} ); } diff --git a/packages/component/src/ui/menu/PureMenu.tsx b/packages/component/src/ui/menu/PureMenu.tsx index 1f21aa932a..b72ca03152 100644 --- a/packages/component/src/ui/menu/PureMenu.tsx +++ b/packages/component/src/ui/menu/PureMenu.tsx @@ -4,12 +4,17 @@ import type { PurePopperProps } from '../popper'; import { PurePopper } from '../popper'; import { StyledMenuWrapper } from './styles'; +export type PureMenuProps = PurePopperProps & { + width?: CSSProperties['width']; + height?: CSSProperties['height']; +}; export const PureMenu = ({ children, placement, width, + height, ...otherProps -}: PurePopperProps & { width?: CSSProperties['width'] }) => { +}: PureMenuProps) => { return ( diff --git a/packages/component/src/ui/menu/styles.ts b/packages/component/src/ui/menu/styles.ts index 48e6336e0f..b8ee3cddc7 100644 --- a/packages/component/src/ui/menu/styles.ts +++ b/packages/component/src/ui/menu/styles.ts @@ -1,14 +1,15 @@ -import { ArrowRightSmallIcon } from '@blocksuite/icons'; import type { CSSProperties } from 'react'; -import { displayFlex, styled } from '../../styles'; +import { displayFlex, styled, textEllipsis } from '../../styles'; import StyledPopperContainer from '../shared/Container'; export const StyledMenuWrapper = styled(StyledPopperContainer)<{ width?: CSSProperties['width']; -}>(({ theme, width }) => { + height?: CSSProperties['height']; +}>(({ theme, width, height }) => { return { width, + height, background: theme.colors.popoverBackground, padding: '8px 4px', fontSize: '14px', @@ -17,13 +18,28 @@ export const StyledMenuWrapper = styled(StyledPopperContainer)<{ }; }); -export const StyledArrow = styled(ArrowRightSmallIcon)({ - position: 'absolute', - right: '12px', - top: 0, - bottom: 0, - margin: 'auto', - fontSize: '20px', +export const StyledStartIconWrapper = styled('div')(({ theme }) => { + return { + marginRight: '12px', + fontSize: '20px', + color: theme.colors.iconColor, + }; +}); +export const StyledEndIconWrapper = styled('div')(({ theme }) => { + return { + marginLeft: '12px', + fontSize: '20px', + color: theme.colors.iconColor, + }; +}); + +export const StyledContent = styled('div')(({ theme }) => { + return { + textAlign: 'left', + flexGrow: 1, + fontSize: theme.font.base, + ...textEllipsis(1), + }; }); export const StyledMenuItem = styled('button')<{ diff --git a/packages/component/src/ui/tree-view/TreeNode.tsx b/packages/component/src/ui/tree-view/TreeNode.tsx index 1355acf582..e37a6b6334 100644 --- a/packages/component/src/ui/tree-view/TreeNode.tsx +++ b/packages/component/src/ui/tree-view/TreeNode.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useDrag, useDrop } from 'react-dnd'; import { @@ -14,21 +14,21 @@ import type { TreeNodeProps, } from './types'; -const NodeLine = ({ +const NodeLine = ({ node, onDrop, allowDrop = true, isTop = false, -}: NodeLIneProps) => { +}: NodeLIneProps) => { const [{ isOver }, drop] = useDrop( () => ({ accept: 'node', - drop: (item: Node, monitor) => { + drop: (item: Node, monitor) => { const didDrop = monitor.didDrop(); if (didDrop) { return; } - onDrop?.(item, node, { + onDrop?.(item.id, node.id, { internal: false, topLine: isTop, bottomLine: !isTop, @@ -44,24 +44,23 @@ const NodeLine = ({ return ; }; -const TreeNodeItem = ({ +const TreeNodeItemWithDnd = ({ node, allowDrop, - collapsed, setCollapsed, ...otherProps -}: TreeNodeItemProps) => { +}: TreeNodeItemProps) => { const { onAdd, onDelete, onDrop } = otherProps; const [{ canDrop, isOver }, drop] = useDrop( () => ({ accept: 'node', - drop: (item: Node, monitor) => { + drop: (item: Node, monitor) => { const didDrop = monitor.didDrop(); if (didDrop || item.id === node.id || !allowDrop) { return; } - onDrop?.(item, node, { + onDrop?.(item.id, node.id, { internal: true, topLine: false, bottomLine: false, @@ -77,44 +76,79 @@ const TreeNodeItem = ({ useEffect(() => { if (isOver && canDrop) { - setCollapsed(false); + setCollapsed(node.id, false); } }, [isOver, canDrop]); return ( -
+ + ); +}; + +const TreeNodeItem = ({ + node, + collapsed, + setCollapsed, + selectedId, + isOver = false, + canDrop = false, + onAdd, + onDelete, + dropRef, +}: TreeNodeItemProps) => { + return ( +
{node.render?.(node, { - isOver: !!(isOver && canDrop), + isOver: isOver && canDrop, onAdd: () => onAdd?.(node), onDelete: () => onDelete?.(node), collapsed, setCollapsed, + isSelected: selectedId === node.id, })}
); }; -export const TreeNode = ({ - node, - index, - allowDrop = true, - ...otherProps -}: TreeNodeProps) => { - const { indent } = otherProps; - const [collapsed, setCollapsed] = useState(false); - +export const TreeNodeWithDnd = ( + props: TreeNodeProps +) => { const [{ isDragging }, drag] = useDrag(() => ({ type: 'node', - item: node, + item: props.node, collect: monitor => ({ isDragging: monitor.isDragging(), }), })); + return ; +}; + +export const TreeNode = ({ + node, + index, + isDragging = false, + allowDrop = true, + dragRef, + ...otherProps +}: TreeNodeProps) => { + const { indent, enableDnd, collapsedIds } = otherProps; + const collapsed = collapsedIds.includes(node.id); + return ( - + - {index === 0 && ( + {enableDnd && index === 0 && ( ({ isTop={true} /> )} - - {(!node.children?.length || collapsed) && ( + {enableDnd ? ( + + ) : ( + + )} + + {enableDnd && (!node.children?.length || collapsed) && ( ({ {node.children && - node.children.map((childNode, index) => ( - - ))} + node.children.map((childNode, index) => + enableDnd ? ( + + ) : ( + + ) + )} ); }; - -export default TreeNode; diff --git a/packages/component/src/ui/tree-view/TreeView.tsx b/packages/component/src/ui/tree-view/TreeView.tsx index 1d72433780..61c87c780e 100644 --- a/packages/component/src/ui/tree-view/TreeView.tsx +++ b/packages/component/src/ui/tree-view/TreeView.tsx @@ -1,15 +1,108 @@ +import { useEffect, useState } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; -import { TreeNode } from './TreeNode'; -import type { TreeViewProps } from './types'; -export const TreeView = ({ data, ...otherProps }: TreeViewProps) => { +import { TreeNode, TreeNodeWithDnd } from './TreeNode'; +import type { TreeNodeProps, TreeViewProps } from './types'; +import { flattenIds } from './utils'; + +export const TreeView = ({ + data, + enableKeyboardSelection, + onSelect, + enableDnd = true, + initialCollapsedIds = [], + ...otherProps +}: TreeViewProps) => { + const [selectedId, setSelectedId] = useState(); + // TODO: should record collapsedIds in localStorage + const [collapsedIds, setCollapsedIds] = + useState(initialCollapsedIds); + + useEffect(() => { + if (!enableKeyboardSelection) { + return; + } + + const flattenedIds = flattenIds(data); + + const handleDirectionKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') { + return; + } + if (selectedId === undefined) { + setSelectedId(flattenedIds[0]); + return; + } + let selectedIndex = flattenedIds.indexOf(selectedId); + if (e.key === 'ArrowDown') { + selectedIndex < flattenedIds.length - 1 && selectedIndex++; + } + if (e.key === 'ArrowUp') { + selectedIndex > 0 && selectedIndex--; + } + + setSelectedId(flattenedIds[selectedIndex]); + }; + + const handleEnterKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Enter') { + return; + } + selectedId && onSelect?.(selectedId); + }; + + document.addEventListener('keydown', handleDirectionKeyDown); + document.addEventListener('keydown', handleEnterKeyDown); + + return () => { + document.removeEventListener('keydown', handleDirectionKeyDown); + document.removeEventListener('keydown', handleEnterKeyDown); + }; + }, [data, selectedId]); + + const setCollapsed: TreeNodeProps['setCollapsed'] = (id, collapsed) => { + if (collapsed) { + setCollapsedIds(ids => [...ids, id]); + } else { + setCollapsedIds(ids => ids.filter(i => i !== id)); + } + }; + + if (enableDnd) { + return ( + + {data.map((node, index) => ( + + ))} + + ); + } + return ( - + <> {data.map((node, index) => ( - + ))} - + ); }; diff --git a/packages/component/src/ui/tree-view/styles.ts b/packages/component/src/ui/tree-view/styles.ts index 5255543a95..9b5f6305d6 100644 --- a/packages/component/src/ui/tree-view/styles.ts +++ b/packages/component/src/ui/tree-view/styles.ts @@ -15,8 +15,8 @@ export const StyledTreeNodeWrapper = styled('div')(() => { position: 'relative', }; }); -export const StyledTreeNodeContainer = styled('div')<{ isDragging: boolean }>( - ({ isDragging, theme }) => { +export const StyledTreeNodeContainer = styled('div')<{ isDragging?: boolean }>( + ({ isDragging = false, theme }) => { return { background: isDragging ? theme.colors.hoverBackground : '', // opacity: isDragging ? 0.4 : 1, diff --git a/packages/component/src/ui/tree-view/types.ts b/packages/component/src/ui/tree-view/types.ts index 494b481db3..8b1af1687c 100644 --- a/packages/component/src/ui/tree-view/types.ts +++ b/packages/component/src/ui/tree-view/types.ts @@ -1,52 +1,71 @@ -import type { CSSProperties, ReactNode } from 'react'; +import type { CSSProperties, ReactNode, Ref } from 'react'; -export type Node = { +export type DropPosition = { + topLine: boolean; + bottomLine: boolean; + internal: boolean; +}; +export type OnDrop = ( + dragId: string, + dropId: string, + position: DropPosition +) => void; + +export type Node = { id: string; - children?: Node[]; - render?: ( - node: Node, + children?: Node[]; + render: ( + node: Node, eventsAndStatus: { isOver: boolean; onAdd: () => void; onDelete: () => void; collapsed: boolean; - setCollapsed: (collapsed: boolean) => void; + setCollapsed: (id: string, collapsed: boolean) => void; + isSelected: boolean; }, - extendProps?: unknown + renderProps?: RenderProps ) => ReactNode; -} & N; - -type CommonProps = { - indent?: CSSProperties['paddingLeft']; - onAdd?: (node: Node) => void; - onDelete?: (node: Node) => void; - onDrop?: ( - dragNode: Node, - dropNode: Node, - position: { - topLine: boolean; - bottomLine: boolean; - internal: boolean; - } - ) => void; }; -export type TreeNodeProps = { - node: Node; +type CommonProps = { + enableDnd?: boolean; + enableKeyboardSelection?: boolean; + indent?: CSSProperties['paddingLeft']; + onAdd?: (node: Node) => void; + onDelete?: (node: Node) => void; + onDrop?: OnDrop; + // Only trigger when the enableKeyboardSelection is true + onSelect?: (id: string) => void; +}; + +export type TreeNodeProps = { + node: Node; index: number; + collapsedIds: string[]; + setCollapsed: (id: string, collapsed: boolean) => void; allowDrop?: boolean; -} & CommonProps; + selectedId?: string; + isDragging?: boolean; + dragRef?: Ref; +} & CommonProps; -export type TreeNodeItemProps = { +export type TreeNodeItemProps = { collapsed: boolean; - setCollapsed: (collapsed: boolean) => void; -} & TreeNodeProps; + setCollapsed: (id: string, collapsed: boolean) => void; -export type TreeViewProps = { - data: Node[]; -} & CommonProps; + isOver?: boolean; + canDrop?: boolean; -export type NodeLIneProps = { + dropRef?: Ref; +} & TreeNodeProps; + +export type TreeViewProps = { + data: Node[]; + initialCollapsedIds?: string[]; +} & CommonProps; + +export type NodeLIneProps = { allowDrop: boolean; isTop?: boolean; -} & Pick, 'node' | 'onDrop'>; +} & Pick, 'node' | 'onDrop'>; diff --git a/packages/component/src/ui/tree-view/utils.ts b/packages/component/src/ui/tree-view/utils.ts new file mode 100644 index 0000000000..869ad1aea3 --- /dev/null +++ b/packages/component/src/ui/tree-view/utils.ts @@ -0,0 +1,18 @@ +import type { Node } from '@affine/component'; + +export function flattenIds(arr: Node[]): string[] { + const result: string[] = []; + + function flatten(arr: Node[]) { + for (let i = 0, len = arr.length; i < len; i++) { + const item = arr[i]; + result.push(item.id); + if (Array.isArray(item.children)) { + flatten(item.children); + } + } + } + + flatten(arr); + return result; +} diff --git a/packages/i18n/src/resources/en.json b/packages/i18n/src/resources/en.json index c904129e23..534e33d59a 100644 --- a/packages/i18n/src/resources/en.json +++ b/packages/i18n/src/resources/en.json @@ -197,5 +197,8 @@ "Pivots": "Pivots", "Add a subpage inside": "Add a subpage inside", "Rename": "Rename", - "Move to": "Move to" + "Move to": "Move to", + "Move page to...": "Move page to...", + "Remove from Pivots": "Remove from Pivots", + "RFP": "Pages can be freely added/removed from pivots, remaining accessible from \"All Pages\"." }