refactor(core): favorite adapter (#6285)

1. abstraction over favourites that supports different type of resources
2. sorting abstraction
This commit is contained in:
pengx17
2024-03-29 04:04:07 +00:00
parent 296362ced1
commit 5490944d04
26 changed files with 450 additions and 213 deletions

View File

@@ -5,8 +5,8 @@ import type {
PageInfoCustomPropertyMeta,
} from '@affine/core/modules/workspace/properties/schema';
import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
import { createFractionalIndexingSortableHelper } from '@affine/core/utils';
import { DebugLogger } from '@affine/debug';
import { generateKeyBetween } from 'fractional-indexing';
import { nanoid } from 'nanoid';
import { getDefaultIconName } from './icons-mapping';
@@ -147,6 +147,11 @@ export class PagePropertiesManager {
this.ensureRequiredProperties();
}
readonly sorter = createFractionalIndexingSortableHelper<
PageInfoCustomProperty,
string | number
>(this);
// prevent infinite loop
private ensuring = false;
ensureRequiredProperties() {
@@ -163,6 +168,22 @@ export class PagePropertiesManager {
this.ensuring = false;
}
getItems() {
return Object.values(this.getCustomProperties());
}
getItemOrder(item: PageInfoCustomProperty): string {
return item.order;
}
setItemOrder(item: PageInfoCustomProperty, order: string) {
item.order = order;
}
getItemId(item: PageInfoCustomProperty) {
return item.id;
}
get workspace() {
return this.adapter.workspace;
}
@@ -204,16 +225,6 @@ export class PagePropertiesManager {
: {};
}
getOrderedCustomProperties() {
return Object.values(this.getCustomProperties()).sort((a, b) =>
a.order > b.order ? 1 : a.order < b.order ? -1 : 0
);
}
largestOrder() {
return this.getOrderedCustomProperties().at(-1)?.order ?? null;
}
getCustomPropertyMeta(id: string): PageInfoCustomPropertyMeta | undefined {
return this.metaManager.customPropertiesSchema[id];
}
@@ -234,7 +245,7 @@ export class PagePropertiesManager {
return;
}
const newOrder = generateKeyBetween(this.largestOrder(), null);
const newOrder = this.sorter.getNewItemOrder();
if (this.properties!.custom[id]) {
logger.warn(`custom property ${id} already exists`);
}
@@ -247,21 +258,6 @@ export class PagePropertiesManager {
};
}
moveCustomProperty(from: number, to: number) {
this.ensureRequiredProperties();
// move from -> to means change from's order to a new order between to and to -1/+1
const properties = this.getOrderedCustomProperties();
const fromProperty = properties[from];
const toProperty = properties[to];
const toNextProperty = properties[from < to ? to + 1 : to - 1];
const args: [string?, string?] =
from < to
? [toProperty.order, toNextProperty?.order ?? null]
: [toNextProperty?.order ?? null, toProperty.order];
const newOrder = generateKeyBetween(...args);
this.properties!.custom[fromProperty.id].order = newOrder;
}
hasCustomProperty(id: string) {
return !!this.properties?.custom[id];
}

View File

@@ -101,10 +101,7 @@ interface SortablePropertiesProps {
const SortableProperties = ({ children }: SortablePropertiesProps) => {
const manager = useContext(managerContext);
const properties = useMemo(
() => manager.getOrderedCustomProperties(),
[manager]
);
const properties = useMemo(() => manager.sorter.getOrderedItems(), [manager]);
const editingItem = useAtomValue(editingPropertyAtom);
const draggable = !manager.readonly && !editingItem;
const sensors = useSensors(
@@ -128,15 +125,12 @@ const SortableProperties = ({ children }: SortablePropertiesProps) => {
return;
}
const { active, over } = event;
const fromIndex = properties.findIndex(p => p.id === active.id);
const toIndex = properties.findIndex(p => p.id === over?.id);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
manager.moveCustomProperty(fromIndex, toIndex);
setLocalProperties(manager.getOrderedCustomProperties());
if (over) {
manager.sorter.move(active.id, over.id);
}
setLocalProperties(manager.sorter.getOrderedItems());
},
[manager, properties, draggable]
[manager, draggable]
);
const filteredProperties = useMemo(
@@ -636,7 +630,7 @@ export const PagePropertiesTableHeader = ({
onOpenChange(!open);
}, [onOpenChange, open]);
const properties = manager.getOrderedCustomProperties();
const properties = manager.sorter.getOrderedItems();
return (
<div className={clsx(styles.tableHeader, className)} style={style}>

View File

@@ -1,10 +1,9 @@
import { FavoriteTag } from '@affine/core/components/page-list';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { toast } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { useService, Workspace } from '@toeverything/infra';
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import { useCallback } from 'react';
export interface FavoriteButtonProps {
@@ -16,24 +15,19 @@ export const useFavorite = (pageId: string) => {
const workspace = useService(Workspace);
const docCollection = workspace.docCollection;
const currentPage = docCollection.getDoc(pageId);
const favAdapter = useService(FavoriteItemsAdapter);
assertExists(currentPage);
const pageMeta = useBlockSuiteDocMeta(docCollection).find(
meta => meta.id === pageId
);
const favorite = pageMeta?.favorite ?? false;
const { toggleFavorite: _toggleFavorite } =
useBlockSuiteMetaHelper(docCollection);
const favorite = useLiveData(favAdapter.isFavorite$(pageId, 'doc'));
const toggleFavorite = useCallback(() => {
_toggleFavorite(pageId);
favAdapter.toggle(pageId, 'doc');
toast(
favorite
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [favorite, pageId, t, _toggleFavorite]);
}, [favorite, pageId, t, favAdapter]);
return { favorite, toggleFavorite };
};

View File

@@ -1,10 +1,8 @@
import { toast } from '@affine/component';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { CollectionService } from '@affine/core/modules/collection';
import type { Tag } from '@affine/core/modules/tag';
import { Workbench } from '@affine/core/modules/workbench';
import type { Collection, Filter } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -30,13 +28,7 @@ import {
} from './page-list-header';
const usePageOperationsRenderer = () => {
const currentWorkspace = useService(Workspace);
const { setTrashModal } = useTrashModalHelper(currentWorkspace.docCollection);
const { toggleFavorite, duplicate } = useBlockSuiteMetaHelper(
currentWorkspace.docCollection
);
const t = useAFFiNEI18N();
const workbench = useService(Workbench);
const collectionService = useService(CollectionService);
const removeFromAllowList = useCallback(
(id: string) => {
@@ -45,57 +37,18 @@ const usePageOperationsRenderer = () => {
},
[collectionService, t]
);
const pageOperationsRenderer = useCallback(
(page: DocMeta, isInAllowList?: boolean) => {
const onDisablePublicSharing = () => {
toast('Successfully disabled', {
portal: document.body,
});
};
return (
<PageOperationCell
favorite={!!page.favorite}
isPublic={!!page.isPublic}
page={page}
isInAllowList={isInAllowList}
onDisablePublicSharing={onDisablePublicSharing}
link={`/workspace/${currentWorkspace.id}/${page.id}`}
onOpenInSplitView={() => workbench.openPage(page.id, { at: 'tail' })}
onDuplicate={() => {
duplicate(page.id, false);
}}
onRemoveToTrash={() =>
setTrashModal({
open: true,
pageIds: [page.id],
pageTitles: [page.title],
})
}
onToggleFavoritePage={() => {
const status = page.favorite;
toggleFavorite(page.id);
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}}
onRemoveFromAllowList={() => removeFromAllowList(page.id)}
/>
);
},
[
currentWorkspace.id,
workbench,
duplicate,
setTrashModal,
toggleFavorite,
t,
removeFromAllowList,
]
[removeFromAllowList]
);
return pageOperationsRenderer;
};

View File

@@ -1,5 +1,6 @@
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
@@ -154,6 +155,8 @@ export const useFavoriteGroupDefinitions = <
T extends ListItem,
>(): ItemGroupDefinition<T>[] => {
const t = useAFFiNEI18N();
const favAdapter = useService(FavoriteItemsAdapter);
const favourites = useLiveData(favAdapter.favorites$);
return useMemo(
() => [
{
@@ -166,7 +169,7 @@ export const useFavoriteGroupDefinitions = <
icon={<FavoritedIcon className={styles.favouritedIcon} />}
/>
),
match: item => !!(item as DocMeta).favorite,
match: item => favourites.some(fav => fav.id === item.id),
},
{
id: 'notFavourited',
@@ -178,10 +181,10 @@ export const useFavoriteGroupDefinitions = <
icon={<FavoriteIcon className={styles.notFavouritedIcon} />}
/>
),
match: item => !(item as DocMeta).favorite,
match: item => !favourites.some(fav => fav.id === item.id),
},
],
[t]
[t, favourites]
);
};

View File

@@ -4,9 +4,14 @@ import {
Menu,
MenuIcon,
MenuItem,
toast,
Tooltip,
} from '@affine/component';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { Workbench } from '@affine/core/modules/workbench';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
@@ -23,6 +28,8 @@ import {
ResetIcon,
SplitViewIcon,
} from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
@@ -37,37 +44,61 @@ import type { AllPageListConfig } from './view';
import { useEditCollection, useEditCollectionName } from './view';
export interface PageOperationCellProps {
favorite: boolean;
isPublic: boolean;
link: string;
page: DocMeta;
isInAllowList?: boolean;
onToggleFavoritePage: () => void;
onRemoveToTrash: () => void;
onDuplicate: () => void;
onDisablePublicSharing: () => void;
onOpenInSplitView: () => void;
onRemoveFromAllowList?: () => void;
}
export const PageOperationCell = ({
favorite,
isPublic,
isInAllowList,
link,
onToggleFavoritePage,
onRemoveToTrash,
onDuplicate,
onDisablePublicSharing,
onOpenInSplitView,
page,
onRemoveFromAllowList,
}: PageOperationCellProps) => {
const t = useAFFiNEI18N();
const currentWorkspace = useService(Workspace);
const { appSettings } = useAppSettingHelper();
const { setTrashModal } = useTrashModalHelper(currentWorkspace.docCollection);
const [openDisableShared, setOpenDisableShared] = useState(false);
const favAdapter = useService(FavoriteItemsAdapter);
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const workbench = useService(Workbench);
const { duplicate } = useBlockSuiteMetaHelper(currentWorkspace.docCollection);
const onDisablePublicSharing = useCallback(() => {
toast('Successfully disabled', {
portal: document.body,
});
}, []);
const onRemoveToTrash = useCallback(() => {
setTrashModal({
open: true,
pageIds: [page.id],
pageTitles: [page.title],
});
}, [page.id, page.title, setTrashModal]);
const onOpenInSplitView = useCallback(() => {
workbench.openPage(page.id, { at: 'tail' });
}, [page.id, workbench]);
const onToggleFavoritePage = useCallback(() => {
const status = favAdapter.isFavorite(page.id, 'doc');
favAdapter.toggle(page.id, 'doc');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [page.id, favAdapter, t]);
const onDuplicate = useCallback(() => {
duplicate(page.id, false);
}, [duplicate, page.id]);
const OperationMenu = (
<>
{isPublic && (
{page.isPublic && (
<DisablePublicSharing
data-testid="disable-public-sharing"
onSelect={() => {
@@ -91,7 +122,7 @@ export const PageOperationCell = ({
onClick={onToggleFavoritePage}
preFix={
<MenuIcon>
{favorite ? (
{favourite ? (
<FavoritedIcon style={{ color: 'var(--affine-primary-color)' }} />
) : (
<FavoriteIcon />
@@ -99,7 +130,7 @@ export const PageOperationCell = ({
</MenuIcon>
}
>
{favorite
{favourite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add']()}
</MenuItem>
@@ -121,7 +152,7 @@ export const PageOperationCell = ({
<Link
className={styles.clearLinkStyle}
onClick={stopPropagationWithoutPrevent}
to={link}
to={`/workspace/${currentWorkspace.id}/${page.id}`}
target={'_blank'}
rel="noopener noreferrer"
>
@@ -157,10 +188,10 @@ export const PageOperationCell = ({
<ColWrapper
hideInSmallContainer
data-testid="page-list-item-favorite"
data-favorite={favorite ? true : undefined}
data-favorite={favourite ? true : undefined}
className={styles.favoriteCell}
>
<FavoriteTag onClick={onToggleFavoritePage} active={favorite} />
<FavoriteTag onClick={onToggleFavoritePage} active={favourite} />
</ColWrapper>
<ColWrapper alignment="start">
<Menu

View File

@@ -21,6 +21,7 @@ export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
export type PageDataForFilter = {
meta: DocMeta;
favorite: boolean;
publicMode: undefined | 'page' | 'edgeless';
};
@@ -33,13 +34,13 @@ export const filterPage = (collection: Collection, page: PageDataForFilter) => {
export const filterPageByRules = (
rules: Filter[],
allowList: string[],
{ meta, publicMode }: PageDataForFilter
{ meta, publicMode, favorite }: PageDataForFilter
) => {
if (allowList?.includes(meta.id)) {
return true;
}
return filterByFilterList(rules, {
'Is Favourited': !!meta.favorite,
'Is Favourited': !!favorite,
'Is Public': !!publicMode,
Created: meta.createDate,
Updated: meta.updatedDate ?? meta.createDate,

View File

@@ -1,6 +1,7 @@
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import type { Collection, Filter } from '@affine/env/filter';
import type { DocMeta } from '@blocksuite/store';
import type { Workspace } from '@toeverything/infra';
import { useLiveData, useService, type Workspace } from '@toeverything/infra';
import { useMemo } from 'react';
import { usePublicPages } from '../../hooks/affine/use-is-shared-page';
@@ -16,6 +17,8 @@ export const useFilteredPageMetas = (
} = {}
) => {
const { getPublicMode } = usePublicPages(workspace);
const favAdapter = useService(FavoriteItemsAdapter);
const favoriteItems = useLiveData(favAdapter.favorites$);
const filteredPageMetas = useMemo(
() =>
@@ -29,6 +32,7 @@ export const useFilteredPageMetas = (
}
const pageData = {
meta: pageMeta,
favorite: favoriteItems.some(fav => fav.id === pageMeta.id),
publicMode: getPublicMode(pageMeta.id),
};
if (
@@ -49,6 +53,7 @@ export const useFilteredPageMetas = (
options.trash,
options.filters,
options.collection,
favoriteItems,
getPublicMode,
]
);

View File

@@ -1,8 +1,10 @@
import { Menu } from '@affine/component';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useCallback } from 'react';
@@ -34,6 +36,8 @@ export const PagesMode = ({
allPageListConfig: AllPageListConfig;
}) => {
const t = useAFFiNEI18N();
const favAdapter = useService(FavoriteItemsAdapter);
const favorites = useLiveData(favAdapter.favorites$);
const {
showFilter,
filters,
@@ -45,6 +49,7 @@ export const PagesMode = ({
allPageListConfig.allPages.map(meta => ({
meta,
publicMode: allPageListConfig.getPublicMode(meta.id),
favorite: favorites.some(f => f.id === meta.id),
}))
);
const pageHeaderColsDef = usePageHeaderColsDef();

View File

@@ -1,3 +1,4 @@
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import type { Collection } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -9,6 +10,7 @@ import {
ToggleCollapseIcon,
} from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useState } from 'react';
@@ -42,6 +44,8 @@ export const RulesMode = ({
const allowListPages: DocMeta[] = [];
const rulesPages: DocMeta[] = [];
const [showTips, setShowTips] = useState(false);
const favAdapter = useService(FavoriteItemsAdapter);
const favorites = useLiveData(favAdapter.favorites$);
useEffect(() => {
setShowTips(!localStorage.getItem('hide-rules-mode-include-page-tips'));
}, []);
@@ -56,6 +60,7 @@ export const RulesMode = ({
const pageData = {
meta,
publicMode: allPageListConfig.getPublicMode(meta.id),
favorite: favorites.some(f => f.id === meta.id),
};
if (
collection.filterList.length &&

View File

@@ -1,8 +1,10 @@
import { Button, Menu } from '@affine/component';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
@@ -35,6 +37,8 @@ export const SelectPage = ({
const clearSelected = useCallback(() => {
onChange([]);
}, []);
const favAdapter = useService(FavoriteItemsAdapter);
const favourites = useLiveData(favAdapter.favorites$);
const {
clickFilter,
createFilter,
@@ -46,6 +50,7 @@ export const SelectPage = ({
allPageListConfig.allPages.map(meta => ({
meta,
publicMode: allPageListConfig.getPublicMode(meta.id),
favorite: favourites.some(fav => fav.id === meta.id),
}))
);
const { searchText, updateSearchText, searchedList } =

View File

@@ -7,6 +7,7 @@ import {
stopPropagation,
} from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons';
@@ -40,9 +41,12 @@ const CollectionRenderer = ({
const [collapsed, setCollapsed] = useState(true);
const [open, setOpen] = useState(false);
const collectionService = useService(CollectionService);
const favAdapter = useService(FavoriteItemsAdapter);
const t = useAFFiNEI18N();
const dragItemId = getDropItemId('collections', collection.id);
const favourites = useLiveData(favAdapter.favorites$);
const removeFromAllowList = useCallback(
(id: string) => {
collectionService.updateCollection(collection.id, () => ({
@@ -85,6 +89,7 @@ const CollectionRenderer = ({
const pageData = {
meta,
publicMode: config.getPublicMode(meta.id),
favorite: favourites.some(fav => fav.id === meta.id),
};
return filterPage(collection, pageData);
});

View File

@@ -2,13 +2,13 @@ import { toast } from '@affine/component';
import { IconButton } from '@affine/component/ui/button';
import { Menu } from '@affine/component/ui/menu';
import { Workbench } from '@affine/core/modules/workbench';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MoreHorizontalIcon } from '@blocksuite/icons';
import type { DocCollection } from '@blocksuite/store';
import { useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
import { OperationItems } from './operation-item';
@@ -38,7 +38,8 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
const t = useAFFiNEI18N();
const { createLinkedPage } = usePageHelper(docCollection);
const { setTrashModal } = useTrashModalHelper(docCollection);
const { removeFromFavorite } = useBlockSuiteMetaHelper(docCollection);
const favAdapter = useService(FavoriteItemsAdapter);
const workbench = useService(Workbench);
const handleRename = useCallback(() => {
@@ -51,9 +52,9 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
}, [createLinkedPage, pageId, t]);
const handleRemoveFromFavourites = useCallback(() => {
removeFromFavorite(pageId);
favAdapter.remove(pageId, 'doc');
toast(t['com.affine.toastMessage.removedFavorites']());
}, [pageId, removeFromFavorite, t]);
}, [favAdapter, pageId, t]);
const handleDelete = useCallback(() => {
setTrashModal({

View File

@@ -1,8 +1,9 @@
import { IconButton } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { PlusIcon } from '@blocksuite/icons';
import type { DocCollection } from '@blocksuite/store';
import { useService } from '@toeverything/infra';
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
@@ -16,7 +17,7 @@ export const AddFavouriteButton = ({
pageId,
}: AddFavouriteButtonProps) => {
const { createPage, createLinkedPage } = usePageHelper(docCollection);
const { setDocMeta } = useDocMetaHelper(docCollection);
const favAdapter = useService(FavoriteItemsAdapter);
const handleAddFavorite = useAsyncCallback(
async e => {
if (pageId) {
@@ -26,10 +27,10 @@ export const AddFavouriteButton = ({
} else {
const page = createPage();
page.load();
setDocMeta(page.id, { favorite: true });
favAdapter.set(page.id, 'doc', true);
}
},
[pageId, createLinkedPage, createPage, setDocMeta]
[pageId, createLinkedPage, createPage, favAdapter]
);
return (

View File

@@ -1,6 +1,8 @@
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import type { DocMeta } from '@blocksuite/store';
import { useDroppable } from '@dnd-kit/core';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { getDropItemId } from '../../../../hooks/affine/use-sidebar-drag';
@@ -15,11 +17,16 @@ export const FavoriteList = ({
docCollection: workspace,
}: FavoriteListProps) => {
const metas = useBlockSuiteDocMeta(workspace);
const favAdapter = useService(FavoriteItemsAdapter);
const dropItemId = getDropItemId('favorites');
const favoriteList = useMemo(
() => metas.filter(p => p.favorite && !p.trash),
[metas]
const favourites = useLiveData(
favAdapter.favorites$.map(favourites =>
favourites.filter(fav => {
const meta = metas.find(m => m.id === fav.id);
return meta && !meta.trash;
})
)
);
const metaMapping = useMemo(
@@ -45,7 +52,7 @@ export const FavoriteList = ({
ref={setNodeRef}
data-over={isOver}
>
{favoriteList.map((pageMeta, index) => {
{favourites.map((pageMeta, index) => {
return (
<FavouritePage
key={`${pageMeta}-${index}`}
@@ -57,7 +64,7 @@ export const FavoriteList = ({
/>
);
})}
{favoriteList.length === 0 && <EmptyItem />}
{favourites.length === 0 && <EmptyItem />}
</div>
);
};

View File

@@ -2,13 +2,13 @@ import { toast } from '@affine/component';
import type { AllPageListConfig } from '@affine/core/components/page-list';
import { FavoriteTag } from '@affine/core/components/page-list';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DocMeta } from '@blocksuite/store';
import { useService, Workspace } from '@toeverything/infra';
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
import { usePublicPages } from './use-is-shared-page';
export const useAllPageListConfig = () => {
@@ -21,22 +21,29 @@ export const useAllPageListConfig = () => {
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
[pageMetas]
);
const { toggleFavorite } = useBlockSuiteMetaHelper(
currentWorkspace.docCollection
);
const favAdapter = useService(FavoriteItemsAdapter);
const t = useAFFiNEI18N();
const favoriteItems = useLiveData(favAdapter.favorites$);
const isActive = useCallback(
(page: DocMeta) => {
return favoriteItems.some(fav => fav.id === page.id);
},
[favoriteItems]
);
const onToggleFavoritePage = useCallback(
(page: DocMeta) => {
const status = page.favorite;
toggleFavorite(page.id);
const status = isActive(page);
favAdapter.toggle(page.id, 'doc');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
},
[t, toggleFavorite]
[favAdapter, isActive, t]
);
return useMemo<AllPageListConfig>(() => {
return {
allPages: pageMetas,
@@ -49,7 +56,7 @@ export const useAllPageListConfig = () => {
<FavoriteTag
style={{ marginRight: 8 }}
onClick={() => onToggleFavoritePage(page)}
active={!!page.favorite}
active={isActive(page)}
/>
);
},
@@ -60,6 +67,7 @@ export const useAllPageListConfig = () => {
getPublicMode,
currentWorkspace.docCollection,
pageMap,
isActive,
onToggleFavoritePage,
]);
};

View File

@@ -19,32 +19,6 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
const collectionService = useService(CollectionService);
const pageRecordList = useService(PageRecordList);
const addToFavorite = useCallback(
(pageId: string) => {
setDocMeta(pageId, {
favorite: true,
});
},
[setDocMeta]
);
const removeFromFavorite = useCallback(
(pageId: string) => {
setDocMeta(pageId, {
favorite: false,
});
},
[setDocMeta]
);
const toggleFavorite = useCallback(
(pageId: string) => {
const { favorite } = getDocMeta(pageId) ?? {};
setDocMeta(pageId, {
favorite: !favorite,
});
},
[getDocMeta, setDocMeta]
);
// TODO-Doma
// "Remove" may cause ambiguity here. Consider renaming as "moveToTrash".
const removeToTrash = useCallback(
@@ -126,7 +100,6 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
setDocMeta(newPage.id, {
tags: currentPageMeta.tags,
favorite: currentPageMeta.favorite,
});
const lastDigitRegex = /\((\d+)\)$/;
@@ -157,10 +130,6 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
publicPage,
cancelPublicPage,
addToFavorite,
removeFromFavorite,
toggleFavorite,
removeToTrash,
restoreFromTrash,
permanentlyDeletePage,

View File

@@ -1,5 +1,6 @@
import { toast } from '@affine/component';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
@@ -32,7 +33,9 @@ export function useRegisterBlocksuiteEditorCommands() {
assertExists(currentPage);
const pageMeta = getDocMeta(pageId);
assertExists(pageMeta);
const favorite = pageMeta.favorite ?? false;
const favAdapter = useService(FavoriteItemsAdapter);
const favorite = useLiveData(favAdapter.isFavorite$(pageId, 'doc'));
const trash = pageMeta.trash ?? false;
const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom);
@@ -44,7 +47,7 @@ export function useRegisterBlocksuiteEditorCommands() {
}));
}, [pageId, setPageHistoryModalState]);
const { toggleFavorite, restoreFromTrash, duplicate } =
const { restoreFromTrash, duplicate } =
useBlockSuiteMetaHelper(docCollection);
const exportHandler = useExportPage(currentPage);
const { setTrashModal } = useTrashModalHelper(docCollection);
@@ -94,7 +97,7 @@ export function useRegisterBlocksuiteEditorCommands() {
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add'](),
run() {
toggleFavorite(pageId);
favAdapter.toggle(pageId, 'doc');
toast(
favorite
? t['com.affine.cmdk.affine.editor.remove-from-favourites']()
@@ -246,11 +249,11 @@ export function useRegisterBlocksuiteEditorCommands() {
pageId,
restoreFromTrash,
t,
toggleFavorite,
trash,
isCloudWorkspace,
openHistoryModal,
duplicate,
page,
favAdapter,
]);
}

View File

@@ -1,12 +1,12 @@
import { toast } from '@affine/component';
import type { DraggableTitleCellData } from '@affine/core/components/page-list';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core';
import { useService, Workspace } from '@toeverything/infra';
import { useCallback } from 'react';
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
import { useTrashModalHelper } from './use-trash-modal-helper';
// Unique droppable IDs
@@ -69,10 +69,10 @@ export function getDragItemId(
export const useSidebarDrag = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useService(Workspace);
const favAdapter = useService(FavoriteItemsAdapter);
const workspace = currentWorkspace.docCollection;
const { setTrashModal } = useTrashModalHelper(workspace);
const { addToFavorite, removeFromFavorite } =
useBlockSuiteMetaHelper(workspace);
const { getDocMeta } = useDocMetaHelper(workspace);
const isDropArea = useCallback(
@@ -123,7 +123,7 @@ export const useSidebarDrag = () => {
const processFavouritesDrag = useCallback(
(e: DragEndEvent) => {
const { pageId } = e.active.data.current as DraggableTitleCellData;
const isFavourited = getDocMeta(pageId)?.favorite;
const isFavourited = favAdapter.isFavorite(pageId, 'doc');
const isFavouriteDrag = String(e.over?.id).startsWith(
DropPrefix.SidebarFavorites
);
@@ -131,11 +131,11 @@ export const useSidebarDrag = () => {
return toast(t['com.affine.collection.addPage.alreadyExists']());
}
processDrag(e, DropPrefix.SidebarFavorites, pageId => {
addToFavorite(pageId);
favAdapter.set(pageId, 'doc', true);
toast(t['com.affine.cmdk.affine.editor.add-to-favourites']());
});
},
[getDocMeta, processDrag, addToFavorite, t]
[processDrag, t, favAdapter]
);
const processRemoveDrag = useCallback(
@@ -146,7 +146,7 @@ export const useSidebarDrag = () => {
if (String(e.active.id).startsWith(DragPrefix.FavouriteListItem)) {
const pageId = e.active.data.current?.pageId;
removeFromFavorite(pageId);
favAdapter.remove(pageId, 'doc');
toast(t['com.affine.cmdk.affine.editor.remove-from-favourites']());
return;
}
@@ -155,7 +155,7 @@ export const useSidebarDrag = () => {
}
},
[removeFromFavorite, t]
[favAdapter, t]
);
return useCallback(

View File

@@ -18,6 +18,7 @@ import { TagService } from './tag';
import { Workbench } from './workbench';
import {
CurrentWorkspaceService,
FavoriteItemsAdapter,
WorkspaceLegacyProperties,
WorkspacePropertiesAdapter,
} from './workspace';
@@ -30,6 +31,7 @@ export function configureBusinessServices(services: ServiceCollection) {
.add(Navigator, [Workbench])
.add(RightSidebar, [GlobalState])
.add(WorkspacePropertiesAdapter, [Workspace])
.add(FavoriteItemsAdapter, [WorkspacePropertiesAdapter])
.add(CollectionService, [Workspace])
.add(WorkspaceLegacyProperties, [Workspace])
.add(TagService, [WorkspaceLegacyProperties, PageRecordList]);

View File

@@ -1,16 +1,17 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
// the adapter is to bridge the workspace rootdoc & native js bindings
import type { Y } from '@blocksuite/store';
import { createYProxy } from '@blocksuite/store';
import type { Workspace } from '@toeverything/infra';
import { createFractionalIndexingSortableHelper } from '@affine/core/utils';
import { createYProxy, type Y } from '@blocksuite/store';
import { LiveData, type Workspace } from '@toeverything/infra';
import { defaultsDeep } from 'lodash-es';
import { Observable } from 'rxjs';
import type {
WorkspaceAffineProperties,
WorkspaceFavoriteItem,
import {
PagePropertyType,
PageSystemPropertyId,
type WorkspaceAffineProperties,
type WorkspaceFavoriteItem,
} from './schema';
import { PagePropertyType, PageSystemPropertyId } from './schema';
const AFFINE_PROPERTIES_ID = 'affine:workspace-properties';
@@ -27,6 +28,7 @@ export class WorkspacePropertiesAdapter {
// provides a easy-to-use interface for workspace properties
public readonly proxy: WorkspaceAffineProperties;
public readonly properties: Y.Map<any>;
public readonly properties$: LiveData<WorkspaceAffineProperties>;
private ensuredRoot = false;
private ensuredPages = {} as Record<string, boolean>;
@@ -36,9 +38,25 @@ export class WorkspacePropertiesAdapter {
const rootDoc = workspace.docCollection.doc;
this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID);
this.proxy = createYProxy(this.properties);
this.properties$ = LiveData.from(
new Observable(observer => {
const update = () => {
requestAnimationFrame(() => {
observer.next(new Proxy(this.proxy, {}));
});
};
update();
this.properties.observeDeep(update);
return () => {
this.properties.unobserveDeep(update);
};
}),
this.proxy
);
}
private ensureRootProperties() {
public ensureRootProperties() {
if (this.ensuredRoot) {
return;
}
@@ -120,10 +138,6 @@ export class WorkspacePropertiesAdapter {
return this.pageProperties?.[pageId] ?? null;
}
isFavorite(id: string, type: WorkspaceFavoriteItem['type']) {
return this.favorites?.[id]?.type === type;
}
getJournalPageDateString(id: string) {
return this.pageProperties?.[id]?.system[PageSystemPropertyId.Journal]
?.value;
@@ -135,3 +149,119 @@ export class WorkspacePropertiesAdapter {
pageProperties!.system[PageSystemPropertyId.Journal].value = date;
}
}
export class FavoriteItemsAdapter {
constructor(private readonly adapter: WorkspacePropertiesAdapter) {
this.migrateFavorites();
}
readonly sorter = createFractionalIndexingSortableHelper<
WorkspaceFavoriteItem,
string
>(this);
static getFavItemKey(id: string, type: WorkspaceFavoriteItem['type']) {
return `${type}:${id}`;
}
favorites$ = this.adapter.properties$.map(() =>
this.getItems().filter(i => i.value)
);
getItems() {
return Object.values(this.adapter.favorites ?? {});
}
get favorites() {
return this.adapter.favorites;
}
get workspace() {
return this.adapter.workspace;
}
getItemId(item: WorkspaceFavoriteItem) {
return item.id;
}
getItemOrder(item: WorkspaceFavoriteItem) {
return item.order;
}
setItemOrder(item: WorkspaceFavoriteItem, order: string) {
item.order = order;
}
// read from the workspace meta and migrate to the properties
private migrateFavorites() {
// only migrate if favorites is empty
if (Object.keys(this.favorites ?? {}).length > 0) {
return;
}
// old favorited pages
const oldFavorites = this.workspace.docCollection.meta.docMetas
.filter(meta => meta.favorite)
.map(meta => meta.id);
this.adapter.transact(() => {
for (const id of oldFavorites) {
this.set(id, 'doc', true);
}
});
}
isFavorite(id: string, type: WorkspaceFavoriteItem['type']) {
const existing = this.getFavoriteItem(id, type);
return existing?.value ?? false;
}
isFavorite$(id: string, type: WorkspaceFavoriteItem['type']) {
return this.favorites$.map(() => {
return this.isFavorite(id, type);
});
}
private getFavoriteItem(id: string, type: WorkspaceFavoriteItem['type']) {
return this.favorites?.[FavoriteItemsAdapter.getFavItemKey(id, type)];
}
// add or set a new fav item to the list. note the id added with prefix
set(
id: string,
type: WorkspaceFavoriteItem['type'],
value: boolean,
order?: string
) {
this.adapter.ensureRootProperties();
if (!this.favorites) {
throw new Error('Favorites is not initialized');
}
const existing = this.getFavoriteItem(id, type);
if (!existing) {
this.favorites[FavoriteItemsAdapter.getFavItemKey(id, type)] = {
id,
type,
value: true,
order: order ?? this.sorter.getNewItemOrder(),
};
} else {
Object.assign(existing, {
value,
order: order ?? existing.order,
});
}
}
toggle(id: string, type: WorkspaceFavoriteItem['type']) {
this.set(id, type, !this.isFavorite(id, type));
}
remove(id: string, type: WorkspaceFavoriteItem['type']) {
this.adapter.ensureRootProperties();
const existing = this.getFavoriteItem(id, type);
if (existing) {
existing.value = false;
}
}
}

View File

@@ -64,8 +64,9 @@ export type PageInfoTagsItem = z.infer<typeof PageInfoTagsItemSchema>;
// ====== workspace properties schema ======
export const WorkspaceFavoriteItemSchema = z.object({
id: z.string(),
order: z.number(),
type: z.enum(['page', 'collection']),
order: z.string(),
type: z.enum(['doc', 'collection']),
value: z.boolean(),
});
export type WorkspaceFavoriteItem = z.infer<typeof WorkspaceFavoriteItemSchema>;

View File

@@ -0,0 +1,113 @@
import { generateKeyBetween } from 'fractional-indexing';
export interface SortableProvider<T, K extends string | number> {
getItems(): T[];
getItemId(item: T): K;
getItemOrder(item: T): string;
setItemOrder(item: T, order: string): void;
}
// Using fractional-indexing managing orders of items in a list
export function createFractionalIndexingSortableHelper<
T,
K extends string | number,
>(provider: SortableProvider<T, K>) {
function getOrderedItems() {
return provider.getItems().sort((a, b) => {
const oa = provider.getItemOrder(a);
const ob = provider.getItemOrder(b);
return oa > ob ? 1 : oa < ob ? -1 : 0;
});
}
function getLargestOrder() {
const lastItem = getOrderedItems().at(-1);
return lastItem ? provider.getItemOrder(lastItem) : null;
}
function getSmallestOrder() {
const firstItem = getOrderedItems().at(0);
return firstItem ? provider.getItemOrder(firstItem) : null;
}
/**
* Get a new order at the end of the list
*/
function getNewItemOrder() {
return generateKeyBetween(getLargestOrder(), null);
}
/**
* Move item from one position to another
*
* in the most common sorting case, moving over will visually place the dragging item to the target position
* the original item in the target position will either move up or down, depending on the direction of the drag
*
* @param fromId
* @param toId
*/
function move(fromId: K, toId: K) {
const items = getOrderedItems();
const from = items.findIndex(i => provider.getItemId(i) === fromId);
const to = items.findIndex(i => provider.getItemId(i) === toId);
const fromItem = items[from];
const toItem = items[to];
const toNextItem = items[from < to ? to + 1 : to - 1];
const toOrder = toItem ? provider.getItemOrder(toItem) : null;
const toNextOrder = toNextItem ? provider.getItemOrder(toNextItem) : null;
const args: [string | null, string | null] =
from < to ? [toOrder, toNextOrder] : [toNextOrder, toOrder];
provider.setItemOrder(fromItem, generateKeyBetween(...args));
}
/**
* Cases example:
* Imagine we have the following items, | a | b | c |
* 1. insertBefore('b', undefined). before is not provided, which means insert b after c
* | a | c |
* ▴
* b
* result: | a | c | b |
*
* 2. insertBefore('b', 'a'). insert b before a
* | a | c |
* ▴
* b
*
* result: | b | a | c |
*/
function insertBefore(
id: string | number,
beforeId: string | number | undefined
) {
const items = getOrderedItems();
// assert id is in the list
const item = items.find(i => provider.getItemId(i) === id);
if (!item) return;
const beforeItemIndex = items.findIndex(
i => provider.getItemId(i) === beforeId
);
const beforeItem = beforeItemIndex !== -1 ? items[beforeItemIndex] : null;
const beforeItemPrev = beforeItem ? items[beforeItemIndex - 1] : null;
const beforeOrder = beforeItem ? provider.getItemOrder(beforeItem) : null;
const beforePrevOrder = beforeItemPrev
? provider.getItemOrder(beforeItemPrev)
: null;
provider.setItemOrder(
item,
generateKeyBetween(beforePrevOrder, beforeOrder)
);
}
return {
getOrderedItems,
getLargestOrder,
getSmallestOrder,
getNewItemOrder,
move,
insertBefore,
};
}

View File

@@ -1,4 +1,5 @@
export * from './create-emotion-cache';
export * from './fractional-indexing';
export * from './intl-formatter';
export * from './mixpanel';
export * from './string2color';

View File

@@ -43,11 +43,12 @@ export const AffineOperationCell: StoryFn<PageOperationCellProps> = ({
}) => <PageOperationCell {...props} />;
AffineOperationCell.args = {
favorite: false,
isPublic: true,
onToggleFavoritePage: () => toast('Toggle favorite page'),
onDisablePublicSharing: () => toast('Disable public sharing'),
onRemoveToTrash: () => toast('Remove to trash'),
page: {
id: '123',
title: 'Test Page Title',
tags: ['tag1', 'tag2'],
createDate: new Date('2021-01-01').getTime(),
},
};
AffineOperationCell.parameters = {
reactRouter: reactRouterParameters({

View File

@@ -20,6 +20,9 @@ declare global {
declare module '@blocksuite/store' {
interface DocMeta {
/**
* @deprecated
*/
favorite?: boolean;
// If a page remove to trash, and it is a subpage, it will remove from its parent `subpageIds`, 'trashRelate' is use for save it parent
trashRelate?: string;