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

@@ -31,6 +31,13 @@ export const runtimeFlagsSchema = z.object({
enableExperimentalFeature: z.boolean(),
enableInfoModal: z.boolean(),
enableOrganize: z.boolean(),
// show the new favorite, which exclusive to each user
enableNewFavorite: z.boolean(),
// show the old favorite
enableOldFavorite: z.boolean(),
// before 0.16, enableNewFavorite = false and enableOldFavorite = true
// after 0.16, enableNewFavorite = true and enableOldFavorite = false
// for debug purpose, we can enable both
});
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;

View File

@@ -15,7 +15,7 @@ export * from './sync';
export * from './utils';
import type { Framework } from './framework';
import { configureDBModule } from './modules/db';
import { configureWorkspaceDBModule } from './modules/db';
import { configureDocModule } from './modules/doc';
import { configureGlobalContextModule } from './modules/global-context';
import { configureLifecycleModule } from './modules/lifecycle';
@@ -31,7 +31,7 @@ import {
export function configureInfraModules(framework: Framework) {
configureWorkspaceModule(framework);
configureDocModule(framework);
configureDBModule(framework);
configureWorkspaceDBModule(framework);
configureGlobalStorageModule(framework);
configureGlobalContextModule(framework);
configureLifecycleModule(framework);

View File

@@ -1,10 +1,12 @@
import type { Framework } from '../../framework';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { DBService } from './services/db';
import { WorkspaceDBService } from './services/db';
export { AFFiNE_DB_SCHEMA } from './schema';
export { DBService } from './services/db';
export { AFFiNE_WORKSPACE_DB_SCHEMA } from './schema';
export { WorkspaceDBService } from './services/db';
export function configureDBModule(framework: Framework) {
framework.scope(WorkspaceScope).service(DBService, [WorkspaceService]);
export function configureWorkspaceDBModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(WorkspaceDBService, [WorkspaceService]);
}

View File

@@ -1 +1 @@
export { AFFiNE_DB_SCHEMA } from './schema';
export { AFFiNE_WORKSPACE_DB_SCHEMA } from './schema';

View File

@@ -2,7 +2,7 @@ import { nanoid } from 'nanoid';
import { type DBSchemaBuilder, f } from '../../../orm';
export const AFFiNE_DB_SCHEMA = {
export const AFFiNE_WORKSPACE_DB_SCHEMA = {
folders: {
id: f.string().primaryKey().optional().default(nanoid),
parentId: f.string().optional(),
@@ -11,4 +11,13 @@ export const AFFiNE_DB_SCHEMA = {
index: f.string(),
},
} as const satisfies DBSchemaBuilder;
export type AFFiNE_DB_SCHEMA = typeof AFFiNE_DB_SCHEMA;
export type AFFiNE_WORKSPACE_DB_SCHEMA = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
favorite: {
key: f.string().primaryKey(),
index: f.string(),
},
} as const satisfies DBSchemaBuilder;
export type AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA =
typeof AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA;

View File

@@ -2,17 +2,29 @@ import { Doc as YDoc } from 'yjs';
import { Service } from '../../../framework';
import { createORMClient, type TableMap, YjsDBAdapter } from '../../../orm';
import { ObjectPool } from '../../../utils';
import type { WorkspaceService } from '../../workspace';
import { AFFiNE_DB_SCHEMA } from '../schema';
import { AFFiNE_WORKSPACE_DB_SCHEMA } from '../schema';
import { AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA } from '../schema/schema';
export class DBService extends Service {
db: TableMap<AFFiNE_DB_SCHEMA>;
const WorkspaceDBClient = createORMClient(AFFiNE_WORKSPACE_DB_SCHEMA);
const WorkspaceUserdataDBClient = createORMClient(
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA
);
type WorkspaceUserdataDBClient = InstanceType<typeof WorkspaceUserdataDBClient>;
export class WorkspaceDBService extends Service {
db: TableMap<AFFiNE_WORKSPACE_DB_SCHEMA>;
userdataDBPool = new ObjectPool<string, WorkspaceUserdataDBClient>({
onDangling() {
return false; // never release
},
});
constructor(private readonly workspaceService: WorkspaceService) {
super();
const Client = createORMClient(AFFiNE_DB_SCHEMA);
this.db = new Client(
new YjsDBAdapter(AFFiNE_DB_SCHEMA, {
this.db = new WorkspaceDBClient(
new YjsDBAdapter(AFFiNE_WORKSPACE_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: db${workspaceId}${guid}
@@ -26,7 +38,33 @@ export class DBService extends Service {
);
}
// eslint-disable-next-line @typescript-eslint/ban-types
userdataDB(userId: (string & {}) | '__local__') {
// __local__ for local workspace
const userdataDb = this.userdataDBPool.get(userId);
if (userdataDb) {
return userdataDb.obj;
}
const newDB = new WorkspaceUserdataDBClient(
new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: userdata${userId}${workspaceId}${guid}
guid: `userdata$${userId}$${this.workspaceService.workspace.id}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(ydoc.guid, 50);
return ydoc;
},
})
);
this.userdataDBPool.put(userId, newDB);
return newDB;
}
static isDBDocId(docId: string) {
return docId.startsWith('db$');
return docId.startsWith('db$') || docId.startsWith('userdata$');
}
}

View File

@@ -1 +1,2 @@
export * from './core';
export type { DBSchemaBuilder, FieldSchemaBuilder, TableMap } from './core';
export { createORMClient, f, YjsDBAdapter } from './core';

View File

@@ -1,5 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
fontSize: cssVar('fontXs'),
minHeight: '16px',
@@ -10,6 +11,7 @@ export const root = style({
justifyContent: 'space-between',
marginBottom: '4px',
padding: '0 8px',
gap: '8px',
selectors: {
'&:not(:first-of-type)': {
marginTop: '16px',
@@ -18,4 +20,5 @@ export const root = style({
});
export const label = style({
color: cssVar('black30'),
flex: '1',
});

View File

@@ -1,5 +1,5 @@
import { FavoriteTag } from '@affine/core/components/page-list';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { toast } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
@@ -11,7 +11,7 @@ export interface FavoriteButtonProps {
export const useFavorite = (pageId: string) => {
const t = useI18n();
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favorite = useLiveData(favAdapter.isFavorite$(pageId, 'doc'));

View File

@@ -1,4 +1,4 @@
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
@@ -131,9 +131,9 @@ export const useTagGroupDefinitions = (): ItemGroupDefinition<ListItem>[] => {
const sortedTagsLiveData$ = useMemo(
() =>
LiveData.computed(get =>
get(tagList.tags$).sort((a, b) =>
get(a.value$).localeCompare(get(b.value$))
)
get(tagList.tags$)
.slice()
.sort((a, b) => get(a.value$).localeCompare(get(b.value$)))
),
[tagList.tags$]
);
@@ -174,7 +174,7 @@ export const useFavoriteGroupDefinitions = <
T extends ListItem,
>(): ItemGroupDefinition<T>[] => {
const t = useI18n();
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favourites = useLiveData(favAdapter.favorites$);
return useMemo(
() => [

View File

@@ -3,6 +3,7 @@ import { createContainer, style } from '@vanilla-extract/css';
import { root as collectionItemRoot } from './collections/collection-list-item.css';
import { root as pageItemRoot } from './docs/page-list-item.css';
import { root as tagItemRoot } from './tags/tag-list-item.css';
export const listRootContainer = createContainer('list-root-container');
export const pageListScrollContainer = style({
width: '100%',
@@ -49,7 +50,7 @@ export const favoriteCell = style({
flexShrink: 0,
opacity: 0,
selectors: {
[`&[data-favorite], ${pageItemRoot}:hover &, ${collectionItemRoot}:hover &`]:
[`&[data-favorite], ${pageItemRoot}:hover &, ${collectionItemRoot}:hover &, ${tagItemRoot}:hover &`]:
{
opacity: 1,
},

View File

@@ -10,7 +10,8 @@ import {
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 { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { FavoriteService } from '@affine/core/modules/favorite';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { mixpanel } from '@affine/core/utils';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
@@ -32,7 +33,12 @@ import {
SplitViewIcon,
} from '@blocksuite/icons/rc';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import {
useLiveData,
useService,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
@@ -63,7 +69,7 @@ export const PageOperationCell = ({
const { appSettings } = useAppSettingHelper();
const { setTrashModal } = useTrashModalHelper(currentWorkspace.docCollection);
const [openDisableShared, setOpenDisableShared] = useState(false);
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const workbench = useService(WorkbenchService).workbench;
const { duplicate } = useBlockSuiteMetaHelper(currentWorkspace.docCollection);
@@ -215,14 +221,16 @@ export const PageOperationCell = ({
);
return (
<>
<ColWrapper
hideInSmallContainer
data-testid="page-list-item-favorite"
data-favorite={favourite ? true : undefined}
className={styles.favoriteCell}
>
<FavoriteTag onClick={onToggleFavoritePage} active={favourite} />
</ColWrapper>
{runtimeConfig.enableNewFavorite && (
<ColWrapper
hideInSmallContainer
data-testid="page-list-item-favorite"
data-favorite={favourite ? true : undefined}
className={styles.favoriteCell}
>
<FavoriteTag onClick={onToggleFavoritePage} active={favourite} />
</ColWrapper>
)}
<ColWrapper alignment="start">
<Menu
items={OperationMenu}
@@ -319,7 +327,7 @@ export const CollectionOperationCell = ({
}: CollectionOperationCellProps) => {
const t = useI18n();
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const docCollection = useService(WorkspaceService).workspace.docCollection;
const { createPage } = usePageHelper(docCollection);
const { openConfirmModal } = useConfirmModal();
@@ -482,12 +490,31 @@ export const TagOperationCell = ({
}: TagOperationCellProps) => {
const t = useI18n();
const [open, setOpen] = useState(false);
const { favoriteService } = useServices({
FavoriteService,
});
const favourite = useLiveData(
favoriteService.favoriteList.isFavorite$('tag', tag.id)
);
const handleDelete = useCallback(() => {
onTagDelete([tag.id]);
}, [onTagDelete, tag.id]);
const onToggleFavoriteCollection = useCallback(() => {
favoriteService.favoriteList.toggle('tag', tag.id);
}, [favoriteService, tag.id]);
return (
<>
<ColWrapper
hideInSmallContainer
data-testid="page-list-item-favorite"
data-favorite={favourite ? true : undefined}
className={styles.favoriteCell}
>
<FavoriteTag onClick={onToggleFavoriteCollection} active={favourite} />
</ColWrapper>
<div className={styles.editTagWrapper} data-show={open}>
<div style={{ width: '100%' }}>
<CreateOrEditTag open={open} onOpenChange={setOpen} tagMeta={tag} />

View File

@@ -1,4 +1,4 @@
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } from '@affine/core/modules/share-doc';
import type { Collection, Filter } from '@affine/env/filter';
import { PublicPageMode } from '@affine/graphql';
@@ -36,7 +36,7 @@ export const useFilteredPageMetas = (
shareDocsService.shareDocs?.revalidate();
}, [shareDocsService]);
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favoriteItems = useLiveData(favAdapter.favorites$);
const filteredPageMetas = useMemo(

View File

@@ -2,7 +2,7 @@ import type { MenuItemProps } from '@affine/component';
import { Menu, MenuIcon, MenuItem } from '@affine/component';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
@@ -79,7 +79,7 @@ export const CollectionOperations = ({
workbench.openCollection(collection.id, { at: 'tail' });
}, [collection.id, workbench]);
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const onToggleFavoritePage = useCallback(() => {
favAdapter.toggle(collection.id, 'collection');

View File

@@ -1,5 +1,5 @@
import { Tooltip } from '@affine/component';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { Collection } from '@affine/env/filter';
import { Trans, useI18n } from '@affine/i18n';
import {
@@ -42,7 +42,7 @@ export const RulesMode = ({
const [showPreview, setShowPreview] = useState(true);
const allowListPages: DocMeta[] = [];
const rulesPages: DocMeta[] = [];
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favorites = useLiveData(favAdapter.favorites$);
allPageListConfig.allPages.forEach(meta => {
if (meta.trash) {

View File

@@ -1,6 +1,6 @@
import { Menu, toast } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } from '@affine/core/modules/share-doc';
import { PublicPageMode } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
@@ -60,20 +60,20 @@ export const SelectPage = ({
}, [onChange]);
const {
workspaceService,
favoriteItemsAdapter,
compatibleFavoriteItemsAdapter,
shareDocsService,
docsService,
} = useServices({
DocsService,
ShareDocsService,
WorkspaceService,
FavoriteItemsAdapter,
CompatibleFavoriteItemsAdapter,
});
const shareDocs = useLiveData(shareDocsService.shareDocs?.list$);
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(docCollection);
const favourites = useLiveData(favoriteItemsAdapter.favorites$);
const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$);
useEffect(() => {
shareDocsService.shareDocs?.revalidate();
@@ -108,14 +108,14 @@ export const SelectPage = ({
const onToggleFavoritePage = useCallback(
(page: DocMeta) => {
const status = isFavorite(page);
favoriteItemsAdapter.toggle(page.id, 'doc');
compatibleFavoriteItemsAdapter.toggle(page.id, 'doc');
toast(
status
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
},
[favoriteItemsAdapter, isFavorite, t]
[compatibleFavoriteItemsAdapter, isFavorite, t]
);
const pageHeaderColsDef = usePageHeaderColsDef();

View File

@@ -2,6 +2,8 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import {
ExplorerCollections,
ExplorerFavorites,
ExplorerMigrationFavorites,
ExplorerOldFavorites,
ExplorerOrganize,
} from '@affine/core/modules/explorer';
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
@@ -161,8 +163,10 @@ export const RootAppSidebar = memo(
</MenuItem>
</SidebarContainer>
<SidebarScrollableContainer>
{runtimeConfig.enableNewFavorite && <ExplorerFavorites />}
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
<ExplorerFavorites />
{runtimeConfig.enableNewFavorite && <ExplorerMigrationFavorites />}
{runtimeConfig.enableOldFavorite && <ExplorerOldFavorites />}
<ExplorerCollections />
<ExplorerTags />
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />

View File

@@ -2,7 +2,7 @@ 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/properties';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } from '@affine/core/modules/share-doc';
import { PublicPageMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
@@ -32,7 +32,7 @@ export const useAllPageListConfig = () => {
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
[pageMetas]
);
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const t = useI18n();
const favoriteItems = useLiveData(favAdapter.favorites$);

View File

@@ -4,7 +4,7 @@ import {
PreconditionStrategy,
registerAffineCommand,
} from '@affine/core/commands';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry';
import { mixpanel } from '@affine/core/utils';
import { WorkspaceFlavour } from '@affine/env/workspace';
@@ -32,7 +32,7 @@ export function useRegisterBlocksuiteEditorCommands() {
const workspace = useService(WorkspaceService).workspace;
const docCollection = workspace.docCollection;
const favAdapter = useService(FavoriteItemsAdapter);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favorite = useLiveData(favAdapter.isFavorite$(docId, 'doc'));
const trash = useLiveData(doc.trash$);

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

View File

@@ -36,7 +36,13 @@ export interface AffineDNDData extends DNDData {
collectionId: string;
}
| {
at: 'explorer:favorite:items';
at: 'explorer:favorite:list';
}
| {
at: 'explorer:old-favorite:list';
}
| {
at: 'explorer:migration-data:list';
}
| {
at: 'all-docs:list';
@@ -64,6 +70,12 @@ export interface AffineDNDData extends DNDData {
| {
at: 'explorer:organize:folder';
}
| {
at: 'explorer:favorite:root';
}
| {
at: 'explorer:old-favorite:root';
}
| {
at: 'explorer:doc';
}

View File

@@ -1115,6 +1115,15 @@
"com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.",
"com.affine.rootAppSidebar.collections": "Collections",
"com.affine.rootAppSidebar.favorites": "Favourites",
"com.affine.rootAppSidebar.migration-data": "Migration data",
"com.affine.rootAppSidebar.migration-data.help": "Migration your favorites data",
"com.affine.rootAppSidebar.migration-data.help.description": "This is the old Favourites data. Previously, Favourites were shared during collaboration. We have redesigned the Favourites feature, which requires you to take certain actions. You can drag this content or directly hide and delete this part of the data.",
"com.affine.rootAppSidebar.migration-data.help.clean-all": "Clear all migration data",
"com.affine.rootAppSidebar.migration-data.help.confirm": "OK",
"com.affine.rootAppSidebar.migration-data.clean-all": "Clean all migration data",
"com.affine.rootAppSidebar.migration-data.clean-all.description": "This will delete all Migration data, don't worry, <b>it won't delete any data entities</b>, it will just clean up the content on the sidebar. Once the content is cleared, the sidebar will no longer display this section until the next occurrence of conflicting data.",
"com.affine.rootAppSidebar.migration-data.clean-all.confirm": "OK",
"com.affine.rootAppSidebar.migration-data.clean-all.cancel": "Cancel",
"com.affine.rootAppSidebar.organize": "Organize",
"com.affine.rootAppSidebar.organize.empty": "No Folders",
"com.affine.rootAppSidebar.organize.empty-folder": "Empty Folder",