feat(core): new favorite (#7590)

This commit is contained in:
EYHN
2024-07-26 08:15:32 +00:00
parent 5207e7abfc
commit 3eb09cde5e
47 changed files with 1248 additions and 167 deletions

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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]

View File

@@ -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({

View File

@@ -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(
() => [

View File

@@ -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,
]
);

View File

@@ -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

View File

@@ -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}

View File

@@ -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}
/>
);
};

View File

@@ -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',
});

View File

@@ -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'),
});

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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',
},
},
});

View 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);

View File

@@ -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
);
}
}
}
}

View 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]);
}

View File

@@ -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);
}

View 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}`;
}
}

View File

@@ -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);
}

View File

@@ -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]);
}

View File

@@ -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();
}

View File

@@ -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,
]);
}

View File

@@ -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$;
}
}
}