mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat(core): new favorite (#7590)
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { Job, JobQueue, WorkspaceService } from '@toeverything/infra';
|
||||
import {
|
||||
DBService,
|
||||
Entity,
|
||||
IndexedDBIndexStorage,
|
||||
IndexedDBJobQueue,
|
||||
JobRunner,
|
||||
LiveData,
|
||||
WorkspaceDBService,
|
||||
} from '@toeverything/infra';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
@@ -69,7 +69,7 @@ export class DocsIndexer extends Entity {
|
||||
|
||||
setupListener() {
|
||||
this.workspaceEngine.doc.storage.eventBus.on(event => {
|
||||
if (DBService.isDBDocId(event.docId)) {
|
||||
if (WorkspaceDBService.isDBDocId(event.docId)) {
|
||||
// skip db doc
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { ExplorerCollections } from './views/sections/collections';
|
||||
export { ExplorerFavorites } from './views/sections/favorites';
|
||||
export { ExplorerMigrationFavorites } from './views/sections/migration-favorites';
|
||||
export { ExplorerOldFavorites } from './views/sections/old-favorites';
|
||||
export { ExplorerOrganize } from './views/sections/organize';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useEditCollection,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { ShareDocsService } from '@affine/core/modules/share-doc';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
@@ -225,12 +225,12 @@ const ExplorerCollectionNodeChildren = ({
|
||||
const t = useI18n();
|
||||
const {
|
||||
docsService,
|
||||
favoriteItemsAdapter,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
shareDocsService,
|
||||
collectionService,
|
||||
} = useServices({
|
||||
DocsService,
|
||||
FavoriteItemsAdapter,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
ShareDocsService,
|
||||
CollectionService,
|
||||
});
|
||||
@@ -251,7 +251,7 @@ const ExplorerCollectionNodeChildren = ({
|
||||
[docsService]
|
||||
)
|
||||
);
|
||||
const favourites = useLiveData(favoriteItemsAdapter.favorites$);
|
||||
const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$);
|
||||
const allowList = useMemo(
|
||||
() => new Set(collection.allowList),
|
||||
[collection.allowList]
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
@@ -35,19 +35,20 @@ export const useExplorerCollectionNodeOperations = (
|
||||
workbenchService,
|
||||
docsService,
|
||||
collectionService,
|
||||
favoriteItemsAdapter,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
} = useServices({
|
||||
DocsService,
|
||||
WorkbenchService,
|
||||
CollectionService,
|
||||
FavoriteItemsAdapter,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
});
|
||||
const deleteInfo = useDeleteCollectionInfo();
|
||||
|
||||
const favorite = useLiveData(
|
||||
useMemo(
|
||||
() => favoriteItemsAdapter.isFavorite$(collectionId, 'collection'),
|
||||
[collectionId, favoriteItemsAdapter]
|
||||
() =>
|
||||
compatibleFavoriteItemsAdapter.isFavorite$(collectionId, 'collection'),
|
||||
[collectionId, compatibleFavoriteItemsAdapter]
|
||||
)
|
||||
);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
@@ -66,8 +67,8 @@ export const useExplorerCollectionNodeOperations = (
|
||||
]);
|
||||
|
||||
const handleToggleFavoritePage = useCallback(() => {
|
||||
favoriteItemsAdapter.toggle(collectionId, 'collection');
|
||||
}, [favoriteItemsAdapter, collectionId]);
|
||||
compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection');
|
||||
}, [compatibleFavoriteItemsAdapter, collectionId]);
|
||||
|
||||
const handleAddDocToCollection = useCallback(() => {
|
||||
openConfirmModal({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@affine/component';
|
||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
@@ -32,20 +32,20 @@ export const useExplorerDocNodeOperations = (
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const { workbenchService, docsService, favoriteItemsAdapter } = useServices({
|
||||
DocsService,
|
||||
WorkbenchService,
|
||||
FavoriteItemsAdapter,
|
||||
});
|
||||
const { workbenchService, docsService, compatibleFavoriteItemsAdapter } =
|
||||
useServices({
|
||||
DocsService,
|
||||
WorkbenchService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
});
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
|
||||
const favorite = useLiveData(
|
||||
useMemo(
|
||||
() => favoriteItemsAdapter.isFavorite$(docId, 'doc'),
|
||||
[docId, favoriteItemsAdapter]
|
||||
)
|
||||
useMemo(() => {
|
||||
return compatibleFavoriteItemsAdapter.isFavorite$(docId, 'doc');
|
||||
}, [docId, compatibleFavoriteItemsAdapter])
|
||||
);
|
||||
|
||||
const handleMoveToTrash = useCallback(() => {
|
||||
@@ -84,8 +84,8 @@ export const useExplorerDocNodeOperations = (
|
||||
}, [docId, options, docsService, workbenchService.workbench]);
|
||||
|
||||
const handleToggleFavoriteDoc = useCallback(() => {
|
||||
favoriteItemsAdapter.toggle(docId, 'doc');
|
||||
}, [favoriteItemsAdapter, docId]);
|
||||
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
|
||||
}, [docId, compatibleFavoriteItemsAdapter]);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
|
||||
@@ -6,10 +6,17 @@ import {
|
||||
toast,
|
||||
} from '@affine/component';
|
||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import { FavoriteService } from '@affine/core/modules/favorite';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FavoritedIcon,
|
||||
FavoriteIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
@@ -25,12 +32,17 @@ export const useExplorerTagNodeOperations = (
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const { docsService, workbenchService, tagService } = useServices({
|
||||
WorkbenchService,
|
||||
TagService,
|
||||
DocsService,
|
||||
});
|
||||
const { docsService, workbenchService, tagService, favoriteService } =
|
||||
useServices({
|
||||
WorkbenchService,
|
||||
TagService,
|
||||
DocsService,
|
||||
FavoriteService,
|
||||
});
|
||||
|
||||
const favorite = useLiveData(
|
||||
favoriteService.favoriteList.favorite$('tag', tagId)
|
||||
);
|
||||
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
||||
|
||||
const handleNewDoc = useCallback(() => {
|
||||
@@ -53,6 +65,10 @@ export const useExplorerTagNodeOperations = (
|
||||
});
|
||||
}, [tagId, workbenchService]);
|
||||
|
||||
const handleToggleFavoriteTag = useCallback(() => {
|
||||
favoriteService.favoriteList.toggle('tag', tagId);
|
||||
}, [favoriteService, tagId]);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -83,6 +99,33 @@ export const useExplorerTagNodeOperations = (
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(runtimeConfig.enableNewFavorite
|
||||
? [
|
||||
{
|
||||
index: 199,
|
||||
view: (
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
{favorite ? (
|
||||
<FavoritedIcon
|
||||
style={{ color: 'var(--affine-primary-color)' }}
|
||||
/>
|
||||
) : (
|
||||
<FavoriteIcon />
|
||||
)}
|
||||
</MenuIcon>
|
||||
}
|
||||
onClick={handleToggleFavoriteTag}
|
||||
>
|
||||
{favorite
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
index: 9999,
|
||||
view: <MenuSeparator key="menu-separator" />,
|
||||
@@ -106,9 +149,11 @@ export const useExplorerTagNodeOperations = (
|
||||
],
|
||||
[
|
||||
appSettings.enableMultiView,
|
||||
favorite,
|
||||
handleMoveToTrash,
|
||||
handleNewDoc,
|
||||
handleOpenInSplitView,
|
||||
handleToggleFavoriteTag,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -39,10 +39,10 @@ export const RootEmpty = ({
|
||||
<FolderIcon className={styles.icon} />
|
||||
</div>
|
||||
<div
|
||||
data-testid="slider-bar-organize-empty-message"
|
||||
data-testid="slider-bar-favorites-empty-message"
|
||||
className={styles.message}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.organize.empty']()}
|
||||
{t['com.affine.rootAppSidebar.favorites.empty']()}
|
||||
</div>
|
||||
{dropEffect && draggedOverDraggable && (
|
||||
<DropEffect
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
type ExplorerTreeNodeDropEffect,
|
||||
ExplorerTreeRoot,
|
||||
} from '@affine/core/modules/explorer/views/tree';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import type { FavoriteSupportType } from '@affine/core/modules/favorite';
|
||||
import {
|
||||
FavoriteService,
|
||||
isFavoriteSupportType,
|
||||
} from '@affine/core/modules/favorite';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
@@ -20,55 +24,42 @@ import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||
import { ExplorerDocNode } from '../../nodes/doc';
|
||||
import { ExplorerFolderNode } from '../../nodes/folder';
|
||||
import { ExplorerTagNode } from '../../nodes/tag';
|
||||
import { RootEmpty } from './empty';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const ExplorerFavorites = () => {
|
||||
const { favoriteItemsAdapter, docsService, workbenchService } = useServices({
|
||||
FavoriteItemsAdapter,
|
||||
const { favoriteService, docsService, workbenchService } = useServices({
|
||||
FavoriteService,
|
||||
DocsService,
|
||||
WorkbenchService,
|
||||
});
|
||||
|
||||
const docs = useLiveData(docsService.list.docs$);
|
||||
const trashDocs = useLiveData(docsService.list.trashDocs$);
|
||||
|
||||
const favorites = useLiveData(
|
||||
favoriteItemsAdapter.orderedFavorites$.map(favs => {
|
||||
return favs.filter(fav => {
|
||||
if (fav.type === 'doc') {
|
||||
return (
|
||||
docs.some(doc => doc.id === fav.id) &&
|
||||
!trashDocs.some(doc => doc.id === fav.id)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
})
|
||||
);
|
||||
const favorites = useLiveData(favoriteService.favoriteList.sortedList$);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
favoriteItemsAdapter.set(
|
||||
favoriteService.favoriteList.add(
|
||||
data.source.data.entity.type,
|
||||
data.source.data.entity.id,
|
||||
data.source.data.entity?.type,
|
||||
true
|
||||
favoriteService.favoriteList.indexAt('before')
|
||||
);
|
||||
}
|
||||
},
|
||||
[favoriteItemsAdapter]
|
||||
[favoriteService]
|
||||
);
|
||||
|
||||
const handleDropEffect = useCallback<ExplorerTreeNodeDropEffect>(data => {
|
||||
if (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
return 'link';
|
||||
}
|
||||
@@ -77,23 +68,26 @@ export const ExplorerFavorites = () => {
|
||||
|
||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||
() => data => {
|
||||
return (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
);
|
||||
return data.source.data.entity?.type
|
||||
? isFavoriteSupportType(data.source.data.entity.type)
|
||||
: false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCreateNewFavoriteDoc = useCallback(() => {
|
||||
const newDoc = docsService.createDoc();
|
||||
favoriteItemsAdapter.set(newDoc.id, 'doc', true);
|
||||
favoriteService.favoriteList.add(
|
||||
'doc',
|
||||
newDoc.id,
|
||||
favoriteService.favoriteList.indexAt('before')
|
||||
);
|
||||
workbenchService.workbench.openDoc(newDoc.id);
|
||||
}, [docsService, favoriteItemsAdapter, workbenchService]);
|
||||
}, [docsService, favoriteService, workbenchService]);
|
||||
|
||||
const handleOnChildrenDrop = useCallback(
|
||||
(
|
||||
favorite: { id: string; type: 'doc' | 'collection' },
|
||||
favorite: { id: string; type: FavoriteSupportType },
|
||||
data: DropTargetDropEvent<AffineDNDData>
|
||||
) => {
|
||||
if (
|
||||
@@ -101,42 +95,41 @@ export const ExplorerFavorites = () => {
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
if (
|
||||
data.source.data.from?.at === 'explorer:favorite:items' &&
|
||||
(data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection')
|
||||
data.source.data.from?.at === 'explorer:favorite:list' &&
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
// is reordering
|
||||
favoriteItemsAdapter.sorter.moveTo(
|
||||
FavoriteItemsAdapter.getFavItemKey(
|
||||
data.source.data.entity.id,
|
||||
data.source.data.entity.type
|
||||
),
|
||||
FavoriteItemsAdapter.getFavItemKey(favorite.id, favorite.type),
|
||||
data.treeInstruction?.type === 'reorder-above' ? 'before' : 'after'
|
||||
favoriteService.favoriteList.reorder(
|
||||
data.source.data.entity.type,
|
||||
data.source.data.entity.id,
|
||||
favoriteService.favoriteList.indexAt(
|
||||
data.treeInstruction?.type === 'reorder-above'
|
||||
? 'before'
|
||||
: 'after',
|
||||
favorite
|
||||
)
|
||||
);
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
favoriteItemsAdapter.set(
|
||||
favoriteService.favoriteList.add(
|
||||
data.source.data.entity.type,
|
||||
data.source.data.entity.id,
|
||||
data.source.data.entity?.type,
|
||||
true
|
||||
);
|
||||
favoriteItemsAdapter.sorter.moveTo(
|
||||
FavoriteItemsAdapter.getFavItemKey(
|
||||
data.source.data.entity.id,
|
||||
data.source.data.entity.type
|
||||
),
|
||||
FavoriteItemsAdapter.getFavItemKey(favorite.id, favorite.type),
|
||||
data.treeInstruction?.type === 'reorder-above' ? 'before' : 'after'
|
||||
favoriteService.favoriteList.indexAt(
|
||||
data.treeInstruction?.type === 'reorder-above'
|
||||
? 'before'
|
||||
: 'after',
|
||||
favorite
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return; // not supported
|
||||
}
|
||||
} else {
|
||||
return; // not supported
|
||||
}
|
||||
},
|
||||
[favoriteItemsAdapter]
|
||||
[favoriteService]
|
||||
);
|
||||
|
||||
const handleChildrenDropEffect = useCallback<ExplorerTreeNodeDropEffect>(
|
||||
@@ -146,14 +139,14 @@ export const ExplorerFavorites = () => {
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
if (
|
||||
data.source.data.from?.at === 'explorer:favorite:items' &&
|
||||
(data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection')
|
||||
data.source.data.from?.at === 'explorer:favorite:list' &&
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
return 'move';
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
return 'link';
|
||||
}
|
||||
@@ -167,8 +160,9 @@ export const ExplorerFavorites = () => {
|
||||
DropTargetOptions<AffineDNDData>['canDrop']
|
||||
>(
|
||||
() => args =>
|
||||
args.source.data.entity?.type === 'doc' ||
|
||||
args.source.data.entity?.type === 'collection',
|
||||
args.source.data.entity?.type
|
||||
? isFavoriteSupportType(args.source.data.entity.type)
|
||||
: false,
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -236,7 +230,7 @@ export const ExplorerFavorites = () => {
|
||||
};
|
||||
|
||||
const childLocation = {
|
||||
at: 'explorer:favorite:items' as const,
|
||||
at: 'explorer:favorite:list' as const,
|
||||
};
|
||||
const ExplorerFavoriteNode = ({
|
||||
favorite,
|
||||
@@ -246,13 +240,13 @@ const ExplorerFavoriteNode = ({
|
||||
}: {
|
||||
favorite: {
|
||||
id: string;
|
||||
type: 'collection' | 'doc';
|
||||
type: FavoriteSupportType;
|
||||
};
|
||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||
onDrop: (
|
||||
favorite: {
|
||||
id: string;
|
||||
type: 'collection' | 'doc';
|
||||
type: FavoriteSupportType;
|
||||
},
|
||||
data: DropTargetDropEvent<AffineDNDData>
|
||||
) => void;
|
||||
@@ -273,6 +267,24 @@ const ExplorerFavoriteNode = ({
|
||||
dropEffect={dropEffect}
|
||||
canDrop={canDrop}
|
||||
/>
|
||||
) : favorite.type === 'tag' ? (
|
||||
<ExplorerTagNode
|
||||
key={favorite.id}
|
||||
tagId={favorite.id}
|
||||
location={childLocation}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={dropEffect}
|
||||
canDrop={canDrop}
|
||||
/>
|
||||
) : favorite.type === 'folder' ? (
|
||||
<ExplorerFolderNode
|
||||
key={favorite.id}
|
||||
nodeId={favorite.id}
|
||||
location={childLocation}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={dropEffect}
|
||||
canDrop={canDrop}
|
||||
/>
|
||||
) : (
|
||||
<ExplorerCollectionNode
|
||||
key={favorite.id}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { IconButton, useConfirmModal } from '@affine/component';
|
||||
import { CategoryDivider } from '@affine/core/components/app-sidebar';
|
||||
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { BroomIcon, HelpIcon } from '@blocksuite/icons/rc';
|
||||
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||
import { ExplorerDocNode } from '../../nodes/doc';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const ExplorerMigrationFavorites = () => {
|
||||
const t = useI18n();
|
||||
|
||||
const { favoriteItemsAdapter, docsService } = useServices({
|
||||
FavoriteItemsAdapter,
|
||||
DocsService,
|
||||
});
|
||||
|
||||
const docs = useLiveData(docsService.list.docs$);
|
||||
const trashDocs = useLiveData(docsService.list.trashDocs$);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const favorites = useLiveData(
|
||||
favoriteItemsAdapter.orderedFavorites$.map(favs => {
|
||||
return favs.filter(fav => {
|
||||
if (fav.type === 'doc') {
|
||||
return (
|
||||
docs.some(doc => doc.id === fav.id) &&
|
||||
!trashDocs.some(doc => doc.id === fav.id)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const handleClickClear = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.rootAppSidebar.migration-data.clean-all'](),
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="com.affine.rootAppSidebar.migration-data.clean-all.description"
|
||||
components={{
|
||||
b: <b className={styles.descriptionHighlight} />,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
confirmText:
|
||||
t['com.affine.rootAppSidebar.migration-data.clean-all.confirm'](),
|
||||
confirmButtonOptions: {
|
||||
type: 'primary',
|
||||
},
|
||||
cancelText:
|
||||
t['com.affine.rootAppSidebar.migration-data.clean-all.cancel'](),
|
||||
onConfirm() {
|
||||
favoriteItemsAdapter.clearAll();
|
||||
},
|
||||
});
|
||||
}, [favoriteItemsAdapter, openConfirmModal, t]);
|
||||
|
||||
const handleClickHelp = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.rootAppSidebar.migration-data.help'](),
|
||||
description:
|
||||
t['com.affine.rootAppSidebar.migration-data.help.description'](),
|
||||
confirmText: t['com.affine.rootAppSidebar.migration-data.help.confirm'](),
|
||||
confirmButtonOptions: {
|
||||
type: 'primary',
|
||||
},
|
||||
cancelText:
|
||||
t['com.affine.rootAppSidebar.migration-data.help.clean-all'](),
|
||||
cancelButtonOptions: {
|
||||
icon: <BroomIcon />,
|
||||
type: 'default',
|
||||
onClick: () => {
|
||||
requestAnimationFrame(() => {
|
||||
handleClickClear();
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [handleClickClear, openConfirmModal, t]);
|
||||
|
||||
if (favorites.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.migration-data']()}>
|
||||
<IconButton
|
||||
data-testid="explorer-bar-favorite-migration-clear-button"
|
||||
onClick={handleClickClear}
|
||||
size="small"
|
||||
>
|
||||
<BroomIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
data-testid="explorer-bar-favorite-migration-help-button"
|
||||
size="small"
|
||||
onClick={handleClickHelp}
|
||||
>
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</CategoryDivider>
|
||||
<ExplorerTreeRoot>
|
||||
{favorites.map((favorite, i) => (
|
||||
<ExplorerMigrationFavoriteNode
|
||||
key={favorite.id + ':' + i}
|
||||
favorite={favorite}
|
||||
/>
|
||||
))}
|
||||
</ExplorerTreeRoot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const childLocation = {
|
||||
at: 'explorer:migration-data:list' as const,
|
||||
};
|
||||
const ExplorerMigrationFavoriteNode = ({
|
||||
favorite,
|
||||
}: {
|
||||
favorite: {
|
||||
id: string;
|
||||
type: 'collection' | 'doc';
|
||||
};
|
||||
}) => {
|
||||
return favorite.type === 'doc' ? (
|
||||
<ExplorerDocNode
|
||||
key={favorite.id}
|
||||
docId={favorite.id}
|
||||
location={childLocation}
|
||||
reorderable={false}
|
||||
canDrop={false}
|
||||
/>
|
||||
) : (
|
||||
<ExplorerCollectionNode
|
||||
key={favorite.id}
|
||||
collectionId={favorite.id}
|
||||
location={childLocation}
|
||||
reorderable={false}
|
||||
canDrop={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const container = style({
|
||||
marginTop: '16px',
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&:after': {
|
||||
display: 'block',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: '-8px',
|
||||
top: '0',
|
||||
width: '6px',
|
||||
height: '100%',
|
||||
background:
|
||||
'repeating-linear-gradient(30deg, #F5CC47, #F5CC47 8px, #000000 8px, #000000 14px)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const descriptionHighlight = style({
|
||||
color: cssVar('--affine-warning-color'),
|
||||
fontWeight: 'normal',
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const content = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '9px 20px 25px 21px',
|
||||
});
|
||||
export const iconWrapper = style({
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
});
|
||||
export const icon = style({
|
||||
fontSize: 20,
|
||||
color: cssVar('iconSecondary'),
|
||||
});
|
||||
export const message = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
textAlign: 'center',
|
||||
color: cssVar('black30'),
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const newButton = style({
|
||||
padding: '0 8px',
|
||||
height: '28px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { FolderIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { DropEffect, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||
import * as styles from './empty.css';
|
||||
|
||||
export const RootEmpty = ({
|
||||
onDrop,
|
||||
canDrop,
|
||||
dropEffect,
|
||||
}: {
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||
dropEffect?: ExplorerTreeNodeDropEffect;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
|
||||
useDropTarget<AffineDNDData>(
|
||||
() => ({
|
||||
data: {
|
||||
at: 'explorer:old-favorite:root',
|
||||
},
|
||||
onDrop: onDrop,
|
||||
canDrop: canDrop,
|
||||
}),
|
||||
[onDrop, canDrop]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.content} ref={dropTargetRef}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<FolderIcon className={styles.icon} />
|
||||
</div>
|
||||
<div
|
||||
data-testid="slider-bar-favorites-empty-message"
|
||||
className={styles.message}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.favorites.empty']()}
|
||||
</div>
|
||||
{dropEffect && draggedOverDraggable && (
|
||||
<DropEffect
|
||||
position={{
|
||||
x: draggedOverPosition.relativeX,
|
||||
y: draggedOverPosition.relativeY,
|
||||
}}
|
||||
dropEffect={dropEffect({
|
||||
source: draggedOverDraggable,
|
||||
treeInstruction: null,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
IconButton,
|
||||
} from '@affine/component';
|
||||
import { CategoryDivider } from '@affine/core/components/app-sidebar';
|
||||
import {
|
||||
type ExplorerTreeNodeDropEffect,
|
||||
ExplorerTreeRoot,
|
||||
} from '@affine/core/modules/explorer/views/tree';
|
||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||
import { ExplorerDocNode } from '../../nodes/doc';
|
||||
import { RootEmpty } from './empty';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
/**
|
||||
* @deprecated remove this after 0.17 released
|
||||
*/
|
||||
export const ExplorerOldFavorites = () => {
|
||||
const { favoriteItemsAdapter, docsService, workbenchService } = useServices({
|
||||
FavoriteItemsAdapter,
|
||||
DocsService,
|
||||
WorkbenchService,
|
||||
});
|
||||
|
||||
const docs = useLiveData(docsService.list.docs$);
|
||||
const trashDocs = useLiveData(docsService.list.trashDocs$);
|
||||
|
||||
const favorites = useLiveData(
|
||||
favoriteItemsAdapter.orderedFavorites$.map(favs => {
|
||||
return favs.filter(fav => {
|
||||
if (fav.type === 'doc') {
|
||||
return (
|
||||
docs.some(doc => doc.id === fav.id) &&
|
||||
!trashDocs.some(doc => doc.id === fav.id)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
) {
|
||||
favoriteItemsAdapter.set(
|
||||
data.source.data.entity.id,
|
||||
data.source.data.entity.type,
|
||||
true
|
||||
);
|
||||
}
|
||||
},
|
||||
[favoriteItemsAdapter]
|
||||
);
|
||||
|
||||
const handleDropEffect = useCallback<ExplorerTreeNodeDropEffect>(data => {
|
||||
if (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
) {
|
||||
return 'link';
|
||||
}
|
||||
return;
|
||||
}, []);
|
||||
|
||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||
() => data => {
|
||||
return (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCreateNewFavoriteDoc = useCallback(() => {
|
||||
const newDoc = docsService.createDoc();
|
||||
favoriteItemsAdapter.set(newDoc.id, 'doc', true);
|
||||
workbenchService.workbench.openDoc(newDoc.id);
|
||||
}, [docsService, favoriteItemsAdapter, workbenchService]);
|
||||
|
||||
const handleOnChildrenDrop = useCallback(
|
||||
(
|
||||
favorite: { id: string; type: 'doc' | 'collection' },
|
||||
data: DropTargetDropEvent<AffineDNDData>
|
||||
) => {
|
||||
if (
|
||||
data.treeInstruction?.type === 'reorder-above' ||
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
if (
|
||||
data.source.data.from?.at === 'explorer:old-favorite:list' &&
|
||||
(data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection')
|
||||
) {
|
||||
// is reordering
|
||||
favoriteItemsAdapter.sorter.moveTo(
|
||||
FavoriteItemsAdapter.getFavItemKey(
|
||||
data.source.data.entity.id,
|
||||
data.source.data.entity.type
|
||||
),
|
||||
FavoriteItemsAdapter.getFavItemKey(favorite.id, favorite.type),
|
||||
data.treeInstruction?.type === 'reorder-above' ? 'before' : 'after'
|
||||
);
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
) {
|
||||
favoriteItemsAdapter.set(
|
||||
data.source.data.entity.id,
|
||||
data.source.data.entity?.type,
|
||||
true
|
||||
);
|
||||
favoriteItemsAdapter.sorter.moveTo(
|
||||
FavoriteItemsAdapter.getFavItemKey(
|
||||
data.source.data.entity.id,
|
||||
data.source.data.entity.type
|
||||
),
|
||||
FavoriteItemsAdapter.getFavItemKey(favorite.id, favorite.type),
|
||||
data.treeInstruction?.type === 'reorder-above' ? 'before' : 'after'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return; // not supported
|
||||
}
|
||||
},
|
||||
[favoriteItemsAdapter]
|
||||
);
|
||||
|
||||
const handleChildrenDropEffect = useCallback<ExplorerTreeNodeDropEffect>(
|
||||
data => {
|
||||
if (
|
||||
data.treeInstruction?.type === 'reorder-above' ||
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
if (
|
||||
data.source.data.from?.at === 'explorer:old-favorite:list' &&
|
||||
(data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection')
|
||||
) {
|
||||
return 'move';
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'collection'
|
||||
) {
|
||||
return 'link';
|
||||
}
|
||||
}
|
||||
return; // not supported
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleChildrenCanDrop = useMemo<
|
||||
DropTargetOptions<AffineDNDData>['canDrop']
|
||||
>(
|
||||
() => args =>
|
||||
args.source.data.entity?.type === 'doc' ||
|
||||
args.source.data.entity?.type === 'collection',
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CategoryDivider
|
||||
className={styles.draggedOverHighlight}
|
||||
label={
|
||||
runtimeConfig.enableNewFavorite
|
||||
? `${t['com.affine.rootAppSidebar.favorites']()} (OLD)`
|
||||
: t['com.affine.rootAppSidebar.favorites']()
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-testid="explorer-bar-add-old-favorite-button"
|
||||
onClick={handleCreateNewFavoriteDoc}
|
||||
size="small"
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</CategoryDivider>
|
||||
<ExplorerTreeRoot
|
||||
placeholder={
|
||||
<RootEmpty
|
||||
onDrop={handleDrop}
|
||||
canDrop={handleCanDrop}
|
||||
dropEffect={handleDropEffect}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{favorites.map((favorite, i) => (
|
||||
<ExplorerFavoriteNode
|
||||
key={favorite.id + ':' + i}
|
||||
favorite={favorite}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={handleChildrenDropEffect}
|
||||
canDrop={handleChildrenCanDrop}
|
||||
/>
|
||||
))}
|
||||
</ExplorerTreeRoot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const childLocation = {
|
||||
at: 'explorer:old-favorite:list' as const,
|
||||
};
|
||||
const ExplorerFavoriteNode = ({
|
||||
favorite,
|
||||
onDrop,
|
||||
canDrop,
|
||||
dropEffect,
|
||||
}: {
|
||||
favorite: {
|
||||
id: string;
|
||||
type: 'collection' | 'doc';
|
||||
};
|
||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||
onDrop: (
|
||||
favorite: {
|
||||
id: string;
|
||||
type: 'collection' | 'doc';
|
||||
},
|
||||
data: DropTargetDropEvent<AffineDNDData>
|
||||
) => void;
|
||||
dropEffect: ExplorerTreeNodeDropEffect;
|
||||
}) => {
|
||||
const handleOnChildrenDrop = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
onDrop(favorite, data);
|
||||
},
|
||||
[favorite, onDrop]
|
||||
);
|
||||
return favorite.type === 'doc' ? (
|
||||
<ExplorerDocNode
|
||||
key={favorite.id}
|
||||
docId={favorite.id}
|
||||
location={childLocation}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={dropEffect}
|
||||
canDrop={canDrop}
|
||||
/>
|
||||
) : (
|
||||
<ExplorerCollectionNode
|
||||
key={favorite.id}
|
||||
collectionId={favorite.id}
|
||||
location={childLocation}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={dropEffect}
|
||||
canDrop={canDrop}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const container = style({
|
||||
marginTop: '16px',
|
||||
});
|
||||
|
||||
export const draggedOverHighlight = style({
|
||||
selectors: {
|
||||
'&[data-dragged-over="true"]': {
|
||||
background: cssVar('--affine-hover-color'),
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
11
packages/frontend/core/src/modules/favorite/constant.ts
Normal file
11
packages/frontend/core/src/modules/favorite/constant.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const FavoriteSupportType = [
|
||||
'collection',
|
||||
'doc',
|
||||
'tag',
|
||||
'folder',
|
||||
] as const;
|
||||
export type FavoriteSupportType = 'collection' | 'doc' | 'tag' | 'folder';
|
||||
export const isFavoriteSupportType = (
|
||||
type: string
|
||||
): type is FavoriteSupportType =>
|
||||
FavoriteSupportType.includes(type as FavoriteSupportType);
|
||||
@@ -0,0 +1,97 @@
|
||||
import { generateFractionalIndexingKeyBetween } from '@affine/core/utils/fractional-indexing';
|
||||
import { Entity } from '@toeverything/infra';
|
||||
|
||||
import type { FavoriteSupportType } from '../constant';
|
||||
import type { FavoriteRecord, FavoriteStore } from '../stores/favorite';
|
||||
|
||||
export class FavoriteList extends Entity {
|
||||
list$ = this.store.watchFavorites();
|
||||
sortedList$ = this.list$.map(v =>
|
||||
v.sort((a, b) => (a.index > b.index ? 1 : -1))
|
||||
);
|
||||
|
||||
constructor(private readonly store: FavoriteStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* get favorite record by type and id
|
||||
*/
|
||||
favorite$(type: FavoriteSupportType, id: string) {
|
||||
return this.store.watchFavorite(type, id);
|
||||
}
|
||||
|
||||
isFavorite$(type: FavoriteSupportType, id: string) {
|
||||
return this.favorite$(type, id).map(v => !!v);
|
||||
}
|
||||
|
||||
add(
|
||||
type: FavoriteSupportType,
|
||||
id: string,
|
||||
index: string = this.indexAt('before')
|
||||
) {
|
||||
return this.store.addFavorite(type, id, index);
|
||||
}
|
||||
|
||||
toggle(
|
||||
type: FavoriteSupportType,
|
||||
id: string,
|
||||
index: string = this.indexAt('before')
|
||||
) {
|
||||
if (this.favorite$(type, id).value) {
|
||||
return this.remove(type, id);
|
||||
} else {
|
||||
return this.add(type, id, index);
|
||||
}
|
||||
}
|
||||
|
||||
remove(type: FavoriteSupportType, id: string) {
|
||||
return this.store.removeFavorite(type, id);
|
||||
}
|
||||
|
||||
reorder(type: FavoriteSupportType, id: string, index: string) {
|
||||
return this.store.reorderFavorite(type, id, index);
|
||||
}
|
||||
|
||||
indexAt(
|
||||
at: 'before' | 'after',
|
||||
targetRecord?: {
|
||||
type: FavoriteSupportType;
|
||||
id: string;
|
||||
}
|
||||
) {
|
||||
if (!targetRecord) {
|
||||
if (at === 'before') {
|
||||
const first = this.sortedList$.value.at(0);
|
||||
return generateFractionalIndexingKeyBetween(null, first?.index || null);
|
||||
} else {
|
||||
const last = this.sortedList$.value.at(-1);
|
||||
return generateFractionalIndexingKeyBetween(last?.index || null, null);
|
||||
}
|
||||
} else {
|
||||
const sortedChildren = this.sortedList$.value;
|
||||
const targetIndex = sortedChildren.findIndex(
|
||||
node => node.id === targetRecord.id && node.type === targetRecord.type
|
||||
);
|
||||
if (targetIndex === -1) {
|
||||
throw new Error('Target favorite record not found');
|
||||
}
|
||||
const target = sortedChildren[targetIndex];
|
||||
const before: FavoriteRecord | null =
|
||||
sortedChildren[targetIndex - 1] || null;
|
||||
const after: FavoriteRecord | null =
|
||||
sortedChildren[targetIndex + 1] || null;
|
||||
if (at === 'before') {
|
||||
return generateFractionalIndexingKeyBetween(
|
||||
before?.index || null,
|
||||
target.index
|
||||
);
|
||||
} else {
|
||||
return generateFractionalIndexingKeyBetween(
|
||||
target.index,
|
||||
after?.index || null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/frontend/core/src/modules/favorite/index.ts
Normal file
23
packages/frontend/core/src/modules/favorite/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type Framework,
|
||||
WorkspaceDBService,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { AuthService } from '../cloud';
|
||||
import { FavoriteList } from './entities/favorite-list';
|
||||
import { FavoriteService } from './services/favorite';
|
||||
import { FavoriteStore } from './stores/favorite';
|
||||
|
||||
export { FavoriteSupportType, isFavoriteSupportType } from './constant';
|
||||
export type { FavoriteList } from './entities/favorite-list';
|
||||
export { FavoriteService } from './services/favorite';
|
||||
|
||||
export function configureFavoriteModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(FavoriteService)
|
||||
.entity(FavoriteList, [FavoriteStore])
|
||||
.store(FavoriteStore, [AuthService, WorkspaceDBService, WorkspaceService]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { FavoriteList } from '../entities/favorite-list';
|
||||
|
||||
export class FavoriteService extends Service {
|
||||
favoriteList = this.framework.createEntity(FavoriteList);
|
||||
}
|
||||
122
packages/frontend/core/src/modules/favorite/stores/favorite.ts
Normal file
122
packages/frontend/core/src/modules/favorite/stores/favorite.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { WorkspaceDBService, WorkspaceService } from '@toeverything/infra';
|
||||
import { LiveData, Store } from '@toeverything/infra';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
import type { AuthService } from '../../cloud';
|
||||
import type { FavoriteSupportType } from '../constant';
|
||||
import { isFavoriteSupportType } from '../constant';
|
||||
|
||||
export interface FavoriteRecord {
|
||||
type: FavoriteSupportType;
|
||||
id: string;
|
||||
index: string;
|
||||
}
|
||||
|
||||
export class FavoriteStore extends Store {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly workspaceDBService: WorkspaceDBService,
|
||||
private readonly workspaceService: WorkspaceService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private get userdataDB$() {
|
||||
return this.authService.session.account$.map(account => {
|
||||
// if is local workspace or no account, use __local__ userdata
|
||||
// sometimes we may have cloud workspace but no account for a short time, we also use __local__ userdata
|
||||
if (
|
||||
this.workspaceService.workspace.meta.flavour ===
|
||||
WorkspaceFlavour.LOCAL ||
|
||||
!account
|
||||
) {
|
||||
return this.workspaceDBService.userdataDB('__local__');
|
||||
}
|
||||
return this.workspaceDBService.userdataDB(account.id);
|
||||
});
|
||||
}
|
||||
|
||||
watchFavorites() {
|
||||
return this.userdataDB$
|
||||
.map(db => LiveData.from(db.favorite.find$(), []))
|
||||
.flat()
|
||||
.map(raw => {
|
||||
return raw
|
||||
.map(data => this.toRecord(data))
|
||||
.filter((record): record is FavoriteRecord => !!record);
|
||||
});
|
||||
}
|
||||
|
||||
addFavorite(
|
||||
type: FavoriteSupportType,
|
||||
id: string,
|
||||
index: string
|
||||
): FavoriteRecord {
|
||||
const db = this.userdataDB$.value;
|
||||
const raw = db.favorite.create({
|
||||
key: this.encodeKey(type, id),
|
||||
index,
|
||||
});
|
||||
return this.toRecord(raw) as FavoriteRecord;
|
||||
}
|
||||
|
||||
reorderFavorite(type: FavoriteSupportType, id: string, index: string) {
|
||||
const db = this.userdataDB$.value;
|
||||
db.favorite.update(this.encodeKey(type, id), { index });
|
||||
}
|
||||
|
||||
removeFavorite(type: FavoriteSupportType, id: string) {
|
||||
const db = this.userdataDB$.value;
|
||||
db.favorite.delete(this.encodeKey(type, id));
|
||||
}
|
||||
|
||||
watchFavorite(type: FavoriteSupportType, id: string) {
|
||||
const db = this.userdataDB$.value;
|
||||
return LiveData.from<FavoriteRecord | undefined>(
|
||||
db.favorite
|
||||
.get$(this.encodeKey(type, id))
|
||||
.pipe(map(data => (data ? this.toRecord(data) : undefined))),
|
||||
null as any
|
||||
);
|
||||
}
|
||||
|
||||
private toRecord(data: {
|
||||
key: string;
|
||||
index: string;
|
||||
}): FavoriteRecord | undefined {
|
||||
const key = this.parseKey(data.key);
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: key.type,
|
||||
id: key.id,
|
||||
index: data.index,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* parse favorite key
|
||||
* key format: ${type}:${id}
|
||||
* type: collection | doc | tag
|
||||
* @returns null if key is invalid
|
||||
*/
|
||||
private parseKey(key: string): {
|
||||
type: FavoriteSupportType;
|
||||
id: string;
|
||||
} | null {
|
||||
const [type, id] = key.split(':');
|
||||
if (!type || !id) {
|
||||
return null;
|
||||
}
|
||||
if (!isFavoriteSupportType(type)) {
|
||||
return null;
|
||||
}
|
||||
return { type: type as FavoriteSupportType, id };
|
||||
}
|
||||
|
||||
private encodeKey(type: FavoriteSupportType, id: string) {
|
||||
return `${type}:${id}`;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { configureCloudModule } from './cloud';
|
||||
import { configureCollectionModule } from './collection';
|
||||
import { configureDocLinksModule } from './doc-link';
|
||||
import { configureDocsSearchModule } from './docs-search';
|
||||
import { configureFavoriteModule } from './favorite';
|
||||
import { configureFindInPageModule } from './find-in-page';
|
||||
import { configureNavigationModule } from './navigation';
|
||||
import { configureOrganizeModule } from './organize';
|
||||
@@ -35,4 +36,5 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureDocsSearchModule(framework);
|
||||
configureDocLinksModule(framework);
|
||||
configureOrganizeModule(framework);
|
||||
configureFavoriteModule(framework);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DBService, type Framework, WorkspaceScope } from '@toeverything/infra';
|
||||
import {
|
||||
type Framework,
|
||||
WorkspaceDBService,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { FolderNode } from './entities/folder-node';
|
||||
import { FolderTree } from './entities/folder-tree';
|
||||
@@ -14,5 +18,5 @@ export function configureOrganizeModule(framework: Framework) {
|
||||
.service(OrganizeService)
|
||||
.entity(FolderTree, [FolderStore])
|
||||
.entity(FolderNode, [FolderStore])
|
||||
.store(FolderStore, [DBService]);
|
||||
.store(FolderStore, [WorkspaceDBService]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { DBService } from '@toeverything/infra';
|
||||
import type { WorkspaceDBService } from '@toeverything/infra';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class FolderStore extends Store {
|
||||
constructor(private readonly dbService: DBService) {
|
||||
constructor(private readonly dbService: WorkspaceDBService) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
FavoriteItemsAdapter,
|
||||
WorkspacePropertiesAdapter,
|
||||
} from './services/adapter';
|
||||
@@ -10,7 +11,9 @@ import {
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { FavoriteService } from '../favorite';
|
||||
import {
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
FavoriteItemsAdapter,
|
||||
WorkspacePropertiesAdapter,
|
||||
} from './services/adapter';
|
||||
@@ -21,5 +24,9 @@ export function configureWorkspacePropertiesModule(framework: Framework) {
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkspaceLegacyProperties, [WorkspaceService])
|
||||
.service(WorkspacePropertiesAdapter, [WorkspaceService])
|
||||
.service(FavoriteItemsAdapter, [WorkspacePropertiesAdapter]);
|
||||
.service(FavoriteItemsAdapter, [WorkspacePropertiesAdapter])
|
||||
.service(CompatibleFavoriteItemsAdapter, [
|
||||
FavoriteItemsAdapter,
|
||||
FavoriteService,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LiveData, Service } from '@toeverything/infra';
|
||||
import { defaultsDeep } from 'lodash-es';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import type { FavoriteService } from '../../favorite';
|
||||
import {
|
||||
PagePropertyType,
|
||||
PageSystemPropertyId,
|
||||
@@ -130,6 +131,9 @@ export class WorkspacePropertiesAdapter extends Service {
|
||||
return this.proxy.schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
get favorites() {
|
||||
return this.proxy.favorites;
|
||||
}
|
||||
@@ -154,8 +158,18 @@ export class WorkspacePropertiesAdapter extends Service {
|
||||
const pageProperties = this.pageProperties?.[id];
|
||||
pageProperties!.system[PageSystemPropertyId.Journal].value = date;
|
||||
}
|
||||
|
||||
/**
|
||||
* After the user completes the migration, call this function to clear the favorite data
|
||||
*/
|
||||
cleanupFavorites() {
|
||||
this.proxy.favorites = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use CompatibleFavoriteItemsAdapter
|
||||
*/
|
||||
export class FavoriteItemsAdapter extends Service {
|
||||
constructor(private readonly adapter: WorkspacePropertiesAdapter) {
|
||||
super();
|
||||
@@ -285,4 +299,70 @@ export class FavoriteItemsAdapter extends Service {
|
||||
existing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.adapter.cleanupFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A service written for compatibility,with the same API as FavoriteItemsAdapter.
|
||||
* When `runtimeConfig.enableNewFavorite` is false, it operates FavoriteItemsAdapter,
|
||||
* and when it is true, it operates FavoriteService.
|
||||
*/
|
||||
export class CompatibleFavoriteItemsAdapter extends Service {
|
||||
constructor(
|
||||
private readonly favoriteItemsAdapter: FavoriteItemsAdapter,
|
||||
private readonly favoriteService: FavoriteService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
toggle(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
if (runtimeConfig.enableNewFavorite) {
|
||||
this.favoriteService.favoriteList.toggle(type, id);
|
||||
} else {
|
||||
this.favoriteItemsAdapter.toggle(id, type);
|
||||
}
|
||||
}
|
||||
|
||||
isFavorite$(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
if (runtimeConfig.enableNewFavorite) {
|
||||
return this.favoriteService.favoriteList.isFavorite$(type, id);
|
||||
} else {
|
||||
return this.favoriteItemsAdapter.isFavorite$(id, type);
|
||||
}
|
||||
}
|
||||
|
||||
isFavorite(id: string, type: WorkspaceFavoriteItem['type']) {
|
||||
if (runtimeConfig.enableNewFavorite) {
|
||||
return this.favoriteService.favoriteList.isFavorite$(type, id).value;
|
||||
} else {
|
||||
return this.favoriteItemsAdapter.isFavorite(id, type);
|
||||
}
|
||||
}
|
||||
|
||||
get favorites$() {
|
||||
if (runtimeConfig.enableNewFavorite) {
|
||||
return this.favoriteService.favoriteList.list$.map<
|
||||
{
|
||||
id: string;
|
||||
order: string;
|
||||
type: 'doc' | 'collection';
|
||||
value: boolean;
|
||||
}[]
|
||||
>(v =>
|
||||
v
|
||||
.filter(i => i.type === 'doc' || i.type === 'collection') // only support doc and collection
|
||||
.map(i => ({
|
||||
id: i.id,
|
||||
order: '',
|
||||
type: i.type as 'doc' | 'collection',
|
||||
value: true,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
return this.favoriteItemsAdapter.favorites$;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user