feat(core): add pinned collections to all docs (#12269)
This commit is contained in:
parent
6eab1a6cb2
commit
61b99c5934
@ -12,7 +12,9 @@ export type DocExplorerContextType = {
|
|||||||
selectedDocIds$: LiveData<string[]>;
|
selectedDocIds$: LiveData<string[]>;
|
||||||
prevCheckAnchorId$?: LiveData<string | null>;
|
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>(
|
export const DocExplorerContext = createContext<DocExplorerContextType>(
|
||||||
@ -27,7 +29,6 @@ export const createDocExplorerContext = () =>
|
|||||||
selectMode$: new LiveData<boolean>(false),
|
selectMode$: new LiveData<boolean>(false),
|
||||||
selectedDocIds$: new LiveData<string[]>([]),
|
selectedDocIds$: new LiveData<string[]>([]),
|
||||||
prevCheckAnchorId$: new LiveData<string | null>(null),
|
prevCheckAnchorId$: new LiveData<string | null>(null),
|
||||||
filters$: new LiveData<ExplorerPreference['filters']>([]),
|
|
||||||
groupBy$: new LiveData<ExplorerPreference['groupBy']>(undefined),
|
groupBy$: new LiveData<ExplorerPreference['groupBy']>(undefined),
|
||||||
orderBy$: new LiveData<ExplorerPreference['orderBy']>(undefined),
|
orderBy$: new LiveData<ExplorerPreference['orderBy']>(undefined),
|
||||||
displayProperties$: new LiveData<ExplorerPreference['displayProperties']>(
|
displayProperties$: new LiveData<ExplorerPreference['displayProperties']>(
|
||||||
|
@ -2,7 +2,7 @@ import { IconButton, Menu, MenuItem, MenuSeparator } from '@affine/component';
|
|||||||
import type { FilterParams } from '@affine/core/modules/collection-rules';
|
import type { FilterParams } from '@affine/core/modules/collection-rules';
|
||||||
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
|
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
|
||||||
import { useI18n } from '@affine/i18n';
|
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 { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
|
||||||
import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties';
|
import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties';
|
||||||
@ -11,8 +11,10 @@ import * as styles from './styles.css';
|
|||||||
|
|
||||||
export const AddFilterMenu = ({
|
export const AddFilterMenu = ({
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
onAdd: (params: FilterParams) => void;
|
onAdd: (params: FilterParams) => void;
|
||||||
|
onBack?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const workspacePropertyService = useService(WorkspacePropertyService);
|
const workspacePropertyService = useService(WorkspacePropertyService);
|
||||||
@ -20,9 +22,17 @@ export const AddFilterMenu = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.variableSelectTitleStyle}>
|
<div className={styles.selectHeaderContainer}>
|
||||||
{t['com.affine.filter']()}
|
{onBack && (
|
||||||
|
<IconButton onClick={onBack}>
|
||||||
|
<ArrowLeftBigIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<div className={styles.variableSelectTitleStyle}>
|
||||||
|
{t['com.affine.filter']()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
|
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type { FilterParams } from '@affine/core/modules/collection-rules';
|
import type { FilterParams } from '@affine/core/modules/collection-rules';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { AddFilter } from './add-filter';
|
import { AddFilter } from './add-filter';
|
||||||
import { Filter } from './filter';
|
import { Filter } from './filter';
|
||||||
@ -6,9 +7,11 @@ import * as styles from './styles.css';
|
|||||||
|
|
||||||
export const Filters = ({
|
export const Filters = ({
|
||||||
filters,
|
filters,
|
||||||
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
filters: FilterParams[];
|
filters: FilterParams[];
|
||||||
|
className?: string;
|
||||||
onChange?: (filters: FilterParams[]) => void;
|
onChange?: (filters: FilterParams[]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const handleDelete = (index: number) => {
|
const handleDelete = (index: number) => {
|
||||||
@ -20,7 +23,7 @@ export const Filters = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={clsx(styles.container, className)}>
|
||||||
{filters.map((filter, index) => {
|
{filters.map((filter, index) => {
|
||||||
return (
|
return (
|
||||||
<Filter
|
<Filter
|
||||||
|
@ -30,12 +30,24 @@ export const filterItemCloseStyle = style({
|
|||||||
marginLeft: '4px',
|
marginLeft: '4px',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const selectHeaderContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
margin: '2px 2px',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
});
|
||||||
|
|
||||||
export const variableSelectTitleStyle = style({
|
export const variableSelectTitleStyle = style({
|
||||||
margin: '2px 12px',
|
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
fontSize: cssVar('fontSm'),
|
fontSize: cssVar('fontSm'),
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVar('textPrimaryColor'),
|
||||||
|
selectors: {
|
||||||
|
'&:first-child': {
|
||||||
|
marginLeft: '12px',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const filterTypeItemIcon = style({
|
export const filterTypeItemIcon = style({
|
||||||
|
@ -34,6 +34,7 @@ export const body = style({
|
|||||||
export const scrollArea = style({
|
export const scrollArea = style({
|
||||||
height: 0,
|
height: 0,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
paddingTop: '24px',
|
||||||
});
|
});
|
||||||
|
|
||||||
// group
|
// group
|
||||||
@ -45,7 +46,7 @@ export const docItem = style({
|
|||||||
transition: 'width 0.2s ease-in-out',
|
transition: 'width 0.2s ease-in-out',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const filterArea = style({
|
export const pinnedCollection = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 8,
|
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,
|
||||||
|
});
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component';
|
import {
|
||||||
|
Button,
|
||||||
|
Masonry,
|
||||||
|
type MasonryGroup,
|
||||||
|
useConfirmModal,
|
||||||
|
usePromptModal,
|
||||||
|
} from '@affine/component';
|
||||||
import {
|
import {
|
||||||
createDocExplorerContext,
|
createDocExplorerContext,
|
||||||
DocExplorerContext,
|
DocExplorerContext,
|
||||||
@ -7,6 +13,10 @@ import { DocListItem } from '@affine/core/components/explorer/docs-view/doc-list
|
|||||||
import { Filters } from '@affine/core/components/filter';
|
import { Filters } from '@affine/core/components/filter';
|
||||||
import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar';
|
import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar';
|
||||||
import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types';
|
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 { CollectionRulesService } from '@affine/core/modules/collection-rules';
|
||||||
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
|
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
|
||||||
import { DocsService } from '@affine/core/modules/doc';
|
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 * as styles from './all-page.css';
|
||||||
import { AllDocsHeader } from './all-page-header';
|
import { AllDocsHeader } from './all-page-header';
|
||||||
import { MigrationAllDocsDataNotification } from './migration-data';
|
import { MigrationAllDocsDataNotification } from './migration-data';
|
||||||
|
import { PinnedCollections } from './pinned-collections';
|
||||||
|
|
||||||
const GroupHeader = memo(function GroupHeader({
|
const GroupHeader = memo(function GroupHeader({
|
||||||
groupId,
|
groupId,
|
||||||
@ -100,11 +111,34 @@ const DocListItemComponent = memo(function DocListItemComponent({
|
|||||||
export const AllPage = () => {
|
export const AllPage = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const docsService = useService(DocsService);
|
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 [explorerContextValue] = useState(createDocExplorerContext);
|
||||||
|
|
||||||
const view = useLiveData(explorerContextValue.view$);
|
const view = useLiveData(explorerContextValue.view$);
|
||||||
const filters = useLiveData(explorerContextValue.filters$);
|
|
||||||
const groupBy = useLiveData(explorerContextValue.groupBy$);
|
const groupBy = useLiveData(explorerContextValue.groupBy$);
|
||||||
const orderBy = useLiveData(explorerContextValue.orderBy$);
|
const orderBy = useLiveData(explorerContextValue.orderBy$);
|
||||||
const groups = useLiveData(explorerContextValue.groups$);
|
const groups = useLiveData(explorerContextValue.groups$);
|
||||||
@ -112,8 +146,8 @@ export const AllPage = () => {
|
|||||||
const collapsedGroups = useLiveData(explorerContextValue.collapsedGroups$);
|
const collapsedGroups = useLiveData(explorerContextValue.collapsedGroups$);
|
||||||
const selectMode = useLiveData(explorerContextValue.selectMode$);
|
const selectMode = useLiveData(explorerContextValue.selectMode$);
|
||||||
|
|
||||||
|
const { openPromptModal } = usePromptModal();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
|
||||||
const masonryItems = useMemo(() => {
|
const masonryItems = useMemo(() => {
|
||||||
const items = groups.map((group: any) => {
|
const items = groups.map((group: any) => {
|
||||||
return {
|
return {
|
||||||
@ -143,12 +177,21 @@ export const AllPage = () => {
|
|||||||
const collectionRulesService = useService(CollectionRulesService);
|
const collectionRulesService = useService(CollectionRulesService);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = collectionRulesService
|
const subscription = collectionRulesService
|
||||||
.watch({
|
.watch(
|
||||||
filters:
|
// collection filters and temp filters can't exist at the same time
|
||||||
filters && filters.length > 0
|
selectedCollectionInfo
|
||||||
? filters
|
? {
|
||||||
: [
|
filters: selectedCollectionInfo.rules.filters,
|
||||||
// if no filters are present, match all non-trash documents
|
groupBy,
|
||||||
|
orderBy,
|
||||||
|
extraAllowList: selectedCollectionInfo.allowList,
|
||||||
|
extraFilters: [
|
||||||
|
{
|
||||||
|
type: 'system',
|
||||||
|
key: 'empty-journal',
|
||||||
|
method: 'is',
|
||||||
|
value: 'false',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'system',
|
type: 'system',
|
||||||
key: 'trash',
|
key: 'trash',
|
||||||
@ -156,23 +199,38 @@ export const AllPage = () => {
|
|||||||
value: 'false',
|
value: 'false',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
groupBy,
|
}
|
||||||
orderBy,
|
: {
|
||||||
extraFilters: [
|
filters:
|
||||||
{
|
tempFilters && tempFilters.length > 0
|
||||||
type: 'system',
|
? tempFilters
|
||||||
key: 'empty-journal',
|
: [
|
||||||
method: 'is',
|
// if no filters are present, match all non-trash documents
|
||||||
value: 'false',
|
{
|
||||||
},
|
type: 'system',
|
||||||
{
|
key: 'trash',
|
||||||
type: 'system',
|
method: 'is',
|
||||||
key: 'trash',
|
value: 'false',
|
||||||
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({
|
.subscribe({
|
||||||
next: result => {
|
next: result => {
|
||||||
explorerContextValue.groups$.next(result.groups);
|
explorerContextValue.groups$.next(result.groups);
|
||||||
@ -186,10 +244,12 @@ export const AllPage = () => {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
collectionRulesService,
|
collectionRulesService,
|
||||||
explorerContextValue.groups$,
|
explorerContextValue,
|
||||||
filters,
|
|
||||||
groupBy,
|
groupBy,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
selectedCollection,
|
||||||
|
selectedCollectionInfo,
|
||||||
|
tempFilters,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -206,12 +266,9 @@ export const AllPage = () => {
|
|||||||
};
|
};
|
||||||
}, [explorerContextValue]);
|
}, [explorerContextValue]);
|
||||||
|
|
||||||
const handleFilterChange = useCallback(
|
const handleFilterChange = useCallback((filters: FilterParams[]) => {
|
||||||
(filters: FilterParams[]) => {
|
setTempFilters(filters);
|
||||||
explorerContextValue.filters$.next(filters);
|
}, []);
|
||||||
},
|
|
||||||
[explorerContextValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCloseFloatingToolbar = useCallback(() => {
|
const handleCloseFloatingToolbar = useCallback(() => {
|
||||||
explorerContextValue.selectMode$.next(false);
|
explorerContextValue.selectMode$.next(false);
|
||||||
@ -246,6 +303,42 @@ export const AllPage = () => {
|
|||||||
});
|
});
|
||||||
}, [docsService.list, openConfirmModal, selectedDocIds, t]);
|
}, [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 (
|
return (
|
||||||
<DocExplorerContext.Provider value={explorerContextValue}>
|
<DocExplorerContext.Provider value={explorerContextValue}>
|
||||||
<ViewTitle title={t['All pages']()} />
|
<ViewTitle title={t['All pages']()} />
|
||||||
@ -255,10 +348,40 @@ export const AllPage = () => {
|
|||||||
</ViewHeader>
|
</ViewHeader>
|
||||||
<ViewBody>
|
<ViewBody>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.filterArea}>
|
<MigrationAllDocsDataNotification />
|
||||||
<MigrationAllDocsDataNotification />
|
<div className={styles.pinnedCollection}>
|
||||||
<Filters filters={filters ?? []} onChange={handleFilterChange} />
|
<PinnedCollections
|
||||||
|
activeCollectionId={selectedCollectionId}
|
||||||
|
onClickAll={() => setSelectedCollectionId(null)}
|
||||||
|
onClickCollection={collectionId => {
|
||||||
|
setSelectedCollectionId(collectionId);
|
||||||
|
setTempFilters([]);
|
||||||
|
}}
|
||||||
|
onAddFilter={params => {
|
||||||
|
setSelectedCollectionId(null);
|
||||||
|
setTempFilters([...(tempFilters ?? []), params]);
|
||||||
|
}}
|
||||||
|
hiddenAdd={tempFilters.length > 0}
|
||||||
|
/>
|
||||||
</div>
|
</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}>
|
<div className={styles.scrollArea}>
|
||||||
<Masonry
|
<Masonry
|
||||||
items={masonryItems}
|
items={masonryItems}
|
||||||
|
@ -6,6 +6,16 @@ export const migrationDataNotificationContainer = style({
|
|||||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||||
padding: '12px 240px 12px 12px',
|
padding: '12px 240px 12px 12px',
|
||||||
borderRadius: '8px',
|
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({
|
export const migrationDataNotificationTitle = style({
|
||||||
|
@ -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,
|
||||||
|
});
|
@ -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} />
|
||||||
|
);
|
||||||
|
};
|
@ -1,20 +1,27 @@
|
|||||||
export { Collection } from './entities/collection';
|
export { Collection } from './entities/collection';
|
||||||
export type { CollectionMeta } from './services/collection';
|
export type { CollectionMeta } from './services/collection';
|
||||||
export { CollectionService } from './services/collection';
|
export { CollectionService } from './services/collection';
|
||||||
|
export { PinnedCollectionService } from './services/pinned-collection';
|
||||||
export type { CollectionInfo } from './stores/collection';
|
export type { CollectionInfo } from './stores/collection';
|
||||||
|
export type { PinnedCollectionRecord } from './stores/pinned-collection';
|
||||||
|
|
||||||
import { type Framework } from '@toeverything/infra';
|
import { type Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
import { CollectionRulesService } from '../collection-rules';
|
import { CollectionRulesService } from '../collection-rules';
|
||||||
|
import { WorkspaceDBService } from '../db';
|
||||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||||
import { Collection } from './entities/collection';
|
import { Collection } from './entities/collection';
|
||||||
import { CollectionService } from './services/collection';
|
import { CollectionService } from './services/collection';
|
||||||
|
import { PinnedCollectionService } from './services/pinned-collection';
|
||||||
import { CollectionStore } from './stores/collection';
|
import { CollectionStore } from './stores/collection';
|
||||||
|
import { PinnedCollectionStore } from './stores/pinned-collection';
|
||||||
|
|
||||||
export function configureCollectionModule(framework: Framework) {
|
export function configureCollectionModule(framework: Framework) {
|
||||||
framework
|
framework
|
||||||
.scope(WorkspaceScope)
|
.scope(WorkspaceScope)
|
||||||
.service(CollectionService, [CollectionStore])
|
.service(CollectionService, [CollectionStore])
|
||||||
.store(CollectionStore, [WorkspaceService])
|
.store(CollectionStore, [WorkspaceService])
|
||||||
.entity(Collection, [CollectionStore, CollectionRulesService]);
|
.entity(Collection, [CollectionStore, CollectionRulesService])
|
||||||
|
.store(PinnedCollectionStore, [WorkspaceDBService])
|
||||||
|
.service(PinnedCollectionService, [PinnedCollectionStore]);
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -42,6 +42,10 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
|||||||
isDeleted: f.boolean().optional(),
|
isDeleted: f.boolean().optional(),
|
||||||
// we will keep deleted properties in the database, for override legacy data
|
// we will keep deleted properties in the database, for override legacy data
|
||||||
},
|
},
|
||||||
|
pinnedCollections: {
|
||||||
|
collectionId: f.string().primaryKey(),
|
||||||
|
index: f.string(),
|
||||||
|
},
|
||||||
} as const satisfies DBSchemaBuilder;
|
} as const satisfies DBSchemaBuilder;
|
||||||
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
|
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
"el-GR": 96,
|
"el-GR": 96,
|
||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 96,
|
"es-AR": 96,
|
||||||
"es-CL": 98,
|
"es-CL": 97,
|
||||||
"es": 96,
|
"es": 96,
|
||||||
"fa": 96,
|
"fa": 96,
|
||||||
"fr": 96,
|
"fr": 96,
|
||||||
|
@ -6968,6 +6968,10 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Select checkbox`
|
* `Select checkbox`
|
||||||
*/
|
*/
|
||||||
["com.affine.all-docs.quick-action.select"](): string;
|
["com.affine.all-docs.quick-action.select"](): string;
|
||||||
|
/**
|
||||||
|
* `All`
|
||||||
|
*/
|
||||||
|
["com.affine.all-docs.pinned-collection.all"](): string;
|
||||||
/**
|
/**
|
||||||
* `core`
|
* `core`
|
||||||
*/
|
*/
|
||||||
|
@ -1739,6 +1739,7 @@
|
|||||||
"com.affine.all-docs.quick-action.split": "Open in split view",
|
"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.tab": "Open in new tab",
|
||||||
"com.affine.all-docs.quick-action.select": "Select checkbox",
|
"com.affine.all-docs.quick-action.select": "Select checkbox",
|
||||||
|
"com.affine.all-docs.pinned-collection.all": "All",
|
||||||
"core": "core",
|
"core": "core",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"invited you to join": "invited you to join",
|
"invited you to join": "invited you to join",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user