diff --git a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts
index 060468261b..53791aaf38 100644
--- a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts
+++ b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts
@@ -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];
}
diff --git a/packages/frontend/core/src/components/affine/page-properties/table.tsx b/packages/frontend/core/src/components/affine/page-properties/table.tsx
index 4776c63d68..068bc5e67f 100644
--- a/packages/frontend/core/src/components/affine/page-properties/table.tsx
+++ b/packages/frontend/core/src/components/affine/page-properties/table.tsx
@@ -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 (
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx
index 409cd33287..c5d83200a8 100644
--- a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx
+++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx
@@ -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 };
};
diff --git a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx
index 9d7b96572d..9265e0a37a 100644
--- a/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx
+++ b/packages/frontend/core/src/components/page-list/docs/virtualized-page-list.tsx
@@ -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 (
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;
};
diff --git a/packages/frontend/core/src/components/page-list/group-definitions.tsx b/packages/frontend/core/src/components/page-list/group-definitions.tsx
index 4d67d4bd39..0483580436 100644
--- a/packages/frontend/core/src/components/page-list/group-definitions.tsx
+++ b/packages/frontend/core/src/components/page-list/group-definitions.tsx
@@ -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[] => {
const t = useAFFiNEI18N();
+ const favAdapter = useService(FavoriteItemsAdapter);
+ const favourites = useLiveData(favAdapter.favorites$);
return useMemo(
() => [
{
@@ -166,7 +169,7 @@ export const useFavoriteGroupDefinitions = <
icon={}
/>
),
- 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={}
/>
),
- match: item => !(item as DocMeta).favorite,
+ match: item => !favourites.some(fav => fav.id === item.id),
},
],
- [t]
+ [t, favourites]
);
};
diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx
index 88028f8a46..acc18c6037 100644
--- a/packages/frontend/core/src/components/page-list/operation-cell.tsx
+++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx
@@ -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 && (
{
@@ -91,7 +122,7 @@ export const PageOperationCell = ({
onClick={onToggleFavoritePage}
preFix={
- {favorite ? (
+ {favourite ? (
) : (
@@ -99,7 +130,7 @@ export const PageOperationCell = ({
}
>
- {favorite
+ {favourite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add']()}
@@ -121,7 +152,7 @@ export const PageOperationCell = ({
@@ -157,10 +188,10 @@ export const PageOperationCell = ({
-
+
);
};
diff --git a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx
index 821717f3d1..99d8643437 100644
--- a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx
+++ b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx
@@ -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(() => {
return {
allPages: pageMetas,
@@ -49,7 +56,7 @@ export const useAllPageListConfig = () => {
onToggleFavoritePage(page)}
- active={!!page.favorite}
+ active={isActive(page)}
/>
);
},
@@ -60,6 +67,7 @@ export const useAllPageListConfig = () => {
getPublicMode,
currentWorkspace.docCollection,
pageMap,
+ isActive,
onToggleFavoritePage,
]);
};
diff --git a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts
index cc48ca589b..273249acc0 100644
--- a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts
+++ b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts
@@ -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,
diff --git a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx
index 8a800e9626..d3220ba1de 100644
--- a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx
+++ b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx
@@ -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,
]);
}
diff --git a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts
index 900d4c3f72..654898e804 100644
--- a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts
+++ b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts
@@ -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(
diff --git a/packages/frontend/core/src/modules/services.ts b/packages/frontend/core/src/modules/services.ts
index 0c31441797..849f19c04f 100644
--- a/packages/frontend/core/src/modules/services.ts
+++ b/packages/frontend/core/src/modules/services.ts
@@ -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]);
diff --git a/packages/frontend/core/src/modules/workspace/properties/adapter.ts b/packages/frontend/core/src/modules/workspace/properties/adapter.ts
index 00bcc043fb..9cd65f9170 100644
--- a/packages/frontend/core/src/modules/workspace/properties/adapter.ts
+++ b/packages/frontend/core/src/modules/workspace/properties/adapter.ts
@@ -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;
+ public readonly properties$: LiveData;
private ensuredRoot = false;
private ensuredPages = {} as Record;
@@ -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;
+ }
+ }
+}
diff --git a/packages/frontend/core/src/modules/workspace/properties/schema.ts b/packages/frontend/core/src/modules/workspace/properties/schema.ts
index f07671b859..95e306fbe9 100644
--- a/packages/frontend/core/src/modules/workspace/properties/schema.ts
+++ b/packages/frontend/core/src/modules/workspace/properties/schema.ts
@@ -64,8 +64,9 @@ export type PageInfoTagsItem = z.infer;
// ====== 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;
diff --git a/packages/frontend/core/src/utils/fractional-indexing.ts b/packages/frontend/core/src/utils/fractional-indexing.ts
new file mode 100644
index 0000000000..30b5a5aabe
--- /dev/null
+++ b/packages/frontend/core/src/utils/fractional-indexing.ts
@@ -0,0 +1,113 @@
+import { generateKeyBetween } from 'fractional-indexing';
+
+export interface SortableProvider {
+ 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) {
+ 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,
+ };
+}
diff --git a/packages/frontend/core/src/utils/index.ts b/packages/frontend/core/src/utils/index.ts
index a2defbb180..90e1777b92 100644
--- a/packages/frontend/core/src/utils/index.ts
+++ b/packages/frontend/core/src/utils/index.ts
@@ -1,4 +1,5 @@
export * from './create-emotion-cache';
+export * from './fractional-indexing';
export * from './intl-formatter';
export * from './mixpanel';
export * from './string2color';
diff --git a/tests/storybook/src/stories/page-list.stories.tsx b/tests/storybook/src/stories/page-list.stories.tsx
index 9a1b540352..20f91b3e58 100644
--- a/tests/storybook/src/stories/page-list.stories.tsx
+++ b/tests/storybook/src/stories/page-list.stories.tsx
@@ -43,11 +43,12 @@ export const AffineOperationCell: StoryFn = ({
}) => ;
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({
diff --git a/tools/@types/env/__all.d.ts b/tools/@types/env/__all.d.ts
index 1e92c49305..224bc064e5 100644
--- a/tools/@types/env/__all.d.ts
+++ b/tools/@types/env/__all.d.ts
@@ -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;