diff --git a/packages/frontend/core/src/mobile/components/explorer/index.ts b/packages/frontend/core/src/mobile/components/explorer/index.ts new file mode 100644 index 0000000000..fec3edc9c5 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/index.ts @@ -0,0 +1,5 @@ +export { CollapsibleSection } from './layouts/collapsible-section'; +export { ExplorerCollections } from './sections/collections'; +export { ExplorerFavorites } from './sections/favorites'; +export { ExplorerOrganize } from './sections/organize'; +export { ExplorerTags } from './sections/tags'; diff --git a/packages/frontend/core/src/mobile/components/explorer/layouts/add-item-placeholder.css.ts b/packages/frontend/core/src/mobile/components/explorer/layouts/add-item-placeholder.css.ts new file mode 100644 index 0000000000..50f6e4da2e --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/layouts/add-item-placeholder.css.ts @@ -0,0 +1,29 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +import { iconContainer, itemRoot, levelIndent } from '../tree/node.css'; + +export const wrapper = style([ + itemRoot, + { + color: cssVarV2('text/tertiary'), + }, +]); +export const root = style({ + paddingLeft: levelIndent, +}); + +export const iconWrapper = style([ + iconContainer, + { + color: cssVarV2('text/tertiary'), + fontSize: 24, + }, +]); + +export const label = style({ + fontSize: 17, + fontWeight: 400, + lineHeight: '22px', + letterSpacing: -0.43, +}); diff --git a/packages/frontend/core/src/mobile/components/explorer/layouts/add-item-placeholder.tsx b/packages/frontend/core/src/mobile/components/explorer/layouts/add-item-placeholder.tsx new file mode 100644 index 0000000000..98afe53c64 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/layouts/add-item-placeholder.tsx @@ -0,0 +1,44 @@ +import { ExplorerTreeContext } from '@affine/core/modules/explorer'; +import { PlusIcon } from '@blocksuite/icons/rc'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import clsx from 'clsx'; +import { type HTMLAttributes, useContext } from 'react'; + +import { levelIndent } from '../tree/node.css'; +import * as styles from './add-item-placeholder.css'; + +export interface AddItemPlaceholderProps + extends HTMLAttributes { + onClick?: () => void; + label?: string; + icon?: React.ReactNode; +} + +export const AddItemPlaceholder = ({ + onClick, + label = 'Add Item', + icon = , + className, + ...attrs +}: AddItemPlaceholderProps) => { + const context = useContext(ExplorerTreeContext); + const level = context?.level ?? 0; + + return ( +
+
+
{icon}
+ {label} +
+
+ ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/layouts/collapsible-section.css.ts b/packages/frontend/core/src/mobile/components/explorer/layouts/collapsible-section.css.ts new file mode 100644 index 0000000000..f6b0d6900e --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/layouts/collapsible-section.css.ts @@ -0,0 +1,49 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +// content +export const content = style({ + paddingTop: 8, +}); + +// trigger +export const triggerRoot = style({ + fontSize: cssVar('fontXs'), + height: 25, + width: '100%', + userSelect: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 16px', + borderRadius: 4, +}); +export const triggerLabel = style({ + flexGrow: '0', + display: 'flex', + gap: 2, + alignItems: 'center', + justifyContent: 'start', + + color: cssVarV2('text/primary'), + fontSize: 20, + lineHeight: '25px', + letterSpacing: -0.45, + fontWeight: 400, +}); +export const triggerCollapseIcon = style({ + vars: { '--y': '1px', '--r': '90deg' }, + color: cssVarV2('icon/tertiary'), + transform: 'translateY(var(--y)) rotate(var(--r))', + transition: 'transform 0.2s', + selectors: { + [`${triggerRoot}[data-collapsed="true"] &`]: { + vars: { '--r': '0deg' }, + }, + }, +}); +export const triggerActions = style({ + display: 'flex', + gap: 8, +}); diff --git a/packages/frontend/core/src/mobile/components/explorer/layouts/collapsible-section.tsx b/packages/frontend/core/src/mobile/components/explorer/layouts/collapsible-section.tsx new file mode 100644 index 0000000000..26947b9d9a --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/layouts/collapsible-section.tsx @@ -0,0 +1,120 @@ +import { + type CollapsibleSectionName, + ExplorerService, +} from '@affine/core/modules/explorer'; +import { ToggleCollapseIcon } from '@blocksuite/icons/rc'; +import * as Collapsible from '@radix-ui/react-collapsible'; +import { useLiveData, useService } from '@toeverything/infra'; +import clsx from 'clsx'; +import { + forwardRef, + type HTMLAttributes, + type ReactNode, + useCallback, +} from 'react'; + +import { + content, + triggerActions, + triggerCollapseIcon, + triggerLabel, + triggerRoot, +} from './collapsible-section.css'; + +interface CollapsibleSectionProps extends HTMLAttributes { + name: CollapsibleSectionName; + title: string; + actions?: ReactNode; + testId?: string; + headerTestId?: string; + headerClassName?: string; + contentClassName?: string; +} + +interface CollapsibleSectionTriggerProps + extends HTMLAttributes { + label: string; + collapsed?: boolean; + actions?: ReactNode; + setCollapsed?: (collapsed: boolean) => void; +} + +const CollapsibleSectionTrigger = forwardRef< + HTMLDivElement, + CollapsibleSectionTriggerProps +>(function CollapsibleSectionTrigger( + { actions, label, collapsed, setCollapsed, className, ...attrs }, + ref +) { + const collapsible = collapsed !== undefined; + return ( +
setCollapsed?.(!collapsed)} + data-collapsed={collapsed} + data-collapsible={collapsible} + {...attrs} + > +
+ {label} + {collapsible ? ( + + ) : null} +
+
e.stopPropagation()}> + {actions} +
+
+ ); +}); + +export const CollapsibleSection = ({ + name, + title, + actions, + testId, + headerClassName, + headerTestId, + contentClassName, + children, + ...attrs +}: CollapsibleSectionProps) => { + const section = useService(ExplorerService).sections[name]; + const collapsed = useLiveData(section.collapsed$); + + const setCollapsed = useCallback( + (v: boolean) => section.setCollapsed(v), + [section] + ); + + return ( + + + + {children} + + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/collection/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/index.tsx new file mode 100644 index 0000000000..898a10d898 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/index.tsx @@ -0,0 +1,233 @@ +import { MenuItem, notify } from '@affine/component'; +import { + filterPage, + useEditCollection, +} from '@affine/core/components/page-list'; +import { CollectionService } from '@affine/core/modules/collection'; +import type { NodeOperation } from '@affine/core/modules/explorer'; +import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; +import { ShareDocsListService } from '@affine/core/modules/share-doc'; +import type { Collection } from '@affine/env/filter'; +import { PublicPageMode } from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; +import track from '@affine/track'; +import type { DocMeta } from '@blocksuite/affine/store'; +import { FilterMinusIcon, ViewLayersIcon } from '@blocksuite/icons/rc'; +import { + DocsService, + GlobalContextService, + LiveData, + useLiveData, + useServices, +} from '@toeverything/infra'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { ExplorerTreeNode } from '../../tree/node'; +import { ExplorerDocNode } from '../doc'; +import { + useExplorerCollectionNodeOperations, + useExplorerCollectionNodeOperationsMenu, +} from './operations'; + +const CollectionIcon = () => ; + +export const ExplorerCollectionNode = ({ + collectionId, + operations: additionalOperations, +}: { + collectionId: string; + operations?: NodeOperation[]; +}) => { + const t = useI18n(); + const { globalContextService, collectionService } = useServices({ + GlobalContextService, + CollectionService, + }); + const { open: openEditCollectionModal } = useEditCollection(); + const active = + useLiveData(globalContextService.globalContext.collectionId.$) === + collectionId; + const [collapsed, setCollapsed] = useState(true); + + const collection = useLiveData(collectionService.collection$(collectionId)); + + const handleRename = useCallback( + (name: string) => { + if (collection && collection.name !== name) { + collectionService.updateCollection(collectionId, () => ({ + ...collection, + name, + })); + + track.$.navigationPanel.organize.renameOrganizeItem({ + type: 'collection', + }); + notify.success({ message: t['com.affine.toastMessage.rename']() }); + } + }, + [collection, collectionId, collectionService, t] + ); + + const handleOpenCollapsed = useCallback(() => { + setCollapsed(false); + }, []); + + const handleEditCollection = useCallback(() => { + if (!collection) { + return; + } + openEditCollectionModal(collection) + .then(collection => { + return collectionService.updateCollection( + collection.id, + () => collection + ); + }) + .catch(err => { + console.error(err); + }); + }, [collection, collectionService, openEditCollectionModal]); + + const collectionOperations = useExplorerCollectionNodeOperationsMenu( + collectionId, + handleOpenCollapsed, + handleEditCollection + ); + const { handleAddDocToCollection } = useExplorerCollectionNodeOperations( + collectionId, + handleOpenCollapsed, + handleEditCollection + ); + + const finalOperations = useMemo(() => { + if (additionalOperations) { + return [...additionalOperations, ...collectionOperations]; + } + return collectionOperations; + }, [collectionOperations, additionalOperations]); + + if (!collection) { + return null; + } + + return ( + + + + ); +}; + +const ExplorerCollectionNodeChildren = ({ + collection, + onAddDoc, +}: { + collection: Collection; + onAddDoc?: () => void; +}) => { + const t = useI18n(); + const { + docsService, + compatibleFavoriteItemsAdapter, + shareDocsListService, + collectionService, + } = useServices({ + DocsService, + CompatibleFavoriteItemsAdapter, + ShareDocsListService, + CollectionService, + }); + + useEffect(() => { + // TODO(@eyhn): loading & error UI + shareDocsListService.shareDocs?.revalidate(); + }, [shareDocsListService]); + + const docMetas = useLiveData( + useMemo( + () => + LiveData.computed(get => { + return get(docsService.list.docs$).map( + doc => get(doc.meta$) as DocMeta + ); + }), + [docsService] + ) + ); + const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$); + const allowList = useMemo( + () => new Set(collection.allowList), + [collection.allowList] + ); + const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$); + + const handleRemoveFromAllowList = useCallback( + (id: string) => { + track.$.navigationPanel.collections.removeOrganizeItem({ type: 'doc' }); + collectionService.deletePageFromCollection(collection.id, id); + notify.success({ + message: t['com.affine.collection.removePage.success'](), + }); + }, + [collection.id, collectionService, t] + ); + + const filtered = docMetas.filter(meta => { + if (meta.trash) return false; + const publicMode = shareDocs?.find(d => d.id === meta.id)?.mode; + const pageData = { + meta: meta as DocMeta, + publicMode: + publicMode === PublicPageMode.Edgeless + ? ('edgeless' as const) + : publicMode === PublicPageMode.Page + ? ('page' as const) + : undefined, + favorite: favourites.some(fav => fav.id === meta.id), + }; + return filterPage(collection, pageData); + }); + + return ( + <> + {filtered.map(doc => ( + } + onClick={() => handleRemoveFromAllowList(doc.id)} + > + {t['Remove special filter']()} + + ), + }, + ] + : [] + } + /> + ))} + + + ); +}; 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 new file mode 100644 index 0000000000..730b43a5f9 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/collection/operations.tsx @@ -0,0 +1,264 @@ +import { + IconButton, + MenuItem, + MenuSeparator, + useConfirmModal, +} from '@affine/component'; +import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; +import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info'; +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 { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import { track } from '@affine/track'; +import { + DeleteIcon, + FilterIcon, + OpenInNewIcon, + PlusIcon, + SplitViewIcon, +} from '@blocksuite/icons/rc'; +import { + FeatureFlagService, + useLiveData, + useServices, + WorkspaceService, +} from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +export const useExplorerCollectionNodeOperations = ( + collectionId: string, + onOpenCollapsed: () => void, + onOpenEdit: () => void +) => { + const t = useI18n(); + const { + workbenchService, + workspaceService, + collectionService, + compatibleFavoriteItemsAdapter, + } = useServices({ + WorkbenchService, + WorkspaceService, + CollectionService, + CompatibleFavoriteItemsAdapter, + }); + const deleteInfo = useDeleteCollectionInfo(); + + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + + const favorite = useLiveData( + useMemo( + () => + compatibleFavoriteItemsAdapter.isFavorite$(collectionId, 'collection'), + [collectionId, compatibleFavoriteItemsAdapter] + ) + ); + const { openConfirmModal } = useConfirmModal(); + + const createAndAddDocument = useCallback(() => { + const newDoc = createPage(); + collectionService.addPageToCollection(collectionId, newDoc.id); + track.$.navigationPanel.collections.createDoc(); + track.$.navigationPanel.collections.addDocToCollection({ + control: 'button', + }); + onOpenCollapsed(); + }, [collectionId, collectionService, createPage, onOpenCollapsed]); + + const handleToggleFavoriteCollection = useCallback(() => { + compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection'); + track.$.navigationPanel.organize.toggleFavorite({ + type: 'collection', + }); + }, [compatibleFavoriteItemsAdapter, collectionId]); + + const handleAddDocToCollection = useCallback(() => { + openConfirmModal({ + title: t['com.affine.collection.add-doc.confirm.title'](), + description: t['com.affine.collection.add-doc.confirm.description'](), + cancelText: t['Cancel'](), + confirmText: t['Confirm'](), + confirmButtonOptions: { + variant: 'primary', + }, + onConfirm: createAndAddDocument, + }); + }, [createAndAddDocument, openConfirmModal, t]); + + const handleOpenInSplitView = useCallback(() => { + workbenchService.workbench.openCollection(collectionId, { at: 'beside' }); + track.$.navigationPanel.organize.openInSplitView({ + type: 'collection', + }); + }, [collectionId, workbenchService.workbench]); + + const handleOpenInNewTab = useCallback(() => { + workbenchService.workbench.openCollection(collectionId, { at: 'new-tab' }); + track.$.navigationPanel.organize.openInNewTab({ type: 'collection' }); + }, [collectionId, workbenchService.workbench]); + + const handleDeleteCollection = useCallback(() => { + collectionService.deleteCollection(deleteInfo, collectionId); + track.$.navigationPanel.organize.deleteOrganizeItem({ + type: 'collection', + }); + }, [collectionId, collectionService, deleteInfo]); + + const handleShowEdit = useCallback(() => { + onOpenEdit(); + }, [onOpenEdit]); + + return useMemo( + () => ({ + favorite, + handleAddDocToCollection, + handleDeleteCollection, + handleOpenInNewTab, + handleOpenInSplitView, + handleShowEdit, + handleToggleFavoriteCollection, + }), + [ + favorite, + handleAddDocToCollection, + handleDeleteCollection, + handleOpenInNewTab, + handleOpenInSplitView, + handleShowEdit, + handleToggleFavoriteCollection, + ] + ); +}; + +export const useExplorerCollectionNodeOperationsMenu = ( + collectionId: string, + onOpenCollapsed: () => void, + onOpenEdit: () => void +): NodeOperation[] => { + const t = useI18n(); + const { featureFlagService } = useServices({ FeatureFlagService }); + const enableMultiView = useLiveData( + featureFlagService.flags.enable_multi_view.$ + ); + + const { + favorite, + handleAddDocToCollection, + handleDeleteCollection, + handleOpenInNewTab, + handleOpenInSplitView, + handleShowEdit, + handleToggleFavoriteCollection, + } = useExplorerCollectionNodeOperations( + collectionId, + onOpenCollapsed, + onOpenEdit + ); + + return useMemo( + () => [ + { + index: 0, + inline: true, + view: ( + + + + ), + }, + { + index: 99, + view: ( + } onClick={handleShowEdit}> + {t['com.affine.collection.menu.edit']()} + + ), + }, + { + index: 99, + view: ( + } + onClick={handleAddDocToCollection} + > + {t['New Page']()} + + ), + }, + { + index: 99, + view: ( + } + onClick={handleToggleFavoriteCollection} + > + {favorite + ? t['com.affine.favoritePageOperation.remove']() + : t['com.affine.favoritePageOperation.add']()} + + ), + }, + { + index: 99, + view: ( + } onClick={handleOpenInNewTab}> + {t['com.affine.workbench.tab.page-menu-open']()} + + ), + }, + ...(BUILD_CONFIG.isElectron && enableMultiView + ? [ + { + index: 99, + view: ( + } + onClick={handleOpenInSplitView} + > + {t['com.affine.workbench.split-view.page-menu-open']()} + + ), + }, + ] + : []), + { + index: 9999, + view: , + }, + { + index: 10000, + view: ( + } + onClick={handleDeleteCollection} + > + {t['Delete']()} + + ), + }, + ], + [ + enableMultiView, + favorite, + handleAddDocToCollection, + handleDeleteCollection, + handleOpenInNewTab, + handleOpenInSplitView, + handleShowEdit, + handleToggleFavoriteCollection, + t, + ] + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx new file mode 100644 index 0000000000..5bdcad26d7 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/index.tsx @@ -0,0 +1,157 @@ +import { Loading } from '@affine/component'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; +import { DocInfoService } from '@affine/core/modules/doc-info'; +import { DocsSearchService } from '@affine/core/modules/docs-search'; +import type { NodeOperation } from '@affine/core/modules/explorer'; +import { useI18n } from '@affine/i18n'; +import track from '@affine/track'; +import { + DocsService, + FeatureFlagService, + GlobalContextService, + LiveData, + useLiveData, + useService, + useServices, +} from '@toeverything/infra'; +import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; + +import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { ExplorerTreeNode } from '../../tree/node'; +import { + useExplorerDocNodeOperations, + useExplorerDocNodeOperationsMenu, +} from './operations'; +import * as styles from './styles.css'; + +export const ExplorerDocNode = ({ + docId, + isLinked, + operations: additionalOperations, +}: { + docId: string; + isLinked?: boolean; + operations?: NodeOperation[]; +}) => { + const t = useI18n(); + const { + docsSearchService, + docsService, + globalContextService, + docDisplayMetaService, + featureFlagService, + } = useServices({ + DocsSearchService, + DocsService, + GlobalContextService, + DocDisplayMetaService, + FeatureFlagService, + }); + const active = + useLiveData(globalContextService.globalContext.docId.$) === docId; + const [collapsed, setCollapsed] = useState(true); + + const docRecord = useLiveData(docsService.list.doc$(docId)); + const DocIcon = useLiveData( + docDisplayMetaService.icon$(docId, { + reference: isLinked, + }) + ); + const docTitle = useLiveData(docDisplayMetaService.title$(docId)); + const isInTrash = useLiveData(docRecord?.trash$); + const enableEmojiIcon = useLiveData( + featureFlagService.flags.enable_emoji_doc_icon.$ + ); + + const Icon = useCallback( + ({ className }: { className?: string }) => ( + + ), + [DocIcon] + ); + + const children = useLiveData( + useMemo( + () => LiveData.from(docsSearchService.watchRefsFrom(docId), null), + [docsSearchService, docId] + ) + ); + + const indexerLoading = useLiveData( + docsSearchService.indexer.status$.map( + v => v.remaining === undefined || v.remaining > 0 + ) + ); + const [referencesLoading, setReferencesLoading] = useState(true); + useLayoutEffect(() => { + setReferencesLoading( + prev => + prev && + indexerLoading /* after loading becomes false, it never becomes true */ + ); + }, [indexerLoading]); + + const handleRename = useAsyncCallback( + async (newName: string) => { + await docsService.changeDocTitle(docId, newName); + track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' }); + }, + [docId, docsService] + ); + + const docInfoModal = useService(DocInfoService).modal; + const option = useMemo( + () => ({ + openInfoModal: () => docInfoModal.open(docId), + openNodeCollapsed: () => setCollapsed(false), + }), + [docId, docInfoModal] + ); + const operations = useExplorerDocNodeOperationsMenu(docId, option); + const { handleAddLinkedPage } = useExplorerDocNodeOperations(docId, option); + + const finalOperations = useMemo(() => { + if (additionalOperations) { + return [...operations, ...additionalOperations]; + } + return operations; + }, [additionalOperations, operations]); + + if (isInTrash || !docRecord) { + return null; + } + + return ( + + + + ) + } + onRename={handleRename} + operations={finalOperations} + data-testid={`explorer-doc-${docId}`} + > + {children?.map(child => ( + + ))} + + + + ); +}; 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 new file mode 100644 index 0000000000..0f96897781 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/operations.tsx @@ -0,0 +1,296 @@ +import { + IconButton, + MenuItem, + MenuSeparator, + toast, + useConfirmModal, +} from '@affine/component'; +import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; +import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; +import type { NodeOperation } from '@affine/core/modules/explorer'; +import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import { track } from '@affine/track'; +import { + DeleteIcon, + DuplicateIcon, + InformationIcon, + LinkedPageIcon, + OpenInNewIcon, + PlusIcon, + SplitViewIcon, +} from '@blocksuite/icons/rc'; +import { + DocsService, + FeatureFlagService, + useLiveData, + useService, + useServices, + WorkspaceService, +} from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +export const useExplorerDocNodeOperations = ( + docId: string, + options: { + openInfoModal: () => void; + openNodeCollapsed: () => void; + } +) => { + const t = useI18n(); + const { + workbenchService, + workspaceService, + docsService, + compatibleFavoriteItemsAdapter, + } = useServices({ + DocsService, + WorkbenchService, + WorkspaceService, + CompatibleFavoriteItemsAdapter, + }); + + const { openConfirmModal } = useConfirmModal(); + + const docRecord = useLiveData(docsService.list.doc$(docId)); + + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + + const favorite = useLiveData( + useMemo(() => { + return compatibleFavoriteItemsAdapter.isFavorite$(docId, 'doc'); + }, [docId, compatibleFavoriteItemsAdapter]) + ); + + const { duplicate } = useBlockSuiteMetaHelper(); + const handleDuplicate = useCallback(() => { + duplicate(docId, true); + track.$.navigationPanel.docs.createDoc(); + }, [docId, duplicate]); + const handleOpenInfoModal = useCallback(() => { + track.$.docInfoPanel.$.open(); + options.openInfoModal(); + }, [options]); + + const handleMoveToTrash = useCallback(() => { + if (!docRecord) { + return; + } + openConfirmModal({ + title: t['com.affine.moveToTrash.title'](), + description: t['com.affine.moveToTrash.confirmModal.description']({ + title: docRecord.title$.value, + }), + confirmText: t['com.affine.moveToTrash.confirmModal.confirm'](), + cancelText: t['com.affine.moveToTrash.confirmModal.cancel'](), + confirmButtonOptions: { + variant: 'error', + }, + onConfirm() { + docRecord.moveToTrash(); + track.$.navigationPanel.docs.deleteDoc({ + control: 'button', + }); + toast(t['com.affine.toastMessage.movedTrash']()); + }, + }); + }, [docRecord, openConfirmModal, t]); + + const handleOpenInNewTab = useCallback(() => { + workbenchService.workbench.openDoc(docId, { + at: 'new-tab', + }); + track.$.navigationPanel.organize.openInNewTab({ + type: 'doc', + }); + }, [docId, workbenchService]); + + const handleOpenInSplitView = useCallback(() => { + workbenchService.workbench.openDoc(docId, { + at: 'beside', + }); + track.$.navigationPanel.organize.openInSplitView({ + type: 'doc', + }); + }, [docId, workbenchService.workbench]); + + const handleAddLinkedPage = useAsyncCallback(async () => { + const newDoc = createPage(); + // TODO: handle timeout & error + await docsService.addLinkedDoc(docId, newDoc.id); + track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' }); + track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' }); + options.openNodeCollapsed(); + }, [createPage, docsService, docId, options]); + + const handleToggleFavoriteDoc = useCallback(() => { + compatibleFavoriteItemsAdapter.toggle(docId, 'doc'); + track.$.navigationPanel.organize.toggleFavorite({ + type: 'doc', + }); + }, [docId, compatibleFavoriteItemsAdapter]); + + return useMemo( + () => ({ + favorite, + handleAddLinkedPage, + handleDuplicate, + handleToggleFavoriteDoc, + handleOpenInSplitView, + handleOpenInNewTab, + handleMoveToTrash, + handleOpenInfoModal, + }), + [ + favorite, + handleAddLinkedPage, + handleDuplicate, + handleMoveToTrash, + handleOpenInNewTab, + handleOpenInSplitView, + handleOpenInfoModal, + handleToggleFavoriteDoc, + ] + ); +}; + +export const useExplorerDocNodeOperationsMenu = ( + docId: string, + options: { + openInfoModal: () => void; + openNodeCollapsed: () => void; + } +): NodeOperation[] => { + const t = useI18n(); + const featureFlagService = useService(FeatureFlagService); + const { + favorite, + handleAddLinkedPage, + handleDuplicate, + handleToggleFavoriteDoc, + handleOpenInSplitView, + handleOpenInNewTab, + handleMoveToTrash, + handleOpenInfoModal, + } = useExplorerDocNodeOperations(docId, options); + + const enableMultiView = useLiveData( + featureFlagService.flags.enable_multi_view.$ + ); + + return useMemo( + () => [ + { + index: 0, + inline: true, + view: ( + } + tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()} + onClick={handleAddLinkedPage} + /> + ), + }, + { + index: 50, + view: ( + } + onClick={handleOpenInfoModal} + > + {t['com.affine.page-properties.page-info.view']()} + + ), + }, + { + index: 99, + view: ( + } + onClick={handleAddLinkedPage} + > + {t['com.affine.page-operation.add-linked-page']()} + + ), + }, + { + index: 99, + view: ( + } onClick={handleDuplicate}> + {t['com.affine.header.option.duplicate']()} + + ), + }, + { + index: 99, + view: ( + } onClick={handleOpenInNewTab}> + {t['com.affine.workbench.tab.page-menu-open']()} + + ), + }, + ...(BUILD_CONFIG.isElectron && enableMultiView + ? [ + { + index: 100, + view: ( + } + onClick={handleOpenInSplitView} + > + {t['com.affine.workbench.split-view.page-menu-open']()} + + ), + }, + ] + : []), + { + index: 199, + view: ( + } + onClick={handleToggleFavoriteDoc} + > + {favorite + ? t['com.affine.favoritePageOperation.remove']() + : t['com.affine.favoritePageOperation.add']()} + + ), + }, + { + index: 9999, + view: , + }, + { + index: 10000, + view: ( + } + onClick={handleMoveToTrash} + > + {t['com.affine.moveToTrash.title']()} + + ), + }, + ], + [ + enableMultiView, + favorite, + handleAddLinkedPage, + handleDuplicate, + handleMoveToTrash, + handleOpenInNewTab, + handleOpenInSplitView, + handleOpenInfoModal, + handleToggleFavoriteDoc, + t, + ] + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/doc/styles.css.ts b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/styles.css.ts new file mode 100644 index 0000000000..6a4ebb8759 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/doc/styles.css.ts @@ -0,0 +1,7 @@ +import { style } from '@vanilla-extract/css'; + +export const loadingIcon = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/folder/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/folder/index.tsx new file mode 100644 index 0000000000..2f884cb779 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/folder/index.tsx @@ -0,0 +1,407 @@ +import { + AnimatedFolderIcon, + IconButton, + MenuItem, + MenuSeparator, + MenuSub, + notify, +} from '@affine/component'; +import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; +import { + useSelectCollection, + useSelectDoc, + useSelectTag, +} from '@affine/core/components/page-list/selector'; +import type { + ExplorerTreeNodeIcon, + NodeOperation, +} from '@affine/core/modules/explorer'; +import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; +import { + type FolderNode, + OrganizeService, +} from '@affine/core/modules/organize'; +import { useI18n } from '@affine/i18n'; +import track from '@affine/track'; +import { + DeleteIcon, + FolderIcon, + LayerIcon, + PageIcon, + PlusIcon, + PlusThickIcon, + RemoveFolderIcon, + TagsIcon, +} from '@blocksuite/icons/rc'; +import { + FeatureFlagService, + useLiveData, + useServices, + WorkspaceService, +} from '@toeverything/infra'; +import { difference } from 'lodash-es'; +import { useCallback, useMemo, useState } from 'react'; + +import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { ExplorerTreeNode } from '../../tree/node'; +import { ExplorerCollectionNode } from '../collection'; +import { ExplorerDocNode } from '../doc'; +import { ExplorerTagNode } from '../tag'; +import { FavoriteFolderOperation } from './operations'; + +export const ExplorerFolderNode = ({ + nodeId, + defaultRenaming, + operations, +}: { + defaultRenaming?: boolean; + nodeId: string; + operations?: + | NodeOperation[] + | ((type: string, node: FolderNode) => NodeOperation[]); +}) => { + const { organizeService } = useServices({ + OrganizeService, + }); + const node = useLiveData(organizeService.folderTree.folderNode$(nodeId)); + const type = useLiveData(node?.type$); + const data = useLiveData(node?.data$); + + const additionalOperations = useMemo(() => { + if (!type || !node) { + return; + } + if (typeof operations === 'function') { + return operations(type, node); + } + return operations; + }, [node, operations, type]); + + if (!node) { + return; + } + + if (type === 'folder') { + return ( + + ); + } + if (!data) return null; + if (type === 'doc') { + return ; + } else if (type === 'collection') { + return ( + + ); + } else if (type === 'tag') { + return ; + } + + return; +}; + +const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({ + collapsed, + className, + draggedOver, + treeInstruction, +}) => ( + +); + +const ExplorerFolderNodeFolder = ({ + node, + defaultRenaming, + operations: additionalOperations, +}: { + defaultRenaming?: boolean; + node: FolderNode; + operations?: NodeOperation[]; +}) => { + const t = useI18n(); + const { workspaceService, featureFlagService } = useServices({ + WorkspaceService, + CompatibleFavoriteItemsAdapter, + FeatureFlagService, + }); + const openDocsSelector = useSelectDoc(); + const openTagsSelector = useSelectTag(); + const openCollectionsSelector = useSelectCollection(); + const name = useLiveData(node.name$); + const enableEmojiIcon = useLiveData( + featureFlagService.flags.enable_emoji_folder_icon.$ + ); + const [collapsed, setCollapsed] = useState(true); + const [newFolderId, setNewFolderId] = useState(null); + + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + const handleDelete = useCallback(() => { + node.delete(); + track.$.navigationPanel.organize.deleteOrganizeItem({ + type: 'folder', + }); + notify.success({ + title: t['com.affine.rootAppSidebar.organize.delete.notify-title']({ + name, + }), + message: t['com.affine.rootAppSidebar.organize.delete.notify-message'](), + }); + }, [name, node, t]); + + const children = useLiveData(node.sortedChildren$); + + const handleRename = useCallback( + (newName: string) => { + node.rename(newName); + }, + [node] + ); + + const handleNewDoc = useCallback(() => { + const newDoc = createPage(); + node.createLink('doc', newDoc.id, node.indexAt('before')); + track.$.navigationPanel.folders.createDoc(); + track.$.navigationPanel.organize.createOrganizeItem({ + type: 'link', + target: 'doc', + }); + setCollapsed(false); + }, [createPage, node]); + + const handleCreateSubfolder = useCallback(() => { + const newFolderId = node.createFolder( + t['com.affine.rootAppSidebar.organize.new-folders'](), + node.indexAt('before') + ); + track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); + setCollapsed(false); + setNewFolderId(newFolderId); + }, [node, t]); + + const handleAddToFolder = useCallback( + (type: 'doc' | 'collection' | 'tag') => { + const initialIds = children + .filter(node => node.type$.value === type) + .map(node => node.data$.value) + .filter(Boolean) as string[]; + const selector = + type === 'doc' + ? openDocsSelector + : type === 'collection' + ? openCollectionsSelector + : openTagsSelector; + selector(initialIds) + .then(selectedIds => { + const newItemIds = difference(selectedIds, initialIds); + const removedItemIds = difference(initialIds, selectedIds); + const removedItems = children.filter( + node => + !!node.data$.value && removedItemIds.includes(node.data$.value) + ); + + newItemIds.forEach(id => { + node.createLink(type, id, node.indexAt('after')); + }); + removedItems.forEach(node => node.delete()); + const updated = newItemIds.length + removedItems.length; + updated && setCollapsed(false); + }) + .catch(err => { + console.error(`Unexpected error while selecting ${type}`, err); + }); + track.$.navigationPanel.organize.createOrganizeItem({ + type: 'link', + target: type, + }); + }, + [ + children, + node, + openCollectionsSelector, + openDocsSelector, + openTagsSelector, + ] + ); + + const folderOperations = useMemo(() => { + return [ + { + index: 0, + inline: true, + view: ( + + + + ), + }, + { + index: 100, + view: ( + } onClick={handleCreateSubfolder}> + {t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()} + + ), + }, + { + index: 101, + view: ( + } + onClick={() => handleAddToFolder('doc')} + > + {t['com.affine.rootAppSidebar.organize.folder.add-docs']()} + + ), + }, + { + index: 102, + view: ( + , + }} + items={ + <> + handleAddToFolder('tag')} + prefixIcon={} + > + {t['com.affine.rootAppSidebar.organize.folder.add-tags']()} + + handleAddToFolder('collection')} + prefixIcon={} + > + {t[ + 'com.affine.rootAppSidebar.organize.folder.add-collections' + ]()} + + + } + > + {t['com.affine.rootAppSidebar.organize.folder.add-others']()} + + ), + }, + + { + index: 200, + view: node.id ? : null, + }, + + { + index: 9999, + view: , + }, + { + index: 10000, + view: ( + } + onClick={handleDelete} + > + {t['com.affine.rootAppSidebar.organize.delete']()} + + ), + }, + ]; + }, [ + handleAddToFolder, + handleCreateSubfolder, + handleDelete, + handleNewDoc, + node, + t, + ]); + + const finalOperations = useMemo(() => { + if (additionalOperations) { + return [...additionalOperations, ...folderOperations]; + } + return folderOperations; + }, [additionalOperations, folderOperations]); + + const childrenOperations = useCallback( + // eslint-disable-next-line @typescript-eslint/ban-types + (type: string, node: FolderNode) => { + if (type === 'doc' || type === 'collection' || type === 'tag') { + return [ + { + index: 999, + view: ( + } + data-event-props="$.navigationPanel.organize.deleteOrganizeItem" + data-event-args-type={node.type$.value} + onClick={() => node.delete()} + > + {t['com.affine.rootAppSidebar.organize.delete-from-folder']()} + + ), + }, + ] satisfies NodeOperation[]; + } + return []; + }, + [t] + ); + + const handleCollapsedChange = useCallback((collapsed: boolean) => { + if (collapsed) { + setNewFolderId(null); // reset new folder id to clear the renaming state + setCollapsed(true); + } else { + setCollapsed(false); + } + }, []); + + return ( + + {children.map(child => ( + + ))} + handleAddToFolder('doc')} + /> + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/folder/operations.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/folder/operations.tsx new file mode 100644 index 0000000000..b2a851e700 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/folder/operations.tsx @@ -0,0 +1,30 @@ +import { MenuItem } from '@affine/component'; +import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; +import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useMemo } from 'react'; + +export const FavoriteFolderOperation = ({ id }: { id: string }) => { + const t = useI18n(); + const compatibleFavoriteItemsAdapter = useService( + CompatibleFavoriteItemsAdapter + ); + + const favorite = useLiveData( + useMemo(() => { + return compatibleFavoriteItemsAdapter.isFavorite$(id, 'folder'); + }, [compatibleFavoriteItemsAdapter, id]) + ); + + return ( + } + onClick={() => compatibleFavoriteItemsAdapter.toggle(id, 'folder')} + > + {favorite + ? t['com.affine.rootAppSidebar.organize.folder-rm-favorite']() + : t['com.affine.rootAppSidebar.organize.folder-add-favorite']()} + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/index.tsx b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/index.tsx new file mode 100644 index 0000000000..2d939bf445 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/index.tsx @@ -0,0 +1,135 @@ +import type { NodeOperation } from '@affine/core/modules/explorer'; +import type { Tag } from '@affine/core/modules/tag'; +import { TagService } from '@affine/core/modules/tag'; +import { useI18n } from '@affine/i18n'; +import track from '@affine/track'; +import { + GlobalContextService, + useLiveData, + useServices, +} from '@toeverything/infra'; +import clsx from 'clsx'; +import { useCallback, useMemo, useState } from 'react'; + +import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { ExplorerTreeNode } from '../../tree/node'; +import { ExplorerDocNode } from '../doc'; +import { + useExplorerTagNodeOperations, + useExplorerTagNodeOperationsMenu, +} from './operations'; +import * as styles from './styles.css'; + +export const ExplorerTagNode = ({ + tagId, + operations: additionalOperations, + defaultRenaming, +}: { + tagId: string; + defaultRenaming?: boolean; + operations?: NodeOperation[]; +}) => { + const t = useI18n(); + const { tagService, globalContextService } = useServices({ + TagService, + GlobalContextService, + }); + const active = + useLiveData(globalContextService.globalContext.tagId.$) === tagId; + const [collapsed, setCollapsed] = useState(true); + + const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId)); + const tagColor = useLiveData(tagRecord?.color$); + const tagName = useLiveData(tagRecord?.value$); + + const Icon = useCallback( + ({ className }: { className?: string }) => { + return ( +
+
+
+ ); + }, + [tagColor] + ); + + const handleRename = useCallback( + (newName: string) => { + if (tagRecord && tagRecord.value$.value !== newName) { + tagRecord.rename(newName); + track.$.navigationPanel.organize.renameOrganizeItem({ + type: 'tag', + }); + } + }, + [tagRecord] + ); + + const option = useMemo( + () => ({ + openNodeCollapsed: () => setCollapsed(false), + }), + [] + ); + const operations = useExplorerTagNodeOperationsMenu(tagId, option); + const { handleNewDoc } = useExplorerTagNodeOperations(tagId, option); + + const finalOperations = useMemo(() => { + if (additionalOperations) { + return [...operations, ...additionalOperations]; + } + return operations; + }, [additionalOperations, operations]); + + if (!tagRecord) { + return null; + } + + return ( + + + + ); +}; + +/** + * the `tag.pageIds$` has a performance issue, + * so we split the tag node children into a separate component, + * so it won't be rendered when the tag node is collapsed. + */ +export const ExplorerTagNodeDocs = ({ + tag, + onNewDoc, +}: { + tag: Tag; + onNewDoc?: () => void; +}) => { + const t = useI18n(); + const tagDocIds = useLiveData(tag.pageIds$); + + return ( + <> + {tagDocIds.map(docId => ( + + ))} + + + ); +}; 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 new file mode 100644 index 0000000000..967113ad06 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/operations.tsx @@ -0,0 +1,207 @@ +import { IconButton, MenuItem, MenuSeparator, toast } from '@affine/component'; +import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; +import { IsFavoriteIcon } from '@affine/core/components/pure/icons'; +import type { NodeOperation } from '@affine/core/modules/explorer'; +import { FavoriteService } from '@affine/core/modules/favorite'; +import { TagService } from '@affine/core/modules/tag'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import { track } from '@affine/track'; +import { + DeleteIcon, + OpenInNewIcon, + PlusIcon, + SplitViewIcon, +} from '@blocksuite/icons/rc'; +import { + DocsService, + FeatureFlagService, + useLiveData, + useService, + useServices, + WorkspaceService, +} from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +export const useExplorerTagNodeOperations = ( + tagId: string, + { + openNodeCollapsed, + }: { + openNodeCollapsed: () => void; + } +) => { + const t = useI18n(); + 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 { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + + const handleNewDoc = useCallback(() => { + if (tagRecord) { + const newDoc = createPage(); + tagRecord?.tag(newDoc.id); + track.$.navigationPanel.tags.createDoc(); + openNodeCollapsed(); + } + }, [createPage, openNodeCollapsed, tagRecord]); + + const handleMoveToTrash = useCallback(() => { + tagService.tagList.deleteTag(tagId); + track.$.navigationPanel.organize.deleteOrganizeItem({ type: 'tag' }); + toast(t['com.affine.tags.delete-tags.toast']()); + }, [t, tagId, tagService.tagList]); + + const handleOpenInSplitView = useCallback(() => { + workbenchService.workbench.openTag(tagId, { + at: 'beside', + }); + track.$.navigationPanel.organize.openInSplitView({ type: 'tag' }); + }, [tagId, workbenchService]); + + const handleToggleFavoriteTag = useCallback(() => { + favoriteService.favoriteList.toggle('tag', tagId); + track.$.navigationPanel.organize.toggleFavorite({ + type: 'tag', + }); + }, [favoriteService, tagId]); + + const handleOpenInNewTab = useCallback(() => { + workbenchService.workbench.openTag(tagId, { + at: 'new-tab', + }); + track.$.navigationPanel.organize.openInNewTab({ type: 'tag' }); + }, [tagId, workbenchService]); + + return useMemo( + () => ({ + favorite, + handleNewDoc, + handleMoveToTrash, + handleOpenInSplitView, + handleToggleFavoriteTag, + handleOpenInNewTab, + }), + [ + favorite, + handleMoveToTrash, + handleNewDoc, + handleOpenInNewTab, + handleOpenInSplitView, + handleToggleFavoriteTag, + ] + ); +}; +export const useExplorerTagNodeOperationsMenu = ( + tagId: string, + option: { + openNodeCollapsed: () => void; + } +): NodeOperation[] => { + const t = useI18n(); + const featureFlagService = useService(FeatureFlagService); + const enableMultiView = useLiveData( + featureFlagService.flags.enable_multi_view.$ + ); + const { + favorite, + handleNewDoc, + handleMoveToTrash, + handleOpenInSplitView, + handleToggleFavoriteTag, + handleOpenInNewTab, + } = useExplorerTagNodeOperations(tagId, option); + + return useMemo( + () => [ + { + index: 0, + inline: true, + view: ( + + + + ), + }, + { + index: 50, + view: ( + } onClick={handleOpenInNewTab}> + {t['com.affine.workbench.tab.page-menu-open']()} + + ), + }, + ...(BUILD_CONFIG.isElectron && enableMultiView + ? [ + { + index: 100, + view: ( + } + onClick={handleOpenInSplitView} + > + {t['com.affine.workbench.split-view.page-menu-open']()} + + ), + }, + ] + : []), + { + index: 199, + view: ( + } + onClick={handleToggleFavoriteTag} + > + {favorite + ? t['com.affine.favoritePageOperation.remove']() + : t['com.affine.favoritePageOperation.add']()} + + ), + }, + { + index: 9999, + view: , + }, + { + index: 10000, + view: ( + } + onClick={handleMoveToTrash} + > + {t['Delete']()} + + ), + }, + ], + [ + enableMultiView, + favorite, + handleMoveToTrash, + handleNewDoc, + handleOpenInNewTab, + handleOpenInSplitView, + handleToggleFavoriteTag, + t, + ] + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/nodes/tag/styles.css.ts b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/styles.css.ts new file mode 100644 index 0000000000..5a96a08bb9 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/nodes/tag/styles.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; + +export const tagIcon = style({ + borderRadius: '50%', + height: '8px', + width: '8px', +}); + +export const tagIconContainer = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '1em', + height: '1em', +}); diff --git a/packages/frontend/core/src/mobile/components/explorer/sections/collections/index.tsx b/packages/frontend/core/src/mobile/components/explorer/sections/collections/index.tsx new file mode 100644 index 0000000000..d2bf33fa74 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/sections/collections/index.tsx @@ -0,0 +1,73 @@ +import { useEditCollectionName } from '@affine/core/components/page-list'; +import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager'; +import { CollectionService } from '@affine/core/modules/collection'; +import { ExplorerService } from '@affine/core/modules/explorer'; +import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import { track } from '@affine/track'; +import { useLiveData, useServices } from '@toeverything/infra'; +import { nanoid } from 'nanoid'; +import { useCallback } from 'react'; + +import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { CollapsibleSection } from '../../layouts/collapsible-section'; +import { ExplorerCollectionNode } from '../../nodes/collection'; + +export const ExplorerCollections = () => { + const t = useI18n(); + const { collectionService, workbenchService, explorerService } = useServices({ + CollectionService, + WorkbenchService, + ExplorerService, + }); + const explorerSection = explorerService.sections.collections; + const collections = useLiveData(collectionService.collections$); + const { open: openCreateCollectionModel } = useEditCollectionName({ + title: t['com.affine.editCollection.createCollection'](), + showTips: true, + }); + + const handleCreateCollection = useCallback(() => { + openCreateCollectionModel('') + .then(name => { + const id = nanoid(); + collectionService.addCollection(createEmptyCollection(id, { name })); + track.$.navigationPanel.organize.createOrganizeItem({ + type: 'collection', + }); + workbenchService.workbench.openCollection(id); + explorerSection.setCollapsed(false); + }) + .catch(err => { + console.error(err); + }); + }, [ + collectionService, + explorerSection, + openCreateCollectionModel, + workbenchService.workbench, + ]); + + return ( + + + {collections.map(collection => ( + + ))} + + + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/sections/favorites/index.tsx b/packages/frontend/core/src/mobile/components/explorer/sections/favorites/index.tsx new file mode 100644 index 0000000000..13f4d2005d --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/sections/favorites/index.tsx @@ -0,0 +1,88 @@ +import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; +import { + ExplorerService, + ExplorerTreeRoot, +} from '@affine/core/modules/explorer'; +import type { FavoriteSupportType } from '@affine/core/modules/favorite'; +import { FavoriteService } from '@affine/core/modules/favorite'; +import { useI18n } from '@affine/i18n'; +import { + useLiveData, + useServices, + WorkspaceService, +} from '@toeverything/infra'; +import { useCallback } from 'react'; + +import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { CollapsibleSection } from '../../layouts/collapsible-section'; +import { ExplorerCollectionNode } from '../../nodes/collection'; +import { ExplorerDocNode } from '../../nodes/doc'; +import { ExplorerFolderNode } from '../../nodes/folder'; +import { ExplorerTagNode } from '../../nodes/tag'; + +export const ExplorerFavorites = () => { + const { favoriteService, workspaceService, explorerService } = useServices({ + FavoriteService, + WorkspaceService, + ExplorerService, + }); + + const t = useI18n(); + const explorerSection = explorerService.sections.favorites; + const favorites = useLiveData(favoriteService.favoriteList.sortedList$); + const isLoading = useLiveData(favoriteService.favoriteList.isLoading$); + const { createPage } = usePageHelper( + workspaceService.workspace.docCollection + ); + + const handleCreateNewFavoriteDoc = useCallback(() => { + const newDoc = createPage(); + favoriteService.favoriteList.add( + 'doc', + newDoc.id, + favoriteService.favoriteList.indexAt('before') + ); + explorerSection.setCollapsed(false); + }, [createPage, explorerSection, favoriteService.favoriteList]); + + return ( + + + {favorites.map(favorite => ( + + ))} + + + + ); +}; + +export const FavoriteNode = ({ + favorite, +}: { + favorite: { + id: string; + type: FavoriteSupportType; + }; +}) => { + return favorite.type === 'doc' ? ( + + ) : favorite.type === 'tag' ? ( + + ) : favorite.type === 'folder' ? ( + + ) : ( + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/sections/favorites/loading.tsx b/packages/frontend/core/src/mobile/components/explorer/sections/favorites/loading.tsx new file mode 100644 index 0000000000..ab807041e2 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/sections/favorites/loading.tsx @@ -0,0 +1,6 @@ +import { Skeleton } from '@affine/component'; + +export const MobileFavoritesLoading = () => { + // TODO(@CatsJuice): loading UI + return ; +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/sections/organize/index.tsx b/packages/frontend/core/src/mobile/components/explorer/sections/organize/index.tsx new file mode 100644 index 0000000000..601e2a6b22 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/sections/organize/index.tsx @@ -0,0 +1,70 @@ +import { Skeleton } from '@affine/component'; +import { + ExplorerService, + ExplorerTreeRoot, +} from '@affine/core/modules/explorer'; +import { OrganizeService } from '@affine/core/modules/organize'; +import { useI18n } from '@affine/i18n'; +import track from '@affine/track'; +import { useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useEffect, useState } from 'react'; + +import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { CollapsibleSection } from '../../layouts/collapsible-section'; +import { ExplorerFolderNode } from '../../nodes/folder'; + +export const ExplorerOrganize = () => { + const { organizeService, explorerService } = useServices({ + OrganizeService, + ExplorerService, + }); + const explorerSection = explorerService.sections.organize; + const collapsed = useLiveData(explorerSection.collapsed$); + const [newFolderId, setNewFolderId] = useState(null); + + const t = useI18n(); + + const folderTree = organizeService.folderTree; + const rootFolder = folderTree.rootFolder; + + const folders = useLiveData(rootFolder.sortedChildren$); + const isLoading = useLiveData(folderTree.isLoading$); + + const handleCreateFolder = useCallback(() => { + const newFolderId = rootFolder.createFolder( + 'New Folder', + rootFolder.indexAt('before') + ); + track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' }); + setNewFolderId(newFolderId); + explorerSection.setCollapsed(false); + return newFolderId; + }, [explorerSection, rootFolder]); + + useEffect(() => { + if (collapsed) setNewFolderId(null); // reset new folder id to clear the renaming state + }, [collapsed]); + + return ( + + {/* TODO(@CatsJuice): Organize loading UI */} + : null}> + {folders.map(child => ( + + ))} + + + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/sections/tags/index.tsx b/packages/frontend/core/src/mobile/components/explorer/sections/tags/index.tsx new file mode 100644 index 0000000000..be9a77fa96 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/sections/tags/index.tsx @@ -0,0 +1,63 @@ +import { ExplorerService } from '@affine/core/modules/explorer'; +import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree'; +import type { Tag } from '@affine/core/modules/tag'; +import { TagService } from '@affine/core/modules/tag'; +import { useI18n } from '@affine/i18n'; +import { track } from '@affine/track'; +import { useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useEffect, useState } from 'react'; + +import { AddItemPlaceholder } from '../../layouts/add-item-placeholder'; +import { CollapsibleSection } from '../../layouts/collapsible-section'; +import { ExplorerTagNode } from '../../nodes/tag'; + +export const ExplorerTags = () => { + const { tagService, explorerService } = useServices({ + TagService, + ExplorerService, + }); + const explorerSection = explorerService.sections.tags; + const collapsed = useLiveData(explorerSection.collapsed$); + const [createdTag, setCreatedTag] = useState(null); + const tags = useLiveData(tagService.tagList.tags$); + + const t = useI18n(); + + const handleCreateNewFavoriteDoc = useCallback(() => { + const newTags = tagService.tagList.createTag( + t['com.affine.rootAppSidebar.tags.new-tag'](), + tagService.randomTagColor() + ); + setCreatedTag(newTags); + track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' }); + explorerSection.setCollapsed(false); + }, [explorerSection, t, tagService]); + + useEffect(() => { + if (collapsed) setCreatedTag(null); // reset created tag to clear the renaming state + }, [collapsed]); + + return ( + + + {tags.map(tag => ( + + ))} + + + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/explorer/tree/node.css.ts b/packages/frontend/core/src/mobile/components/explorer/tree/node.css.ts new file mode 100644 index 0000000000..d8b465a5e7 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/tree/node.css.ts @@ -0,0 +1,133 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { createVar, style } from '@vanilla-extract/css'; + +export const levelIndent = createVar(); + +export const itemRoot = style({ + display: 'inline-flex', + alignItems: 'center', + textAlign: 'left', + color: 'inherit', + width: '100%', + minHeight: '30px', + userSelect: 'none', + cursor: 'pointer', + fontSize: cssVar('fontSm'), + position: 'relative', + marginTop: '0px', + padding: '8px', + borderRadius: 0, + gap: 12, + selectors: { + '&[data-disabled="true"]': { + cursor: 'default', + color: cssVar('textSecondaryColor'), + pointerEvents: 'none', + }, + '&[data-dragging="true"]': { + opacity: 0.5, + }, + }, + + ':after': { + content: '', + width: `calc(100% + ${levelIndent})`, + height: 0.5, + background: cssVar('borderColor'), + bottom: 0, + position: 'absolute', + right: 0, + }, +}); + +export const collapsedIconContainer = style({ + width: '16px', + height: '16px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '2px', + transition: 'transform 0.2s', + color: cssVarV2('icon/primary'), + fontSize: 16, + selectors: { + '&[data-collapsed="true"]': { + transform: 'rotate(-90deg)', + }, + '&[data-disabled="true"]': { + opacity: 0.3, + pointerEvents: 'none', + }, + }, +}); +export const collapsedIcon = style({ + transition: 'transform 0.2s ease-in-out', + selectors: { + '&[data-collapsed="true"]': { + transform: 'rotate(-90deg)', + }, + }, +}); + +export const itemMain = style({ + display: 'flex', + alignItems: 'center', + width: 0, + flex: 1, + position: 'relative', + gap: 12, +}); + +export const iconContainer = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + color: cssVarV2('icon/primary'), + + width: 32, + height: 32, + fontSize: 24, +}); + +export const itemContent = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + alignItems: 'center', + flex: 1, + color: cssVarV2('text/primary'), + + fontSize: 17, + lineHeight: '22px', + letterSpacing: -0.43, + fontWeight: 400, +}); + +export const itemRenameAnchor = style({ + pointerEvents: 'none', + position: 'absolute', + left: 0, + top: -10, + width: 10, + height: 10, +}); + +export const contentContainer = style({ + marginTop: 0, + paddingLeft: levelIndent, + position: 'relative', +}); + +export const linkItemRoot = style({ + color: 'inherit', +}); + +export const collapseContentPlaceholder = style({ + display: 'none', + selectors: { + '&:only-child': { + display: 'initial', + }, + }, +}); diff --git a/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx b/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx new file mode 100644 index 0000000000..495eb6ad24 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/explorer/tree/node.tsx @@ -0,0 +1,241 @@ +import { MenuItem, MobileMenu } from '@affine/component'; +import { RenameModal } from '@affine/component/rename-modal'; +import { AppSidebarService } from '@affine/core/modules/app-sidebar'; +import type { + BaseExplorerTreeNodeProps, + NodeOperation, +} from '@affine/core/modules/explorer'; +import { ExplorerTreeContext } from '@affine/core/modules/explorer'; +import { WorkbenchLink } from '@affine/core/modules/workbench'; +import { extractEmojiIcon } from '@affine/core/utils'; +import { useI18n } from '@affine/i18n'; +import { ArrowDownSmallIcon, EditIcon } from '@blocksuite/icons/rc'; +import * as Collapsible from '@radix-ui/react-collapsible'; +import { useLiveData, useService } from '@toeverything/infra'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import { + Fragment, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; + +import * as styles from './node.css'; + +interface ExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {} + +export const ExplorerTreeNode = ({ + children, + icon: Icon, + name: rawName, + onClick, + to, + active, + defaultRenaming, + renameable, + onRename, + disabled, + collapsed, + extractEmojiAsIcon, + setCollapsed, + operations = [], + postfix, + childrenOperations = [], + childrenPlaceholder, + linkComponent: LinkComponent = WorkbenchLink, + ...otherProps +}: ExplorerTreeNodeProps) => { + const t = useI18n(); + const context = useContext(ExplorerTreeContext); + const level = context?.level ?? 0; + // If no onClick or to is provided, clicking on the node will toggle the collapse state + const clickForCollapse = !onClick && !to && !disabled; + const [childCount, setChildCount] = useState(0); + const [renaming, setRenaming] = useState(defaultRenaming); + const rootRef = useRef(null); + + const appSidebarService = useService(AppSidebarService).sidebar; + const sidebarWidth = useLiveData(appSidebarService.width$); + + const { emoji, name } = useMemo(() => { + if (!extractEmojiAsIcon || !rawName) { + return { + emoji: null, + name: rawName, + }; + } + const { emoji, rest } = extractEmojiIcon(rawName); + return { + emoji, + name: rest, + }; + }, [extractEmojiAsIcon, rawName]); + + const presetOperations = useMemo( + () => + ( + [ + renameable + ? { + index: 0, + view: ( + } + onClick={() => setRenaming(true)} + > + {t['com.affine.menu.rename']()} + + ), + } + : null, + ] as (NodeOperation | null)[] + ).filter((t): t is NodeOperation => t !== null), + [renameable, t] + ); + + const { menuOperations } = useMemo(() => { + const sorted = [...presetOperations, ...operations].sort( + (a, b) => a.index - b.index + ); + return { + menuOperations: sorted.filter(({ inline }) => !inline), + inlineOperations: sorted.filter(({ inline }) => !!inline), + }; + }, [presetOperations, operations]); + + const contextValue = useMemo(() => { + return { + operations: childrenOperations, + level: (context?.level ?? 0) + 1, + registerChild: () => { + setChildCount(c => c + 1); + return () => setChildCount(c => c - 1); + }, + }; + }, [childrenOperations, context?.level]); + + const handleCollapsedChange = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); // for links + setCollapsed(!collapsed); + }, + [collapsed, setCollapsed] + ); + + const handleRename = useCallback( + (newName: string) => onRename?.(newName), + [onRename] + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (e.defaultPrevented) { + return; + } + if (!clickForCollapse) { + onClick?.(); + } else { + setCollapsed(!collapsed); + } + }, + [clickForCollapse, collapsed, onClick, setCollapsed] + ); + + const content = ( +
+
+ {menuOperations.length > 0 ? ( +
{ + // prevent jump to page + e.preventDefault(); + }} + > + ( + {view} + ))} + > +
+ {emoji ?? (Icon && )} +
+
+
+ ) : ( +
+ {emoji ?? (Icon && )} +
+ )} + +
{name}
+ + {postfix} +
+ +
+ +
+ + {renameable && ( + +
+ + )} +
+ ); + + return ( + +
+ {to ? ( + + {content} + + ) : ( +
{content}
+ )} +
+ + {/* For lastInGroup check, the placeholder must be placed above all children in the dom */} +
+ {childCount === 0 && !collapsed && childrenPlaceholder} +
+ + {collapsed ? null : children} + +
+
+ ); +}; diff --git a/packages/frontend/core/src/mobile/components/page-header/index.tsx b/packages/frontend/core/src/mobile/components/page-header/index.tsx index 661610b45e..bcc22e03de 100644 --- a/packages/frontend/core/src/mobile/components/page-header/index.tsx +++ b/packages/frontend/core/src/mobile/components/page-header/index.tsx @@ -84,6 +84,7 @@ export const PageHeader = forwardRef( style={{ padding: 10 }} onClick={handleRouteBack} icon={} + data-testid="page-header-back" /> ) : null} {prefix} diff --git a/packages/frontend/core/src/mobile/pages/workspace/home.tsx b/packages/frontend/core/src/mobile/pages/workspace/home.tsx index e48c96ead2..7aa97f4173 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/home.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/home.tsx @@ -1,21 +1,19 @@ import { SafeArea, useThemeColorV2 } from '@affine/component'; + +import { AppTabs } from '../../components'; import { ExplorerCollections, ExplorerFavorites, - ExplorerMigrationFavorites, - ExplorerMobileContext, ExplorerOrganize, -} from '@affine/core/modules/explorer'; -import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags'; - -import { AppTabs } from '../../components'; + ExplorerTags, +} from '../../components/explorer'; import { HomeHeader, RecentDocs } from '../../views'; export const Component = () => { useThemeColorV2('layer/background/secondary'); return ( - + <> @@ -29,12 +27,11 @@ export const Component = () => { > -
- + ); }; diff --git a/packages/frontend/core/src/mobile/views/recent-docs/index.tsx b/packages/frontend/core/src/mobile/views/recent-docs/index.tsx index 3a62852c8c..3db902f023 100644 --- a/packages/frontend/core/src/mobile/views/recent-docs/index.tsx +++ b/packages/frontend/core/src/mobile/views/recent-docs/index.tsx @@ -1,9 +1,9 @@ import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta'; -import { CollapsibleSection } from '@affine/core/modules/explorer'; import { useService, WorkspaceService } from '@toeverything/infra'; import { useMemo } from 'react'; import { DocCard } from '../../components/doc-card'; +import { CollapsibleSection } from '../../components/explorer'; import * as styles from './styles.css'; export const RecentDocs = ({ max = 5 }: { max?: number }) => { diff --git a/packages/frontend/core/src/modules/app-sidebar/views/category-divider/index.css.ts b/packages/frontend/core/src/modules/app-sidebar/views/category-divider/index.css.ts index 17a1fb3875..229aee5964 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/category-divider/index.css.ts +++ b/packages/frontend/core/src/modules/app-sidebar/views/category-divider/index.css.ts @@ -64,27 +64,3 @@ export const collapseIcon = style({ }, }, }); - -// ------------- mobile ------------- -export const mobileRoot = style([ - root, - { - height: 25, - padding: '0 16px', - selectors: { - '&[data-collapsible="true"]:hover': { - backgroundColor: 'none', - }, - }, - }, -]); -export const mobileLabel = style([ - label, - { - color: cssVarV2('text/primary'), - fontSize: 20, - lineHeight: '25px', - letterSpacing: -0.45, - fontWeight: 400, - }, -]); diff --git a/packages/frontend/core/src/modules/app-sidebar/views/category-divider/index.tsx b/packages/frontend/core/src/modules/app-sidebar/views/category-divider/index.tsx index 177fbcd17c..f8006a2fe4 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/category-divider/index.tsx +++ b/packages/frontend/core/src/modules/app-sidebar/views/category-divider/index.tsx @@ -9,7 +9,6 @@ export type CategoryDividerProps = PropsWithChildren< label: string; className?: string; collapsed?: boolean; - mobile?: boolean; setCollapsed?: (collapsed: boolean) => void; } & { [key: `data-${string}`]: unknown; @@ -23,7 +22,6 @@ export const CategoryDivider = forwardRef( children, className, collapsed, - mobile, setCollapsed, ...otherProps }: CategoryDividerProps, @@ -33,16 +31,15 @@ export const CategoryDivider = forwardRef( return (
setCollapsed?.(!collapsed)} - data-mobile={mobile} data-collapsed={collapsed} data-collapsible={collapsible} {...otherProps} > -
+
{label} {collapsible ? ( ) : null}
- {mobile ? null : ( -
e.stopPropagation()}> - {children} -
- )} +
e.stopPropagation()}> + {children} +
); } diff --git a/packages/frontend/core/src/modules/explorer/index.ts b/packages/frontend/core/src/modules/explorer/index.ts index d20cea21a4..96f9d0b4d8 100644 --- a/packages/frontend/core/src/modules/explorer/index.ts +++ b/packages/frontend/core/src/modules/explorer/index.ts @@ -9,11 +9,18 @@ import { ExplorerService } from './services/explorer'; export { ExplorerService } from './services/explorer'; export type { CollapsibleSectionName } from './types'; export { CollapsibleSection } from './views/layouts/collapsible-section'; -export { ExplorerMobileContext } from './views/mobile.context'; export { ExplorerCollections } from './views/sections/collections'; export { ExplorerFavorites } from './views/sections/favorites'; export { ExplorerMigrationFavorites } from './views/sections/migration-favorites'; export { ExplorerOrganize } from './views/sections/organize'; +// for mobile +export { ExplorerTreeRoot } from './views/tree'; +export { ExplorerTreeContext } from './views/tree/context'; +export type { + BaseExplorerTreeNodeProps, + ExplorerTreeNodeIcon, +} from './views/tree/node'; +export type { NodeOperation } from './views/tree/types'; export function configureExplorerModule(framework: Framework) { framework diff --git a/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.css.ts b/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.css.ts index eb952e8fc2..7fe9cc356f 100644 --- a/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.css.ts +++ b/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.css.ts @@ -13,8 +13,3 @@ export const header = style({ }, }, }); - -// mobile -export const mobileContent = style({ - paddingTop: 8, -}); diff --git a/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.tsx b/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.tsx index 828e2d5d26..f231139cc4 100644 --- a/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.tsx +++ b/packages/frontend/core/src/modules/explorer/views/layouts/collapsible-section.tsx @@ -7,18 +7,11 @@ import { type ReactNode, type RefObject, useCallback, - useContext, } from 'react'; import { ExplorerService } from '../../services/explorer'; import type { CollapsibleSectionName } from '../../types'; -import { ExplorerMobileContext } from '../mobile.context'; -import { - content, - header, - mobileContent, - root, -} from './collapsible-section.css'; +import { content, header, root } from './collapsible-section.css'; interface CollapsibleSectionProps extends PropsWithChildren { name: CollapsibleSectionName; @@ -50,7 +43,6 @@ export const CollapsibleSection = ({ contentClassName, }: CollapsibleSectionProps) => { - const mobile = useContext(ExplorerMobileContext); const section = useService(ExplorerService).sections[name]; const collapsed = useLiveData(section.collapsed$); @@ -70,7 +62,6 @@ export const CollapsibleSection = ({ data-testid={testId} > {children} diff --git a/packages/frontend/core/src/modules/explorer/views/mobile.context.ts b/packages/frontend/core/src/modules/explorer/views/mobile.context.ts deleted file mode 100644 index 2943d1a275..0000000000 --- a/packages/frontend/core/src/modules/explorer/views/mobile.context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from 'react'; - -/** - * To enable mobile manually - * > Using `environment.isMobile` directly will affect current web entry on mobile - * > So we control it manually for now - */ -export const ExplorerMobileContext = createContext(false); 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 2d57ec7192..6fd8e4dee6 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 @@ -73,15 +73,8 @@ export const useExplorerCollectionNodeOperations = ( track.$.navigationPanel.collections.addDocToCollection({ control: 'button', }); - workbenchService.workbench.openDoc(newDoc.id); onOpenCollapsed(); - }, [ - collectionId, - collectionService, - createPage, - onOpenCollapsed, - workbenchService.workbench, - ]); + }, [collectionId, collectionService, createPage, onOpenCollapsed]); const handleToggleFavoriteCollection = useCallback(() => { compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection'); 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 654f903f53..82a6ac17b6 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 @@ -129,9 +129,8 @@ export const useExplorerDocNodeOperations = ( await docsService.addLinkedDoc(docId, newDoc.id); track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' }); track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' }); - workbenchService.workbench.openDoc(newDoc.id); options.openNodeCollapsed(); - }, [createPage, docsService, docId, workbenchService.workbench, options]); + }, [createPage, docsService, docId, options]); const handleToggleFavoriteDoc = useCallback(() => { compatibleFavoriteItemsAdapter.toggle(docId, 'doc'); diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx index d0ccf0a87a..7f71818c26 100644 --- a/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx +++ b/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx @@ -20,7 +20,6 @@ import { type FolderNode, OrganizeService, } from '@affine/core/modules/organize'; -import { WorkbenchService } from '@affine/core/modules/workbench'; import type { AffineDNDData } from '@affine/core/types/dnd'; import { Unreachable } from '@affine/env/constant'; import { useI18n } from '@affine/i18n'; @@ -173,7 +172,7 @@ const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({ /> ); -export const ExplorerFolderNodeFolder = ({ +const ExplorerFolderNodeFolder = ({ node, onDrop, defaultRenaming, @@ -187,13 +186,11 @@ export const ExplorerFolderNodeFolder = ({ node: FolderNode; } & GenericExplorerNode) => { const t = useI18n(); - const { workbenchService, workspaceService, featureFlagService } = - useServices({ - WorkbenchService, - WorkspaceService, - CompatibleFavoriteItemsAdapter, - FeatureFlagService, - }); + const { workspaceService, featureFlagService } = useServices({ + WorkspaceService, + CompatibleFavoriteItemsAdapter, + FeatureFlagService, + }); const openDocsSelector = useSelectDoc(); const openTagsSelector = useSelectTag(); const openCollectionsSelector = useSelectCollection(); @@ -552,14 +549,13 @@ export const ExplorerFolderNodeFolder = ({ const handleNewDoc = useCallback(() => { const newDoc = createPage(); node.createLink('doc', newDoc.id, node.indexAt('before')); - workbenchService.workbench.openDoc(newDoc.id); track.$.navigationPanel.folders.createDoc(); track.$.navigationPanel.organize.createOrganizeItem({ type: 'link', target: 'doc', }); setCollapsed(false); - }, [createPage, node, workbenchService.workbench]); + }, [createPage, node]); const handleCreateSubfolder = useCallback(() => { const newFolderId = node.createFolder( 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 52ffb77f3d..a9592e3e50 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 @@ -64,10 +64,9 @@ export const useExplorerTagNodeOperations = ( const newDoc = createPage(); tagRecord?.tag(newDoc.id); track.$.navigationPanel.tags.createDoc(); - workbenchService.workbench.openDoc(newDoc.id); openNodeCollapsed(); } - }, [createPage, openNodeCollapsed, tagRecord, workbenchService.workbench]); + }, [createPage, openNodeCollapsed, tagRecord]); const handleMoveToTrash = useCallback(() => { tagService.tagList.deleteTag(tagId); diff --git a/packages/frontend/core/src/modules/explorer/views/sections/favorites/index.tsx b/packages/frontend/core/src/modules/explorer/views/sections/favorites/index.tsx index 054bb2fb0c..cacf1e1283 100644 --- a/packages/frontend/core/src/modules/explorer/views/sections/favorites/index.tsx +++ b/packages/frontend/core/src/modules/explorer/views/sections/favorites/index.tsx @@ -13,7 +13,6 @@ import { FavoriteService, isFavoriteSupportType, } from '@affine/core/modules/favorite'; -import { WorkbenchService } from '@affine/core/modules/workbench'; import type { AffineDNDData } from '@affine/core/types/dnd'; import { isNewTabTrigger } from '@affine/core/utils'; import { useI18n } from '@affine/i18n'; @@ -41,14 +40,8 @@ import { import { RootEmpty } from './empty'; export const ExplorerFavorites = () => { - const { - favoriteService, - workspaceService, - workbenchService, - explorerService, - } = useServices({ + const { favoriteService, workspaceService, explorerService } = useServices({ FavoriteService, - WorkbenchService, WorkspaceService, ExplorerService, }); @@ -88,23 +81,18 @@ export const ExplorerFavorites = () => { const handleCreateNewFavoriteDoc: MouseEventHandler = useCallback( e => { - const newDoc = createPage(); + const newDoc = createPage( + undefined, + isNewTabTrigger(e) ? 'new-tab' : true + ); favoriteService.favoriteList.add( 'doc', newDoc.id, favoriteService.favoriteList.indexAt('before') ); - workbenchService.workbench.openDoc(newDoc.id, { - at: isNewTabTrigger(e) ? 'new-tab' : 'active', - }); explorerSection.setCollapsed(false); }, - [ - createPage, - explorerSection, - favoriteService.favoriteList, - workbenchService.workbench, - ] + [createPage, explorerSection, favoriteService.favoriteList] ); const handleOnChildrenDrop = useCallback( diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts b/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts index 8d1873661a..1a7e536db8 100644 --- a/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts +++ b/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts @@ -179,67 +179,3 @@ export const draggedOverEffect = style({ }, }, }); - -// ---------- mobile ---------- -export const mobileItemRoot = style([ - itemRoot, - { - padding: '8px', - borderRadius: 0, - flexDirection: 'row-reverse', - gap: 12, - selectors: { - '&:hover': { - background: 'none', - }, - '&:active': { - background: cssVar('hoverColor'), - }, - '&[data-active="true"]': { - background: 'transparent', - }, - }, - - ':after': { - content: '', - width: `calc(100% + ${levelIndent})`, - height: 0.5, - background: cssVar('borderColor'), - bottom: 0, - position: 'absolute', - right: 0, - }, - }, -]); -export const mobileItemMain = style([itemMain, {}]); - -export const mobileIconContainer = style([ - iconContainer, - { - width: 32, - height: 32, - fontSize: 24, - }, -]); - -export const mobileCollapsedIconContainer = style([ - collapsedIconContainer, - { - fontSize: 16, - }, -]); -export const mobileItemContent = style([ - itemContent, - { - fontSize: 17, - lineHeight: '22px', - letterSpacing: -0.43, - fontWeight: 400, - }, -]); -export const mobileContentContainer = style([ - contentContainer, - { - marginTop: 0, - }, -]); diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx index 6e79716e9a..2d011fd297 100644 --- a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx +++ b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx @@ -37,7 +37,6 @@ import { useState, } from 'react'; -import { ExplorerMobileContext } from '../mobile.context'; import { ExplorerTreeContext } from './context'; import { DropEffect } from './drop-effect'; import * as styles from './node.css'; @@ -57,6 +56,38 @@ export type ExplorerTreeNodeIcon = React.ComponentType<{ collapsed?: boolean; }>; +export interface BaseExplorerTreeNodeProps { + name?: string; + icon?: ExplorerTreeNodeIcon; + children?: React.ReactNode; + active?: boolean; + defaultRenaming?: boolean; + extractEmojiAsIcon?: boolean; + collapsed: boolean; + setCollapsed: (collapsed: boolean) => void; + renameable?: boolean; + onRename?: (newName: string) => void; + disabled?: boolean; + onClick?: () => void; + to?: To; + postfix?: React.ReactNode; + operations?: NodeOperation[]; + childrenOperations?: NodeOperation[]; + childrenPlaceholder?: React.ReactNode; + linkComponent?: React.ComponentType< + React.PropsWithChildren<{ to: To; className?: string }> & RefAttributes + >; + [key: `data-${string}`]: any; +} + +interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps { + canDrop?: DropTargetOptions['canDrop']; + reorderable?: boolean; + dndData?: AffineDNDData; + onDrop?: (data: DropTargetDropEvent) => void; + dropEffect?: ExplorerTreeNodeDropEffect; +} + export const ExplorerTreeNode = ({ children, icon: Icon, @@ -82,34 +113,7 @@ export const ExplorerTreeNode = ({ onDrop, dropEffect, ...otherProps -}: { - name?: string; - icon?: ExplorerTreeNodeIcon; - children?: React.ReactNode; - active?: boolean; - reorderable?: boolean; - defaultRenaming?: boolean; - extractEmojiAsIcon?: boolean; - collapsed: boolean; - setCollapsed: (collapsed: boolean) => void; - renameable?: boolean; - onRename?: (newName: string) => void; - disabled?: boolean; - onClick?: () => void; - to?: To; - postfix?: React.ReactNode; - canDrop?: DropTargetOptions['canDrop']; - operations?: NodeOperation[]; - childrenOperations?: NodeOperation[]; - childrenPlaceholder?: React.ReactNode; - linkComponent?: React.ComponentType< - React.PropsWithChildren<{ to: To; className?: string }> & RefAttributes - >; - dndData?: AffineDNDData; - onDrop?: (data: DropTargetDropEvent) => void; - dropEffect?: ExplorerTreeNodeDropEffect; -} & { [key in `data-${string}`]?: any }) => { - const mobile = useContext(ExplorerMobileContext); +}: WebExplorerTreeNodeProps) => { const t = useI18n(); const cid = useId(); const context = useContext(ExplorerTreeContext); @@ -141,21 +145,19 @@ export const ExplorerTreeNode = ({ AffineDNDData & { draggable: { __cid: string } } >( () => ({ - canDrag: () => !mobile, data: { ...dndData?.draggable, __cid: cid }, dragPreviewPosition: 'pointer-outside', }), - [cid, dndData, mobile] + [cid, dndData] ); const handleCanDrop = useMemo['canDrop']>( () => args => { - if (mobile) return false; if (!reorderable && args.treeInstruction?.type !== 'make-child') { return false; } return (typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true; }, - [canDrop, mobile, reorderable] + [canDrop, reorderable] ); const { dropTargetRef, @@ -319,7 +321,7 @@ export const ExplorerTreeNode = ({ const content = (
@@ -327,11 +329,7 @@ export const ExplorerTreeNode = ({ data-disabled={disabled} onClick={handleCollapsedChange} data-testid="explorer-collapsed-button" - className={ - mobile - ? styles.mobileCollapsedIconContainer - : styles.collapsedIconContainer - } + className={styles.collapsedIconContainer} >
-
-
+
+
{emoji ?? (Icon && ( ))}
-
- {name} -
+
{name}
{postfix} - {mobile ? null : ( -
{ - // prevent jump to page - e.preventDefault(); - }} - > - {inlineOperations.map(({ view }, index) => ( - {view} - ))} - {menuOperations.length > 0 && ( - ( - {view} - ))} +
{ + // prevent jump to page + e.preventDefault(); + }} + > + {inlineOperations.map(({ view }, index) => ( + {view} + ))} + {menuOperations.length > 0 && ( + ( + {view} + ))} + > + - - - - - )} -
- )} + + +
+ )} +
{renameable && ( @@ -411,10 +403,7 @@ export const ExplorerTreeNode = ({ {...otherProps} >
{ + const section = await expandCollapsibleSection(page, 'favorites'); + const newButton = section.getByTestId('explorer-bar-add-favorite-button'); + await newButton.tap(); + + // const testTitleText = 'Test Favorited Doc'; + const title = getBlockSuiteEditorTitle(page); + await expect(title).toBeVisible(); + // TODO(@CatsJuice): Mobile editor is not ready yet + // await title.fill(testTitleText); + const docId = getCurrentDocIdFromUrl(page); + + await pageBack(page); + const section2 = await expandCollapsibleSection(page, 'favorites'); + const node = section2.getByTestId(`explorer-doc-${docId}`); + await expect(node).toBeVisible(); + + // const label = node.getByText(testTitleText); + // await expect(label).toBeVisible(); +}); diff --git a/tests/affine-mobile/e2e/utils.ts b/tests/affine-mobile/e2e/utils.ts index e70049323d..1fbaa6fb36 100644 --- a/tests/affine-mobile/e2e/utils.ts +++ b/tests/affine-mobile/e2e/utils.ts @@ -13,3 +13,10 @@ export async function expandCollapsibleSection(page: Page, name: string) { await expect(section).toBeVisible(); return section; } + +/** + * Click header "<" button + */ +export async function pageBack(page: Page) { + await page.getByTestId('page-header-back').tap(); +}