feat(core): add pinned collections to all docs (#12269)

This commit is contained in:
EYHN 2025-05-14 18:18:43 +09:00 committed by GitHub
parent 6eab1a6cb2
commit 61b99c5934
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 558 additions and 47 deletions

View File

@ -12,7 +12,9 @@ export type DocExplorerContextType = {
selectedDocIds$: LiveData<string[]>;
prevCheckAnchorId$?: LiveData<string | null>;
} & {
[K in keyof ExplorerPreference as `${K}$`]: LiveData<ExplorerPreference[K]>;
[K in keyof Omit<ExplorerPreference, 'filters'> as `${K}$`]: LiveData<
ExplorerPreference[K]
>;
};
export const DocExplorerContext = createContext<DocExplorerContextType>(
@ -27,7 +29,6 @@ export const createDocExplorerContext = () =>
selectMode$: new LiveData<boolean>(false),
selectedDocIds$: new LiveData<string[]>([]),
prevCheckAnchorId$: new LiveData<string | null>(null),
filters$: new LiveData<ExplorerPreference['filters']>([]),
groupBy$: new LiveData<ExplorerPreference['groupBy']>(undefined),
orderBy$: new LiveData<ExplorerPreference['orderBy']>(undefined),
displayProperties$: new LiveData<ExplorerPreference['displayProperties']>(

View File

@ -2,7 +2,7 @@ import { IconButton, Menu, MenuItem, MenuSeparator } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { useI18n } from '@affine/i18n';
import { FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc';
import { ArrowLeftBigIcon, FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties';
@ -11,8 +11,10 @@ import * as styles from './styles.css';
export const AddFilterMenu = ({
onAdd,
onBack,
}: {
onAdd: (params: FilterParams) => void;
onBack?: () => void;
}) => {
const t = useI18n();
const workspacePropertyService = useService(WorkspacePropertyService);
@ -20,9 +22,17 @@ export const AddFilterMenu = ({
return (
<>
<div className={styles.variableSelectTitleStyle}>
{t['com.affine.filter']()}
<div className={styles.selectHeaderContainer}>
{onBack && (
<IconButton onClick={onBack}>
<ArrowLeftBigIcon />
</IconButton>
)}
<div className={styles.variableSelectTitleStyle}>
{t['com.affine.filter']()}
</div>
</div>
<MenuSeparator />
<MenuItem
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}

View File

@ -1,4 +1,5 @@
import type { FilterParams } from '@affine/core/modules/collection-rules';
import clsx from 'clsx';
import { AddFilter } from './add-filter';
import { Filter } from './filter';
@ -6,9 +7,11 @@ import * as styles from './styles.css';
export const Filters = ({
filters,
className,
onChange,
}: {
filters: FilterParams[];
className?: string;
onChange?: (filters: FilterParams[]) => void;
}) => {
const handleDelete = (index: number) => {
@ -20,7 +23,7 @@ export const Filters = ({
};
return (
<div className={styles.container}>
<div className={clsx(styles.container, className)}>
{filters.map((filter, index) => {
return (
<Filter

View File

@ -30,12 +30,24 @@ export const filterItemCloseStyle = style({
marginLeft: '4px',
});
export const selectHeaderContainer = style({
display: 'flex',
flexDirection: 'row',
margin: '2px 2px',
alignItems: 'center',
gap: '4px',
});
export const variableSelectTitleStyle = style({
margin: '2px 12px',
fontWeight: 500,
lineHeight: '22px',
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
selectors: {
'&:first-child': {
marginLeft: '12px',
},
},
});
export const filterTypeItemIcon = style({

View File

@ -34,6 +34,7 @@ export const body = style({
export const scrollArea = style({
height: 0,
flex: 1,
paddingTop: '24px',
});
// group
@ -45,7 +46,7 @@ export const docItem = style({
transition: 'width 0.2s ease-in-out',
});
export const filterArea = style({
export const pinnedCollection = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
@ -60,3 +61,23 @@ export const filterArea = style({
},
},
});
export const filterArea = style({
display: 'flex',
flexDirection: 'row',
gap: 8,
padding: '0 24px',
paddingTop: '24px',
'@container': {
'docs-body (width <= 500px)': {
padding: '0 20px',
},
'docs-body (width <= 393px)': {
padding: '0 16px',
},
},
});
export const filters = style({
flex: 1,
});

View File

@ -1,4 +1,10 @@
import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component';
import {
Button,
Masonry,
type MasonryGroup,
useConfirmModal,
usePromptModal,
} from '@affine/component';
import {
createDocExplorerContext,
DocExplorerContext,
@ -7,6 +13,10 @@ import { DocListItem } from '@affine/core/components/explorer/docs-view/doc-list
import { Filters } from '@affine/core/components/filter';
import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar';
import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types';
import {
CollectionService,
PinnedCollectionService,
} from '@affine/core/modules/collection';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
import { DocsService } from '@affine/core/modules/doc';
@ -35,6 +45,7 @@ import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import * as styles from './all-page.css';
import { AllDocsHeader } from './all-page-header';
import { MigrationAllDocsDataNotification } from './migration-data';
import { PinnedCollections } from './pinned-collections';
const GroupHeader = memo(function GroupHeader({
groupId,
@ -100,11 +111,34 @@ const DocListItemComponent = memo(function DocListItemComponent({
export const AllPage = () => {
const t = useI18n();
const docsService = useService(DocsService);
const collectionService = useService(CollectionService);
const pinnedCollectionService = useService(PinnedCollectionService);
const [selectedCollectionId, setSelectedCollectionId] = useState<
string | null
>(null);
const selectedCollection = useLiveData(
selectedCollectionId
? collectionService.collection$(selectedCollectionId)
: null
);
useEffect(() => {
// if selected collection is not found, set selected collection id to null
if (!selectedCollection && selectedCollectionId) {
setSelectedCollectionId(null);
}
}, [selectedCollection, selectedCollectionId]);
const selectedCollectionInfo = useLiveData(
selectedCollection ? selectedCollection.info$ : null
);
const [tempFilters, setTempFilters] = useState<FilterParams[]>([]);
const [explorerContextValue] = useState(createDocExplorerContext);
const view = useLiveData(explorerContextValue.view$);
const filters = useLiveData(explorerContextValue.filters$);
const groupBy = useLiveData(explorerContextValue.groupBy$);
const orderBy = useLiveData(explorerContextValue.orderBy$);
const groups = useLiveData(explorerContextValue.groups$);
@ -112,8 +146,8 @@ export const AllPage = () => {
const collapsedGroups = useLiveData(explorerContextValue.collapsedGroups$);
const selectMode = useLiveData(explorerContextValue.selectMode$);
const { openPromptModal } = usePromptModal();
const { openConfirmModal } = useConfirmModal();
const masonryItems = useMemo(() => {
const items = groups.map((group: any) => {
return {
@ -143,12 +177,21 @@ export const AllPage = () => {
const collectionRulesService = useService(CollectionRulesService);
useEffect(() => {
const subscription = collectionRulesService
.watch({
filters:
filters && filters.length > 0
? filters
: [
// if no filters are present, match all non-trash documents
.watch(
// collection filters and temp filters can't exist at the same time
selectedCollectionInfo
? {
filters: selectedCollectionInfo.rules.filters,
groupBy,
orderBy,
extraAllowList: selectedCollectionInfo.allowList,
extraFilters: [
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
@ -156,23 +199,38 @@ export const AllPage = () => {
value: 'false',
},
],
groupBy,
orderBy,
extraFilters: [
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
],
})
}
: {
filters:
tempFilters && tempFilters.length > 0
? tempFilters
: [
// if no filters are present, match all non-trash documents
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
],
groupBy,
orderBy,
extraFilters: [
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
],
}
)
.subscribe({
next: result => {
explorerContextValue.groups$.next(result.groups);
@ -186,10 +244,12 @@ export const AllPage = () => {
};
}, [
collectionRulesService,
explorerContextValue.groups$,
filters,
explorerContextValue,
groupBy,
orderBy,
selectedCollection,
selectedCollectionInfo,
tempFilters,
]);
useEffect(() => {
@ -206,12 +266,9 @@ export const AllPage = () => {
};
}, [explorerContextValue]);
const handleFilterChange = useCallback(
(filters: FilterParams[]) => {
explorerContextValue.filters$.next(filters);
},
[explorerContextValue]
);
const handleFilterChange = useCallback((filters: FilterParams[]) => {
setTempFilters(filters);
}, []);
const handleCloseFloatingToolbar = useCallback(() => {
explorerContextValue.selectMode$.next(false);
@ -246,6 +303,42 @@ export const AllPage = () => {
});
}, [docsService.list, openConfirmModal, selectedDocIds, t]);
const handleSaveFilters = useCallback(() => {
openPromptModal({
title: t['com.affine.editCollection.saveCollection'](),
label: t['com.affine.editCollectionName.name'](),
inputOptions: {
placeholder: t['com.affine.editCollectionName.name.placeholder'](),
},
children: t['com.affine.editCollectionName.createTips'](),
confirmText: t['com.affine.editCollection.save'](),
cancelText: t['com.affine.editCollection.button.cancel'](),
confirmButtonOptions: {
variant: 'primary',
},
onConfirm(name) {
const id = collectionService.createCollection({
name,
rules: {
filters: tempFilters,
},
});
pinnedCollectionService.addPinnedCollection({
collectionId: id,
index: pinnedCollectionService.indexAt('after'),
});
setTempFilters([]);
setSelectedCollectionId(id);
},
});
}, [
collectionService,
openPromptModal,
pinnedCollectionService,
t,
tempFilters,
]);
return (
<DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={t['All pages']()} />
@ -255,10 +348,40 @@ export const AllPage = () => {
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<div className={styles.filterArea}>
<MigrationAllDocsDataNotification />
<Filters filters={filters ?? []} onChange={handleFilterChange} />
<MigrationAllDocsDataNotification />
<div className={styles.pinnedCollection}>
<PinnedCollections
activeCollectionId={selectedCollectionId}
onClickAll={() => setSelectedCollectionId(null)}
onClickCollection={collectionId => {
setSelectedCollectionId(collectionId);
setTempFilters([]);
}}
onAddFilter={params => {
setSelectedCollectionId(null);
setTempFilters([...(tempFilters ?? []), params]);
}}
hiddenAdd={tempFilters.length > 0}
/>
</div>
{tempFilters.length > 0 && (
<div className={styles.filterArea}>
<Filters
className={styles.filters}
filters={tempFilters ?? []}
onChange={handleFilterChange}
/>
<Button
variant="plain"
onClick={() => {
setTempFilters([]);
}}
>
{t['Cancel']()}
</Button>
<Button onClick={handleSaveFilters}>{t['save']()}</Button>
</div>
)}
<div className={styles.scrollArea}>
<Masonry
items={masonryItems}

View File

@ -6,6 +6,16 @@ export const migrationDataNotificationContainer = style({
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
padding: '12px 240px 12px 12px',
borderRadius: '8px',
margin: '0 24px',
marginTop: '24px',
'@container': {
'docs-body (width <= 500px)': {
margin: '0 20px',
},
'docs-body (width <= 393px)': {
margin: '0 16px',
},
},
});
export const migrationDataNotificationTitle = style({

View File

@ -0,0 +1,34 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const item = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
minWidth: '46px',
lineHeight: '24px',
fontSize: cssVar('fontBase'),
color: cssVarV2('text/secondary'),
borderRadius: 4,
backgroundColor: 'var(--affine-background-primary-color)',
cursor: 'pointer',
userSelect: 'none',
':hover': {
color: cssVarV2('text/primary'),
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
selectors: {
'&[data-active="true"]': {
color: cssVarV2('text/primary'),
backgroundColor: cssVarV2('layer/background/secondary'),
},
},
});
export const container = style({
display: 'flex',
flexDirection: 'row',
gap: 4,
});

View File

@ -0,0 +1,180 @@
import { Divider, IconButton, Menu, MenuItem } from '@affine/component';
import { AddFilterMenu } from '@affine/core/components/filter/add-filter';
import {
CollectionService,
type PinnedCollectionRecord,
PinnedCollectionService,
} from '@affine/core/modules/collection';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useI18n } from '@affine/i18n';
import { CollectionsIcon, FilterIcon, PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo, useState } from 'react';
import * as styles from './pinned-collections.css';
export const PinnedCollectionItem = ({
record,
isActive,
onClick,
}: {
record: PinnedCollectionRecord;
isActive: boolean;
onClick: () => void;
}) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const collection = useLiveData(
collectionService.collection$(record.collectionId)
);
const name = useLiveData(collection?.name$);
if (!collection) {
return null;
}
return (
<div
className={styles.item}
role="button"
data-active={isActive ? 'true' : undefined}
onClick={onClick}
>
{name ?? t['Untitled']()}
</div>
);
};
export const PinnedCollections = ({
activeCollectionId,
onClickAll,
onClickCollection,
onAddFilter,
hiddenAdd,
}: {
activeCollectionId: string | null;
onClickAll: () => void;
onClickCollection: (collectionId: string) => void;
onAddFilter: (params: FilterParams) => void;
hiddenAdd?: boolean;
}) => {
const t = useI18n();
const pinnedCollectionService = useService(PinnedCollectionService);
const pinnedCollections = useLiveData(
pinnedCollectionService.sortedPinnedCollections$
);
const handleAddPinnedCollection = (collectionId: string) => {
pinnedCollectionService.addPinnedCollection({
collectionId,
index: pinnedCollectionService.indexAt('after'),
});
};
return (
<div className={styles.container}>
<div
className={styles.item}
data-active={activeCollectionId === null ? 'true' : undefined}
onClick={onClickAll}
role="button"
>
{t['com.affine.all-docs.pinned-collection.all']()}
</div>
{pinnedCollections.map(record => (
<PinnedCollectionItem
key={record.collectionId}
record={record}
isActive={activeCollectionId === record.collectionId}
onClick={() => onClickCollection(record.collectionId)}
/>
))}
{!hiddenAdd && (
<AddPinnedCollection
onAddPinnedCollection={handleAddPinnedCollection}
onAddFilter={onAddFilter}
/>
)}
</div>
);
};
export const AddPinnedCollection = ({
onAddPinnedCollection,
onAddFilter,
}: {
onAddPinnedCollection: (collectionId: string) => void;
onAddFilter: (params: FilterParams) => void;
}) => {
return (
<Menu
items={
<AddPinnedCollectionMenuContent
onAddPinnedCollection={onAddPinnedCollection}
onAddFilter={onAddFilter}
/>
}
>
<IconButton size="16">
<PlusIcon />
</IconButton>
</Menu>
);
};
export const AddPinnedCollectionMenuContent = ({
onAddPinnedCollection,
onAddFilter,
}: {
onAddPinnedCollection: (collectionId: string) => void;
onAddFilter: (params: FilterParams) => void;
}) => {
const [addingFilter, setAddingFilter] = useState<boolean>(false);
const collectionService = useService(CollectionService);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const pinnedCollectionService = useService(PinnedCollectionService);
const pinnedCollections = useLiveData(
pinnedCollectionService.pinnedCollections$
);
const unpinnedCollectionMetas = useMemo(
() =>
collectionMetas.filter(
meta =>
!pinnedCollections.some(
collection => collection.collectionId === meta.id
)
),
[pinnedCollections, collectionMetas]
);
const t = useI18n();
return !addingFilter ? (
<>
<MenuItem
prefixIcon={<FilterIcon />}
onClick={e => {
// prevent default to avoid closing the menu
e.preventDefault();
setAddingFilter(true);
}}
>
{t['com.affine.filter']()}
</MenuItem>
{unpinnedCollectionMetas.length > 0 && <Divider />}
{unpinnedCollectionMetas.map(meta => (
<MenuItem
key={meta.id}
prefixIcon={<CollectionsIcon />}
suffixIcon={<PlusIcon />}
onClick={() => {
onAddPinnedCollection(meta.id);
}}
>
{meta.name ?? t['Untitled']()}
</MenuItem>
))}
</>
) : (
<AddFilterMenu onBack={() => setAddingFilter(false)} onAdd={onAddFilter} />
);
};

View File

@ -1,20 +1,27 @@
export { Collection } from './entities/collection';
export type { CollectionMeta } from './services/collection';
export { CollectionService } from './services/collection';
export { PinnedCollectionService } from './services/pinned-collection';
export type { CollectionInfo } from './stores/collection';
export type { PinnedCollectionRecord } from './stores/pinned-collection';
import { type Framework } from '@toeverything/infra';
import { CollectionRulesService } from '../collection-rules';
import { WorkspaceDBService } from '../db';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { Collection } from './entities/collection';
import { CollectionService } from './services/collection';
import { PinnedCollectionService } from './services/pinned-collection';
import { CollectionStore } from './stores/collection';
import { PinnedCollectionStore } from './stores/pinned-collection';
export function configureCollectionModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(CollectionService, [CollectionStore])
.store(CollectionStore, [WorkspaceService])
.entity(Collection, [CollectionStore, CollectionRulesService]);
.entity(Collection, [CollectionStore, CollectionRulesService])
.store(PinnedCollectionStore, [WorkspaceDBService])
.service(PinnedCollectionService, [PinnedCollectionStore]);
}

View File

@ -0,0 +1,71 @@
import {
generateFractionalIndexingKeyBetween,
LiveData,
Service,
} from '@toeverything/infra';
import type {
PinnedCollectionRecord,
PinnedCollectionStore,
} from '../stores/pinned-collection';
export class PinnedCollectionService extends Service {
constructor(private readonly pinnedCollectionStore: PinnedCollectionStore) {
super();
}
pinnedCollections$ = LiveData.from<PinnedCollectionRecord[]>(
this.pinnedCollectionStore.watchPinnedCollections(),
[]
);
sortedPinnedCollections$ = this.pinnedCollections$.map(records =>
records.toSorted((a, b) => {
return a.index > b.index ? 1 : -1;
})
);
addPinnedCollection(record: PinnedCollectionRecord) {
this.pinnedCollectionStore.addPinnedCollection(record);
}
removePinnedCollection(collectionId: string) {
this.pinnedCollectionStore.removePinnedCollection(collectionId);
}
indexAt(at: 'before' | 'after', targetId?: string) {
if (!targetId) {
if (at === 'before') {
const first = this.sortedPinnedCollections$.value.at(0);
return generateFractionalIndexingKeyBetween(null, first?.index || null);
} else {
const last = this.sortedPinnedCollections$.value.at(-1);
return generateFractionalIndexingKeyBetween(last?.index || null, null);
}
} else {
const sortedChildren = this.sortedPinnedCollections$.value;
const targetIndex = sortedChildren.findIndex(
node => node.collectionId === targetId
);
if (targetIndex === -1) {
throw new Error('Target node not found');
}
const target = sortedChildren[targetIndex];
const before: PinnedCollectionRecord | null =
sortedChildren[targetIndex - 1] || null;
const after: PinnedCollectionRecord | null =
sortedChildren[targetIndex + 1] || null;
if (at === 'before') {
return generateFractionalIndexingKeyBetween(
before?.index || null,
target.index
);
} else {
return generateFractionalIndexingKeyBetween(
target.index,
after?.index || null
);
}
}
}
}

View File

@ -0,0 +1,30 @@
import { Store } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import type { WorkspaceDBService } from '../../db';
export interface PinnedCollectionRecord {
collectionId: string;
index: string;
}
export class PinnedCollectionStore extends Store {
constructor(private readonly workspaceDBService: WorkspaceDBService) {
super();
}
watchPinnedCollections(): Observable<PinnedCollectionRecord[]> {
return this.workspaceDBService.db.pinnedCollections.find$();
}
addPinnedCollection(record: PinnedCollectionRecord) {
this.workspaceDBService.db.pinnedCollections.create({
collectionId: record.collectionId,
index: record.index,
});
}
removePinnedCollection(collectionId: string) {
this.workspaceDBService.db.pinnedCollections.delete(collectionId);
}
}

View File

@ -42,6 +42,10 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
isDeleted: f.boolean().optional(),
// we will keep deleted properties in the database, for override legacy data
},
pinnedCollections: {
collectionId: f.string().primaryKey(),
index: f.string(),
},
} as const satisfies DBSchemaBuilder;
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;

View File

@ -6,7 +6,7 @@
"el-GR": 96,
"en": 100,
"es-AR": 96,
"es-CL": 98,
"es-CL": 97,
"es": 96,
"fa": 96,
"fr": 96,

View File

@ -6968,6 +6968,10 @@ export function useAFFiNEI18N(): {
* `Select checkbox`
*/
["com.affine.all-docs.quick-action.select"](): string;
/**
* `All`
*/
["com.affine.all-docs.pinned-collection.all"](): string;
/**
* `core`
*/

View File

@ -1739,6 +1739,7 @@
"com.affine.all-docs.quick-action.split": "Open in split view",
"com.affine.all-docs.quick-action.tab": "Open in new tab",
"com.affine.all-docs.quick-action.select": "Select checkbox",
"com.affine.all-docs.pinned-collection.all": "All",
"core": "core",
"dark": "Dark",
"invited you to join": "invited you to join",