refactor(mobile): use separate explorer components for mobile (#8503)
close AF-1488 - remove dnd related logic - separate styles - remove empty status, always show a `New` button in each level of tree
This commit is contained in:
@@ -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';
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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<HTMLDivElement> {
|
||||||
|
onClick?: () => void;
|
||||||
|
label?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddItemPlaceholder = ({
|
||||||
|
onClick,
|
||||||
|
label = 'Add Item',
|
||||||
|
icon = <PlusIcon />,
|
||||||
|
className,
|
||||||
|
...attrs
|
||||||
|
}: AddItemPlaceholderProps) => {
|
||||||
|
const context = useContext(ExplorerTreeContext);
|
||||||
|
const level = context?.level ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.root}
|
||||||
|
style={assignInlineVars({
|
||||||
|
[levelIndent]: level * 20 + 'px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(styles.wrapper, className)}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
<div className={styles.iconWrapper}>{icon}</div>
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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<HTMLDivElement> {
|
||||||
|
name: CollapsibleSectionName;
|
||||||
|
title: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
testId?: string;
|
||||||
|
headerTestId?: string;
|
||||||
|
headerClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollapsibleSectionTriggerProps
|
||||||
|
extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={clsx(triggerRoot, className)}
|
||||||
|
ref={ref}
|
||||||
|
role="switch"
|
||||||
|
onClick={() => setCollapsed?.(!collapsed)}
|
||||||
|
data-collapsed={collapsed}
|
||||||
|
data-collapsible={collapsible}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
<div className={triggerLabel}>
|
||||||
|
{label}
|
||||||
|
{collapsible ? (
|
||||||
|
<ToggleCollapseIcon
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
data-testid="category-divider-collapse-button"
|
||||||
|
className={triggerCollapseIcon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={triggerActions} onClick={e => e.stopPropagation()}>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Collapsible.Root
|
||||||
|
data-collapsed={collapsed}
|
||||||
|
open={!collapsed}
|
||||||
|
data-testid={testId}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
<CollapsibleSectionTrigger
|
||||||
|
label={title}
|
||||||
|
actions={actions}
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
data-testid={headerTestId}
|
||||||
|
className={headerClassName}
|
||||||
|
/>
|
||||||
|
<Collapsible.Content
|
||||||
|
data-testid="collapsible-section-content"
|
||||||
|
className={clsx(content, contentClassName)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 = () => <ViewLayersIcon />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ExplorerTreeNode
|
||||||
|
icon={CollectionIcon}
|
||||||
|
name={collection.name || t['Untitled']()}
|
||||||
|
renameable
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
to={`/collection/${collection.id}`}
|
||||||
|
active={active}
|
||||||
|
onRename={handleRename}
|
||||||
|
operations={finalOperations}
|
||||||
|
data-testid={`explorer-collection-${collectionId}`}
|
||||||
|
>
|
||||||
|
<ExplorerCollectionNodeChildren
|
||||||
|
collection={collection}
|
||||||
|
onAddDoc={handleAddDocToCollection}
|
||||||
|
/>
|
||||||
|
</ExplorerTreeNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 => (
|
||||||
|
<ExplorerDocNode
|
||||||
|
key={doc.id}
|
||||||
|
docId={doc.id}
|
||||||
|
operations={
|
||||||
|
allowList
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<FilterMinusIcon />}
|
||||||
|
onClick={() => handleRemoveFromAllowList(doc.id)}
|
||||||
|
>
|
||||||
|
{t['Remove special filter']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddItemPlaceholder label={t['New Page']()} onClick={onAddDoc} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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: (
|
||||||
|
<IconButton
|
||||||
|
size="16"
|
||||||
|
onClick={handleAddDocToCollection}
|
||||||
|
tooltip={t[
|
||||||
|
'com.affine.rootAppSidebar.explorer.collection-add-tooltip'
|
||||||
|
]()}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem prefixIcon={<FilterIcon />} onClick={handleShowEdit}>
|
||||||
|
{t['com.affine.collection.menu.edit']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<PlusIcon />}
|
||||||
|
onClick={handleAddDocToCollection}
|
||||||
|
>
|
||||||
|
{t['New Page']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
|
||||||
|
onClick={handleToggleFavoriteCollection}
|
||||||
|
>
|
||||||
|
{favorite
|
||||||
|
? t['com.affine.favoritePageOperation.remove']()
|
||||||
|
: t['com.affine.favoritePageOperation.add']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
||||||
|
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<SplitViewIcon />}
|
||||||
|
onClick={handleOpenInSplitView}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
index: 9999,
|
||||||
|
view: <MenuSeparator key="menu-separator" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 10000,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
prefixIcon={<DeleteIcon />}
|
||||||
|
onClick={handleDeleteCollection}
|
||||||
|
>
|
||||||
|
{t['Delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
enableMultiView,
|
||||||
|
favorite,
|
||||||
|
handleAddDocToCollection,
|
||||||
|
handleDeleteCollection,
|
||||||
|
handleOpenInNewTab,
|
||||||
|
handleOpenInSplitView,
|
||||||
|
handleShowEdit,
|
||||||
|
handleToggleFavoriteCollection,
|
||||||
|
t,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 className={className} />
|
||||||
|
),
|
||||||
|
[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 (
|
||||||
|
<ExplorerTreeNode
|
||||||
|
icon={Icon}
|
||||||
|
name={t.t(docTitle)}
|
||||||
|
renameable
|
||||||
|
extractEmojiAsIcon={enableEmojiIcon}
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
to={`/${docId}`}
|
||||||
|
active={active}
|
||||||
|
postfix={
|
||||||
|
referencesLoading &&
|
||||||
|
!collapsed && (
|
||||||
|
<div className={styles.loadingIcon}>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRename={handleRename}
|
||||||
|
operations={finalOperations}
|
||||||
|
data-testid={`explorer-doc-${docId}`}
|
||||||
|
>
|
||||||
|
{children?.map(child => (
|
||||||
|
<ExplorerDocNode key={child.docId} docId={child.docId} isLinked />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AddItemPlaceholder
|
||||||
|
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||||
|
onClick={handleAddLinkedPage}
|
||||||
|
/>
|
||||||
|
</ExplorerTreeNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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: (
|
||||||
|
<IconButton
|
||||||
|
size="16"
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||||
|
onClick={handleAddLinkedPage}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 50,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<InformationIcon />}
|
||||||
|
onClick={handleOpenInfoModal}
|
||||||
|
>
|
||||||
|
{t['com.affine.page-properties.page-info.view']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<LinkedPageIcon />}
|
||||||
|
onClick={handleAddLinkedPage}
|
||||||
|
>
|
||||||
|
{t['com.affine.page-operation.add-linked-page']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem prefixIcon={<DuplicateIcon />} onClick={handleDuplicate}>
|
||||||
|
{t['com.affine.header.option.duplicate']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: (
|
||||||
|
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
||||||
|
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 100,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<SplitViewIcon />}
|
||||||
|
onClick={handleOpenInSplitView}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
index: 199,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
|
||||||
|
onClick={handleToggleFavoriteDoc}
|
||||||
|
>
|
||||||
|
{favorite
|
||||||
|
? t['com.affine.favoritePageOperation.remove']()
|
||||||
|
: t['com.affine.favoritePageOperation.add']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 9999,
|
||||||
|
view: <MenuSeparator key="menu-separator" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 10000,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
prefixIcon={<DeleteIcon />}
|
||||||
|
onClick={handleMoveToTrash}
|
||||||
|
>
|
||||||
|
{t['com.affine.moveToTrash.title']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
enableMultiView,
|
||||||
|
favorite,
|
||||||
|
handleAddLinkedPage,
|
||||||
|
handleDuplicate,
|
||||||
|
handleMoveToTrash,
|
||||||
|
handleOpenInNewTab,
|
||||||
|
handleOpenInSplitView,
|
||||||
|
handleOpenInfoModal,
|
||||||
|
handleToggleFavoriteDoc,
|
||||||
|
t,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const loadingIcon = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<ExplorerFolderNodeFolder
|
||||||
|
node={node}
|
||||||
|
defaultRenaming={defaultRenaming}
|
||||||
|
operations={additionalOperations}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) return null;
|
||||||
|
if (type === 'doc') {
|
||||||
|
return <ExplorerDocNode docId={data} operations={additionalOperations} />;
|
||||||
|
} else if (type === 'collection') {
|
||||||
|
return (
|
||||||
|
<ExplorerCollectionNode
|
||||||
|
collectionId={data}
|
||||||
|
operations={additionalOperations}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (type === 'tag') {
|
||||||
|
return <ExplorerTagNode tagId={data} operations={additionalOperations} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
|
||||||
|
collapsed,
|
||||||
|
className,
|
||||||
|
draggedOver,
|
||||||
|
treeInstruction,
|
||||||
|
}) => (
|
||||||
|
<AnimatedFolderIcon
|
||||||
|
className={className}
|
||||||
|
open={
|
||||||
|
!collapsed || (!!draggedOver && treeInstruction?.type === 'make-child')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<string | null>(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: (
|
||||||
|
<IconButton
|
||||||
|
size="16"
|
||||||
|
onClick={handleNewDoc}
|
||||||
|
tooltip={t[
|
||||||
|
'com.affine.rootAppSidebar.explorer.organize-add-tooltip'
|
||||||
|
]()}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 100,
|
||||||
|
view: (
|
||||||
|
<MenuItem prefixIcon={<FolderIcon />} onClick={handleCreateSubfolder}>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 101,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<PageIcon />}
|
||||||
|
onClick={() => handleAddToFolder('doc')}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 102,
|
||||||
|
view: (
|
||||||
|
<MenuSub
|
||||||
|
triggerOptions={{
|
||||||
|
prefixIcon: <PlusThickIcon />,
|
||||||
|
}}
|
||||||
|
items={
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => handleAddToFolder('tag')}
|
||||||
|
prefixIcon={<TagsIcon />}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.folder.add-tags']()}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => handleAddToFolder('collection')}
|
||||||
|
prefixIcon={<LayerIcon />}
|
||||||
|
>
|
||||||
|
{t[
|
||||||
|
'com.affine.rootAppSidebar.organize.folder.add-collections'
|
||||||
|
]()}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.folder.add-others']()}
|
||||||
|
</MenuSub>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
index: 200,
|
||||||
|
view: node.id ? <FavoriteFolderOperation id={node.id} /> : null,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
index: 9999,
|
||||||
|
view: <MenuSeparator key="menu-separator" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 10000,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
prefixIcon={<DeleteIcon />}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [
|
||||||
|
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: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
prefixIcon={<RemoveFolderIcon />}
|
||||||
|
data-event-props="$.navigationPanel.organize.deleteOrganizeItem"
|
||||||
|
data-event-args-type={node.type$.value}
|
||||||
|
onClick={() => node.delete()}
|
||||||
|
>
|
||||||
|
{t['com.affine.rootAppSidebar.organize.delete-from-folder']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] 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 (
|
||||||
|
<ExplorerTreeNode
|
||||||
|
icon={ExplorerFolderIcon}
|
||||||
|
name={name}
|
||||||
|
defaultRenaming={defaultRenaming}
|
||||||
|
renameable
|
||||||
|
extractEmojiAsIcon={enableEmojiIcon}
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={handleCollapsedChange}
|
||||||
|
onRename={handleRename}
|
||||||
|
operations={finalOperations}
|
||||||
|
data-testid={`explorer-folder-${node.id}`}
|
||||||
|
>
|
||||||
|
{children.map(child => (
|
||||||
|
<ExplorerFolderNode
|
||||||
|
key={child.id}
|
||||||
|
nodeId={child.id as string}
|
||||||
|
defaultRenaming={child.id === newFolderId}
|
||||||
|
operations={childrenOperations}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddItemPlaceholder
|
||||||
|
label={t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
||||||
|
onClick={() => handleAddToFolder('doc')}
|
||||||
|
/>
|
||||||
|
</ExplorerTreeNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
|
||||||
|
onClick={() => compatibleFavoriteItemsAdapter.toggle(id, 'folder')}
|
||||||
|
>
|
||||||
|
{favorite
|
||||||
|
? t['com.affine.rootAppSidebar.organize.folder-rm-favorite']()
|
||||||
|
: t['com.affine.rootAppSidebar.organize.folder-add-favorite']()}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className={clsx(styles.tagIconContainer, className)}>
|
||||||
|
<div
|
||||||
|
className={styles.tagIcon}
|
||||||
|
style={{
|
||||||
|
backgroundColor: tagColor,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[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 (
|
||||||
|
<ExplorerTreeNode
|
||||||
|
icon={Icon}
|
||||||
|
name={tagName || t['Untitled']()}
|
||||||
|
renameable
|
||||||
|
collapsed={collapsed}
|
||||||
|
setCollapsed={setCollapsed}
|
||||||
|
to={`/tag/${tagId}`}
|
||||||
|
active={active}
|
||||||
|
defaultRenaming={defaultRenaming}
|
||||||
|
onRename={handleRename}
|
||||||
|
operations={finalOperations}
|
||||||
|
data-testid={`explorer-tag-${tagId}`}
|
||||||
|
>
|
||||||
|
<ExplorerTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} />
|
||||||
|
</ExplorerTreeNode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 => (
|
||||||
|
<ExplorerDocNode key={docId} docId={docId} />
|
||||||
|
))}
|
||||||
|
<AddItemPlaceholder label={t['New Page']()} onClick={onNewDoc} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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: (
|
||||||
|
<IconButton
|
||||||
|
size="16"
|
||||||
|
onClick={handleNewDoc}
|
||||||
|
tooltip={t['com.affine.rootAppSidebar.explorer.tag-add-tooltip']()}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 50,
|
||||||
|
view: (
|
||||||
|
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
||||||
|
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
index: 100,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<SplitViewIcon />}
|
||||||
|
onClick={handleOpenInSplitView}
|
||||||
|
>
|
||||||
|
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
index: 199,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<IsFavoriteIcon favorite={!!favorite} />}
|
||||||
|
onClick={handleToggleFavoriteTag}
|
||||||
|
>
|
||||||
|
{favorite
|
||||||
|
? t['com.affine.favoritePageOperation.remove']()
|
||||||
|
: t['com.affine.favoritePageOperation.add']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 9999,
|
||||||
|
view: <MenuSeparator key="menu-separator" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 10000,
|
||||||
|
view: (
|
||||||
|
<MenuItem
|
||||||
|
type={'danger'}
|
||||||
|
prefixIcon={<DeleteIcon />}
|
||||||
|
onClick={handleMoveToTrash}
|
||||||
|
>
|
||||||
|
{t['Delete']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
enableMultiView,
|
||||||
|
favorite,
|
||||||
|
handleMoveToTrash,
|
||||||
|
handleNewDoc,
|
||||||
|
handleOpenInNewTab,
|
||||||
|
handleOpenInSplitView,
|
||||||
|
handleToggleFavoriteTag,
|
||||||
|
t,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<CollapsibleSection
|
||||||
|
name="collections"
|
||||||
|
testId="explorer-collections"
|
||||||
|
title={t['com.affine.rootAppSidebar.collections']()}
|
||||||
|
>
|
||||||
|
<ExplorerTreeRoot>
|
||||||
|
{collections.map(collection => (
|
||||||
|
<ExplorerCollectionNode
|
||||||
|
key={collection.id}
|
||||||
|
collectionId={collection.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddItemPlaceholder
|
||||||
|
data-testid="explorer-bar-add-collection-button"
|
||||||
|
label={t['com.affine.rootAppSidebar.collection.new']()}
|
||||||
|
onClick={handleCreateCollection}
|
||||||
|
/>
|
||||||
|
</ExplorerTreeRoot>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<CollapsibleSection
|
||||||
|
name="favorites"
|
||||||
|
title={t['com.affine.rootAppSidebar.favorites']()}
|
||||||
|
testId="explorer-favorites"
|
||||||
|
headerTestId="explorer-favorite-category-divider"
|
||||||
|
>
|
||||||
|
<ExplorerTreeRoot placeholder={isLoading ? 'Loading' : null}>
|
||||||
|
{favorites.map(favorite => (
|
||||||
|
<FavoriteNode key={favorite.id} favorite={favorite} />
|
||||||
|
))}
|
||||||
|
<AddItemPlaceholder
|
||||||
|
data-testid="explorer-bar-add-favorite-button"
|
||||||
|
data-event-props="$.navigationPanel.favorites.createDoc"
|
||||||
|
data-event-args-control="addFavorite"
|
||||||
|
onClick={handleCreateNewFavoriteDoc}
|
||||||
|
label={t['New Page']()}
|
||||||
|
/>
|
||||||
|
</ExplorerTreeRoot>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FavoriteNode = ({
|
||||||
|
favorite,
|
||||||
|
}: {
|
||||||
|
favorite: {
|
||||||
|
id: string;
|
||||||
|
type: FavoriteSupportType;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
return favorite.type === 'doc' ? (
|
||||||
|
<ExplorerDocNode docId={favorite.id} />
|
||||||
|
) : favorite.type === 'tag' ? (
|
||||||
|
<ExplorerTagNode tagId={favorite.id} />
|
||||||
|
) : favorite.type === 'folder' ? (
|
||||||
|
<ExplorerFolderNode nodeId={favorite.id} />
|
||||||
|
) : (
|
||||||
|
<ExplorerCollectionNode collectionId={favorite.id} />
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Skeleton } from '@affine/component';
|
||||||
|
|
||||||
|
export const MobileFavoritesLoading = () => {
|
||||||
|
// TODO(@CatsJuice): loading UI
|
||||||
|
return <Skeleton />;
|
||||||
|
};
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<CollapsibleSection
|
||||||
|
name="organize"
|
||||||
|
title={t['com.affine.rootAppSidebar.organize']()}
|
||||||
|
>
|
||||||
|
{/* TODO(@CatsJuice): Organize loading UI */}
|
||||||
|
<ExplorerTreeRoot placeholder={isLoading ? <Skeleton /> : null}>
|
||||||
|
{folders.map(child => (
|
||||||
|
<ExplorerFolderNode
|
||||||
|
key={child.id}
|
||||||
|
nodeId={child.id as string}
|
||||||
|
defaultRenaming={child.id === newFolderId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddItemPlaceholder
|
||||||
|
data-testid="explorer-bar-add-organize-button"
|
||||||
|
label={t['com.affine.rootAppSidebar.organize.add-folder']()}
|
||||||
|
onClick={handleCreateFolder}
|
||||||
|
/>
|
||||||
|
</ExplorerTreeRoot>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<Tag | null>(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 (
|
||||||
|
<CollapsibleSection
|
||||||
|
name="tags"
|
||||||
|
title={t['com.affine.rootAppSidebar.tags']()}
|
||||||
|
>
|
||||||
|
<ExplorerTreeRoot>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<ExplorerTagNode
|
||||||
|
key={tag.id}
|
||||||
|
tagId={tag.id}
|
||||||
|
defaultRenaming={createdTag?.id === tag.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddItemPlaceholder
|
||||||
|
data-testid="explorer-bar-add-favorite-button"
|
||||||
|
onClick={handleCreateNewFavoriteDoc}
|
||||||
|
label={t[
|
||||||
|
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
|
||||||
|
]()}
|
||||||
|
/>
|
||||||
|
</ExplorerTreeRoot>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<HTMLDivElement>(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: (
|
||||||
|
<MenuItem
|
||||||
|
key={'explorer-tree-rename'}
|
||||||
|
type={'default'}
|
||||||
|
prefixIcon={<EditIcon />}
|
||||||
|
onClick={() => setRenaming(true)}
|
||||||
|
>
|
||||||
|
{t['com.affine.menu.rename']()}
|
||||||
|
</MenuItem>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: 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 = (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={styles.itemRoot}
|
||||||
|
data-active={active}
|
||||||
|
data-disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className={styles.itemMain}>
|
||||||
|
{menuOperations.length > 0 ? (
|
||||||
|
<div
|
||||||
|
onClick={e => {
|
||||||
|
// prevent jump to page
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MobileMenu
|
||||||
|
items={menuOperations.map(({ view }, index) => (
|
||||||
|
<Fragment key={index}>{view}</Fragment>
|
||||||
|
))}
|
||||||
|
>
|
||||||
|
<div className={styles.iconContainer}>
|
||||||
|
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
|
||||||
|
</div>
|
||||||
|
</MobileMenu>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.iconContainer}>
|
||||||
|
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.itemContent}>{name}</div>
|
||||||
|
|
||||||
|
{postfix}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-disabled={disabled}
|
||||||
|
onClick={handleCollapsedChange}
|
||||||
|
data-testid="explorer-collapsed-button"
|
||||||
|
className={styles.collapsedIconContainer}
|
||||||
|
>
|
||||||
|
<ArrowDownSmallIcon
|
||||||
|
className={styles.collapsedIcon}
|
||||||
|
data-collapsed={collapsed !== false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renameable && (
|
||||||
|
<RenameModal
|
||||||
|
open={!!renaming}
|
||||||
|
width={sidebarWidth - 32}
|
||||||
|
onOpenChange={setRenaming}
|
||||||
|
onRename={handleRename}
|
||||||
|
currentName={rawName ?? ''}
|
||||||
|
>
|
||||||
|
<div className={styles.itemRenameAnchor} />
|
||||||
|
</RenameModal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible.Root
|
||||||
|
open={!collapsed}
|
||||||
|
onOpenChange={setCollapsed}
|
||||||
|
style={assignInlineVars({
|
||||||
|
[styles.levelIndent]: `${level * 20}px`,
|
||||||
|
})}
|
||||||
|
ref={rootRef}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<div className={styles.contentContainer} data-open={!collapsed}>
|
||||||
|
{to ? (
|
||||||
|
<LinkComponent to={to} className={styles.linkItemRoot}>
|
||||||
|
{content}
|
||||||
|
</LinkComponent>
|
||||||
|
) : (
|
||||||
|
<div>{content}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Collapsible.Content>
|
||||||
|
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
|
||||||
|
<div className={styles.collapseContentPlaceholder}>
|
||||||
|
{childCount === 0 && !collapsed && childrenPlaceholder}
|
||||||
|
</div>
|
||||||
|
<ExplorerTreeContext.Provider value={contextValue}>
|
||||||
|
{collapsed ? null : children}
|
||||||
|
</ExplorerTreeContext.Provider>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -84,6 +84,7 @@ export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
|
|||||||
style={{ padding: 10 }}
|
style={{ padding: 10 }}
|
||||||
onClick={handleRouteBack}
|
onClick={handleRouteBack}
|
||||||
icon={<ArrowLeftSmallIcon />}
|
icon={<ArrowLeftSmallIcon />}
|
||||||
|
data-testid="page-header-back"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{prefix}
|
{prefix}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { SafeArea, useThemeColorV2 } from '@affine/component';
|
import { SafeArea, useThemeColorV2 } from '@affine/component';
|
||||||
|
|
||||||
|
import { AppTabs } from '../../components';
|
||||||
import {
|
import {
|
||||||
ExplorerCollections,
|
ExplorerCollections,
|
||||||
ExplorerFavorites,
|
ExplorerFavorites,
|
||||||
ExplorerMigrationFavorites,
|
|
||||||
ExplorerMobileContext,
|
|
||||||
ExplorerOrganize,
|
ExplorerOrganize,
|
||||||
} from '@affine/core/modules/explorer';
|
ExplorerTags,
|
||||||
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
|
} from '../../components/explorer';
|
||||||
|
|
||||||
import { AppTabs } from '../../components';
|
|
||||||
import { HomeHeader, RecentDocs } from '../../views';
|
import { HomeHeader, RecentDocs } from '../../views';
|
||||||
|
|
||||||
export const Component = () => {
|
export const Component = () => {
|
||||||
useThemeColorV2('layer/background/secondary');
|
useThemeColorV2('layer/background/secondary');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExplorerMobileContext.Provider value={true}>
|
<>
|
||||||
<HomeHeader />
|
<HomeHeader />
|
||||||
<RecentDocs />
|
<RecentDocs />
|
||||||
<SafeArea bottom>
|
<SafeArea bottom>
|
||||||
@@ -29,12 +27,11 @@ export const Component = () => {
|
|||||||
>
|
>
|
||||||
<ExplorerFavorites />
|
<ExplorerFavorites />
|
||||||
<ExplorerOrganize />
|
<ExplorerOrganize />
|
||||||
<ExplorerMigrationFavorites />
|
|
||||||
<ExplorerCollections />
|
<ExplorerCollections />
|
||||||
<ExplorerTags />
|
<ExplorerTags />
|
||||||
</div>
|
</div>
|
||||||
</SafeArea>
|
</SafeArea>
|
||||||
<AppTabs />
|
<AppTabs />
|
||||||
</ExplorerMobileContext.Provider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
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 { useService, WorkspaceService } from '@toeverything/infra';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { DocCard } from '../../components/doc-card';
|
import { DocCard } from '../../components/doc-card';
|
||||||
|
import { CollapsibleSection } from '../../components/explorer';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
export const RecentDocs = ({ max = 5 }: { max?: number }) => {
|
export const RecentDocs = ({ max = 5 }: { max?: number }) => {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export type CategoryDividerProps = PropsWithChildren<
|
|||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
mobile?: boolean;
|
|
||||||
setCollapsed?: (collapsed: boolean) => void;
|
setCollapsed?: (collapsed: boolean) => void;
|
||||||
} & {
|
} & {
|
||||||
[key: `data-${string}`]: unknown;
|
[key: `data-${string}`]: unknown;
|
||||||
@@ -23,7 +22,6 @@ export const CategoryDivider = forwardRef(
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
collapsed,
|
collapsed,
|
||||||
mobile,
|
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: CategoryDividerProps,
|
}: CategoryDividerProps,
|
||||||
@@ -33,16 +31,15 @@ export const CategoryDivider = forwardRef(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(mobile ? styles.mobileRoot : styles.root, className)}
|
className={clsx(styles.root, className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role="switch"
|
role="switch"
|
||||||
onClick={() => setCollapsed?.(!collapsed)}
|
onClick={() => setCollapsed?.(!collapsed)}
|
||||||
data-mobile={mobile}
|
|
||||||
data-collapsed={collapsed}
|
data-collapsed={collapsed}
|
||||||
data-collapsible={collapsible}
|
data-collapsible={collapsible}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
>
|
>
|
||||||
<div className={mobile ? styles.mobileLabel : styles.label}>
|
<div className={styles.label}>
|
||||||
{label}
|
{label}
|
||||||
{collapsible ? (
|
{collapsible ? (
|
||||||
<ToggleCollapseIcon
|
<ToggleCollapseIcon
|
||||||
@@ -53,11 +50,9 @@ export const CategoryDivider = forwardRef(
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{mobile ? null : (
|
<div className={styles.actions} onClick={e => e.stopPropagation()}>
|
||||||
<div className={styles.actions} onClick={e => e.stopPropagation()}>
|
{children}
|
||||||
{children}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ import { ExplorerService } from './services/explorer';
|
|||||||
export { ExplorerService } from './services/explorer';
|
export { ExplorerService } from './services/explorer';
|
||||||
export type { CollapsibleSectionName } from './types';
|
export type { CollapsibleSectionName } from './types';
|
||||||
export { CollapsibleSection } from './views/layouts/collapsible-section';
|
export { CollapsibleSection } from './views/layouts/collapsible-section';
|
||||||
export { ExplorerMobileContext } from './views/mobile.context';
|
|
||||||
export { ExplorerCollections } from './views/sections/collections';
|
export { ExplorerCollections } from './views/sections/collections';
|
||||||
export { ExplorerFavorites } from './views/sections/favorites';
|
export { ExplorerFavorites } from './views/sections/favorites';
|
||||||
export { ExplorerMigrationFavorites } from './views/sections/migration-favorites';
|
export { ExplorerMigrationFavorites } from './views/sections/migration-favorites';
|
||||||
export { ExplorerOrganize } from './views/sections/organize';
|
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) {
|
export function configureExplorerModule(framework: Framework) {
|
||||||
framework
|
framework
|
||||||
|
|||||||
@@ -13,8 +13,3 @@ export const header = style({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// mobile
|
|
||||||
export const mobileContent = style({
|
|
||||||
paddingTop: 8,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,18 +7,11 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { ExplorerService } from '../../services/explorer';
|
import { ExplorerService } from '../../services/explorer';
|
||||||
import type { CollapsibleSectionName } from '../../types';
|
import type { CollapsibleSectionName } from '../../types';
|
||||||
import { ExplorerMobileContext } from '../mobile.context';
|
import { content, header, root } from './collapsible-section.css';
|
||||||
import {
|
|
||||||
content,
|
|
||||||
header,
|
|
||||||
mobileContent,
|
|
||||||
root,
|
|
||||||
} from './collapsible-section.css';
|
|
||||||
|
|
||||||
interface CollapsibleSectionProps extends PropsWithChildren {
|
interface CollapsibleSectionProps extends PropsWithChildren {
|
||||||
name: CollapsibleSectionName;
|
name: CollapsibleSectionName;
|
||||||
@@ -50,7 +43,6 @@ export const CollapsibleSection = ({
|
|||||||
|
|
||||||
contentClassName,
|
contentClassName,
|
||||||
}: CollapsibleSectionProps) => {
|
}: CollapsibleSectionProps) => {
|
||||||
const mobile = useContext(ExplorerMobileContext);
|
|
||||||
const section = useService(ExplorerService).sections[name];
|
const section = useService(ExplorerService).sections[name];
|
||||||
|
|
||||||
const collapsed = useLiveData(section.collapsed$);
|
const collapsed = useLiveData(section.collapsed$);
|
||||||
@@ -70,7 +62,6 @@ export const CollapsibleSection = ({
|
|||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
<CategoryDivider
|
<CategoryDivider
|
||||||
mobile={mobile}
|
|
||||||
data-testid={headerTestId}
|
data-testid={headerTestId}
|
||||||
label={title}
|
label={title}
|
||||||
setCollapsed={setCollapsed}
|
setCollapsed={setCollapsed}
|
||||||
@@ -82,7 +73,7 @@ export const CollapsibleSection = ({
|
|||||||
</CategoryDivider>
|
</CategoryDivider>
|
||||||
<Collapsible.Content
|
<Collapsible.Content
|
||||||
data-testid="collapsible-section-content"
|
data-testid="collapsible-section-content"
|
||||||
className={clsx(mobile ? mobileContent : content, contentClassName)}
|
className={clsx(content, contentClassName)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -73,15 +73,8 @@ export const useExplorerCollectionNodeOperations = (
|
|||||||
track.$.navigationPanel.collections.addDocToCollection({
|
track.$.navigationPanel.collections.addDocToCollection({
|
||||||
control: 'button',
|
control: 'button',
|
||||||
});
|
});
|
||||||
workbenchService.workbench.openDoc(newDoc.id);
|
|
||||||
onOpenCollapsed();
|
onOpenCollapsed();
|
||||||
}, [
|
}, [collectionId, collectionService, createPage, onOpenCollapsed]);
|
||||||
collectionId,
|
|
||||||
collectionService,
|
|
||||||
createPage,
|
|
||||||
onOpenCollapsed,
|
|
||||||
workbenchService.workbench,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleToggleFavoriteCollection = useCallback(() => {
|
const handleToggleFavoriteCollection = useCallback(() => {
|
||||||
compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection');
|
compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection');
|
||||||
|
|||||||
@@ -129,9 +129,8 @@ export const useExplorerDocNodeOperations = (
|
|||||||
await docsService.addLinkedDoc(docId, newDoc.id);
|
await docsService.addLinkedDoc(docId, newDoc.id);
|
||||||
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
|
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
|
||||||
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
|
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
|
||||||
workbenchService.workbench.openDoc(newDoc.id);
|
|
||||||
options.openNodeCollapsed();
|
options.openNodeCollapsed();
|
||||||
}, [createPage, docsService, docId, workbenchService.workbench, options]);
|
}, [createPage, docsService, docId, options]);
|
||||||
|
|
||||||
const handleToggleFavoriteDoc = useCallback(() => {
|
const handleToggleFavoriteDoc = useCallback(() => {
|
||||||
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
|
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
type FolderNode,
|
type FolderNode,
|
||||||
OrganizeService,
|
OrganizeService,
|
||||||
} from '@affine/core/modules/organize';
|
} from '@affine/core/modules/organize';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
|
||||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
import { Unreachable } from '@affine/env/constant';
|
import { Unreachable } from '@affine/env/constant';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -173,7 +172,7 @@ const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ExplorerFolderNodeFolder = ({
|
const ExplorerFolderNodeFolder = ({
|
||||||
node,
|
node,
|
||||||
onDrop,
|
onDrop,
|
||||||
defaultRenaming,
|
defaultRenaming,
|
||||||
@@ -187,13 +186,11 @@ export const ExplorerFolderNodeFolder = ({
|
|||||||
node: FolderNode;
|
node: FolderNode;
|
||||||
} & GenericExplorerNode) => {
|
} & GenericExplorerNode) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { workbenchService, workspaceService, featureFlagService } =
|
const { workspaceService, featureFlagService } = useServices({
|
||||||
useServices({
|
WorkspaceService,
|
||||||
WorkbenchService,
|
CompatibleFavoriteItemsAdapter,
|
||||||
WorkspaceService,
|
FeatureFlagService,
|
||||||
CompatibleFavoriteItemsAdapter,
|
});
|
||||||
FeatureFlagService,
|
|
||||||
});
|
|
||||||
const openDocsSelector = useSelectDoc();
|
const openDocsSelector = useSelectDoc();
|
||||||
const openTagsSelector = useSelectTag();
|
const openTagsSelector = useSelectTag();
|
||||||
const openCollectionsSelector = useSelectCollection();
|
const openCollectionsSelector = useSelectCollection();
|
||||||
@@ -552,14 +549,13 @@ export const ExplorerFolderNodeFolder = ({
|
|||||||
const handleNewDoc = useCallback(() => {
|
const handleNewDoc = useCallback(() => {
|
||||||
const newDoc = createPage();
|
const newDoc = createPage();
|
||||||
node.createLink('doc', newDoc.id, node.indexAt('before'));
|
node.createLink('doc', newDoc.id, node.indexAt('before'));
|
||||||
workbenchService.workbench.openDoc(newDoc.id);
|
|
||||||
track.$.navigationPanel.folders.createDoc();
|
track.$.navigationPanel.folders.createDoc();
|
||||||
track.$.navigationPanel.organize.createOrganizeItem({
|
track.$.navigationPanel.organize.createOrganizeItem({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
target: 'doc',
|
target: 'doc',
|
||||||
});
|
});
|
||||||
setCollapsed(false);
|
setCollapsed(false);
|
||||||
}, [createPage, node, workbenchService.workbench]);
|
}, [createPage, node]);
|
||||||
|
|
||||||
const handleCreateSubfolder = useCallback(() => {
|
const handleCreateSubfolder = useCallback(() => {
|
||||||
const newFolderId = node.createFolder(
|
const newFolderId = node.createFolder(
|
||||||
|
|||||||
@@ -64,10 +64,9 @@ export const useExplorerTagNodeOperations = (
|
|||||||
const newDoc = createPage();
|
const newDoc = createPage();
|
||||||
tagRecord?.tag(newDoc.id);
|
tagRecord?.tag(newDoc.id);
|
||||||
track.$.navigationPanel.tags.createDoc();
|
track.$.navigationPanel.tags.createDoc();
|
||||||
workbenchService.workbench.openDoc(newDoc.id);
|
|
||||||
openNodeCollapsed();
|
openNodeCollapsed();
|
||||||
}
|
}
|
||||||
}, [createPage, openNodeCollapsed, tagRecord, workbenchService.workbench]);
|
}, [createPage, openNodeCollapsed, tagRecord]);
|
||||||
|
|
||||||
const handleMoveToTrash = useCallback(() => {
|
const handleMoveToTrash = useCallback(() => {
|
||||||
tagService.tagList.deleteTag(tagId);
|
tagService.tagList.deleteTag(tagId);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
FavoriteService,
|
FavoriteService,
|
||||||
isFavoriteSupportType,
|
isFavoriteSupportType,
|
||||||
} from '@affine/core/modules/favorite';
|
} from '@affine/core/modules/favorite';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
|
||||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||||
import { isNewTabTrigger } from '@affine/core/utils';
|
import { isNewTabTrigger } from '@affine/core/utils';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -41,14 +40,8 @@ import {
|
|||||||
import { RootEmpty } from './empty';
|
import { RootEmpty } from './empty';
|
||||||
|
|
||||||
export const ExplorerFavorites = () => {
|
export const ExplorerFavorites = () => {
|
||||||
const {
|
const { favoriteService, workspaceService, explorerService } = useServices({
|
||||||
favoriteService,
|
|
||||||
workspaceService,
|
|
||||||
workbenchService,
|
|
||||||
explorerService,
|
|
||||||
} = useServices({
|
|
||||||
FavoriteService,
|
FavoriteService,
|
||||||
WorkbenchService,
|
|
||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
ExplorerService,
|
ExplorerService,
|
||||||
});
|
});
|
||||||
@@ -88,23 +81,18 @@ export const ExplorerFavorites = () => {
|
|||||||
|
|
||||||
const handleCreateNewFavoriteDoc: MouseEventHandler = useCallback(
|
const handleCreateNewFavoriteDoc: MouseEventHandler = useCallback(
|
||||||
e => {
|
e => {
|
||||||
const newDoc = createPage();
|
const newDoc = createPage(
|
||||||
|
undefined,
|
||||||
|
isNewTabTrigger(e) ? 'new-tab' : true
|
||||||
|
);
|
||||||
favoriteService.favoriteList.add(
|
favoriteService.favoriteList.add(
|
||||||
'doc',
|
'doc',
|
||||||
newDoc.id,
|
newDoc.id,
|
||||||
favoriteService.favoriteList.indexAt('before')
|
favoriteService.favoriteList.indexAt('before')
|
||||||
);
|
);
|
||||||
workbenchService.workbench.openDoc(newDoc.id, {
|
|
||||||
at: isNewTabTrigger(e) ? 'new-tab' : 'active',
|
|
||||||
});
|
|
||||||
explorerSection.setCollapsed(false);
|
explorerSection.setCollapsed(false);
|
||||||
},
|
},
|
||||||
[
|
[createPage, explorerSection, favoriteService.favoriteList]
|
||||||
createPage,
|
|
||||||
explorerSection,
|
|
||||||
favoriteService.favoriteList,
|
|
||||||
workbenchService.workbench,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOnChildrenDrop = useCallback(
|
const handleOnChildrenDrop = useCallback(
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { ExplorerMobileContext } from '../mobile.context';
|
|
||||||
import { ExplorerTreeContext } from './context';
|
import { ExplorerTreeContext } from './context';
|
||||||
import { DropEffect } from './drop-effect';
|
import { DropEffect } from './drop-effect';
|
||||||
import * as styles from './node.css';
|
import * as styles from './node.css';
|
||||||
@@ -57,6 +56,38 @@ export type ExplorerTreeNodeIcon = React.ComponentType<{
|
|||||||
collapsed?: boolean;
|
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<any>
|
||||||
|
>;
|
||||||
|
[key: `data-${string}`]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
|
||||||
|
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||||
|
reorderable?: boolean;
|
||||||
|
dndData?: AffineDNDData;
|
||||||
|
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||||
|
dropEffect?: ExplorerTreeNodeDropEffect;
|
||||||
|
}
|
||||||
|
|
||||||
export const ExplorerTreeNode = ({
|
export const ExplorerTreeNode = ({
|
||||||
children,
|
children,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -82,34 +113,7 @@ export const ExplorerTreeNode = ({
|
|||||||
onDrop,
|
onDrop,
|
||||||
dropEffect,
|
dropEffect,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: {
|
}: WebExplorerTreeNodeProps) => {
|
||||||
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<AffineDNDData>['canDrop'];
|
|
||||||
operations?: NodeOperation[];
|
|
||||||
childrenOperations?: NodeOperation[];
|
|
||||||
childrenPlaceholder?: React.ReactNode;
|
|
||||||
linkComponent?: React.ComponentType<
|
|
||||||
React.PropsWithChildren<{ to: To; className?: string }> & RefAttributes<any>
|
|
||||||
>;
|
|
||||||
dndData?: AffineDNDData;
|
|
||||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
|
||||||
dropEffect?: ExplorerTreeNodeDropEffect;
|
|
||||||
} & { [key in `data-${string}`]?: any }) => {
|
|
||||||
const mobile = useContext(ExplorerMobileContext);
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const cid = useId();
|
const cid = useId();
|
||||||
const context = useContext(ExplorerTreeContext);
|
const context = useContext(ExplorerTreeContext);
|
||||||
@@ -141,21 +145,19 @@ export const ExplorerTreeNode = ({
|
|||||||
AffineDNDData & { draggable: { __cid: string } }
|
AffineDNDData & { draggable: { __cid: string } }
|
||||||
>(
|
>(
|
||||||
() => ({
|
() => ({
|
||||||
canDrag: () => !mobile,
|
|
||||||
data: { ...dndData?.draggable, __cid: cid },
|
data: { ...dndData?.draggable, __cid: cid },
|
||||||
dragPreviewPosition: 'pointer-outside',
|
dragPreviewPosition: 'pointer-outside',
|
||||||
}),
|
}),
|
||||||
[cid, dndData, mobile]
|
[cid, dndData]
|
||||||
);
|
);
|
||||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||||
() => args => {
|
() => args => {
|
||||||
if (mobile) return false;
|
|
||||||
if (!reorderable && args.treeInstruction?.type !== 'make-child') {
|
if (!reorderable && args.treeInstruction?.type !== 'make-child') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true;
|
return (typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true;
|
||||||
},
|
},
|
||||||
[canDrop, mobile, reorderable]
|
[canDrop, reorderable]
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
dropTargetRef,
|
dropTargetRef,
|
||||||
@@ -319,7 +321,7 @@ export const ExplorerTreeNode = ({
|
|||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={mobile ? styles.mobileItemRoot : styles.itemRoot}
|
className={styles.itemRoot}
|
||||||
data-active={active}
|
data-active={active}
|
||||||
data-disabled={disabled}
|
data-disabled={disabled}
|
||||||
>
|
>
|
||||||
@@ -327,11 +329,7 @@ export const ExplorerTreeNode = ({
|
|||||||
data-disabled={disabled}
|
data-disabled={disabled}
|
||||||
onClick={handleCollapsedChange}
|
onClick={handleCollapsedChange}
|
||||||
data-testid="explorer-collapsed-button"
|
data-testid="explorer-collapsed-button"
|
||||||
className={
|
className={styles.collapsedIconContainer}
|
||||||
mobile
|
|
||||||
? styles.mobileCollapsedIconContainer
|
|
||||||
: styles.collapsedIconContainer
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<ArrowDownSmallIcon
|
<ArrowDownSmallIcon
|
||||||
className={styles.collapsedIcon}
|
className={styles.collapsedIcon}
|
||||||
@@ -339,10 +337,8 @@ export const ExplorerTreeNode = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={clsx(mobile ? styles.mobileItemMain : styles.itemMain)}>
|
<div className={styles.itemMain}>
|
||||||
<div
|
<div className={styles.iconContainer}>
|
||||||
className={mobile ? styles.mobileIconContainer : styles.iconContainer}
|
|
||||||
>
|
|
||||||
{emoji ??
|
{emoji ??
|
||||||
(Icon && (
|
(Icon && (
|
||||||
<Icon
|
<Icon
|
||||||
@@ -352,38 +348,34 @@ export const ExplorerTreeNode = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={mobile ? styles.mobileItemContent : styles.itemContent}>
|
<div className={styles.itemContent}>{name}</div>
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
{postfix}
|
{postfix}
|
||||||
{mobile ? null : (
|
<div
|
||||||
<div
|
className={styles.postfix}
|
||||||
className={styles.postfix}
|
onClick={e => {
|
||||||
onClick={e => {
|
// prevent jump to page
|
||||||
// prevent jump to page
|
e.preventDefault();
|
||||||
e.preventDefault();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{inlineOperations.map(({ view }, index) => (
|
||||||
{inlineOperations.map(({ view }, index) => (
|
<Fragment key={index}>{view}</Fragment>
|
||||||
<Fragment key={index}>{view}</Fragment>
|
))}
|
||||||
))}
|
{menuOperations.length > 0 && (
|
||||||
{menuOperations.length > 0 && (
|
<Menu
|
||||||
<Menu
|
items={menuOperations.map(({ view }, index) => (
|
||||||
items={menuOperations.map(({ view }, index) => (
|
<Fragment key={index}>{view}</Fragment>
|
||||||
<Fragment key={index}>{view}</Fragment>
|
))}
|
||||||
))}
|
>
|
||||||
|
<IconButton
|
||||||
|
size="16"
|
||||||
|
data-testid="explorer-tree-node-operation-button"
|
||||||
|
style={{ marginLeft: 4 }}
|
||||||
>
|
>
|
||||||
<IconButton
|
<MoreHorizontalIcon />
|
||||||
size="16"
|
</IconButton>
|
||||||
data-testid="explorer-tree-node-operation-button"
|
</Menu>
|
||||||
style={{ marginLeft: 4 }}
|
)}
|
||||||
>
|
</div>
|
||||||
<MoreHorizontalIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renameable && (
|
{renameable && (
|
||||||
@@ -411,10 +403,7 @@ export const ExplorerTreeNode = ({
|
|||||||
{...otherProps}
|
{...otherProps}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(styles.contentContainer, styles.draggedOverEffect)}
|
||||||
mobile ? styles.mobileContentContainer : styles.contentContainer,
|
|
||||||
styles.draggedOverEffect
|
|
||||||
)}
|
|
||||||
data-open={!collapsed}
|
data-open={!collapsed}
|
||||||
data-self-dragged-over={isSelfDraggedOver}
|
data-self-dragged-over={isSelfDraggedOver}
|
||||||
ref={dropTargetRef}
|
ref={dropTargetRef}
|
||||||
|
|||||||
@@ -949,6 +949,9 @@
|
|||||||
"com.affine.rootAppSidebar.organize.folder.create-subfolder": "Create a subfolder",
|
"com.affine.rootAppSidebar.organize.folder.create-subfolder": "Create a subfolder",
|
||||||
"com.affine.rootAppSidebar.organize.new-folders": "New folder",
|
"com.affine.rootAppSidebar.organize.new-folders": "New folder",
|
||||||
"com.affine.rootAppSidebar.organize.root-folder-only": "Only folder can be placed on here",
|
"com.affine.rootAppSidebar.organize.root-folder-only": "Only folder can be placed on here",
|
||||||
|
"com.affine.rootAppSidebar.organize.add-more": "Add More",
|
||||||
|
"com.affine.rootAppSidebar.organize.add-folder": "Add Folder",
|
||||||
|
"com.affine.rootAppSidebar.collection.new": "New Collection",
|
||||||
"com.affine.rootAppSidebar.others": "Others",
|
"com.affine.rootAppSidebar.others": "Others",
|
||||||
"com.affine.rootAppSidebar.tag.doc-only": "Only doc can be placed on here",
|
"com.affine.rootAppSidebar.tag.doc-only": "Only doc can be placed on here",
|
||||||
"com.affine.rootAppSidebar.tags": "Tags",
|
"com.affine.rootAppSidebar.tags": "Tags",
|
||||||
|
|||||||
27
tests/affine-mobile/e2e/explorer-favorite.spec.ts
Normal file
27
tests/affine-mobile/e2e/explorer-favorite.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { test } from '@affine-test/kit/mobile';
|
||||||
|
import { getBlockSuiteEditorTitle } from '@affine-test/kit/utils/page-logic';
|
||||||
|
import { getCurrentDocIdFromUrl } from '@affine-test/kit/utils/url';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import { expandCollapsibleSection, pageBack } from './utils';
|
||||||
|
|
||||||
|
test('Create new doc in favorites', async ({ page }) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -13,3 +13,10 @@ export async function expandCollapsibleSection(page: Page, name: string) {
|
|||||||
await expect(section).toBeVisible();
|
await expect(section).toBeVisible();
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click header "<" button
|
||||||
|
*/
|
||||||
|
export async function pageBack(page: Page) {
|
||||||
|
await page.getByTestId('page-header-back').tap();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user