feat(mobile): new docs list for mobile (#12329)

close AF-2514

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Enhanced document explorer on mobile with live updates, responsive masonry layout, and improved empty state handling for all documents, collections, and tags.
  - Added customization for card height and masonry item width in document explorer views.
  - Extended layout components to support additional flexbox styling options for improved layout flexibility.

- **Bug Fixes**
  - Improved flexibility in layout components by supporting additional flexbox styling options.

- **Refactor**
  - Replaced older static document list and menu components with a unified, context-driven explorer for a more dynamic and interactive experience.
  - Removed obsolete CSS and component files related to the previous document list and menu implementations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-23 07:07:09 +00:00
parent d0539fde22
commit a96cd3eb0a
15 changed files with 241 additions and 366 deletions

View File

@@ -16,6 +16,8 @@ export type WrapperProps = {
marginLeft?: CSSProperties['marginLeft'];
marginRight?: CSSProperties['marginRight'];
marginBottom?: CSSProperties['marginBottom'];
flexGrow?: CSSProperties['flexGrow'];
flexShrink?: CSSProperties['flexShrink'];
};
export type FlexWrapperProps = {
@@ -64,6 +66,8 @@ export const Wrapper = styled('div', {
marginLeft,
marginRight,
marginBottom,
flexGrow,
flexShrink,
}) => {
return {
display,
@@ -79,6 +83,8 @@ export const Wrapper = styled('div', {
marginLeft,
marginRight,
marginBottom,
flexGrow,
flexShrink,
};
});

View File

@@ -69,15 +69,15 @@ const GroupHeader = memo(function GroupHeader({
return header;
});
const calcCardHeightById = (id: string) => {
const calcCardHeightById = (id: string, base = 250, scale = 10) => {
if (!id) {
return 250;
return base;
}
const max = 5;
const min = 1;
const code = id.charCodeAt(0);
const value = Math.floor((code % (max - min)) + min);
return 250 + value * 10;
return base + value * scale;
};
export const DocListItemComponent = memo(function DocListItemComponent({
@@ -93,9 +93,15 @@ export const DocListItemComponent = memo(function DocListItemComponent({
export const DocsExplorer = ({
className,
disableMultiDelete,
masonryItemWidthMin,
heightBase,
heightScale,
}: {
className?: string;
disableMultiDelete?: boolean;
masonryItemWidthMin?: number;
heightBase?: number;
heightScale?: number;
}) => {
const t = useI18n();
const contextValue = useContext(DocExplorerContext);
@@ -126,7 +132,7 @@ export const DocsExplorer = ({
? 42
: view === 'grid'
? 280
: calcCardHeightById(docId),
: calcCardHeightById(docId, heightBase, heightScale),
'data-view': view,
className: styles.docItem,
};
@@ -134,7 +140,7 @@ export const DocsExplorer = ({
} satisfies MasonryGroup;
});
return items;
}, [groupBy, groups, view]);
}, [groupBy, groups, heightBase, heightScale, view]);
const handleCloseFloatingToolbar = useCallback(() => {
contextValue.selectMode$?.next(false);
@@ -206,7 +212,7 @@ export const DocsExplorer = ({
groupsGap={12}
groupHeaderGapWithItems={12}
columns={view === 'list' ? 1 : undefined}
itemWidthMin={220}
itemWidthMin={masonryItemWidthMin ?? 220}
preloadHeight={100}
itemWidth={'stretch'}
virtualScroll

View File

@@ -1,14 +1,88 @@
import { useThemeColorV2 } from '@affine/component';
import { useThemeColorV2, Wrapper } from '@affine/component';
import { EmptyDocs } from '@affine/core/components/affine/empty';
import {
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { Page } from '../../components/page';
import { AllDocList, AllDocsHeader } from '../../views';
import { AllDocsHeader } from '../../views';
const AllDocs = () => {
const [explorerContextValue] = useState(() =>
createDocExplorerContext({
quickFavorite: true,
displayProperties: ['createdAt', 'updatedAt', 'tags'],
view: 'masonry',
showDragHandle: false,
})
);
const collectionRulesService = useService(CollectionRulesService);
const groups = useLiveData(explorerContextValue.groups$);
const isEmpty =
groups.length === 0 ||
(groups.length && groups.every(group => !group.items.length));
useEffect(() => {
const subscription = collectionRulesService
.watch({
filters: [
{ type: 'system', key: 'trash', method: 'is', value: 'false' },
],
extraFilters: [
{ type: 'system', key: 'trash', method: 'is', value: 'false' },
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
],
orderBy: {
type: 'property',
key: 'createdAt',
desc: true,
},
})
.subscribe({
next: result => {
explorerContextValue.groups$.next(result.groups);
},
error: console.error,
});
return () => subscription.unsubscribe();
}, [collectionRulesService, explorerContextValue.groups$]);
if (isEmpty) {
return (
<>
<EmptyDocs absoluteCenter />
<Wrapper height={0} flexGrow={1} />
</>
);
}
return (
<DocExplorerContext.Provider value={explorerContextValue}>
<DocsExplorer
masonryItemWidthMin={150}
heightBase={180}
heightScale={12}
/>
</DocExplorerContext.Provider>
);
};
export const Component = () => {
useThemeColorV2('layer/background/mobile/primary');
return (
<Page header={<AllDocsHeader />} tab>
<AllDocList />
<AllDocs />
</Page>
);
};

View File

@@ -1,11 +1,20 @@
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
import { Wrapper } from '@affine/component';
import {
EmptyCollectionDetail,
EmptyDocs,
} from '@affine/core/components/affine/empty';
import {
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
import { PageHeader } from '@affine/core/mobile/components';
import { Page } from '@affine/core/mobile/components/page';
import type { Collection } from '@affine/core/modules/collection';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { AllDocList } from '../doc/list';
import * as styles from './detail.css';
export const DetailHeader = ({ collection }: { collection: Collection }) => {
@@ -20,6 +29,55 @@ export const DetailHeader = ({ collection }: { collection: Collection }) => {
);
};
const CollectionDocs = ({ collection }: { collection: Collection }) => {
const [explorerContextValue] = useState(() =>
createDocExplorerContext({
quickFavorite: true,
displayProperties: ['createdAt', 'updatedAt', 'tags'],
view: 'masonry',
showDragHandle: false,
})
);
const groups = useLiveData(explorerContextValue.groups$);
const isEmpty =
groups.length === 0 ||
(groups.length && groups.every(group => !group.items.length));
useEffect(() => {
const subscription = collection.watch().subscribe({
next: result => {
explorerContextValue.groups$.next([
{
key: 'collection',
items: result,
},
]);
},
error: console.error,
});
return () => subscription.unsubscribe();
}, [collection, explorerContextValue.groups$]);
if (isEmpty) {
return (
<>
<EmptyDocs absoluteCenter />
<Wrapper height={0} flexGrow={1} />
</>
);
}
return (
<DocExplorerContext.Provider value={explorerContextValue}>
<DocsExplorer
masonryItemWidthMin={150}
heightBase={180}
heightScale={12}
/>
</DocExplorerContext.Provider>
);
};
export const CollectionDetail = ({
collection,
}: {
@@ -38,7 +96,7 @@ export const CollectionDetail = ({
return (
<Page header={<DetailHeader collection={collection} />}>
<AllDocList collection={collection} />
<CollectionDocs collection={collection} />
</Page>
);
};

View File

@@ -1,3 +0,0 @@
export * from './list';
export * from './masonry';
export * from './menu';

View File

@@ -1,36 +0,0 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const groupTitle = style({
display: 'flex',
alignItems: 'center',
padding: '0px 16px',
width: '100%',
});
// to override style defined in `core`
globalStyle(`${groupTitle} > div`, {
marginRight: -4,
});
globalStyle(`${groupTitle} div[data-testid^='group-label']`, {
fontSize: `20px !important`,
color: `${cssVarV2('text/primary')} !important`,
lineHeight: '25px !important',
});
export const groupTitleIcon = style({
color: cssVarV2('icon/tertiary'),
transition: 'transform 0.2s',
selectors: {
'[data-state="closed"] &': {
transform: `rotate(-90deg)`,
},
},
});
export const groups = style({
display: 'flex',
flexDirection: 'column',
gap: 32,
});
export const emptySpaceY = style({
height: 0,
flexGrow: 1,
});

View File

@@ -1,107 +0,0 @@
import { EmptyDocs } from '@affine/core/components/affine/empty';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
type ItemGroupProps,
useAllDocDisplayProperties,
} from '@affine/core/components/page-list';
import type { Collection } from '@affine/core/modules/collection';
import { DocsService } from '@affine/core/modules/doc';
import type { Tag } from '@affine/core/modules/tag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import type { DocMeta } from '@blocksuite/affine/store';
import { ToggleDownIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { useEffect, useMemo, useState } from 'react';
import * as styles from './list.css';
import { MasonryDocs } from './masonry';
export const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
const [properties] = useAllDocDisplayProperties();
const showTags = properties.displayProperties.tags;
if (group.id === 'all') {
return <MasonryDocs items={group.items} showTags={showTags} />;
}
return (
<Collapsible.Root defaultOpen>
<Collapsible.Trigger className={styles.groupTitle}>
{group.label}
<ToggleDownIcon className={styles.groupTitleIcon} />
</Collapsible.Trigger>
<Collapsible.Content>
<MasonryDocs items={group.items} showTags={showTags} />
</Collapsible.Content>
</Collapsible.Root>
);
};
export interface AllDocListProps {
collection?: Collection;
tag?: Tag;
trash?: boolean;
}
export const AllDocList = ({ trash, collection, tag }: AllDocListProps) => {
const [properties] = useAllDocDisplayProperties();
const workspace = useService(WorkspaceService).workspace;
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
const docsService = useService(DocsService);
const allTrashPageIds = useLiveData(
LiveData.from(docsService.allTrashDocIds$(), [])
);
const tagPageIds = useLiveData(tag?.pageIds$);
const [filteredPageIds, setFilteredPageIds] = useState<string[]>([]);
useEffect(() => {
const subscription = collection?.watch().subscribe(docIds => {
setFilteredPageIds(docIds);
});
return () => subscription?.unsubscribe();
}, [collection]);
const finalPageMetas = useMemo(() => {
const collectionFilteredPageMetas = collection
? allPageMetas.filter(page => filteredPageIds.includes(page.id))
: allPageMetas;
const filteredPageMetas = collectionFilteredPageMetas.filter(
page => allTrashPageIds.includes(page.id) === !!trash
);
if (tag) {
const pageIdsSet = new Set(tagPageIds);
return filteredPageMetas.filter(page => pageIdsSet.has(page.id));
}
return filteredPageMetas;
}, [
allPageMetas,
allTrashPageIds,
collection,
filteredPageIds,
tag,
tagPageIds,
trash,
]);
if (!finalPageMetas.length) {
return (
<>
<EmptyDocs absoluteCenter tagId={tag?.id} />
<div className={styles.emptySpaceY} />
</>
);
}
return (
<MasonryDocs
items={finalPageMetas}
showTags={properties.displayProperties.tags}
/>
);
};

View File

@@ -1,18 +0,0 @@
import { style } from '@vanilla-extract/css';
export const paddingX = 16;
export const columnGap = 17;
export const columns = style({
padding: `16px ${paddingX}px`,
display: 'flex',
gap: columnGap,
});
export const column = style({
display: 'flex',
flexDirection: 'column',
gap: 10,
width: 0,
flex: 1,
});

View File

@@ -1,44 +0,0 @@
import { Masonry } from '@affine/component';
import type { DocMeta } from '@blocksuite/affine/store';
import { useMemo } from 'react';
import { calcRowsById, DocCard } from '../../../components';
const fullStyle = {
width: '100%',
height: '100%',
};
export const MasonryDocs = ({
items,
showTags,
}: {
items: DocMeta[];
showTags?: boolean;
}) => {
const masonryItems = useMemo(
() =>
items.map(item => {
return {
id: item.id,
height: calcRowsById(item.id) * 18 + 95,
children: (
<DocCard style={fullStyle} meta={item} showTags={showTags} />
),
};
}),
[items, showTags]
);
return (
<Masonry
style={fullStyle}
itemWidthMin={160}
gapX={17}
gapY={10}
paddingX={16}
paddingY={16}
virtualScroll
items={masonryItems}
/>
);
};

View File

@@ -1,65 +0,0 @@
import { bodyEmphasized, bodyRegular } from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
gap: 10,
});
export const head = style([
bodyEmphasized,
{
padding: '10px 20px',
color: cssVarV2('text/primary'),
},
]);
export const item = style([
bodyRegular,
{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 4,
height: 34,
padding: '0 20px',
},
]);
export const itemSuffix = style({
display: 'flex',
gap: 8,
alignItems: 'center',
});
export const itemSuffixText = style({
color: cssVarV2('text/secondary'),
});
export const itemSuffixIcon = style({
color: cssVarV2('icon/primary'),
fontSize: 20,
});
export const divider = style({
width: '100%',
height: 16,
display: 'flex',
alignItems: 'center',
':before': {
content: "''",
height: 0.5,
width: '100%',
backgroundColor: cssVarV2('layer/insideBorder/border'),
},
});
export const propertiesList = style({
padding: '4px 20px',
display: 'flex',
gap: 8,
});
export const propertyButton = style({
opacity: 0.4,
selectors: {
'&[data-selected="true"]': {
opacity: 1,
},
},
});

View File

@@ -1,73 +0,0 @@
import { Button, MobileMenu, MobileMenuItem } from '@affine/component';
import {
getGroupOptions,
useAllDocDisplayProperties,
} from '@affine/core/components/page-list';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useMemo } from 'react';
import * as styles from './menu.css';
export const AllDocsMenu = () => {
const t = useI18n();
const [properties, setProperties] = useAllDocDisplayProperties();
const groupOptions = useMemo(() => getGroupOptions(t), [t]);
const activeGroup = useMemo(
() => groupOptions.find(g => g.value === properties.groupBy),
[groupOptions, properties.groupBy]
);
return (
<div className={styles.root}>
<header className={styles.head}>Display Settings</header>
<MobileMenu
items={
<>
<div className={styles.divider} />
{groupOptions.map(group => (
<MobileMenuItem
onSelect={() => setProperties('groupBy', group.value)}
selected={properties.groupBy === group.value}
key={group.value}
>
{group.label}
</MobileMenuItem>
))}
</>
}
>
<div className={styles.item}>
<span>{t['com.affine.page.display.grouping']()}</span>
<div className={styles.itemSuffix}>
<span className={styles.itemSuffixText}>{activeGroup?.label}</span>
<ArrowRightSmallIcon className={styles.itemSuffixIcon} />
</div>
</div>
</MobileMenu>
<div className={styles.divider} />
<div className={styles.item}>
{t['com.affine.page.display.display-properties']()}
</div>
<div className={styles.propertiesList}>
<Button
size="large"
className={styles.propertyButton}
data-selected={properties.displayProperties.tags}
onClick={() =>
setProperties('displayProperties', {
...properties.displayProperties,
tags: !properties.displayProperties.tags,
})
}
>
Tag
</Button>
</div>
</div>
);
};

View File

@@ -1,4 +1,3 @@
export * from './collection';
export * from './doc/';
export * from './header';
export * from './tag';

View File

@@ -1,13 +1,93 @@
import { Wrapper } from '@affine/component';
import { EmptyDocs } from '@affine/core/components/affine/empty';
import {
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
import { Page } from '@affine/core/mobile/components/page';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import type { Tag } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { AllDocList } from '../doc';
import { TagDetailHeader } from './detail-header';
const TagDocs = ({ tag }: { tag: Tag }) => {
const [explorerContextValue] = useState(() =>
createDocExplorerContext({
quickFavorite: true,
displayProperties: ['createdAt', 'updatedAt', 'tags'],
view: 'masonry',
showDragHandle: false,
})
);
const collectionRulesService = useService(CollectionRulesService);
const groups = useLiveData(explorerContextValue.groups$);
const isEmpty =
groups.length === 0 ||
(groups.length && groups.every(group => !group.items.length));
useEffect(() => {
const subscription = collectionRulesService
.watch({
filters: [
{ type: 'system', key: 'trash', method: 'is', value: 'false' },
{
type: 'system',
key: 'tags',
method: 'include-all',
value: tag.id,
},
],
extraFilters: [
{ type: 'system', key: 'trash', method: 'is', value: 'false' },
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
],
orderBy: {
type: 'property',
key: 'createdAt',
desc: true,
},
})
.subscribe({
next: result => {
explorerContextValue.groups$.next(result.groups);
},
error: console.error,
});
return () => subscription.unsubscribe();
}, [collectionRulesService, explorerContextValue.groups$, tag.id]);
if (isEmpty) {
return (
<>
<EmptyDocs absoluteCenter tagId={tag.id} />
<Wrapper height={0} flexGrow={1} />
</>
);
}
return (
<DocExplorerContext.Provider value={explorerContextValue}>
<DocsExplorer
masonryItemWidthMin={150}
heightBase={180}
heightScale={12}
/>
</DocExplorerContext.Provider>
);
};
export const TagDetail = ({ tag }: { tag: Tag }) => {
return (
<Page header={<TagDetailHeader tag={tag} />} tab>
<AllDocList tag={tag} />
<TagDocs tag={tag} />
</Page>
);
};

View File

@@ -13,7 +13,7 @@ test.beforeEach(async ({ page }) => {
const docsTab = page.locator('#app-tabs').getByRole('tab', { name: 'all' });
await expect(docsTab).toBeVisible();
await docsTab.click();
await page.getByTestId('doc-card').first().click();
await page.getByTestId('doc-list-item').first().click();
await expect(page.locator('.affine-page-viewport')).toBeVisible();
});
@@ -53,7 +53,5 @@ test('can add text property', async ({ page }) => {
).toBeVisible();
await page.getByTestId('mobile-menu-back-button').last().click();
await expect(page.getByTestId('mobile-menu-back-button')).toContainText(
'Getting Started'
);
await expect(page.getByTestId('mobile-menu-back-button')).toBeVisible();
});

View File

@@ -47,6 +47,6 @@ test('all tab', async ({ page }) => {
await docsTab.click();
const todayDocs = page.getByTestId('doc-card');
const todayDocs = page.getByTestId('doc-list-item');
expect(await todayDocs.count()).toBeGreaterThan(0);
});