From 54da85ec622df4a28245b8525e05a82dfbca59ff Mon Sep 17 00:00:00 2001 From: EYHN Date: Fri, 26 Jul 2024 04:35:31 +0000 Subject: [PATCH] feat(core): init organize (#7456) --- packages/common/env/src/global.ts | 1 + packages/common/infra/src/index.ts | 3 + .../common/infra/src/livedata/livedata.ts | 4 + packages/common/infra/src/modules/db/index.ts | 10 + .../infra/src/modules/db/schema/index.ts | 1 + .../infra/src/modules/db/schema/schema.ts | 14 + .../infra/src/modules/db/services/db.ts | 32 + .../infra/src/modules/doc/entities/doc.ts | 14 +- .../common/infra/src/modules/doc/index.ts | 2 +- .../infra/src/modules/doc/services/docs.ts | 43 ++ .../infra/src/modules/doc/stores/docs.ts | 12 + .../global-context/entities/global-context.ts | 9 + packages/common/infra/src/orm/core/table.ts | 2 +- packages/common/infra/src/orm/index.ts | 1 + packages/common/infra/src/sync/doc/index.ts | 4 + packages/common/infra/src/sync/doc/local.ts | 4 + .../src/components/rename-modal/index.tsx | 1 + packages/frontend/component/src/index.ts | 2 + .../component/src/ui/dnd/dnd.stories.tsx | 39 +- .../component/src/ui/dnd/draggable.ts | 38 +- .../src/ui/dnd/drop-indicator.css.ts | 2 +- .../component/src/ui/dnd/drop-indicator.tsx | 2 - .../component/src/ui/dnd/drop-target.ts | 252 ++++++-- .../frontend/component/src/ui/input/input.tsx | 35 +- .../component/src/ui/lottie/folder-icon.json | 421 ++++++++++++ .../component/src/ui/lottie/folder-icon.tsx | 39 ++ .../component/src/ui/modal/confirm-modal.tsx | 12 +- packages/frontend/core/package.json | 3 +- .../affine/page-properties/common.ts | 20 - .../page-properties/info-modal/info-modal.tsx | 30 +- .../affine/page-properties/table.tsx | 11 +- .../page-properties/tags-inline-editor.tsx | 18 +- .../app-sidebar/category-divider/index.tsx | 36 +- .../block-suite-editor/lit-adaper.tsx | 2 +- .../collections/collection-list-item.css.ts | 2 +- .../collections/collection-list-item.tsx | 232 +++---- .../page-list/docs/page-list-item.css.ts | 4 +- .../page-list/docs/page-list-item.tsx | 393 +++++------ .../components/page-list/operation-cell.tsx | 3 +- .../components/page-list/tags/create-tag.tsx | 25 +- .../page-list/tags/tag-list-item.css.ts | 2 +- .../page-list/tags/tag-list-item.tsx | 236 +++---- .../page-list/tags/virtualized-tag-list.tsx | 2 +- .../collections/add-collection-button.tsx | 24 - .../collections/collections-list.tsx | 327 ---------- .../collections/index.tsx | 1 - .../collections/styles.css.ts | 111 ---- .../components/drag-menu-item-overlay.tsx | 16 - .../components/draggable-menu-item.css.ts | 33 - .../components/operation-item.tsx | 182 ------ .../components/operation-menu-button.tsx | 124 ---- .../components/postfix-item.tsx | 69 -- .../workspace-slider-bar/doc-tree/doc.css.ts | 61 -- .../workspace-slider-bar/doc-tree/doc.tsx | 174 ----- .../workspace-slider-bar/doc-tree/node.css.ts | 6 - .../workspace-slider-bar/doc-tree/node.tsx | 103 --- .../favorite/add-favourite-button.tsx | 68 -- .../favorite/empty-item.tsx | 22 - .../favorite/favorite-list.tsx | 132 ---- .../favorite/favourite-nav-item.tsx | 81 --- .../workspace-slider-bar/favorite/index.tsx | 1 - .../favorite/styles.css.ts | 123 ---- .../src/components/root-app-sidebar/index.tsx | 98 +-- .../root-app-sidebar/trash-button.tsx | 73 +++ .../src/hooks/affine/use-global-dnd-helper.ts | 281 -------- .../frontend/core/src/layouts/styles.css.ts | 19 - .../core/src/layouts/workspace-layout.tsx | 126 +--- .../modules/collection/services/collection.ts | 6 + .../docs-search/entities/docs-indexer.ts | 5 + .../core/src/modules/explorer/README.md | 3 + .../core/src/modules/explorer/index.ts | 3 + .../views/nodes/collection/empty.css.tsx | 19 + .../explorer/views/nodes/collection/empty.tsx | 24 + .../explorer/views/nodes/collection/index.tsx | 317 +++++++++ .../views/nodes/collection/operations.tsx | 216 +++++++ .../explorer/views/nodes/doc/empty.css.tsx | 19 + .../explorer/views/nodes/doc/empty.tsx | 24 + .../explorer/views/nodes/doc/index.tsx | 250 +++++++ .../explorer/views/nodes/doc/operations.tsx | 200 ++++++ .../explorer/views/nodes/doc/styles.css.ts | 7 + .../explorer/views/nodes/folder/empty.css.ts | 44 ++ .../explorer/views/nodes/folder/empty.tsx | 53 ++ .../explorer/views/nodes/folder/index.tsx | 612 ++++++++++++++++++ .../explorer/views/nodes/tag/empty.css.tsx | 19 + .../explorer/views/nodes/tag/empty.tsx | 24 + .../explorer/views/nodes/tag/index.tsx | 196 ++++++ .../explorer/views/nodes/tag/operations.tsx | 115 ++++ .../explorer/views/nodes/tag/styles.css.ts | 15 + .../src/modules/explorer/views/nodes/types.ts | 49 ++ .../views/sections/collections/empty.css.ts | 35 + .../views/sections/collections/empty.tsx | 30 + .../views/sections/collections/index.tsx | 72 +++ .../views/sections/collections/styles.css.ts | 5 + .../views/sections/favorites/empty.css.ts | 36 ++ .../views/sections/favorites/empty.tsx | 61 ++ .../views/sections/favorites/index.tsx | 286 ++++++++ .../views/sections/favorites/styles.css.ts | 16 + .../views/sections/organize/empty.css.ts | 36 ++ .../views/sections/organize/empty.tsx | 30 + .../views/sections/organize/index.tsx | 125 ++++ .../views/sections/organize/styles.css.ts | 15 + .../explorer/views/sections/tags/empty.css.ts | 36 ++ .../explorer/views/sections/tags/empty.tsx | 30 + .../explorer/views/sections/tags/index.tsx | 64 ++ .../views/sections/tags/styles.css.ts | 15 + .../modules/explorer/views/tree/context.tsx | 11 + .../explorer/views/tree/drop-effect.css.ts | 25 + .../explorer/views/tree/drop-effect.tsx | 36 ++ .../src/modules/explorer/views/tree/index.tsx | 7 + .../modules/explorer/views/tree/node.css.ts | 166 +++++ .../src/modules/explorer/views/tree/node.tsx | 422 ++++++++++++ .../modules/explorer/views/tree/root.css.ts | 10 + .../src/modules/explorer/views/tree/root.tsx | 41 ++ .../src/modules/explorer/views/tree/types.ts | 5 + packages/frontend/core/src/modules/index.ts | 2 + .../modules/organize/entities/folder-node.ts | 168 +++++ .../modules/organize/entities/folder-tree.ts | 32 + .../core/src/modules/organize/index.ts | 18 + .../src/modules/organize/services/organize.ts | 6 + .../src/modules/organize/stores/folder.ts | 148 +++++ .../core/src/modules/organize/types.ts | 7 + .../core/src/modules/tag/service/tag.ts | 25 + .../src/pages/workspace/collection/index.tsx | 34 +- .../detail-page/detail-page-header.tsx | 3 +- .../workspace/detail-page/detail-page.tsx | 3 +- .../core/src/pages/workspace/tag/index.tsx | 31 +- .../core/src/pages/workspace/trash-page.tsx | 23 +- packages/frontend/core/src/types/dnd.ts | 77 +++ .../__tests__/fractional-indexing.spec.ts | 41 ++ .../core/src/utils/fractional-indexing.ts | 100 +++ packages/frontend/i18n/src/resources/en.json | 23 + tests/affine-cloud/e2e/collaboration.spec.ts | 4 +- tests/affine-local/e2e/doc-info-modal.spec.ts | 6 +- tests/affine-local/e2e/drag-page.spec.ts | 68 +- .../e2e/local-first-collections-items.spec.ts | 40 +- .../e2e/local-first-favorites-items.spec.ts | 36 +- tests/kit/utils/editor.ts | 2 +- tests/kit/utils/page-logic.ts | 28 +- tools/cli/src/webpack/runtime-config.ts | 2 + yarn.lock | 26 +- 140 files changed, 6257 insertions(+), 2804 deletions(-) create mode 100644 packages/common/infra/src/modules/db/index.ts create mode 100644 packages/common/infra/src/modules/db/schema/index.ts create mode 100644 packages/common/infra/src/modules/db/schema/schema.ts create mode 100644 packages/common/infra/src/modules/db/services/db.ts create mode 100644 packages/common/infra/src/orm/index.ts create mode 100644 packages/frontend/component/src/ui/lottie/folder-icon.json create mode 100644 packages/frontend/component/src/ui/lottie/folder-icon.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/collections/add-collection-button.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/components/draggable-menu-item.css.ts delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/doc.css.ts delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/doc.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/node.css.ts delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/node.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/empty-item.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/index.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/styles.css.ts create mode 100644 packages/frontend/core/src/components/root-app-sidebar/trash-button.tsx delete mode 100644 packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts delete mode 100644 packages/frontend/core/src/layouts/styles.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/README.md create mode 100644 packages/frontend/core/src/modules/explorer/index.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/collection/empty.css.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/collection/empty.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/collection/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/doc/empty.css.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/doc/empty.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/doc/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/doc/styles.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/folder/empty.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/folder/empty.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/tag/empty.css.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/tag/empty.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/tag/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/tag/styles.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/nodes/types.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/collections/empty.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/collections/empty.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/collections/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/collections/styles.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/favorites/empty.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/favorites/empty.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/favorites/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/favorites/styles.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/organize/empty.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/organize/empty.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/organize/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/organize/styles.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/tags/empty.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/tags/empty.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/tags/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/sections/tags/styles.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/context.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/drop-effect.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/drop-effect.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/index.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/node.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/node.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/root.css.ts create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/root.tsx create mode 100644 packages/frontend/core/src/modules/explorer/views/tree/types.ts create mode 100644 packages/frontend/core/src/modules/organize/entities/folder-node.ts create mode 100644 packages/frontend/core/src/modules/organize/entities/folder-tree.ts create mode 100644 packages/frontend/core/src/modules/organize/index.ts create mode 100644 packages/frontend/core/src/modules/organize/services/organize.ts create mode 100644 packages/frontend/core/src/modules/organize/stores/folder.ts create mode 100644 packages/frontend/core/src/modules/organize/types.ts create mode 100644 packages/frontend/core/src/types/dnd.ts create mode 100644 packages/frontend/core/src/utils/__tests__/fractional-indexing.spec.ts diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index 06b162bbe..5c4fb8199 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -30,6 +30,7 @@ export const runtimeFlagsSchema = z.object({ enableEnhanceShareMode: z.boolean(), enableExperimentalFeature: z.boolean(), enableInfoModal: z.boolean(), + enableOrganize: z.boolean(), }); export type RuntimeConfig = z.infer; diff --git a/packages/common/infra/src/index.ts b/packages/common/infra/src/index.ts index e06e9400f..6124ef192 100644 --- a/packages/common/infra/src/index.ts +++ b/packages/common/infra/src/index.ts @@ -4,6 +4,7 @@ export * from './blocksuite'; export * from './framework'; export * from './initialization'; export * from './livedata'; +export * from './modules/db'; export * from './modules/doc'; export * from './modules/global-context'; export * from './modules/lifecycle'; @@ -14,6 +15,7 @@ export * from './sync'; export * from './utils'; import type { Framework } from './framework'; +import { configureDBModule } from './modules/db'; import { configureDocModule } from './modules/doc'; import { configureGlobalContextModule } from './modules/global-context'; import { configureLifecycleModule } from './modules/lifecycle'; @@ -29,6 +31,7 @@ import { export function configureInfraModules(framework: Framework) { configureWorkspaceModule(framework); configureDocModule(framework); + configureDBModule(framework); configureGlobalStorageModule(framework); configureGlobalContextModule(framework); configureLifecycleModule(framework); diff --git a/packages/common/infra/src/livedata/livedata.ts b/packages/common/infra/src/livedata/livedata.ts index afcc36916..742163ea7 100644 --- a/packages/common/infra/src/livedata/livedata.ts +++ b/packages/common/infra/src/livedata/livedata.ts @@ -449,6 +449,10 @@ export class LiveData ) as any; } + static flat(v: T): Flat> { + return new LiveData(v).flat(); + } + waitFor(predicate: (v: T) => unknown, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const subscription = this.subscribe(v => { diff --git a/packages/common/infra/src/modules/db/index.ts b/packages/common/infra/src/modules/db/index.ts new file mode 100644 index 000000000..d36b09b0e --- /dev/null +++ b/packages/common/infra/src/modules/db/index.ts @@ -0,0 +1,10 @@ +import type { Framework } from '../../framework'; +import { WorkspaceScope, WorkspaceService } from '../workspace'; +import { DBService } from './services/db'; + +export { AFFiNE_DB_SCHEMA } from './schema'; +export { DBService } from './services/db'; + +export function configureDBModule(framework: Framework) { + framework.scope(WorkspaceScope).service(DBService, [WorkspaceService]); +} diff --git a/packages/common/infra/src/modules/db/schema/index.ts b/packages/common/infra/src/modules/db/schema/index.ts new file mode 100644 index 000000000..e394f67c7 --- /dev/null +++ b/packages/common/infra/src/modules/db/schema/index.ts @@ -0,0 +1 @@ +export { AFFiNE_DB_SCHEMA } from './schema'; diff --git a/packages/common/infra/src/modules/db/schema/schema.ts b/packages/common/infra/src/modules/db/schema/schema.ts new file mode 100644 index 000000000..6075ea0af --- /dev/null +++ b/packages/common/infra/src/modules/db/schema/schema.ts @@ -0,0 +1,14 @@ +import { nanoid } from 'nanoid'; + +import { type DBSchemaBuilder, f } from '../../../orm'; + +export const AFFiNE_DB_SCHEMA = { + folders: { + id: f.string().primaryKey().optional().default(nanoid), + parentId: f.string().optional(), + data: f.string(), + type: f.string(), + index: f.string(), + }, +} as const satisfies DBSchemaBuilder; +export type AFFiNE_DB_SCHEMA = typeof AFFiNE_DB_SCHEMA; diff --git a/packages/common/infra/src/modules/db/services/db.ts b/packages/common/infra/src/modules/db/services/db.ts new file mode 100644 index 000000000..5c05bc0a9 --- /dev/null +++ b/packages/common/infra/src/modules/db/services/db.ts @@ -0,0 +1,32 @@ +import { Doc as YDoc } from 'yjs'; + +import { Service } from '../../../framework'; +import { createORMClient, type TableMap, YjsDBAdapter } from '../../../orm'; +import type { WorkspaceService } from '../../workspace'; +import { AFFiNE_DB_SCHEMA } from '../schema'; + +export class DBService extends Service { + db: TableMap; + + constructor(private readonly workspaceService: WorkspaceService) { + super(); + const Client = createORMClient(AFFiNE_DB_SCHEMA); + this.db = new Client( + new YjsDBAdapter(AFFiNE_DB_SCHEMA, { + getDoc: guid => { + const ydoc = new YDoc({ + // guid format: db${workspaceId}${guid} + guid: `db$${this.workspaceService.workspace.id}$${guid}`, + }); + this.workspaceService.workspace.engine.doc.addDoc(ydoc, false); + this.workspaceService.workspace.engine.doc.setPriority(ydoc.guid, 50); + return ydoc; + }, + }) + ); + } + + static isDBDocId(docId: string) { + return docId.startsWith('db$'); + } +} diff --git a/packages/common/infra/src/modules/doc/entities/doc.ts b/packages/common/infra/src/modules/doc/entities/doc.ts index b7309be81..4e4fb8b55 100644 --- a/packages/common/infra/src/modules/doc/entities/doc.ts +++ b/packages/common/infra/src/modules/doc/entities/doc.ts @@ -1,9 +1,13 @@ import { Entity } from '../../../framework'; import type { DocScope } from '../scopes/doc'; +import type { DocsStore } from '../stores/docs'; import type { DocMode } from './record'; export class Doc extends Entity { - constructor(public readonly scope: DocScope) { + constructor( + public readonly scope: DocScope, + private readonly store: DocsStore + ) { super(); } @@ -42,4 +46,12 @@ export class Doc extends Entity { restoreFromTrash() { return this.record.restoreFromTrash(); } + + waitForSyncReady() { + return this.store.waitForDocLoadReady(this.id); + } + + setPriorityLoad(priority: number) { + return this.store.setPriorityLoad(this.id, priority); + } } diff --git a/packages/common/infra/src/modules/doc/index.ts b/packages/common/infra/src/modules/doc/index.ts index d0024eba4..f92ac009c 100644 --- a/packages/common/infra/src/modules/doc/index.ts +++ b/packages/common/infra/src/modules/doc/index.ts @@ -28,6 +28,6 @@ export function configureDocModule(framework: Framework) { .entity(DocRecord, [DocsStore]) .entity(DocRecordList, [DocsStore]) .scope(DocScope) - .entity(Doc, [DocScope]) + .entity(Doc, [DocScope, DocsStore]) .service(DocService); } diff --git a/packages/common/infra/src/modules/doc/services/docs.ts b/packages/common/infra/src/modules/doc/services/docs.ts index d2fcdb665..7e8121851 100644 --- a/packages/common/infra/src/modules/doc/services/docs.ts +++ b/packages/common/infra/src/modules/doc/services/docs.ts @@ -1,4 +1,5 @@ import { Unreachable } from '@affine/env/constant'; +import type { RootBlockModel } from '@blocksuite/blocks'; import { Service } from '../../../framework'; import { initEmptyPage } from '../../../initialization'; @@ -59,6 +60,7 @@ export class DocsService extends Service { ) { const doc = this.store.createBlockSuiteDoc(); initEmptyPage(doc, options.title); + this.store.markDocSyncStateAsReady(doc.id); const docRecord = this.list.doc$(doc.id).value; if (!docRecord) { throw new Unreachable(); @@ -68,4 +70,45 @@ export class DocsService extends Service { } return docRecord; } + + async addLinkedDoc(targetDocId: string, linkedDocId: string) { + const { doc, release } = this.open(targetDocId); + doc.setPriorityLoad(10); + await doc.waitForSyncReady(); + const text = doc.blockSuiteDoc.Text.fromDelta([ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: linkedDocId, + }, + }, + }, + ]); + const [frame] = doc.blockSuiteDoc.getBlocksByFlavour('affine:note'); + frame && + doc.blockSuiteDoc.addBlock( + 'affine:paragraph' as never, // TODO(eyhn): fix type + { text }, + frame.id + ); + release(); + } + + async changeDocTitle(docId: string, newTitle: string) { + const { doc, release } = this.open(docId); + doc.setPriorityLoad(10); + await doc.waitForSyncReady(); + const pageBlock = doc.blockSuiteDoc.getBlocksByFlavour('affine:page').at(0) + ?.model as RootBlockModel | undefined; + if (pageBlock) { + doc.blockSuiteDoc.transact(() => { + pageBlock.title.delete(0, pageBlock.title.length); + pageBlock.title.insert(newTitle, 0); + }); + doc.record.setMeta({ title: newTitle }); + } + release(); + } } diff --git a/packages/common/infra/src/modules/doc/stores/docs.ts b/packages/common/infra/src/modules/doc/stores/docs.ts index e5818ee62..2e828144e 100644 --- a/packages/common/infra/src/modules/doc/stores/docs.ts +++ b/packages/common/infra/src/modules/doc/stores/docs.ts @@ -112,4 +112,16 @@ export class DocsStore extends Store { watchDocModeSetting(id: string) { return this.localState.watch(`page:${id}:mode`); } + + waitForDocLoadReady(id: string) { + return this.workspaceService.workspace.engine.doc.waitForReady(id); + } + + setPriorityLoad(id: string, priority: number) { + return this.workspaceService.workspace.engine.doc.setPriority(id, priority); + } + + markDocSyncStateAsReady(id: string) { + this.workspaceService.workspace.engine.doc.markAsReady(id); + } } diff --git a/packages/common/infra/src/modules/global-context/entities/global-context.ts b/packages/common/infra/src/modules/global-context/entities/global-context.ts index 92f3861c7..c022df614 100644 --- a/packages/common/infra/src/modules/global-context/entities/global-context.ts +++ b/packages/common/infra/src/modules/global-context/entities/global-context.ts @@ -8,10 +8,19 @@ export class GlobalContext extends Entity { workspaceId = this.define('workspaceId'); + isDoc = this.define('isDoc'); docId = this.define('docId'); + isCollection = this.define('isCollection'); + collectionId = this.define('collectionId'); + + isTrash = this.define('isTrash'); + docMode = this.define('docMode'); + isTag = this.define('isTag'); + tagId = this.define('tagId'); + define(key: string) { this.memento.set(key, null); const livedata$ = LiveData.from(this.memento.watch(key), null); diff --git a/packages/common/infra/src/orm/core/table.ts b/packages/common/infra/src/orm/core/table.ts index 0fc6468b5..8ac7c14da 100644 --- a/packages/common/infra/src/orm/core/table.ts +++ b/packages/common/infra/src/orm/core/table.ts @@ -159,7 +159,7 @@ export class Table { return record || null; } - get$(key: PrimaryKeyFieldType): Observable> { + get$(key: PrimaryKeyFieldType): Observable | null> { let ob$ = this.subscribedKeys.get(key); if (!ob$) { diff --git a/packages/common/infra/src/orm/index.ts b/packages/common/infra/src/orm/index.ts new file mode 100644 index 000000000..4b0e04137 --- /dev/null +++ b/packages/common/infra/src/orm/index.ts @@ -0,0 +1 @@ +export * from './core'; diff --git a/packages/common/infra/src/sync/doc/index.ts b/packages/common/infra/src/sync/doc/index.ts index 435f76d30..d15464071 100644 --- a/packages/common/infra/src/sync/doc/index.ts +++ b/packages/common/infra/src/sync/doc/index.ts @@ -77,6 +77,10 @@ export class DocEngine { }); } + markAsReady(docId: string) { + this.localPart.actions.markAsReady(docId); + } + constructor( storage: DocStorage, private readonly server?: DocServer | null diff --git a/packages/common/infra/src/sync/doc/local.ts b/packages/common/infra/src/sync/doc/local.ts index 15bead64d..2bf6ad3f3 100644 --- a/packages/common/infra/src/sync/doc/local.ts +++ b/packages/common/infra/src/sync/doc/local.ts @@ -163,6 +163,10 @@ export class DocEngineLocalPart { this.status.docs.set(doc.guid, doc); this.statusUpdatedSubject$.next(doc.guid); }, + markAsReady: (docId: string) => { + this.status.readyDocs.add(docId); + this.statusUpdatedSubject$.next(docId); + }, }; readonly jobs = { diff --git a/packages/frontend/component/src/components/rename-modal/index.tsx b/packages/frontend/component/src/components/rename-modal/index.tsx index 80cbc74d7..befb76f95 100644 --- a/packages/frontend/component/src/components/rename-modal/index.tsx +++ b/packages/frontend/component/src/components/rename-modal/index.tsx @@ -48,6 +48,7 @@ export const RenameModal = ({ items={ { - type DataType = DNDData, { type: 'big' | 'small' }>; + type DataType = DNDData< + Record, + { type: 'big' | 'small' | 'tips' } + >; const { dragRef, dragging, draggingPosition, dropTarget, CustomDragPreview } = useDraggable(() => ({}), []); const { dropTargetRef: bigDropTargetRef } = useDropTarget( @@ -189,6 +192,16 @@ export const DynamicDragPreview = () => { }), [] ); + const { + dropTargetRef: tipsDropTargetRef, + draggedOver: tipsDraggedOver, + draggedOverPosition: tipsDraggedOverPosition, + } = useDropTarget( + () => ({ + data: { type: 'tips' }, + }), + [] + ); return (
{ > Small
+
+ Tips + {tipsDraggedOver && ( +
+ tips +
+ )} +
{ }>; canDrag?: DraggableGet; disableDragPreview?: boolean; + dragPreviewPosition?: DraggableDragPreviewPosition; } +export type DraggableDragPreviewPosition = + | 'pointer-outside' + | 'pointer-center' + | 'native'; + export type DraggableCustomDragPreviewProps = React.PropsWithChildren<{ - position?: 'pointer-outside' | 'pointer-center' | 'native'; + position?: DraggableDragPreviewPosition; }>; export const useDraggable = ( @@ -172,9 +178,11 @@ export const useDraggable = ( disableNativeDragPreview({ nativeSetDragImage }); return; } + + let previewPosition: DraggableDragPreviewPosition = + options.dragPreviewPosition ?? 'native'; + if (enableCustomDragPreview.current) { - let previewPosition: DraggableCustomDragPreviewProps['position'] = - 'native'; setCustomNativeDragPreview({ getOffset: (...args) => { if (previewPosition === 'pointer-center') { @@ -199,7 +207,7 @@ export const useDraggable = ( children, position, }: DraggableCustomDragPreviewProps) => { - previewPosition = position; + previewPosition = position || previewPosition; return ReactDOM.createPortal(children, container); } ); @@ -208,6 +216,28 @@ export const useDraggable = ( }, nativeSetDragImage, }); + } else if (previewPosition !== 'native') { + setCustomNativeDragPreview({ + getOffset: (...args) => { + if (previewPosition === 'pointer-center') { + return centerUnderPointer(...args); + } else if (previewPosition === 'pointer-outside') { + return pointerOutsideOfPreview({ + x: '8px', + y: '4px', + })(...args); + } else { + return preserveOffsetOnSource({ + element: source.element, + input: location.current.input, + })(...args); + } + }, + render({ container }) { + container.append(source.element.cloneNode(true)); + }, + nativeSetDragImage, + }); } }, }); diff --git a/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts b/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts index 5f5408f87..99861b28e 100644 --- a/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts +++ b/packages/frontend/component/src/ui/dnd/drop-indicator.css.ts @@ -100,7 +100,7 @@ export const horizontal = style({ right: 0, '::before': { // Horizontal indicators have the terminal on the left - left: `calc(-${terminalSize})`, + left: `calc(-1 * ${terminalSize})`, }, }); diff --git a/packages/frontend/component/src/ui/dnd/drop-indicator.tsx b/packages/frontend/component/src/ui/dnd/drop-indicator.tsx index 481bc44d6..b9ecbb152 100644 --- a/packages/frontend/component/src/ui/dnd/drop-indicator.tsx +++ b/packages/frontend/component/src/ui/dnd/drop-indicator.tsx @@ -1,5 +1,3 @@ -/** @jsx jsx */ - import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; import { cssVar } from '@toeverything/theme'; diff --git a/packages/frontend/component/src/ui/dnd/drop-target.ts b/packages/frontend/component/src/ui/dnd/drop-target.ts index 6f1d04440..e6784accc 100644 --- a/packages/frontend/component/src/ui/dnd/drop-target.ts +++ b/packages/frontend/component/src/ui/dnd/drop-target.ts @@ -20,6 +20,9 @@ type DropTargetGetFeedback = Parameters< source: { data: D['draggable']; }; +} & { + treeInstruction: Instruction | null; + closestEdge: Edge | null; }; type DropTargetGet = @@ -27,17 +30,60 @@ type DropTargetGet = | ((data: DropTargetGetFeedback) => T); function dropTargetGet( - get: T + get: T, + options: DropTargetOptions ): T extends undefined ? undefined : T extends DropTargetGet - ? (args: DropTargetGetFeedback) => I + ? ( + args: Omit, 'treeInstruction' | 'closestEdge'> + ) => I : never { if (get === undefined) { return undefined as any; } - return ((args: DropTargetGetFeedback) => - typeof get === 'function' ? (get as any)(args) : get) as any; + return (( + args: Omit, 'treeInstruction' | 'closestEdge'> + ) => { + if (typeof get === 'function') { + return (get as any)({ + ...args, + get treeInstruction() { + return options.treeInstruction + ? extractInstruction( + attachInstruction( + {}, + { + input: args.input, + element: args.element, + currentLevel: options.treeInstruction.currentLevel, + indentPerLevel: options.treeInstruction.indentPerLevel, + mode: options.treeInstruction.mode, + block: options.treeInstruction.block, + } + ) + ) + : null; + }, + get closestEdge() { + return options.closestEdge + ? extractClosestEdge( + attachClosestEdge( + {}, + { + input: args.input, + element: args.element, + allowedEdges: options.closestEdge.allowedEdges, + } + ) + ) + : null; + }, + }); + } else { + return get; + } + }) as any; } export type DropTargetDropEvent = Parameters< @@ -52,6 +98,8 @@ export type DropTargetDragEvent = Parameters< source: { data: D['draggable'] }; }; +export type DropTargetTreeInstruction = Instruction; + export interface DropTargetOptions { data?: DropTargetGet; canDrop?: DropTargetGet; @@ -80,8 +128,26 @@ export const useDropTarget = ( null ); const [closestEdge, setClosestEdge] = useState(null); + const [dropEffect, setDropEffect] = useState<'copy' | 'link' | 'move' | null>( + null + ); + const [draggedOverDraggable, setDraggedOverDraggable] = useState<{ + data: D['draggable']; + } | null>(null); + const [draggedOverPosition, setDraggedOverPosition] = useState<{ + /** + * relative position to the drop target element top-left corner + */ + relativeX: number; + relativeY: number; + clientX: number; + clientY: number; + }>({ relativeX: 0, relativeY: 0, clientX: 0, clientY: 0 }); const enableDraggedOver = useRef(false); + const enableDraggedOverDraggable = useRef(false); + const enableDraggedOverPosition = useRef(false); + const enableDropEffect = useRef(false); // eslint-disable-next-line react-hooks/exhaustive-deps const options = useMemo(getOptions, deps); @@ -92,19 +158,36 @@ export const useDropTarget = ( } return dropTargetForElements({ element: dropTargetRef.current, - canDrop: dropTargetGet(options.canDrop), - getDropEffect: dropTargetGet(options.dropEffect), - getIsSticky: dropTargetGet(options.isSticky), + canDrop: dropTargetGet(options.canDrop, options), + getDropEffect: dropTargetGet(options.dropEffect, options), + getIsSticky: dropTargetGet(options.isSticky, options), onDrop: args => { if (enableDraggedOver.current) { setDraggedOver(false); } + if (enableDraggedOverDraggable.current) { + setDraggedOverDraggable(null); + } + if (enableDraggedOverPosition.current) { + setDraggedOverPosition({ + relativeX: 0, + relativeY: 0, + clientX: 0, + clientY: 0, + }); + } if (options.treeInstruction) { setTreeInstruction(null); + if (dropTargetRef.current) { + delete dropTargetRef.current.dataset['treeInstruction']; + } } if (options.closestEdge) { setClosestEdge(null); } + if (enableDropEffect.current) { + setDropEffect(null); + } if (dropTargetRef.current) { delete dropTargetRef.current.dataset['draggedOver']; } @@ -120,7 +203,7 @@ export const useDropTarget = ( } }, getData: args => { - const originData = dropTargetGet(options.data ?? {})(args); + const originData = dropTargetGet(options.data ?? {}, options)(args); const { input, element } = args; const withInstruction = options.treeInstruction ? attachInstruction(originData, { @@ -141,43 +224,116 @@ export const useDropTarget = ( : withInstruction; return withClosestEdge; }, - onDragEnter: () => { - if (enableDraggedOver.current) { - setDraggedOver(true); - } - if (dropTargetRef.current) { - dropTargetRef.current.dataset['draggedOver'] = 'true'; - } - }, onDrag: args => { - let instruction = null; - let closestEdge = null; - if (options.treeInstruction) { - instruction = extractInstruction(args.self.data); - setTreeInstruction(instruction); + if ( + args.location.current.dropTargets[0]?.element === + dropTargetRef.current + ) { + if (enableDraggedOverDraggable.current) { + setDraggedOverDraggable({ data: args.source.data }); + } + let instruction = null; + let closestEdge = null; + if (options.treeInstruction) { + instruction = extractInstruction(args.self.data); + setTreeInstruction(instruction); + if (dropTargetRef.current) { + dropTargetRef.current.dataset['treeInstruction'] = + instruction?.type; + } + } + if (options.closestEdge) { + closestEdge = extractClosestEdge(args.self.data); + setClosestEdge(closestEdge); + } + if (enableDropEffect.current) { + setDropEffect(args.self.dropEffect); + } + if (enableDraggedOverPosition.current) { + const rect = args.self.element.getBoundingClientRect(); + const { clientX, clientY } = args.location.current.input; + setDraggedOverPosition({ + relativeX: clientX - rect.x, + relativeY: clientY - rect.y, + clientX: clientX, + clientY: clientY, + }); + } + options.onDrag?.({ + ...args, + treeInstruction: instruction, + closestEdge, + } as DropTargetDropEvent); } - if (options.closestEdge) { - closestEdge = extractClosestEdge(args.self.data); - setClosestEdge(closestEdge); - } - options.onDrag?.({ - ...args, - treeInstruction: instruction, - closestEdge, - } as DropTargetDropEvent); }, - onDragLeave: () => { - if (enableDraggedOver.current) { - setDraggedOver(false); - } - if (options.treeInstruction) { - setTreeInstruction(null); - } - if (options.closestEdge) { - setClosestEdge(null); - } - if (dropTargetRef.current) { - delete dropTargetRef.current.dataset['draggedOver']; + onDropTargetChange: args => { + if ( + args.location.current.dropTargets[0]?.element === + dropTargetRef.current + ) { + if (enableDraggedOver.current) { + setDraggedOver(true); + } + if (options.treeInstruction) { + const instruction = extractInstruction(args.self.data); + setTreeInstruction(instruction); + if (dropTargetRef.current) { + dropTargetRef.current.dataset['treeInstruction'] = + instruction?.type; + } + } + if (options.closestEdge) { + const closestEdge = extractClosestEdge(args.self.data); + setClosestEdge(closestEdge); + } + if (enableDropEffect.current) { + setDropEffect(args.self.dropEffect); + } + if (enableDraggedOverDraggable.current) { + setDraggedOverDraggable({ data: args.source.data }); + } + if (enableDraggedOverPosition.current) { + const rect = args.self.element.getBoundingClientRect(); + setDraggedOverPosition({ + relativeX: args.location.current.input.clientX - rect.x, + relativeY: args.location.current.input.clientY - rect.y, + clientX: args.location.current.input.clientX, + clientY: args.location.current.input.clientY, + }); + } + if (dropTargetRef.current) { + dropTargetRef.current.dataset['draggedOver'] = 'true'; + } + } else { + if (enableDraggedOver.current) { + setDraggedOver(false); + } + if (enableDraggedOverDraggable.current) { + setDraggedOverDraggable(null); + } + if (options.treeInstruction) { + setTreeInstruction(null); + if (dropTargetRef.current) { + delete dropTargetRef.current.dataset['treeInstruction']; + } + } + if (enableDropEffect.current) { + setDropEffect(args.self.dropEffect); + } + if (enableDraggedOverPosition.current) { + setDraggedOverPosition({ + relativeX: 0, + relativeY: 0, + clientX: 0, + clientY: 0, + }); + } + if (options.closestEdge) { + setClosestEdge(null); + } + if (dropTargetRef.current) { + delete dropTargetRef.current.dataset['draggedOver']; + } } }, }); @@ -189,6 +345,18 @@ export const useDropTarget = ( enableDraggedOver.current = true; return draggedOver; }, + get draggedOverDraggable() { + enableDraggedOverDraggable.current = true; + return draggedOverDraggable; + }, + get draggedOverPosition() { + enableDraggedOverPosition.current = true; + return draggedOverPosition; + }, + get dropEffect() { + enableDropEffect.current = true; + return dropEffect; + }, treeInstruction, closestEdge, }; diff --git a/packages/frontend/component/src/ui/input/input.tsx b/packages/frontend/component/src/ui/input/input.tsx index b55c3138d..6d29f6e79 100644 --- a/packages/frontend/component/src/ui/input/input.tsx +++ b/packages/frontend/component/src/ui/input/input.tsx @@ -9,7 +9,7 @@ import type { KeyboardEventHandler, ReactNode, } from 'react'; -import { forwardRef, useCallback } from 'react'; +import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react'; import { input, inputWrapper } from './style.css'; @@ -18,6 +18,7 @@ export type InputProps = { onChange?: (value: string) => void; onBlur?: FocusEventHandler; onKeyDown?: KeyboardEventHandler; + autoSelect?: boolean; noBorder?: boolean; status?: 'error' | 'success' | 'warning' | 'default'; size?: 'default' | 'large' | 'extraLarge'; @@ -43,23 +44,20 @@ export const Input = forwardRef(function Input( onEnter, onKeyDown, autoFocus, + autoSelect, ...otherProps }: InputProps, upstreamRef: ForwardedRef ) { - const handleAutoFocus = useCallback( - (ref: HTMLInputElement | null) => { - if (ref) { - window.setTimeout(() => ref.focus(), 0); - if (typeof upstreamRef === 'function') { - upstreamRef(ref); - } else if (upstreamRef) { - upstreamRef.current = ref; - } + const inputRef = useRef(null); + useLayoutEffect(() => { + if (inputRef.current && (autoFocus || autoSelect)) { + inputRef.current?.focus(); + if (autoSelect) { + inputRef.current?.select(); } - }, - [upstreamRef] - ); + } + }, [autoFocus, autoSelect, upstreamRef]); return (
(function Input( large: size === 'large', 'extra-large': size === 'extraLarge', })} - ref={autoFocus ? handleAutoFocus : upstreamRef} + ref={ref => { + inputRef.current = ref; + if (upstreamRef) { + if (typeof upstreamRef === 'function') { + upstreamRef(ref); + } else { + upstreamRef.current = ref; + } + } + }} disabled={disabled} style={inputStyle} onChange={useCallback( diff --git a/packages/frontend/component/src/ui/lottie/folder-icon.json b/packages/frontend/component/src/ui/lottie/folder-icon.json new file mode 100644 index 000000000..027e384fb --- /dev/null +++ b/packages/frontend/component/src/ui/lottie/folder-icon.json @@ -0,0 +1,421 @@ +{ + "v": "5.12.1", + "fr": 60, + "ip": 0, + "op": 89, + "w": 240, + "h": 240, + "nm": "folder", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "“图层 2”轮廓 3", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { "a": 0, "k": [120, 120, 0], "ix": 2, "l": 2 }, + "a": { "a": 0, "k": [12, 12, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [1000, 1000, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [-1.105, 0], + [0, 0], + [0, 0], + [0, 0], + [-0.366, -0.46], + [0, 0], + [1.105, 0], + [0, 0], + [0, 1.105] + ], + "o": [ + [0, -1.105], + [0, 0], + [0.53, 0], + [0, 0], + [0.634, 0], + [0.272, 0.342], + [0, 1.105], + [0, 0], + [-1.105, 0], + [0, 0] + ], + "v": [ + [-9, -5.25], + [-7, -7.25], + [-2.328, -7.25], + [0.5, -5.25], + [7, -5.25], + [8.565, -4.495], + [9, 5.25], + [7, 7.25], + [-7, 7.25], + [-9, 5.25] + ], + "c": true + } + ] + }, + { + "t": 5, + "s": [ + { + "i": [ + [0, 0], + [-1.105, 0], + [0, 0], + [0, 0], + [0, 0], + [-0.002, -0.756], + [0, 0], + [1.105, 0], + [0, 0], + [0, 1.105] + ], + "o": [ + [0.225, -0.675], + [0, 0], + [0.53, 0], + [0, 0], + [2.525, -0.05], + [-0.002, 0.982], + [-0.375, 1.05], + [0, 0], + [-1.105, 0], + [0, 0] + ], + "v": [ + [-7.5, 0.35], + [-5.5, -1.65], + [-0.828, -1.65], + [2, -1.637], + [7.825, -1.612], + [10.577, 0.344], + [9.075, 5.25], + [7.075, 7.25], + [-7, 7.25], + [-9, 5.25] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [0.466666666667, 0.458823529412, 0.490196078431, 1], + "ix": 3 + }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 1.5, "ix": 5 }, + "lc": 1, + "lj": 2, + "bm": 0, + "nm": "描边 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [12, 11.75], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "组 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 1, + "op": 18060, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "形状图层 1", + "td": 1, + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { "a": 0, "k": [120, 120, 0], "ix": 2, "l": 2 }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 0, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [77.675, -57.65], + [77.675, 39.75], + [-74.425, 39.801], + [-74.425, -57.6] + ], + "c": true + } + ] + }, + { + "t": 5, + "s": [ + { + "i": [ + [0, 0], + [0, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [0, 0], + [0, 0] + ], + "v": [ + [107.675, -32.825], + [84.175, 45.15], + [-80.425, 45.075], + [-80.425, -34.825] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1, 1], "ix": 4 }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-1.575, 18.575], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "矩形 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 18060, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "“图层 2”轮廓", + "tt": 2, + "tp": 2, + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { "a": 0, "k": [120, 120, 0], "ix": 2, "l": 2 }, + "a": { "a": 0, "k": [12, 12, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [1000, 1000, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [0, 0], + [-1.105, 0], + [0, 0], + [-0.375, -0.375], + [0, 0], + [0, 0], + [0, -1.105], + [0, 0], + [1.105, 0], + [0, 0], + [0, 1.105] + ], + "o": [ + [0, -1.105], + [0, 0], + [0.53, 0], + [0, 0], + [0, 0], + [1.105, 0], + [0, 0], + [0, 1.105], + [0, 0], + [-1.105, 0], + [0, 0] + ], + "v": [ + [-9, -5.25], + [-7, -7.25], + [-2.328, -7.25], + [-0.914, -6.664], + [0.5, -5.25], + [7, -5.25], + [9, -3.25], + [9, 5.25], + [7, 7.25], + [-7, 7.25], + [-9, 5.25] + ], + "c": true + }, + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [0.466666666667, 0.458823529412, 0.490196078431, 1], + "ix": 3 + }, + "o": { "a": 0, "k": 100, "ix": 4 }, + "w": { "a": 0, "k": 1.5, "ix": 5 }, + "lc": 1, + "lj": 2, + "bm": 0, + "nm": "描边 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [12, 11.75], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "组 1", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 18060, + "st": 0, + "ct": 1, + "bm": 0 + } + ], + "markers": [], + "props": {} +} diff --git a/packages/frontend/component/src/ui/lottie/folder-icon.tsx b/packages/frontend/component/src/ui/lottie/folder-icon.tsx new file mode 100644 index 000000000..42d976350 --- /dev/null +++ b/packages/frontend/component/src/ui/lottie/folder-icon.tsx @@ -0,0 +1,39 @@ +import clsx from 'clsx'; +import type { LottieRef } from 'lottie-react'; +import Lottie from 'lottie-react'; +import { useEffect, useRef } from 'react'; + +import animationData from './folder-icon.json'; +import * as styles from './styles.css'; + +export interface FolderIconProps { + closed: boolean; // eg, when folder icon is a "dragged over" state + className?: string; +} + +// animated folder icon that has two states: closed and opened +export const AnimatedFolderIcon = ({ closed, className }: FolderIconProps) => { + const lottieRef: LottieRef = useRef(null); + + useEffect(() => { + if (lottieRef.current) { + const lottie = lottieRef.current; + if (closed) { + lottie.setDirection(1); + } else { + lottie.setDirection(-1); + } + lottie.play(); + } + }, [closed]); + + return ( + + ); +}; diff --git a/packages/frontend/component/src/ui/modal/confirm-modal.tsx b/packages/frontend/component/src/ui/modal/confirm-modal.tsx index 20d4432dd..f05963912 100644 --- a/packages/frontend/component/src/ui/modal/confirm-modal.tsx +++ b/packages/frontend/component/src/ui/modal/confirm-modal.tsx @@ -62,11 +62,19 @@ export const ConfirmModal = ({ })} > - -
diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 4e4499e5d..6cc51f76f 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -22,7 +22,7 @@ "@blocksuite/block-std": "0.16.0-canary-202407250137-267cd23", "@blocksuite/blocks": "0.16.0-canary-202407250137-267cd23", "@blocksuite/global": "0.16.0-canary-202407250137-267cd23", - "@blocksuite/icons": "2.1.59", + "@blocksuite/icons": "2.1.60", "@blocksuite/inline": "0.16.0-canary-202407250137-267cd23", "@blocksuite/presets": "0.16.0-canary-202407250137-267cd23", "@blocksuite/store": "0.16.0-canary-202407250137-267cd23", @@ -60,6 +60,7 @@ "file-type": "^19.1.0", "foxact": "^0.2.33", "fractional-indexing": "^3.2.0", + "fractional-indexing-jittered": "^0.9.1", "fuse.js": "^7.0.0", "graphql": "^16.8.1", "history": "^5.3.0", diff --git a/packages/frontend/core/src/components/affine/page-properties/common.ts b/packages/frontend/core/src/components/affine/page-properties/common.ts index 772b069d0..11b8fc162 100644 --- a/packages/frontend/core/src/components/affine/page-properties/common.ts +++ b/packages/frontend/core/src/components/affine/page-properties/common.ts @@ -1,26 +1,6 @@ -import { cssVar } from '@toeverything/theme'; import { createContext } from 'react'; import type { PagePropertiesManager } from './page-properties-manager'; // @ts-expect-error this should always be set export const managerContext = createContext(); - -type TagColorHelper = T extends `paletteLine${infer Color}` ? Color : never; -export type TagColorName = TagColorHelper[0]>; - -const tagColorIds: TagColorName[] = [ - 'Red', - 'Magenta', - 'Orange', - 'Yellow', - 'Green', - 'Teal', - 'Blue', - 'Purple', - 'Grey', -]; - -export const tagColors = tagColorIds.map( - color => [color, cssVar(`paletteLine${color}`)] as const -); diff --git a/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx index 882711244..c7cba6337 100644 --- a/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/info-modal/info-modal.tsx @@ -5,12 +5,11 @@ import { Scrollable, } from '@affine/component'; import { DocsSearchService } from '@affine/core/modules/docs-search'; -import type { Doc } from '@blocksuite/store'; import { LiveData, useLiveData, - useService, - type Workspace, + useServices, + WorkspaceService, } from '@toeverything/infra'; import { Suspense, useCallback, useContext, useMemo, useRef } from 'react'; @@ -30,27 +29,26 @@ import { TimeRow } from './time-row'; export const InfoModal = ({ open, onOpenChange, - page, - workspace, + docId, }: { open: boolean; onOpenChange: (open: boolean) => void; - page: Doc; - workspace: Workspace; + docId: string; }) => { + const { docsSearchService, workspaceService } = useServices({ + DocsSearchService, + WorkspaceService, + }); const titleInputHandleRef = useRef(null); - - const manager = usePagePropertiesManager(page); - + const manager = usePagePropertiesManager(docId); const handleClose = useCallback(() => { onOpenChange(false); }, [onOpenChange]); - const docsSearchService = useService(DocsSearchService); const references = useLiveData( useMemo( - () => LiveData.from(docsSearchService.watchRefsFrom(page.id), null), - [docsSearchService, page.id] + () => LiveData.from(docsSearchService.watchRefsFrom(docId), null), + [docId, docsSearchService] ) ); @@ -76,14 +74,14 @@ export const InfoModal = ({
{ ); }; -export const usePagePropertiesManager = (page: Doc) => { +export const usePagePropertiesManager = (docId: string) => { // the workspace properties adapter adapter is reactive, // which means it's reference will change when any of the properties change // also it will trigger a re-render of the component const adapter = useCurrentWorkspacePropertiesAdapter(); const manager = useMemo(() => { - return new PagePropertiesManager(adapter, page.id); - }, [adapter, page.id]); + return new PagePropertiesManager(adapter, docId); + }, [adapter, docId]); return manager; }; // this is the main component that renders the page properties table at the top of the page below // the page title -export const PagePropertiesTable = ({ page }: { page: Doc }) => { - const manager = usePagePropertiesManager(page); +export const PagePropertiesTable = ({ docId }: { docId: string }) => { + const manager = usePagePropertiesManager(docId); // if the given page is not in the current workspace, then we don't render anything // eg. when it is in history modal diff --git a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx index 4ca306149..b41b58d46 100644 --- a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx @@ -13,7 +13,6 @@ import type { HTMLAttributes, PropsWithChildren } from 'react'; import { useCallback, useMemo, useReducer, useRef, useState } from 'react'; import { TagItem, TempTagItem } from '../../page-list'; -import { tagColors } from './common'; import type { MenuItemOption } from './menu-items'; import { renderMenuItemOptions } from './menu-items'; import * as styles from './tags-inline-editor.css'; @@ -80,7 +79,8 @@ export const EditTagMenu = ({ }>) => { const t = useI18n(); const legacyProperties = useService(WorkspaceLegacyProperties); - const tagList = useService(TagService).tagList; + const tagService = useService(TagService); + const tagList = tagService.tagList; const tag = useLiveData(tagList.tagByTagId$(tagId)); const tagColor = useLiveData(tag?.color$); const tagValue = useLiveData(tag?.value$); @@ -133,7 +133,7 @@ export const EditTagMenu = ({ options.push('-'); options.push( - tagColors.map(([name, color], i) => { + tagService.tagColors.map(([name, color], i) => { return { text: name, icon: ( @@ -170,6 +170,7 @@ export const EditTagMenu = ({ t, tag, tagColor, + tagService.tagColors, tagValue, ]); @@ -185,7 +186,8 @@ const isCreateNewTag = ( export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { const t = useI18n(); - const tagList = useService(TagService).tagList; + const tagService = useService(TagService); + const tagList = tagService.tagList; const tags = useLiveData(tagList.tags$); const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId)); const [inputValue, setInputValue] = useState(''); @@ -265,10 +267,12 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { const [nextColor, rotateNextColor] = useReducer( color => { - const idx = tagColors.findIndex(c => c[1] === color); - return tagColors[(idx + 1) % tagColors.length][1]; + const idx = tagService.tagColors.findIndex(c => c[1] === color); + return tagService.tagColors[(idx + 1) % tagService.tagColors.length][1]; }, - tagColors[Math.floor(Math.random() * tagColors.length)][1] + tagService.tagColors[ + Math.floor(Math.random() * tagService.tagColors.length) + ][1] ); const onCreateTag = useCallback( diff --git a/packages/frontend/core/src/components/app-sidebar/category-divider/index.tsx b/packages/frontend/core/src/components/app-sidebar/category-divider/index.tsx index b4a703675..13a06697a 100644 --- a/packages/frontend/core/src/components/app-sidebar/category-divider/index.tsx +++ b/packages/frontend/core/src/components/app-sidebar/category-divider/index.tsx @@ -1,17 +1,29 @@ import clsx from 'clsx'; -import type { PropsWithChildren } from 'react'; +import { type ForwardedRef, forwardRef, type PropsWithChildren } from 'react'; import * as styles from './index.css'; -interface CategoryDividerProps extends PropsWithChildren { - label: string; -} +export type CategoryDividerProps = PropsWithChildren< + { + label: string; + className?: string; + } & { + [key: `data-${string}`]: unknown; + } +>; -export function CategoryDivider({ label, children }: CategoryDividerProps) { - return ( -
-
{label}
- {children} -
- ); -} +export const CategoryDivider = forwardRef( + ( + { label, children, className, ...otherProps }: CategoryDividerProps, + ref: ForwardedRef + ) => { + return ( +
+
{label}
+ {children} +
+ ); + } +); + +CategoryDivider.displayName = 'CategoryDivider'; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index 690d1ec29..f2dbf0e9d 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -193,7 +193,7 @@ export const BlocksuiteDocEditor = forwardRef< ) : ( )} - + { props.title, ]); - const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: getDNDId('collection-list', 'collection', props.collectionId), - data: { - preview: collectionTitleElement, - } satisfies DraggableTitleCellData, - disabled: !props.draggable, - }); + const { dragRef, dragging, CustomDragPreview } = useDraggable( + () => ({ + data: { + entity: { + type: 'collection', + id: props.collectionId, + }, + from: { + at: 'all-collections:list', + }, + }, + canDrag: props.draggable, + }), + [props.collectionId, props.draggable] + ); return ( - - - -
- - -
- + <> + + + +
+ + +
+ +
+
- -
- {props.operations ? ( - - - - ) : null} -
+ {props.operations ? ( + + + + ) : null} + + + {collectionTitleElement} + + ); }; @@ -171,58 +174,69 @@ type collectionListWrapperProps = PropsWithChildren< } >; -function CollectionListItemWrapper({ - to, - isDragging, - collectionId, - onClick, - children, - draggable, -}: collectionListWrapperProps) { - const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (!selectionState.selectable) { - return; - } - if (e.shiftKey) { - stopPropagation(e); - setSelectionActive(true); - onClick?.(); - return; - } - if (selectionState.selectionActive) { - return onClick?.(); - } - }, - [ +const CollectionListItemWrapper = forwardRef( + ( + { + to, + isDragging, + collectionId, onClick, - selectionState.selectable, - selectionState.selectionActive, - setSelectionActive, - ] - ); - - const commonProps = useMemo( - () => ({ - 'data-testid': 'collection-list-item', - 'data-collection-id': collectionId, - 'data-draggable': draggable, - className: styles.root, - 'data-clickable': !!onClick || !!to, - 'data-dragging': isDragging, - onClick: handleClick, - }), - [collectionId, draggable, isDragging, onClick, to, handleClick] - ); - - if (to) { - return ( - - {children} - + children, + draggable, + }: collectionListWrapperProps, + ref: ForwardedRef + ) => { + const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!selectionState.selectable) { + return; + } + if (e.shiftKey) { + stopPropagation(e); + setSelectionActive(true); + onClick?.(); + return; + } + if (selectionState.selectionActive) { + return onClick?.(); + } + }, + [ + onClick, + selectionState.selectable, + selectionState.selectionActive, + setSelectionActive, + ] ); - } else { - return
{children}
; + + const commonProps = useMemo( + () => ({ + 'data-testid': 'collection-list-item', + 'data-collection-id': collectionId, + 'data-draggable': draggable, + className: styles.root, + 'data-clickable': !!onClick || !!to, + 'data-dragging': isDragging, + onClick: handleClick, + }), + [collectionId, draggable, isDragging, onClick, to, handleClick] + ); + + if (to) { + return ( + + {children} + + ); + } else { + return ( +
+ {children} +
+ ); + } } -} +); + +CollectionListItemWrapper.displayName = 'CollectionListItemWrapper'; diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts b/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts index 560f2ba72..be8e970d4 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.css.ts @@ -23,8 +23,8 @@ export const root = style({ }); export const dragPageItemOverlay = style({ - height: '54px', - borderRadius: '10px', + height: '45px', + borderRadius: '8px', display: 'flex', alignItems: 'center', background: cssVar('hoverColorFilled'), diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx index 0c3b07c7f..9b93cca41 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-item.tsx @@ -1,11 +1,10 @@ -import { Checkbox, Tooltip } from '@affine/component'; -import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper'; +import { Checkbox, Tooltip, useDraggable } from '@affine/component'; import { TagService } from '@affine/core/modules/tag'; +import type { AffineDNDData } from '@affine/core/types/dnd'; import { i18nTime } from '@affine/i18n'; -import { useDraggable } from '@dnd-kit/core'; import { useLiveData, useService } from '@toeverything/infra'; -import type { PropsWithChildren } from 'react'; -import { useCallback, useEffect, useMemo } from 'react'; +import type { ForwardedRef, PropsWithChildren } from 'react'; +import { forwardRef, useCallback, useEffect, useMemo } from 'react'; import { WorkbenchLink } from '../../../modules/workbench/view/workbench-link'; import { @@ -14,7 +13,7 @@ import { selectionStateAtom, useAtom, } from '../scoped-atoms'; -import type { DraggableTitleCellData, PageListItemProps } from '../types'; +import type { PageListItemProps } from '../types'; import { useAllDocDisplayProperties } from '../use-all-doc-display-properties'; import { ColWrapper, stopPropagation } from '../utils'; import * as styles from './page-list-item.css'; @@ -167,76 +166,84 @@ export const PageListItem = (props: PageListItemProps) => { props.title, ]); - const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: getDNDId('doc-list', 'doc', props.pageId), - data: { - preview: pageTitleElement, - } satisfies DraggableTitleCellData, - disabled: !props.draggable, - }); + const { dragRef, CustomDragPreview, dragging } = useDraggable( + () => ({ + canDrag: props.draggable, + data: { + entity: { + type: 'doc', + id: props.pageId, + }, + from: { + at: 'all-docs:list', + }, + }, + }), + [props.draggable, props.pageId] + ); return ( - - - -
- - -
- + <> + + + +
+ + +
+ +
+
-
- - - {props.operations ? ( - - ) : null} -
+ + {props.operations ? ( + + + + ) : null} + + + {pageTitleElement} + + ); }; @@ -247,132 +254,142 @@ type PageListWrapperProps = PropsWithChildren< } >; -function PageListItemWrapper({ - to, - isDragging, - pageId, - pageIds, - onClick, - children, - draggable, -}: PageListWrapperProps) { - const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); - const [anchorIndex, setAnchorIndex] = useAtom(anchorIndexAtom); - const [rangeIds, setRangeIds] = useAtom(rangeIdsAtom); - - const handleShiftClick = useCallback( - (currentIndex: number) => { - if (anchorIndex === undefined) { - setAnchorIndex(currentIndex); - onClick?.(); - return; - } - - const lowerIndex = Math.min(anchorIndex, currentIndex); - const upperIndex = Math.max(anchorIndex, currentIndex); - const newRangeIds = pageIds.slice(lowerIndex, upperIndex + 1); - - const currentSelected = selectionState.selectedIds || []; - - // Set operations - const setRange = new Set(rangeIds); - const newSelected = new Set( - currentSelected.filter(id => !setRange.has(id)).concat(newRangeIds) - ); - - selectionState.onSelectedIdsChange?.([...newSelected]); - setRangeIds(newRangeIds); - }, - [ - anchorIndex, - onClick, - pageIds, - selectionState, - setAnchorIndex, - rangeIds, - setRangeIds, - ] - ); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (!selectionState.selectable) { - return; - } - stopPropagation(e); - const currentIndex = pageIds.indexOf(pageId); - - if (e.shiftKey) { - if (!selectionState.selectionActive) { - setSelectionActive(true); - setAnchorIndex(currentIndex); - onClick?.(); - return false; - } - handleShiftClick(currentIndex); - return false; - } else { - setAnchorIndex(undefined); - setRangeIds([]); - onClick?.(); - return; - } - }, - [ - handleShiftClick, - onClick, +const PageListItemWrapper = forwardRef( + ( + { + to, + isDragging, pageId, pageIds, - selectionState.selectable, + onClick, + children, + draggable, + }: PageListWrapperProps, + ref: ForwardedRef + ) => { + const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); + const [anchorIndex, setAnchorIndex] = useAtom(anchorIndexAtom); + const [rangeIds, setRangeIds] = useAtom(rangeIdsAtom); + + const handleShiftClick = useCallback( + (currentIndex: number) => { + if (anchorIndex === undefined) { + setAnchorIndex(currentIndex); + onClick?.(); + return; + } + + const lowerIndex = Math.min(anchorIndex, currentIndex); + const upperIndex = Math.max(anchorIndex, currentIndex); + const newRangeIds = pageIds.slice(lowerIndex, upperIndex + 1); + + const currentSelected = selectionState.selectedIds || []; + + // Set operations + const setRange = new Set(rangeIds); + const newSelected = new Set( + currentSelected.filter(id => !setRange.has(id)).concat(newRangeIds) + ); + + selectionState.onSelectedIdsChange?.([...newSelected]); + setRangeIds(newRangeIds); + }, + [ + anchorIndex, + onClick, + pageIds, + selectionState, + setAnchorIndex, + rangeIds, + setRangeIds, + ] + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!selectionState.selectable) { + return; + } + stopPropagation(e); + const currentIndex = pageIds.indexOf(pageId); + + if (e.shiftKey) { + if (!selectionState.selectionActive) { + setSelectionActive(true); + setAnchorIndex(currentIndex); + onClick?.(); + return false; + } + handleShiftClick(currentIndex); + return false; + } else { + setAnchorIndex(undefined); + setRangeIds([]); + onClick?.(); + return; + } + }, + [ + handleShiftClick, + onClick, + pageId, + pageIds, + selectionState.selectable, + selectionState.selectionActive, + setAnchorIndex, + setRangeIds, + setSelectionActive, + ] + ); + + const commonProps = useMemo( + () => ({ + 'data-testid': 'page-list-item', + 'data-page-id': pageId, + 'data-draggable': draggable, + className: styles.root, + 'data-clickable': !!onClick || !!to, + 'data-dragging': isDragging, + onClick: onClick ? handleClick : undefined, + }), + [pageId, draggable, onClick, to, isDragging, handleClick] + ); + + useEffect(() => { + if (selectionState.selectionActive) { + // listen for shift key up + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setAnchorIndex(undefined); + setRangeIds([]); + } + }; + window.addEventListener('keyup', handleKeyUp); + return () => { + window.removeEventListener('keyup', handleKeyUp); + }; + } + return; + }, [ selectionState.selectionActive, setAnchorIndex, setRangeIds, setSelectionActive, - ] - ); + ]); - const commonProps = useMemo( - () => ({ - 'data-testid': 'page-list-item', - 'data-page-id': pageId, - 'data-draggable': draggable, - className: styles.root, - 'data-clickable': !!onClick || !!to, - 'data-dragging': isDragging, - onClick: onClick ? handleClick : undefined, - }), - [pageId, draggable, onClick, to, isDragging, handleClick] - ); - - useEffect(() => { - if (selectionState.selectionActive) { - // listen for shift key up - const handleKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') { - setAnchorIndex(undefined); - setRangeIds([]); - } - }; - window.addEventListener('keyup', handleKeyUp); - return () => { - window.removeEventListener('keyup', handleKeyUp); - }; + if (to) { + return ( + + {children} + + ); + } else { + return ( +
+ {children} +
+ ); } - return; - }, [ - selectionState.selectionActive, - setAnchorIndex, - setRangeIds, - setSelectionActive, - ]); - - if (to) { - return ( - - {children} - - ); - } else { - return
{children}
; } -} +); +PageListItemWrapper.displayName = 'PageListItemWrapper'; diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index fd4e6797f..ac937897e 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -239,8 +239,7 @@ export const PageOperationCell = ({ ) : null} ( /> ); -const randomTagColor = () => { - const randomIndex = Math.floor(Math.random() * tagColors.length); - return tagColors[randomIndex][1]; -}; - export const CreateOrEditTag = ({ open, onOpenChange, @@ -32,7 +26,8 @@ export const CreateOrEditTag = ({ onOpenChange: (open: boolean) => void; tagMeta?: TagMeta; }) => { - const tagList = useService(TagService).tagList; + const tagService = useService(TagService); + const tagList = tagService.tagList; const tagOptions = useLiveData(tagList.tagMetas$); const tag = useLiveData(tagList.tagByTagId$(tagMeta?.id)); const t = useI18n(); @@ -43,14 +38,16 @@ export const CreateOrEditTag = ({ setTagName(value); }, []); - const [tagIcon, setTagIcon] = useState(tagMeta?.color || randomTagColor()); + const [tagIcon, setTagIcon] = useState( + tagMeta?.color || tagService.randomTagColor() + ); const handleChangeIcon = useCallback((value: string) => { setTagIcon(value); }, []); const tags = useMemo(() => { - return tagColors.map(([_, color]) => { + return tagService.tagColors.map(([name, color]) => { return { name: name, color: color, @@ -60,7 +57,7 @@ export const CreateOrEditTag = ({ }, }; }); - }, [handleChangeIcon]); + }, [handleChangeIcon, tagService.tagColors]); const items = useMemo(() => { const tagItems = tags.map(item => { @@ -81,11 +78,11 @@ export const CreateOrEditTag = ({ const onClose = useCallback(() => { if (!tagMeta) { - handleChangeIcon(randomTagColor()); + handleChangeIcon(tagService.randomTagColor()); setTagName(''); } onOpenChange(false); - }, [handleChangeIcon, onOpenChange, tagMeta]); + }, [handleChangeIcon, onOpenChange, tagMeta, tagService]); const onConfirm = useCallback(() => { if (!tagName?.trim()) return; @@ -129,8 +126,8 @@ export const CreateOrEditTag = ({ useEffect(() => { setTagName(tagMeta?.title || ''); - setTagIcon(tagMeta?.color || randomTagColor()); - }, [tagMeta?.color, tagMeta?.title]); + setTagIcon(tagMeta?.color || tagService.randomTagColor()); + }, [tagMeta?.color, tagMeta?.title, tagService]); if (!open) { return null; diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts b/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts index b144ea023..378a615f4 100644 --- a/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts @@ -22,7 +22,7 @@ export const root = style({ }, }); export const dragPageItemOverlay = style({ - height: '54px', + height: '45px', borderRadius: '10px', display: 'flex', alignItems: 'center', diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx b/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx index eed062b77..92b3c0ec0 100644 --- a/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx @@ -1,13 +1,12 @@ -import { Checkbox } from '@affine/component'; -import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper'; +import { Checkbox, useDraggable } from '@affine/component'; import { WorkbenchLink } from '@affine/core/modules/workbench'; +import type { AffineDNDData } from '@affine/core/types/dnd'; import { useI18n } from '@affine/i18n'; -import { useDraggable } from '@dnd-kit/core'; -import type { PropsWithChildren } from 'react'; -import { useCallback, useMemo } from 'react'; +import type { ForwardedRef, PropsWithChildren } from 'react'; +import { forwardRef, useCallback, useMemo } from 'react'; import { selectionStateAtom, useAtom } from '../scoped-atoms'; -import type { DraggableTitleCellData, TagListItemProps } from '../types'; +import type { TagListItemProps } from '../types'; import { ColWrapper, stopPropagation } from '../utils'; import * as styles from './tag-list-item.css'; @@ -83,45 +82,62 @@ const TagListOperationsCell = ({ }; export const TagListItem = (props: TagListItemProps) => { - const tagTitleElement = useMemo(() => { - return ( -
-
- - -
-
- ); - }, [props.color, props.onSelectedChange, props.selectable, props.selected]); - - const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: getDNDId('tag-list', 'tag', props.tagId), - data: { - preview: tagTitleElement, - } satisfies DraggableTitleCellData, - disabled: !props.draggable, - }); + const { dragRef, CustomDragPreview, dragging } = useDraggable( + () => ({ + canDrag: props.draggable, + data: { + entity: { + type: 'tag', + id: props.tagId, + }, + from: { + at: 'all-tags:list', + }, + }, + }), + [props.draggable, props.tagId] + ); return ( - - - + <> + + + +
+ + +
+ +
+ +
+ {props.operations ? ( + + + + ) : null} +
+ +
{
- - - - {props.operations ? ( - - - - ) : null} - +
+
+ ); }; @@ -157,58 +159,68 @@ type TagListWrapperProps = PropsWithChildren< } >; -function TagListItemWrapper({ - to, - isDragging, - tagId, - onClick, - children, - draggable, -}: TagListWrapperProps) { - const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (!selectionState.selectable) { - return; - } - if (e.shiftKey) { - stopPropagation(e); - setSelectionActive(true); - onClick?.(); - return; - } - if (selectionState.selectionActive) { - return onClick?.(); - } - }, - [ +const TagListItemWrapper = forwardRef( + ( + { + to, + isDragging, + tagId, onClick, - selectionState.selectable, - selectionState.selectionActive, - setSelectionActive, - ] - ); - - const commonProps = useMemo( - () => ({ - 'data-testid': 'tag-list-item', - 'data-tag-id': tagId, - 'data-draggable': draggable, - className: styles.root, - 'data-clickable': !!onClick || !!to, - 'data-dragging': isDragging, - onClick: handleClick, - }), - [tagId, draggable, isDragging, onClick, to, handleClick] - ); - - if (to) { - return ( - - {children} - + children, + draggable, + }: TagListWrapperProps, + ref: ForwardedRef + ) => { + const [selectionState, setSelectionActive] = useAtom(selectionStateAtom); + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!selectionState.selectable) { + return; + } + if (e.shiftKey) { + stopPropagation(e); + setSelectionActive(true); + onClick?.(); + return; + } + if (selectionState.selectionActive) { + return onClick?.(); + } + }, + [ + onClick, + selectionState.selectable, + selectionState.selectionActive, + setSelectionActive, + ] ); - } else { - return
{children}
; + + const commonProps = useMemo( + () => ({ + 'data-testid': 'tag-list-item', + 'data-tag-id': tagId, + 'data-draggable': draggable, + className: styles.root, + 'data-clickable': !!onClick || !!to, + 'data-dragging': isDragging, + onClick: handleClick, + }), + [tagId, draggable, isDragging, onClick, to, handleClick] + ); + + if (to) { + return ( + + {children} + + ); + } else { + return ( +
+ {children} +
+ ); + } } -} +); +TagListItemWrapper.displayName = 'TagListItemWrapper'; diff --git a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx index 1b56efee3..113916a21 100644 --- a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx +++ b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx @@ -86,7 +86,7 @@ export const VirtualizedTagList = ({ } diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/add-collection-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/add-collection-button.tsx deleted file mode 100644 index 0df309fe3..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/add-collection-button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { IconButton } from '@affine/component/ui/button'; -import { PlusIcon } from '@blocksuite/icons/rc'; -import type { ReactElement } from 'react'; - -export const AddCollectionButton = ({ - node, - onClick, -}: { - node: ReactElement | null; - onClick: () => void; -}) => { - return ( - <> - - - - {node} - - ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx deleted file mode 100644 index 785a7c82a..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import { - AnimatedCollectionsIcon, - toast, - useConfirmModal, -} from '@affine/component'; -import { RenameModal } from '@affine/component/rename-modal'; -import { Button, IconButton } from '@affine/component/ui/button'; -import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils'; -import { - CollectionOperations, - filterPage, - stopPropagation, -} from '@affine/core/components/page-list'; -import { - type DNDIdentifier, - getDNDId, - parseDNDId, - resolveDragEndIntent, -} from '@affine/core/hooks/affine/use-global-dnd-helper'; -import { CollectionService } from '@affine/core/modules/collection'; -import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; -import type { Collection } from '@affine/env/filter'; -import { useI18n } from '@affine/i18n'; -import { - MoreHorizontalIcon, - PlusIcon, - ViewLayersIcon, -} from '@blocksuite/icons/rc'; -import type { DocCollection } from '@blocksuite/store'; -import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { useLiveData, useService } from '@toeverything/infra'; -import { useCallback, useMemo, useState } from 'react'; - -import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config'; -import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta'; -import { WorkbenchService } from '../../../../modules/workbench'; -import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link'; -import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; -import * as draggableMenuItemStyles from '../components/draggable-menu-item.css'; -import { SidebarDocItem } from '../doc-tree/doc'; -import { SidebarDocTreeNode } from '../doc-tree/node'; -import type { CollectionsListProps } from '../index'; -import * as styles from './styles.css'; - -const animateLayoutChanges: AnimateLayoutChanges = ({ - isSorting, - wasDragging, -}) => (isSorting || wasDragging ? false : true); - -export const CollectionSidebarNavItem = ({ - collection, - docCollection, - className, - dndId, -}: { - collection: Collection; - docCollection: DocCollection; - dndId: DNDIdentifier; - className?: string; -}) => { - const [open, setOpen] = useState(false); - const collectionService = useService(CollectionService); - const { createPage } = usePageHelper(docCollection); - const { openConfirmModal } = useConfirmModal(); - const t = useI18n(); - - const overlayPreview = useMemo(() => { - return ( - } title={collection.name} /> - ); - }, [collection.name]); - - const { - setNodeRef, - isDragging, - attributes, - listeners, - transform, - over, - active, - transition, - } = useSortable({ - id: dndId, - data: { - preview: overlayPreview, - }, - animateLayoutChanges, - }); - - const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin'; - const dragOverIntent = resolveDragEndIntent(active, over); - - const style = { - transform: CSS.Translate.toString(transform), - transition: isSorting ? transition : undefined, - }; - - const isOver = over?.id === dndId && dragOverIntent === 'collection:add'; - - const currentPath = useLiveData( - useService(WorkbenchService).workbench.location$.map( - location => location.pathname - ) - ); - const path = `/collection/${collection.id}`; - - const onRename = useCallback( - (name: string) => { - collectionService.updateCollection(collection.id, () => ({ - ...collection, - name, - })); - toast(t['com.affine.toastMessage.rename']()); - }, - [collection, collectionService, t] - ); - const handleOpen = useCallback(() => { - setOpen(true); - }, []); - - const createAndAddDocument = useCallback(() => { - const newDoc = createPage(); - collectionService.addPageToCollection(collection.id, newDoc.id); - }, [collection.id, collectionService, createPage]); - - const onConfirmAddDocToCollection = useCallback(() => { - openConfirmModal({ - title: t['com.affine.collection.add-doc.confirm.title'](), - description: t['com.affine.collection.add-doc.confirm.description'](), - cancelText: t['Cancel'](), - confirmText: t['Confirm'](), - confirmButtonOptions: { - type: 'primary', - }, - onConfirm: createAndAddDocument, - }); - }, [createAndAddDocument, openConfirmModal, t]); - - const postfix = ( -
{ - // prevent drag - e.stopPropagation(); - }} - style={{ display: 'flex', alignItems: 'center' }} - > - - - - - - - - - -
- ); - - return ( - - } - rootProps={{ - className, - style, - ...attributes, - }} - menuItemProps={{ - ...listeners, - 'data-draggable': true, - 'data-dragging': isDragging, - 'data-testid': 'collection-item', - 'data-collection-id': collection.id, - 'data-type': 'collection-list-item', - className: draggableMenuItemStyles.draggableMenuItem, - active: isOver || currentPath === path, - icon: , - postfix, - }} - > - {collection.name} - - ); -}; - -const CollectionSidebarNavItemContent = ({ - collection, - docCollection, - dndId, -}: { - collection: Collection; - docCollection: DocCollection; - dndId: DNDIdentifier; -}) => { - const t = useI18n(); - const pages = useBlockSuiteDocMeta(docCollection); - const favAdapter = useService(FavoriteItemsAdapter); - const collectionService = useService(CollectionService); - - const config = useAllPageListConfig(); - const favourites = useLiveData(favAdapter.favorites$); - const allowList = useMemo( - () => new Set(collection.allowList), - [collection.allowList] - ); - const removeFromAllowList = useCallback( - (id: string) => { - collectionService.deletePageFromCollection(collection.id, id); - toast(t['com.affine.collection.removePage.success']()); - }, - [collection, collectionService, t] - ); - - const filtered = pages.filter(meta => { - if (meta.trash) return false; - const pageData = { - meta, - publicMode: config.getPublicMode(meta.id), - favorite: favourites.some(fav => fav.id === meta.id), - }; - return filterPage(collection, pageData); - }); - - return ( -
- {filtered.length > 0 ? ( - filtered.map(page => { - return ( - - ); - }) - ) : ( -
- {t['com.affine.collection.emptyCollection']()} -
- )} -
- ); -}; - -export const CollectionsList = ({ - docCollection: workspace, - onCreate, -}: CollectionsListProps) => { - const collections = useLiveData(useService(CollectionService).collections$); - const t = useI18n(); - - if (collections.length === 0) { - return ( -
-
-
- -
-
- {t['com.affine.collections.empty.message']()} -
-
- -
- ); - } - return ( -
- {collections.map(view => { - const dragItemId = getDNDId( - 'sidebar-collections', - 'collection', - view.id - ); - - return ( - - ); - })} -
- ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx deleted file mode 100644 index ec946faa2..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './collections-list'; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts deleted file mode 100644 index 08da5372e..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/styles.css.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { cssVar } from '@toeverything/theme'; -import { globalStyle, style } from '@vanilla-extract/css'; -export const wrapper = style({ - display: 'flex', - flexDirection: 'column', - gap: 2, - userSelect: 'none', - // marginLeft:8, -}); -export const collapsedIcon = style({ - transition: 'transform 0.2s ease-in-out', - selectors: { - '&[data-collapsed="true"]': { - transform: 'rotate(-90deg)', - }, - }, -}); -export const view = style({ - display: 'flex', - alignItems: 'center', -}); -export const viewTitle = style({ - display: 'flex', - alignItems: 'center', -}); -export const more = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 2, - fontSize: 16, - color: cssVar('iconColor'), - ':hover': { - backgroundColor: cssVar('hoverColor'), - }, -}); -export const deleteFolder = style({ - ':hover': { - color: cssVar('errorColor'), - backgroundColor: cssVar('backgroundErrorColor'), - }, -}); -globalStyle(`${deleteFolder}:hover svg`, { - color: cssVar('errorColor'), -}); -export const menuDividerStyle = style({ - marginTop: '2px', - marginBottom: '2px', - marginLeft: '12px', - marginRight: '8px', - height: '1px', - background: cssVar('borderColor'), -}); -export const collapsibleContent = style({ - overflow: 'hidden', - marginTop: '4px', - selectors: { - '&[data-hidden="true"]': { - display: 'none', - }, - }, -}); -export const emptyCollectionWrapper = style({ - padding: '9px 0', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: 8, -}); -export const emptyCollectionContent = style({ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: 6, -}); -export const emptyCollectionIconWrapper = style({ - width: 36, - height: 36, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: '50%', - backgroundColor: cssVar('hoverColor'), -}); -export const emptyCollectionIcon = style({ - fontSize: 20, - color: cssVar('iconSecondary'), -}); -export const emptyCollectionMessage = style({ - fontSize: cssVar('fontSm'), - textAlign: 'center', - color: cssVar('black30'), - userSelect: 'none', -}); -export const emptyCollectionNewButton = style({ - padding: '0 8px', - height: '28px', - fontSize: cssVar('fontXs'), -}); -export const docsListContainer = style({ - display: 'flex', - flexDirection: 'column', - gap: 2, -}); -export const noReferences = style({ - fontSize: cssVar('fontSm'), - textAlign: 'left', - paddingLeft: '32px', - color: cssVar('black30'), - userSelect: 'none', -}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx deleted file mode 100644 index e82b41725..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/drag-menu-item-overlay.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as styles from '../favorite/styles.css'; - -export const DragMenuItemOverlay = ({ - title, - icon, -}: { - icon: React.ReactNode; - title: React.ReactNode; -}) => { - return ( -
- {icon} - {title} -
- ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/draggable-menu-item.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/draggable-menu-item.css.ts deleted file mode 100644 index 47f4dee74..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/draggable-menu-item.css.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { cssVar } from '@toeverything/theme'; -import { style } from '@vanilla-extract/css'; - -export const draggableMenuItem = style({ - selectors: { - '&[data-draggable=true]:before': { - content: '""', - position: 'absolute', - top: '50%', - transform: 'translateY(-50%)', - left: 0, - width: 4, - height: 4, - transition: 'height 0.2s, opacity 0.2s', - backgroundColor: cssVar('placeholderColor'), - borderRadius: '2px', - opacity: 0, - willChange: 'height, opacity', - }, - '&[data-draggable=true]:hover:before': { - height: 12, - opacity: 1, - }, - '&[data-draggable=true][data-dragging=true]': { - backgroundColor: cssVar('hoverColor'), - }, - '&[data-draggable=true][data-dragging=true]:before': { - height: 32, - width: 2, - opacity: 1, - }, - }, -}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx deleted file mode 100644 index fdfa8ce94..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-item.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { MenuItemProps } from '@affine/component'; -import { MenuIcon, MenuItem, MenuSeparator } from '@affine/component'; -import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; -import { useI18n } from '@affine/i18n'; -import { - DeleteIcon, - EditIcon, - FavoriteIcon, - FilterMinusIcon, - InformationIcon, - LinkedPageIcon, - SplitViewIcon, -} from '@blocksuite/icons/rc'; -import type { ReactElement } from 'react'; -import { useMemo } from 'react'; - -type OperationItemsProps = { - inFavorites?: boolean; - isReferencePage?: boolean; - inAllowList?: boolean; - onRemoveFromAllowList?: () => void; - setRenameModalOpen?: () => void; - onRename: () => void; - onAddLinkedPage: () => void; - onRemoveFromFavourites?: () => void; - onDelete: () => void; - onOpenInSplitView: () => void; - onOpenInfoModal: () => void; -}; - -export const OperationItems = ({ - inFavorites, - isReferencePage, - inAllowList, - onRemoveFromAllowList, - onRename, - onAddLinkedPage, - onRemoveFromFavourites, - onDelete, - onOpenInSplitView, - onOpenInfoModal, -}: OperationItemsProps) => { - const { appSettings } = useAppSettingHelper(); - const t = useI18n(); - const actions = useMemo< - Array< - | { - icon: ReactElement; - name: string; - click: () => void; - type?: MenuItemProps['type']; - element?: undefined; - } - | { - element: ReactElement; - } - > - >( - () => [ - { - icon: ( - - - - ), - name: t['Rename'](), - click: onRename, - }, - ...(runtimeConfig.enableInfoModal - ? [ - { - icon: ( - - - - ), - name: t['com.affine.page-properties.page-info.view'](), - click: onOpenInfoModal, - }, - ] - : []), - { - icon: ( - - - - ), - name: t['com.affine.page-operation.add-linked-page'](), - click: onAddLinkedPage, - }, - ...(inFavorites && onRemoveFromFavourites && !isReferencePage - ? [ - { - icon: ( - - - - ), - name: t['Remove from favorites'](), - click: onRemoveFromFavourites, - }, - ] - : []), - ...(inAllowList && onRemoveFromAllowList - ? [ - { - icon: ( - - - - ), - name: t['Remove special filter'](), - click: onRemoveFromAllowList, - }, - ] - : []), - - ...(appSettings.enableMultiView - ? [ - // open split view - { - icon: ( - - - - ), - name: t['com.affine.workbench.split-view.page-menu-open'](), - click: onOpenInSplitView, - }, - ] - : []), - - { - element: , - }, - { - icon: ( - - - - ), - name: t['com.affine.moveToTrash.title'](), - click: onDelete, - type: 'danger', - }, - ], - [ - t, - onRename, - onAddLinkedPage, - inFavorites, - onRemoveFromFavourites, - isReferencePage, - inAllowList, - onRemoveFromAllowList, - appSettings.enableMultiView, - onOpenInSplitView, - onOpenInfoModal, - onDelete, - ] - ); - return ( - <> - {actions.map(action => { - if (action.element) { - return action.element; - } - return ( - - {action.name} - - ); - })} - - ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx deleted file mode 100644 index 9b611662c..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { toast } from '@affine/component'; -import { IconButton } from '@affine/component/ui/button'; -import { Menu } from '@affine/component/ui/menu'; -import { InfoModal } from '@affine/core/components/affine/page-properties'; -import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; -import { WorkbenchService } from '@affine/core/modules/workbench'; -import { useI18n } from '@affine/i18n'; -import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; -import { useService, useServices, WorkspaceService } from '@toeverything/infra'; -import { useCallback, useState } from 'react'; - -import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper'; -import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils'; -import { OperationItems } from './operation-item'; - -export type OperationMenuButtonProps = { - pageId: string; - pageTitle: string; - setRenameModalOpen: () => void; - inFavorites?: boolean; - isReferencePage?: boolean; - inAllowList?: boolean; - removeFromAllowList?: (id: string) => void; -}; - -export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { - const { - pageId, - pageTitle, - setRenameModalOpen, - removeFromAllowList, - inAllowList, - inFavorites, - isReferencePage, - } = props; - const t = useI18n(); - const [openInfoModal, setOpenInfoModal] = useState(false); - - const { workspaceService } = useServices({ - WorkspaceService, - }); - const page = workspaceService.workspace.docCollection.getDoc(pageId); - const { createLinkedPage } = usePageHelper( - workspaceService.workspace.docCollection - ); - const { setTrashModal } = useTrashModalHelper( - workspaceService.workspace.docCollection - ); - - const favAdapter = useService(FavoriteItemsAdapter); - const workbench = useService(WorkbenchService).workbench; - - const handleRename = useCallback(() => { - setRenameModalOpen?.(); - }, [setRenameModalOpen]); - - const handleAddLinkedPage = useCallback(() => { - createLinkedPage(pageId); - toast(t['com.affine.toastMessage.addLinkedPage']()); - }, [createLinkedPage, pageId, t]); - - const handleRemoveFromFavourites = useCallback(() => { - favAdapter.remove(pageId, 'doc'); - toast(t['com.affine.toastMessage.removedFavorites']()); - }, [favAdapter, pageId, t]); - - const handleDelete = useCallback(() => { - setTrashModal({ - open: true, - pageIds: [pageId], - pageTitles: [pageTitle], - }); - }, [pageId, pageTitle, setTrashModal]); - - const handleRemoveFromAllowList = useCallback(() => { - removeFromAllowList?.(pageId); - }, [pageId, removeFromAllowList]); - - const handleOpenInSplitView = useCallback(() => { - workbench.openDoc(pageId, { at: 'tail' }); - }, [pageId, workbench]); - - const handleOpenInfoModal = useCallback(() => { - setOpenInfoModal(true); - }, [setOpenInfoModal]); - - return ( - <> - - } - > - - - - - {page ? ( - - ) : null} - - ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx deleted file mode 100644 index a6e9638e4..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { toast } from '@affine/component'; -import { RenameModal } from '@affine/component/rename-modal'; -import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { useI18n } from '@affine/i18n'; -import { useServices, WorkspaceService } from '@toeverything/infra'; -import { useCallback, useState } from 'react'; - -import { AddFavouriteButton } from '../favorite/add-favourite-button'; -import * as styles from '../favorite/styles.css'; -import { OperationMenuButton } from './operation-menu-button'; - -export type PostfixItemProps = { - pageId: string; - pageTitle: string; - inFavorites?: boolean; - isReferencePage?: boolean; - inAllowList?: boolean; - removeFromAllowList?: (id: string) => void; -}; - -export const PostfixItem = ({ ...props }: PostfixItemProps) => { - const { pageId, pageTitle } = props; - const t = useI18n(); - const [open, setOpen] = useState(false); - const { workspaceService } = useServices({ - WorkspaceService, - }); - const { setDocTitle } = useDocMetaHelper( - workspaceService.workspace.docCollection - ); - - const handleRename = useCallback( - (newName: string) => { - setDocTitle(pageId, newName); - setOpen(false); - toast(t['com.affine.toastMessage.rename']()); - }, - [pageId, setDocTitle, t] - ); - - return ( -
{ - // prevent drag - e.stopPropagation(); - }} - onClick={e => { - // prevent jump to page - e.stopPropagation(); - e.preventDefault(); - }} - > - - { - setOpen(true); - }} - {...props} - /> - -
- ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/doc.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/doc.css.ts deleted file mode 100644 index 3c58024de..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/doc.css.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { cssVar } from '@toeverything/theme'; -import { globalStyle, style } from '@vanilla-extract/css'; - -export const title = style({ - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', -}); - -globalStyle(`[data-draggable=true] ${title}:before`, { - content: '""', - position: 'absolute', - top: '50%', - transform: 'translateY(-50%)', - left: 0, - width: 4, - height: 4, - transition: 'height 0.2s, opacity 0.2s', - backgroundColor: cssVar('placeholderColor'), - borderRadius: '2px', - opacity: 0, - willChange: 'height, opacity', -}); -globalStyle(`[data-draggable=true] ${title}:hover:before`, { - height: 12, - opacity: 1, -}); -globalStyle(`[data-draggable=true][data-dragging=true] ${title}`, { - opacity: 0.5, -}); -globalStyle(`[data-draggable=true][data-dragging=true] ${title}:before`, { - height: 32, - width: 2, - opacity: 1, -}); - -export const label = style({ - selectors: { - '&[data-untitled="true"]': { - opacity: 0.6, - }, - }, -}); -export const labelContainer = style({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', -}); -export const labelTooltipContainer = style({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', -}); - -export const noReferences = style({ - fontSize: cssVar('fontSm'), - textAlign: 'left', - paddingLeft: '32px', - color: cssVar('black30'), - userSelect: 'none', -}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/doc.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/doc.tsx deleted file mode 100644 index 90bac69be..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/doc.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { Loading, Tooltip } from '@affine/component'; -import type { MenuItemProps } from '@affine/core/components/app-sidebar'; -import { - type DNDIdentifier, - type DndWhere, - getDNDId, -} from '@affine/core/hooks/affine/use-global-dnd-helper'; -import { DocsSearchService } from '@affine/core/modules/docs-search'; -import { - WorkbenchLink, - WorkbenchService, -} from '@affine/core/modules/workbench'; -import { useI18n } from '@affine/i18n'; -import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc'; -import { useDraggable } from '@dnd-kit/core'; -import { - DocsService, - LiveData, - useLiveData, - useServices, -} from '@toeverything/infra'; -import { nanoid } from 'nanoid'; -import { useEffect, useMemo, useState } from 'react'; - -import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; -import { PostfixItem, type PostfixItemProps } from '../components/postfix-item'; -import * as styles from './doc.css'; -import { SidebarDocTreeNode } from './node'; - -export type SidebarDocItemProps = { - docId: string; - postfixConfig?: Omit< - PostfixItemProps, - 'pageId' | 'pageTitle' | 'isReferencePage' - >; - isReference?: boolean; - dragConfig?: { - parentId?: DNDIdentifier; - where: DndWhere; - }; - menuItemProps?: Partial & Record<`data-${string}`, string>; -}; - -export const SidebarDocItem = function SidebarDocItem({ - docId, - postfixConfig, - isReference, - dragConfig, - menuItemProps, -}: SidebarDocItemProps) { - const { docsSearchService, workbenchService, docsService } = useServices({ - DocsSearchService, - WorkbenchService, - DocsService, - }); - const t = useI18n(); - const location = useLiveData(workbenchService.workbench.location$); - const active = location.pathname === '/' + docId; - - const docRecord = useLiveData(docsService.list.doc$(docId)); - const docMode = useLiveData(docRecord?.mode$); - const docTitle = useLiveData(docRecord?.title$); - const icon = useMemo(() => { - return docMode === 'edgeless' ? : ; - }, [docMode]); - - const references = useLiveData( - useMemo( - () => LiveData.from(docsSearchService.watchRefsFrom(docId), null), - [docsSearchService, docId] - ) - ); - const indexerLoading = useLiveData( - docsSearchService.indexer.status$.map( - v => v.remaining === undefined || v.remaining > 0 - ) - ); - const [referencesLoading, setReferencesLoading] = useState(true); - useEffect(() => { - setReferencesLoading( - prev => - prev && - indexerLoading /* after loading becomes false, it never becomes true */ - ); - }, [indexerLoading]); - const untitled = !docTitle; - const title = docTitle || t['Untitled'](); - - // drag (not available for sub-docs) - const dragItemId = dragConfig - ? getDNDId(dragConfig.where, 'doc', docId, dragConfig.parentId) - : nanoid(); - const docTitleElement = useMemo(() => { - return ; - }, [icon, docTitle]); - const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: dragItemId, - data: { preview: docTitleElement }, - disabled: !dragConfig || isReference, - }); - - const dragAttrs: Partial = isReference - ? { - // prevent dragging parent node - onMouseDown: e => e.stopPropagation(), - } - : { ...attributes, ...listeners }; - - // workaround to avoid invisible in playwright caused by nested drag - delete dragAttrs['aria-disabled']; - - return ( - - ), - ...dragAttrs, - ...menuItemProps, - }} - subTree={ - references ? ( - references.length > 0 ? ( - references.map(({ docId: childDocId }) => { - return ( - - ); - }) - ) : ( -
- {t['com.affine.rootAppSidebar.docs.no-subdoc']()} -
- ) - ) : null - } - > -
- - {title || t['Untitled']()} - - {referencesLoading && ( - -
- -
-
- )} -
-
- ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/node.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/node.css.ts deleted file mode 100644 index 45d626291..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/node.css.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -export const collapseContent = style({ - paddingTop: 2, - paddingLeft: 20, -}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/node.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/node.tsx deleted file mode 100644 index df0177c1d..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/doc-tree/node.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { - MenuItem, - type MenuItemProps, - MenuLinkItem, -} from '@affine/core/components/app-sidebar'; -import type { Collection } from '@affine/env/filter'; -import * as Collapsible from '@radix-ui/react-collapsible'; -import { - createContext, - forwardRef, - type PropsWithChildren, - type ReactNode, - useContext, - useState, -} from 'react'; -import { Link, type To } from 'react-router-dom'; - -import * as styles from './node.css'; - -type SidebarDocTreeNode = - | { - type: 'collection'; - data: Collection; - } - // | { type: 'tag' } - // | { type: 'folder' } - | { - type: 'doc'; - data: string; - }; - -export type SidebarDocTreeNodeProps = PropsWithChildren<{ - node: SidebarDocTreeNode; - subTree?: ReactNode; - to?: To; - linkComponent?: React.ComponentType<{ to: To; className?: string }>; - - menuItemProps?: MenuItemProps & Record<`data-${string}`, unknown>; - rootProps?: Collapsible.CollapsibleProps & Record<`data-${string}`, unknown>; -}>; - -type SidebarDocTreeNodeContext = { - ancestors: SidebarDocTreeNode[]; -}; - -export const sidebarDocTreeContext = - createContext(null); - -/** - * Tree node for the sidebar doc/folder/tag/collection tree. - * This component is used to manage: - * - Collapsing state - * - Ancestors context - * - Link/Menu item rendering - * - Subtree indentation (left/top) - */ -export const SidebarDocTreeNode = forwardRef(function SidebarDocTreeNode( - { - node, - children, - subTree, - to, - linkComponent: LinkComponent = Link, - menuItemProps, - rootProps, - }: SidebarDocTreeNodeProps, - ref: React.Ref -) { - const [collapsed, setCollapsed] = useState(true); - const { ancestors } = useContext(sidebarDocTreeContext) ?? { ancestors: [] }; - - const finalMenuItemProps: SidebarDocTreeNodeProps['menuItemProps'] = { - ...menuItemProps, - collapsed, - onCollapsedChange: setCollapsed, - }; - - return ( - - - {to ? ( - - {children} - - ) : ( - {children} - )} - - {collapsed ? null : subTree} - - - - ); -}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx deleted file mode 100644 index e441817ab..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/add-favourite-button.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { IconButton } from '@affine/component/ui/button'; -import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; -import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry'; -import { mixpanel } from '@affine/core/utils'; -import { PlusIcon } from '@blocksuite/icons/rc'; -import { useService, useServices, WorkspaceService } from '@toeverything/infra'; - -import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils'; - -type AddFavouriteButtonProps = { - pageId?: string; -}; - -export const AddFavouriteButton = ({ pageId }: AddFavouriteButtonProps) => { - const { workspaceService } = useServices({ - WorkspaceService, - }); - const { createPage, createLinkedPage } = usePageHelper( - workspaceService.workspace.docCollection - ); - const favAdapter = useService(FavoriteItemsAdapter); - const telemetry = useService(TelemetryWorkspaceContextService); - const handleAddFavorite = useAsyncCallback( - async e => { - const page = telemetry.getPageContext(); - - if (pageId) { - e.stopPropagation(); - e.preventDefault(); - createLinkedPage(pageId); - mixpanel.track('DocCreated', { - // page: - segment: 'all doc', - module: 'favorite', - control: 'new fav sub doc', - type: 'doc', - category: 'page', - page: page, - }); - } else { - const page = createPage(); - page.load(); - favAdapter.set(page.id, 'doc', true); - mixpanel.track('DocCreated', { - // page: - segment: 'all doc', - module: 'favorite', - control: 'new fav doc', - type: 'doc', - category: 'page', - page: page, - }); - } - }, - [telemetry, pageId, createLinkedPage, createPage, favAdapter] - ); - - return ( - - - - ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/empty-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/empty-item.tsx deleted file mode 100644 index 64b08488a..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/empty-item.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useI18n } from '@affine/i18n'; -import { FavoriteIcon } from '@blocksuite/icons/rc'; - -import * as styles from './styles.css'; -export const EmptyItem = () => { - const t = useI18n(); - return ( -
-
- -
-
- {t['com.affine.rootAppSidebar.favorites.empty']()} -
-
- ); -}; - -export default EmptyItem; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx deleted file mode 100644 index 4c6685502..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { CategoryDivider } from '@affine/core/components/app-sidebar'; -import { - getDNDId, - resolveDragEndIntent, -} from '@affine/core/hooks/affine/use-global-dnd-helper'; -import { CollectionService } from '@affine/core/modules/collection'; -import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; -import type { WorkspaceFavoriteItem } from '@affine/core/modules/properties/services/schema'; -import { useI18n } from '@affine/i18n'; -import { useDndContext, useDroppable } from '@dnd-kit/core'; -import { - SortableContext, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { - DocsService, - useLiveData, - useService, - useServices, -} from '@toeverything/infra'; -import { Fragment, useCallback, useMemo } from 'react'; - -import { CollectionSidebarNavItem } from '../collections'; -import type { FavoriteListProps } from '../index'; -import { AddFavouriteButton } from './add-favourite-button'; -import EmptyItem from './empty-item'; -import { FavouriteDocSidebarNavItem } from './favourite-nav-item'; -import * as styles from './styles.css'; - -const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => { - const { favoriteItemsAdapter, docsService, collectionService } = useServices({ - FavoriteItemsAdapter, - DocsService, - CollectionService, - }); - const collections = useLiveData(collectionService.collections$); - const docs = useLiveData(docsService.list.docs$); - const trashDocs = useLiveData(docsService.list.trashDocs$); - const dropItemId = getDNDId('sidebar-pin', 'container', workspace.id); - - const favourites = 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; - }); - }) - ); - - // disable drop styles when dragging from the pin list - const { active } = useDndContext(); - - const { setNodeRef, over } = useDroppable({ - id: dropItemId, - }); - - const intent = resolveDragEndIntent(active, over); - const shouldRenderDragOver = intent === 'pin:add'; - - const renderFavItem = useCallback( - (item: WorkspaceFavoriteItem) => { - if (item.type === 'collection') { - const collection = collections.find(c => c.id === item.id); - if (collection) { - const dragItemId = getDNDId( - 'sidebar-pin', - 'collection', - collection.id - ); - return ( - - ); - } - } else if (item.type === 'doc') { - return ( - - ); - } - return null; - }, - [collections, workspace] - ); - - const t = useI18n(); - - return ( -
- - - - {favourites.map(item => { - return {renderFavItem(item)}; - })} - {favourites.length === 0 && } -
- ); -}; - -export const FavoriteList = ({ - docCollection: workspace, -}: FavoriteListProps) => { - const favAdapter = useService(FavoriteItemsAdapter); - const favourites = useLiveData(favAdapter.orderedFavorites$); - const sortItems = useMemo(() => { - return favourites.map(fav => getDNDId('sidebar-pin', fav.type, fav.id)); - }, [favourites]); - return ( - - - - ); -}; - -export default FavoriteList; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx deleted file mode 100644 index d9ea55e87..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-nav-item.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { - getDNDId, - parseDNDId, -} from '@affine/core/hooks/affine/use-global-dnd-helper'; -import { useI18n } from '@affine/i18n'; -import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc'; -import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { DocsService, useLiveData, useService } from '@toeverything/infra'; -import { useMemo } from 'react'; - -import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay'; -import { SidebarDocItem } from '../doc-tree/doc'; -import * as styles from './styles.css'; - -const animateLayoutChanges: AnimateLayoutChanges = ({ - isSorting, - wasDragging, -}) => (isSorting || wasDragging ? false : true); - -export const FavouriteDocSidebarNavItem = ({ docId }: { docId: string }) => { - const t = useI18n(); - const docsService = useService(DocsService); - const docRecord = useLiveData(docsService.list.doc$(docId)); - const docMode = useLiveData(docRecord?.mode$); - const docTitle = useLiveData(docRecord?.title$); - const pageTitle = docTitle || t['Untitled'](); - - const icon = useMemo(() => { - return docMode === 'edgeless' ? : ; - }, [docMode]); - - const overlayPreview = useMemo(() => { - return ; - }, [icon, pageTitle]); - - const dragItemId = getDNDId('sidebar-pin', 'doc', docId); - - const { - setNodeRef, - isDragging, - attributes, - listeners, - transform, - transition, - active, - } = useSortable({ - id: dragItemId, - data: { - preview: overlayPreview, - }, - animateLayoutChanges, - }); - - const isSorting = parseDNDId(active?.id)?.where === 'sidebar-pin'; - const style = { - transform: CSS.Translate.toString(transform), - transition: isSorting ? transition : undefined, - }; - - return ( -
- -
- ); -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/index.tsx deleted file mode 100644 index c2c2dc0aa..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './favorite-list'; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/styles.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/styles.css.ts deleted file mode 100644 index e52611959..000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/styles.css.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { cssVar } from '@toeverything/theme'; -import { globalStyle, style } from '@vanilla-extract/css'; -export const label = style({ - selectors: { - '&[data-untitled="true"]': { - opacity: 0.6, - }, - }, -}); -export const labelContainer = style({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', -}); -export const labelTooltipContainer = style({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', -}); -export const favItemWrapper = style({ - display: 'flex', - flexDirection: 'column', - flexShrink: 0, - userSelect: 'none', -}); -export const collapsibleContent = style({ - overflow: 'hidden', - marginTop: '4px', - selectors: { - '&[data-hidden="true"]': { - display: 'none', - }, - }, -}); -export const collapsibleContentInner = style({ - display: 'flex', - flexDirection: 'column', -}); - -export const dragPageItemOverlay = style({ - display: 'flex', - alignItems: 'center', - background: cssVar('hoverColorFilled'), - boxShadow: cssVar('menuShadow'), - minHeight: '30px', - maxWidth: '360px', - width: '100%', - fontSize: cssVar('fontSm'), - gap: '8px', - padding: '4px', - borderRadius: '4px', -}); -globalStyle(`${dragPageItemOverlay} svg`, { - width: '20px', - height: '20px', - color: cssVar('iconColor'), -}); -globalStyle(`${dragPageItemOverlay} span`, { - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - overflow: 'hidden', -}); -export const favoriteList = style({ - overflow: 'hidden', - borderRadius: '4px', - display: 'flex', - flexDirection: 'column', - gap: 2, - selectors: { - '&[data-over="true"]': { - background: cssVar('hoverColorFilled'), - }, - }, -}); -export const favoritePostfixItem = style({ - display: 'flex', - alignItems: 'center', -}); -export const menuItem = style({ - gap: '8px', -}); -globalStyle(`${menuItem} svg`, { - width: '20px', - height: '20px', - color: cssVar('iconColor'), -}); -globalStyle(`${menuItem}.danger:hover svg`, { - color: cssVar('errorColor'), -}); -export const emptyFavouritesContent = style({ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: 6, - padding: '9px 20px 25px 21px', -}); -export const emptyFavouritesIconWrapper = style({ - width: 36, - height: 36, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: '50%', - backgroundColor: cssVar('hoverColor'), -}); -export const emptyFavouritesIcon = style({ - fontSize: 20, - color: cssVar('iconSecondary'), -}); -export const emptyFavouritesMessage = style({ - fontSize: cssVar('fontSm'), - textAlign: 'center', - color: cssVar('black30'), - userSelect: 'none', -}); -export const noReferences = style({ - fontSize: cssVar('fontSm'), - textAlign: 'left', - paddingLeft: '32px', - color: cssVar('black30'), - lineHeight: '30px', - userSelect: 'none', -}); diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index d82b92c7b..cefff5a7b 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -1,23 +1,23 @@ -import { AnimatedDeleteIcon } from '@affine/component'; -import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { CollectionService } from '@affine/core/modules/collection'; +import { + ExplorerCollections, + ExplorerFavorites, + ExplorerOrganize, +} from '@affine/core/modules/explorer'; +import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags'; import { TelemetryWorkspaceContextService } from '@affine/core/modules/telemetry/services/telemetry'; import { mixpanel } from '@affine/core/utils'; import { apis, events } from '@affine/electron-api'; import { useI18n } from '@affine/i18n'; import { FolderIcon, SettingsIcon } from '@blocksuite/icons/rc'; import type { Doc } from '@blocksuite/store'; -import { useDroppable } from '@dnd-kit/core'; import type { Workspace } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra'; import { useAtomValue } from 'jotai'; -import { nanoid } from 'nanoid'; -import type { HTMLAttributes, ReactElement } from 'react'; -import { forwardRef, memo, useCallback, useEffect } from 'react'; +import type { ReactElement } from 'react'; +import { memo, useEffect } from 'react'; import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper'; -import { useNavigateHelper } from '../../hooks/use-navigate-helper'; import { WorkbenchService } from '../../modules/workbench'; import { AddPageButton, @@ -31,10 +31,6 @@ import { SidebarContainer, SidebarScrollableContainer, } from '../app-sidebar'; -import { createEmptyCollection, useEditCollectionName } from '../page-list'; -import { CollectionsList } from '../pure/workspace-slider-bar/collections'; -import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button'; -import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list'; import { WorkspaceSelector } from '../workspace-selector'; import ImportPage from './import-page'; import { @@ -44,6 +40,7 @@ import { workspaceWrapper, } from './index.css'; import { AppSidebarJournalButton } from './journal-button'; +import { TrashButton } from './trash-button'; import { UpdaterButton } from './updater-button'; import { UserInfo } from './user-info'; @@ -61,29 +58,6 @@ export type RootAppSidebarProps = { }; }; -const RouteMenuLinkItem = forwardRef< - HTMLDivElement, - { - path: string; - icon: ReactElement; - active?: boolean; - children?: ReactElement; - } & HTMLAttributes ->(({ path, icon, active, children, ...props }, ref) => { - return ( - - {children} - - ); -}); -RouteMenuLinkItem.displayName = 'RouteMenuLinkItem'; - /** * This is for the whole affine app sidebar. * This component wraps the app sidebar in `@affine/component` with logic and data. @@ -113,8 +87,6 @@ export const RootAppSidebar = memo( const allPageActive = currentPath === '/all'; - const trashActive = currentPath === '/trash'; - const onClickNewPage = useAsyncCallback(async () => { const page = createPage(); page.load(); @@ -129,7 +101,6 @@ export const RootAppSidebar = memo( }); }, [createPage, openPage, telemetry]); - const navigateHelper = useNavigateHelper(); // Listen to the "New Page" action from the menu useEffect(() => { if (environment.isDesktop) { @@ -147,28 +118,6 @@ export const RootAppSidebar = memo( } }, [sidebarOpen]); - const dropItemId = getDNDId('sidebar-trash', 'container', 'trash'); - const trashDroppable = useDroppable({ - id: dropItemId, - }); - - const collection = useService(CollectionService); - const { node, open } = useEditCollectionName({ - title: t['com.affine.editCollection.createCollection'](), - showTips: true, - }); - const handleCreateCollection = useCallback(() => { - open('') - .then(name => { - const id = nanoid(); - collection.addCollection(createEmptyCollection(id, { name })); - navigateHelper.jumpToCollection(docCollection.id, id); - }) - .catch(err => { - console.error(err); - }); - }, [docCollection.id, collection, navigateHelper, open]); - return ( - } active={allPageActive} - path={paths.all(currentWorkspaceId)} + to={paths.all(currentWorkspaceId)} > {t['com.affine.workspaceSubPath.all']()} - + @@ -212,28 +161,15 @@ export const RootAppSidebar = memo( - - - - - + {runtimeConfig.enableOrganize && } + + + {/* fixme: remove the following spacer */}
- } - active={trashActive || trashDroppable.isOver} - path={paths.trash(currentWorkspaceId)} - > - - {t['com.affine.workspaceSubPath.trash']()} - - +
diff --git a/packages/frontend/core/src/components/root-app-sidebar/trash-button.tsx b/packages/frontend/core/src/components/root-app-sidebar/trash-button.tsx new file mode 100644 index 000000000..2d51c27ab --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/trash-button.tsx @@ -0,0 +1,73 @@ +import { + AnimatedDeleteIcon, + useConfirmModal, + useDropTarget, +} from '@affine/component'; +import { WorkbenchLink } from '@affine/core/modules/workbench'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; +import { + DocsService, + GlobalContextService, + useLiveData, + useService, +} from '@toeverything/infra'; + +import { MenuLinkItem } from '../app-sidebar'; + +export const TrashButton = () => { + const t = useI18n(); + const docsService = useService(DocsService); + const { openConfirmModal } = useConfirmModal(); + const globalContextService = useService(GlobalContextService); + const trashActive = useLiveData(globalContextService.globalContext.isTrash.$); + + const { dropTargetRef, draggedOver } = useDropTarget( + () => ({ + data: { + at: 'app-sidebar:trash', + }, + canDrop(data) { + return data.source.data.entity?.type === 'doc'; + }, + onDrop(data) { + if (data.source.data.entity?.type === 'doc') { + const docId = data.source.data.entity.id; + const docRecord = docsService.list.doc$(docId).value; + if (docRecord) { + openConfirmModal({ + title: t['com.affine.moveToTrash.confirmModal.title'](), + description: t['com.affine.moveToTrash.confirmModal.description']( + { + title: docRecord.title$.value || t['Untitled'](), + } + ), + confirmText: t.Delete(), + confirmButtonOptions: { + type: 'error', + }, + onConfirm() { + docRecord.moveToTrash(); + }, + }); + } + } + }, + }), + [docsService.list, openConfirmModal, t] + ); + + return ( + } + active={trashActive || draggedOver} + linkComponent={WorkbenchLink} + to={'/trash'} + > + + {t['com.affine.workspaceSubPath.trash']()} + + + ); +}; diff --git a/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts b/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts deleted file mode 100644 index 65c14329f..000000000 --- a/packages/frontend/core/src/hooks/affine/use-global-dnd-helper.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { toast } from '@affine/component'; -import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { CollectionService } from '@affine/core/modules/collection'; -import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; -import { useI18n } from '@affine/i18n'; -import type { - Active, - DragEndEvent, - Over, - UniqueIdentifier, -} from '@dnd-kit/core'; -import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; -import { useMemo } from 'react'; - -import { useDeleteCollectionInfo } from './use-delete-collection-info'; -import { useTrashModalHelper } from './use-trash-modal-helper'; - -export type DndWhere = - | 'sidebar-pin' - | 'sidebar-collections' - | 'sidebar-trash' - | 'doc-list' - | 'collection-list' - | 'tag-list'; - -export type DNDItemKind = 'container' | 'collection' | 'doc' | 'tag'; - -// where:kind:id -// we want to make the id something that can be used to identify the item -// -// Note, not all combinations are valid -type DNDItemIdentifier = `${DndWhere}:${DNDItemKind}:${string}`; -export type DNDIdentifier = - | `${DNDItemIdentifier}/${DNDItemIdentifier}` - | DNDItemIdentifier; - -export type DndItem = { - where: DndWhere; - kind: DNDItemKind; - itemId: string; - parent?: DndItem; // for now we only support one level of nesting -}; - -export function getDNDId( - where: DndWhere, - kind: DNDItemKind, - id: string, - parentId?: DNDIdentifier -): DNDIdentifier { - const itemId = `${where}:${kind}:${id}` as DNDItemIdentifier; - return parentId ? `${parentId}/${itemId}` : itemId; -} - -export function parseDNDId( - id: UniqueIdentifier | null | undefined -): DndItem | undefined { - if (typeof id !== 'string') return undefined; - const parts = id.split('/'); - if (parts.length === 1) { - const [where, kind, itemId] = id.split(':') as [ - DndWhere, - DNDItemKind, - string, - ]; - return where && kind && itemId - ? { - where, - kind, - itemId, - } - : undefined; - } else if (parts.length === 2) { - const item = parseDNDId(parts[1]); - const parent = parseDNDId(parts[0]); - if (!item || !parent) return undefined; - return { - ...item, - parent, - }; - } else { - throw new Error('Invalid DND ID'); - } -} - -export function resolveDragEndIntent( - active?: Active | null, - over?: Over | null -) { - const dragItem = parseDNDId(active?.id); - const dropItem = parseDNDId(over?.id); - - if (!dragItem) return null; - - // any doc item to trash - if ( - dropItem?.where === 'sidebar-trash' && - (dragItem.kind === 'doc' || dragItem.kind === 'collection') - ) { - return 'trash:move-to'; - } - - // add page to collection - if ( - dragItem.kind === 'doc' && - dragItem.where !== dropItem?.where && - dropItem?.kind === 'collection' - ) { - return 'collection:add'; - } - - // move a doc from one collection to another - if ( - dragItem.kind === 'doc' && - dragItem?.where === 'collection-list' && - dragItem.parent?.kind === 'collection' && - dropItem?.kind !== 'collection' - ) { - return 'collection:remove'; - } - - // move any doc/collection to sidebar pin - if ( - dragItem.where !== 'sidebar-pin' && - dropItem?.where === 'sidebar-pin' && - (dragItem.kind === 'doc' || dragItem.kind === 'collection') - ) { - return 'pin:add'; - } - - // from sidebar pin to sidebar pin (reorder) - if ( - dragItem.where === 'sidebar-pin' && - dropItem?.where === 'sidebar-pin' && - (dragItem.kind === 'doc' || dragItem.kind === 'collection') && - (dropItem.kind === 'doc' || dropItem.kind === 'collection') - ) { - return 'pin:reorder'; - } - - // from sidebar pin to outside (remove from favourites) - if ( - dragItem.where === 'sidebar-pin' && - dropItem?.where !== 'sidebar-pin' && - (dragItem.kind === 'doc' || dragItem.kind === 'collection') - ) { - return 'pin:remove'; - } - - return null; -} - -export type GlobalDragEndIntent = ReturnType; - -export const useGlobalDNDHelper = () => { - const t = useI18n(); - const currentWorkspace = useService(WorkspaceService).workspace; - const favAdapter = useService(FavoriteItemsAdapter); - const workspace = currentWorkspace.docCollection; - const { setTrashModal } = useTrashModalHelper(workspace); - const { getDocMeta } = useDocMetaHelper(workspace); - const collectionService = useService(CollectionService); - const collections = useLiveData(collectionService.collections$); - const deleteInfo = useDeleteCollectionInfo(); - - return useMemo(() => { - return { - handleDragEnd: (e: DragEndEvent) => { - const intent = resolveDragEndIntent(e.active, e.over); - - const dragItem = parseDNDId(e.active.id); - const dropItem = parseDNDId(e.over?.id); - - switch (intent) { - case 'pin:remove': - if ( - dragItem && - favAdapter.isFavorite( - dragItem.itemId, - dragItem.kind as 'doc' | 'collection' - ) - ) { - favAdapter.remove( - dragItem.itemId, - dragItem.kind as 'doc' | 'collection' - ); - toast( - t['com.affine.cmdk.affine.editor.remove-from-favourites']() - ); - } - return; - - case 'pin:reorder': - if (dragItem && dropItem) { - const fromId = FavoriteItemsAdapter.getFavItemKey( - dragItem.itemId, - dragItem.kind as 'doc' | 'collection' - ); - const toId = FavoriteItemsAdapter.getFavItemKey( - dropItem.itemId, - dropItem.kind as 'doc' | 'collection' - ); - favAdapter.sorter.move(fromId, toId); - } - - return; - - case 'pin:add': - if ( - dragItem && - !favAdapter.isFavorite( - dragItem.itemId, - dragItem.kind as 'doc' | 'collection' - ) - ) { - favAdapter.set( - dragItem.itemId, - dragItem.kind as 'collection' | 'doc', - true - ); - toast(t['com.affine.cmdk.affine.editor.add-to-favourites']()); - } - return; - - case 'collection:add': - if (dragItem && dropItem) { - const pageId = dragItem.itemId; - const collectionId = dropItem.itemId; - const collection = collections.find(c => { - return c.id === collectionId; - }); - - if (collection?.allowList.includes(pageId)) { - toast(t['com.affine.collection.addPage.alreadyExists']()); - } else { - collectionService.addPageToCollection(collectionId, pageId); - toast(t['com.affine.collection.addPage.success']()); - } - } - return; - - case 'collection:remove': - if (dragItem) { - const pageId = dragItem.itemId; - const collId = dragItem.parent?.itemId; - if (collId) { - collectionService.deletePageFromCollection(collId, pageId); - toast(t['com.affine.collection.removePage.success']()); - } - } - return; - - case 'trash:move-to': - if (dragItem) { - const pageId = dragItem.itemId; - if (dragItem.kind === 'doc') { - const pageTitle = getDocMeta(pageId)?.title ?? t['Untitled'](); - setTrashModal({ - open: true, - pageIds: [pageId], - pageTitles: [pageTitle], - }); - } else { - collectionService.deleteCollection(deleteInfo, dragItem.itemId); - } - } - return; - default: - return; - } - }, - }; - }, [ - collectionService, - collections, - deleteInfo, - favAdapter, - getDocMeta, - setTrashModal, - t, - ]); -}; diff --git a/packages/frontend/core/src/layouts/styles.css.ts b/packages/frontend/core/src/layouts/styles.css.ts deleted file mode 100644 index 61da61f31..000000000 --- a/packages/frontend/core/src/layouts/styles.css.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -export const dragOverlay = style({ - display: 'flex', - alignItems: 'center', - zIndex: 1001, - cursor: 'grabbing', - maxWidth: '360px', - transition: 'transform 0.2s, opacity 0.2s', - willChange: 'transform opacity', - selectors: { - '&[data-over-drop=true]': { - transform: 'scale(0.8)', - }, - '&[data-sorting=true]': { - opacity: 0, - }, - }, -}); diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 543160022..d40efe491 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -6,14 +6,6 @@ import { import { useI18n } from '@affine/i18n'; import { ZipTransformer } from '@blocksuite/blocks'; import { assertExists } from '@blocksuite/global/utils'; -import { - DndContext, - DragOverlay, - MouseSensor, - useDndContext, - useSensor, - useSensors, -} from '@dnd-kit/core'; import { type DocMode, DocsService, @@ -26,9 +18,8 @@ import { WorkspaceService, } from '@toeverything/infra'; import { useAtomValue, useSetAtom } from 'jotai'; -import type { PropsWithChildren, ReactNode } from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; +import type { PropsWithChildren } from 'react'; +import { useCallback, useEffect } from 'react'; import { catchError, EMPTY, @@ -46,16 +37,11 @@ import { AppContainer } from '../components/affine/app-container'; import { SyncAwareness } from '../components/affine/awareness'; import { appSidebarResizingAtom } from '../components/app-sidebar'; import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils'; -import type { DraggableTitleCellData } from '../components/page-list'; import { AIIsland } from '../components/pure/ai-island'; import { RootAppSidebar } from '../components/root-app-sidebar'; import { MainContainer } from '../components/workspace'; import { WorkspaceUpgrade } from '../components/workspace-upgrade'; import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper'; -import { - resolveDragEndIntent, - useGlobalDNDHelper, -} from '../hooks/affine/use-global-dnd-helper'; import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands'; import { useSubscriptionNotifyReader } from '../hooks/affine/use-subscription-notify'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; @@ -71,7 +57,6 @@ import { import { SWRConfigProvider } from '../providers/swr-config-provider'; import { pathGenerator } from '../shared'; import { mixpanel } from '../utils'; -import * as styles from './styles.css'; export const WorkspaceLayout = function WorkspaceLayout({ children, @@ -215,6 +200,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { activeTab: 'appearance', open: true, }); + mixpanel.track('SettingsViewed', { // page: segment: 'navigation panel', @@ -225,99 +211,33 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { const resizing = useAtomValue(appSidebarResizingAtom); - const sensors = useSensors( - useSensor( - MouseSensor, - useMemo( - /* useMemo is necessary to avoid re-render */ - () => ({ - activationConstraint: { - distance: 10, - }, - }), - [] - ) - ) - ); - - const { handleDragEnd } = useGlobalDNDHelper(); const { appSettings } = useAppSettingHelper(); return ( <> - {/* This DndContext is used for drag page from all-pages list into a folder in sidebar */} - - - { - assertExists(currentWorkspace); - return openPage(currentWorkspace.id, pageId); - }, - [currentWorkspace, openPage] - )} - createPage={handleCreatePage} - paths={pathGenerator} - /> + + { + assertExists(currentWorkspace); + return openPage(currentWorkspace.id, pageId); + }, + [currentWorkspace, openPage] + )} + createPage={handleCreatePage} + paths={pathGenerator} + /> - - {needUpgrade || upgrading ? : children} - - - - + + {needUpgrade || upgrading ? : children} + + ); }; - -function GlobalDragOverlay() { - const { active, over } = useDndContext(); - const [preview, setPreview] = useState(); - - useEffect(() => { - if (active) { - const data = active.data.current as DraggableTitleCellData; - setPreview(data.preview); - } - // do not update content since it may disappear because of virtual rendering - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [active?.id]); - - const intent = resolveDragEndIntent(active, over); - - const overDropZone = - intent === 'pin:add' || - intent === 'collection:add' || - intent === 'trash:move-to'; - - const accent = - intent === 'pin:remove' - ? 'warning' - : intent === 'trash:move-to' - ? 'error' - : 'normal'; - - const sorting = intent === 'pin:reorder'; - - return createPortal( - - {preview ? ( -
- {preview} -
- ) : null} -
, - document.body - ); -} diff --git a/packages/frontend/core/src/modules/collection/services/collection.ts b/packages/frontend/core/src/modules/collection/services/collection.ts index bdc9aaa58..41d42ce20 100644 --- a/packages/frontend/core/src/modules/collection/services/collection.ts +++ b/packages/frontend/core/src/modules/collection/services/collection.ts @@ -50,6 +50,12 @@ export class CollectionService extends Service { [] ); + collection$(id: string) { + return this.collections$.map(collections => { + return collections.find(v => v.id === id); + }); + } + readonly collectionsTrash$ = LiveData.from( new Observable(subscriber => { subscriber.next(this.collectionsTrashYArray?.toArray() ?? []); diff --git a/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts b/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts index 5c02227c2..c15ef38c2 100644 --- a/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts +++ b/packages/frontend/core/src/modules/docs-search/entities/docs-indexer.ts @@ -1,6 +1,7 @@ import { DebugLogger } from '@affine/debug'; import type { Job, JobQueue, WorkspaceService } from '@toeverything/infra'; import { + DBService, Entity, IndexedDBIndexStorage, IndexedDBJobQueue, @@ -68,6 +69,10 @@ export class DocsIndexer extends Entity { setupListener() { this.workspaceEngine.doc.storage.eventBus.on(event => { + if (DBService.isDBDocId(event.docId)) { + // skip db doc + return; + } if (event.clientId === this.workspaceEngine.doc.clientId) { const docId = normalizeDocId(event.docId); diff --git a/packages/frontend/core/src/modules/explorer/README.md b/packages/frontend/core/src/modules/explorer/README.md new file mode 100644 index 000000000..6c2634312 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/README.md @@ -0,0 +1,3 @@ +# explorer + +file manager in app left sidebar diff --git a/packages/frontend/core/src/modules/explorer/index.ts b/packages/frontend/core/src/modules/explorer/index.ts new file mode 100644 index 000000000..9373ab8cd --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/index.ts @@ -0,0 +1,3 @@ +export { ExplorerCollections } from './views/sections/collections'; +export { ExplorerFavorites } from './views/sections/favorites'; +export { ExplorerOrganize } from './views/sections/organize'; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/collection/empty.css.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/collection/empty.css.tsx new file mode 100644 index 000000000..049d07b21 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/collection/empty.css.tsx @@ -0,0 +1,19 @@ +import { cssVar } from '@toeverything/theme'; +import { fallbackVar, style } from '@vanilla-extract/css'; + +import { levelIndent } from '../../tree/node.css'; + +export const noReferences = style({ + fontSize: cssVar('fontSm'), + textAlign: 'left', + padding: '4px 0 4px 32px', + color: cssVar('black30'), + userSelect: 'none', + paddingLeft: `calc(${fallbackVar(levelIndent, '20px')} + 32px)`, + selectors: { + '&[data-dragged-over="true"]': { + background: cssVar('--affine-hover-color'), + borderRadius: '4px', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/collection/empty.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/collection/empty.tsx new file mode 100644 index 000000000..372eea2d9 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/collection/empty.tsx @@ -0,0 +1,24 @@ +import { type DropTargetDropEvent, useDropTarget } from '@affine/component'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; + +import * as styles from './empty.css'; + +export const Empty = ({ + onDrop, +}: { + onDrop: (data: DropTargetDropEvent) => void; +}) => { + const { dropTargetRef } = useDropTarget( + () => ({ + onDrop, + }), + [onDrop] + ); + const t = useI18n(); + return ( +
+ {t['com.affine.collection.emptyCollection']()} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/collection/index.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/collection/index.tsx new file mode 100644 index 000000000..6c3b528a1 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/collection/index.tsx @@ -0,0 +1,317 @@ +import { + AnimatedCollectionsIcon, + type DropTargetDropEvent, + type DropTargetOptions, + MenuIcon, + MenuItem, + toast, +} from '@affine/component'; +import { + filterPage, + useEditCollection, +} from '@affine/core/components/page-list'; +import { CollectionService } from '@affine/core/modules/collection'; +import { FavoriteItemsAdapter } 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'; +import { PublicPageMode } from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; +import { FilterMinusIcon } from '@blocksuite/icons/rc'; +import type { DocMeta } from '@blocksuite/store'; +import { + DocsService, + GlobalContextService, + LiveData, + useLiveData, + useService, + useServices, +} from '@toeverything/infra'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree'; +import { ExplorerDocNode } from '../doc'; +import type { GenericExplorerNode } from '../types'; +import { Empty } from './empty'; +import { useExplorerCollectionNodeOperations } from './operations'; + +export const ExplorerCollectionNode = ({ + collectionId, + onDrop, + location, + reorderable, + operations: additionalOperations, + canDrop, + dropEffect, +}: { + collectionId: string; +} & GenericExplorerNode) => { + const t = useI18n(); + const { globalContextService } = useServices({ + GlobalContextService, + }); + const { open: openEditCollectionModal, node: editModal } = + useEditCollection(); + const active = + useLiveData(globalContextService.globalContext.collectionId.$) === + collectionId; + const [collapsed, setCollapsed] = useState(true); + + const collectionService = useService(CollectionService); + const collection = useLiveData(collectionService.collection$(collectionId)); + + const dndData = useMemo(() => { + return { + draggable: { + entity: { + type: 'collection', + id: collectionId, + }, + from: location, + }, + dropTarget: { + at: 'explorer:doc', + }, + } satisfies AffineDNDData; + }, [collectionId, location]); + + const handleRename = useCallback( + (name: string) => { + if (collection) { + collectionService.updateCollection(collectionId, () => ({ + ...collection, + name, + })); + toast(t['com.affine.toastMessage.rename']()); + } + }, + [collection, collectionId, collectionService, t] + ); + + const handleAddDocToCollection = useCallback( + (docId: string) => { + if (!collection) { + return; + } + if (collection.allowList.includes(docId)) { + toast(t['com.affine.collection.addPage.alreadyExists']()); + } else { + collectionService.addPageToCollection(collection.id, docId); + } + }, + [collection, collectionService, t] + ); + + const handleDropOnCollection = useCallback( + (data: DropTargetDropEvent) => { + if (collection && data.treeInstruction?.type === 'make-child') { + if (data.source.data.entity?.type === 'doc') { + handleAddDocToCollection(data.source.data.entity.id); + } + } else { + onDrop?.(data); + } + }, + [collection, onDrop, handleAddDocToCollection] + ); + + const handleDropEffectOnCollection = useCallback( + data => { + if (collection && data.treeInstruction?.type === 'make-child') { + if (data.source.data.entity?.type === 'doc') { + return 'link'; + } + } else { + return dropEffect?.(data); + } + return; + }, + [collection, dropEffect] + ); + + const handleDropOnPlaceholder = useCallback( + (data: DropTargetDropEvent) => { + if (collection && data.source.data.entity?.type === 'doc') { + handleAddDocToCollection(data.source.data.entity.id); + } + }, + [collection, handleAddDocToCollection] + ); + + const handleOpenCollapsed = useCallback(() => { + setCollapsed(false); + }, []); + + const handleEditCollection = useCallback(() => { + if (!collection) { + return; + } + openEditCollectionModal(collection) + .then(collection => { + return collectionService.updateCollection( + collection.id, + () => collection + ); + }) + .catch(err => { + console.error(err); + }); + }, [collection, collectionService, openEditCollectionModal]); + + const collectionOperations = useExplorerCollectionNodeOperations( + collectionId, + handleOpenCollapsed, + handleEditCollection + ); + + const finalOperations = useMemo(() => { + if (additionalOperations) { + return [...additionalOperations, ...collectionOperations]; + } + return collectionOperations; + }, [collectionOperations, additionalOperations]); + + const handleCanDrop = useMemo['canDrop']>( + () => args => { + const entityType = args.source.data.entity?.type; + return args.treeInstruction?.type !== 'make-child' + ? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true) + : entityType === 'doc'; + }, + [canDrop] + ); + + if (!collection) { + return null; + } + + return ( + <> + ( + + )} + name={collection.name || t['Untitled']()} + dndData={dndData} + onDrop={handleDropOnCollection} + renameable + collapsed={collapsed} + setCollapsed={setCollapsed} + to={`/collection/${collection.id}`} + active={active} + canDrop={handleCanDrop} + reorderable={reorderable} + onRename={handleRename} + childrenPlaceholder={} + operations={finalOperations} + dropEffect={handleDropEffectOnCollection} + data-testid={`explorer-collection-${collectionId}`} + > + + + {editModal} + + ); +}; + +const ExplorerCollectionNodeChildren = ({ + collection, +}: { + collection: Collection; +}) => { + const t = useI18n(); + const { + docsService, + favoriteItemsAdapter, + shareDocsService, + collectionService, + } = useServices({ + DocsService, + FavoriteItemsAdapter, + ShareDocsService, + CollectionService, + }); + + useEffect(() => { + // TODO(@eyhn): loading & error UI + shareDocsService.shareDocs?.revalidate(); + }, [shareDocsService]); + + const docMetas = useLiveData( + useMemo( + () => + LiveData.computed(get => { + return get(docsService.list.docs$).map( + doc => get(doc.meta$) as DocMeta + ); + }), + [docsService] + ) + ); + const favourites = useLiveData(favoriteItemsAdapter.favorites$); + const allowList = useMemo( + () => new Set(collection.allowList), + [collection.allowList] + ); + const shareDocs = useLiveData(shareDocsService.shareDocs?.list$); + + const handleRemoveFromAllowList = useCallback( + (id: string) => { + collectionService.deletePageFromCollection(collection.id, id); + toast(t['com.affine.collection.removePage.success']()); + }, + [collection.id, collectionService, t] + ); + + const filtered = docMetas.filter(meta => { + if (meta.trash) return false; + const publicMode = shareDocs?.find(d => d.id === meta.id)?.mode; + const pageData = { + meta: meta as DocMeta, + publicMode: + publicMode === PublicPageMode.Edgeless + ? ('edgeless' as const) + : publicMode === PublicPageMode.Page + ? ('page' as const) + : undefined, + favorite: favourites.some(fav => fav.id === meta.id), + }; + return filterPage(collection, pageData); + }); + + return filtered.map(doc => ( + + + + } + onClick={() => handleRemoveFromAllowList(doc.id)} + > + {t['Remove special filter']()} + + ), + }, + ] + : [] + } + /> + )); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx new file mode 100644 index 000000000..2a7da69ce --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/collection/operations.tsx @@ -0,0 +1,216 @@ +import { + IconButton, + MenuIcon, + MenuItem, + MenuSeparator, + useConfirmModal, +} 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 { CollectionService } from '@affine/core/modules/collection'; +import { FavoriteItemsAdapter } from '@affine/core/modules/properties'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import { + DeleteIcon, + FavoritedIcon, + FavoriteIcon, + FilterIcon, + PlusIcon, + SplitViewIcon, +} from '@blocksuite/icons/rc'; +import { DocsService, useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +import type { NodeOperation } from '../../tree/types'; + +export const useExplorerCollectionNodeOperations = ( + collectionId: string, + onOpenCollapsed: () => void, + onOpenEdit: () => void +): NodeOperation[] => { + const t = useI18n(); + const { appSettings } = useAppSettingHelper(); + const { + workbenchService, + docsService, + collectionService, + favoriteItemsAdapter, + } = useServices({ + DocsService, + WorkbenchService, + CollectionService, + FavoriteItemsAdapter, + }); + const deleteInfo = useDeleteCollectionInfo(); + + const favorite = useLiveData( + useMemo( + () => favoriteItemsAdapter.isFavorite$(collectionId, 'collection'), + [collectionId, favoriteItemsAdapter] + ) + ); + const { openConfirmModal } = useConfirmModal(); + + const createAndAddDocument = useCallback(() => { + const newDoc = docsService.createDoc(); + collectionService.addPageToCollection(collectionId, newDoc.id); + workbenchService.workbench.openDoc(newDoc.id); + onOpenCollapsed(); + }, [ + collectionId, + collectionService, + docsService, + onOpenCollapsed, + workbenchService.workbench, + ]); + + const handleToggleFavoritePage = useCallback(() => { + favoriteItemsAdapter.toggle(collectionId, 'collection'); + }, [favoriteItemsAdapter, collectionId]); + + const handleAddDocToCollection = useCallback(() => { + openConfirmModal({ + title: t['com.affine.collection.add-doc.confirm.title'](), + description: t['com.affine.collection.add-doc.confirm.description'](), + cancelText: t['Cancel'](), + confirmText: t['Confirm'](), + confirmButtonOptions: { + type: 'primary', + }, + onConfirm: createAndAddDocument, + }); + }, [createAndAddDocument, openConfirmModal, t]); + + const handleOpenInSplitView = useCallback(() => { + workbenchService.workbench.openCollection(collectionId, { at: 'beside' }); + }, [collectionId, workbenchService.workbench]); + + const handleDeleteCollection = useCallback(() => { + collectionService.deleteCollection(deleteInfo, collectionId); + }, [collectionId, collectionService, deleteInfo]); + + const handleShowEdit = useCallback(() => { + onOpenEdit(); + }, [onOpenEdit]); + + return useMemo( + () => [ + { + index: 0, + inline: true, + view: ( + + + + ), + }, + { + index: 99, + view: ( + + + + } + onClick={handleShowEdit} + > + {t['com.affine.collection.menu.edit']()} + + ), + }, + { + index: 99, + view: ( + + + + } + onClick={handleAddDocToCollection} + > + {t['New Page']()} + + ), + }, + { + index: 99, + view: ( + + {favorite ? ( + + ) : ( + + )} + + } + onClick={handleToggleFavoritePage} + > + {favorite + ? t['com.affine.favoritePageOperation.remove']() + : t['com.affine.favoritePageOperation.add']()} + + ), + }, + ...(appSettings.enableMultiView + ? [ + { + index: 99, + view: ( + + + + } + onClick={handleOpenInSplitView} + > + {t['com.affine.workbench.split-view.page-menu-open']()} + + ), + }, + ] + : []), + { + index: 9999, + view: , + }, + { + index: 10000, + view: ( + + + + } + onClick={handleDeleteCollection} + > + {t['Delete']()} + + ), + }, + ], + [ + appSettings.enableMultiView, + favorite, + handleAddDocToCollection, + handleDeleteCollection, + handleOpenInSplitView, + handleShowEdit, + handleToggleFavoritePage, + t, + ] + ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/doc/empty.css.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/doc/empty.css.tsx new file mode 100644 index 000000000..049d07b21 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/doc/empty.css.tsx @@ -0,0 +1,19 @@ +import { cssVar } from '@toeverything/theme'; +import { fallbackVar, style } from '@vanilla-extract/css'; + +import { levelIndent } from '../../tree/node.css'; + +export const noReferences = style({ + fontSize: cssVar('fontSm'), + textAlign: 'left', + padding: '4px 0 4px 32px', + color: cssVar('black30'), + userSelect: 'none', + paddingLeft: `calc(${fallbackVar(levelIndent, '20px')} + 32px)`, + selectors: { + '&[data-dragged-over="true"]': { + background: cssVar('--affine-hover-color'), + borderRadius: '4px', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/doc/empty.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/doc/empty.tsx new file mode 100644 index 000000000..914d3d821 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/doc/empty.tsx @@ -0,0 +1,24 @@ +import { type DropTargetDropEvent, useDropTarget } from '@affine/component'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; + +import * as styles from './empty.css'; + +export const Empty = ({ + onDrop, +}: { + onDrop: (data: DropTargetDropEvent) => void; +}) => { + const { dropTargetRef } = useDropTarget( + () => ({ + onDrop, + }), + [onDrop] + ); + const t = useI18n(); + return ( +
+ {t['com.affine.rootAppSidebar.docs.no-subdoc']()} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/doc/index.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/doc/index.tsx new file mode 100644 index 000000000..ef44b236e --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/doc/index.tsx @@ -0,0 +1,250 @@ +import { + type DropTargetDropEvent, + type DropTargetOptions, + Loading, + toast, + Tooltip, +} from '@affine/component'; +import { InfoModal } from '@affine/core/components/affine/page-properties'; +import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { DocsSearchService } from '@affine/core/modules/docs-search'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; +import { + EdgelessIcon, + LinkedEdgelessIcon, + LinkedPageIcon, + PageIcon, +} from '@blocksuite/icons/rc'; +import { + DocsService, + GlobalContextService, + LiveData, + useLiveData, + useServices, +} from '@toeverything/infra'; +import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; + +import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree'; +import type { GenericExplorerNode } from '../types'; +import { Empty } from './empty'; +import { useExplorerDocNodeOperations } from './operations'; +import * as styles from './styles.css'; + +export const ExplorerDocNode = ({ + docId, + onDrop, + location, + reorderable, + isLinked, + canDrop, + operations: additionalOperations, + dropEffect, +}: { + docId: string; + isLinked?: boolean; +} & GenericExplorerNode) => { + const t = useI18n(); + const { docsSearchService, docsService, globalContextService } = useServices({ + DocsSearchService, + DocsService, + GlobalContextService, + }); + const active = + useLiveData(globalContextService.globalContext.docId.$) === docId; + const [collapsed, setCollapsed] = useState(true); + + const docRecord = useLiveData(docsService.list.doc$(docId)); + const docMode = useLiveData(docRecord?.mode$); + const docTitle = useLiveData(docRecord?.title$); + const isInTrash = useLiveData(docRecord?.trash$); + + const Icon = useCallback( + ({ className }: { className?: string }) => { + return isLinked ? ( + docMode === 'edgeless' ? ( + + ) : ( + + ) + ) : docMode === 'edgeless' ? ( + + ) : ( + + ); + }, + [docMode, isLinked] + ); + + const children = useLiveData( + useMemo( + () => LiveData.from(docsSearchService.watchRefsFrom(docId), null), + [docsSearchService, docId] + ) + ); + + const indexerLoading = useLiveData( + docsSearchService.indexer.status$.map( + v => v.remaining === undefined || v.remaining > 0 + ) + ); + const [referencesLoading, setReferencesLoading] = useState(true); + useLayoutEffect(() => { + setReferencesLoading( + prev => + prev && + indexerLoading /* after loading becomes false, it never becomes true */ + ); + }, [indexerLoading]); + + const dndData = useMemo(() => { + return { + draggable: { + entity: { + type: 'doc', + id: docId, + }, + from: location, + }, + dropTarget: { + at: 'explorer:doc', + }, + } satisfies AffineDNDData; + }, [docId, location]); + + const handleRename = useAsyncCallback( + async (newName: string) => { + await docsService.changeDocTitle(docId, newName); + }, + [docId, docsService] + ); + + const handleDropOnDoc = useAsyncCallback( + async (data: DropTargetDropEvent) => { + if (data.treeInstruction?.type === 'make-child') { + if (data.source.data.entity?.type === 'doc') { + await docsService.addLinkedDoc(docId, data.source.data.entity.id); + } else { + toast(t['com.affine.rootAppSidebar.doc.link-doc-only']()); + } + } else { + onDrop?.(data); + } + }, + [docId, docsService, onDrop, t] + ); + + const handleDropEffectOnDoc = useCallback( + data => { + if (data.treeInstruction?.type === 'make-child') { + if (data.source.data.entity?.type === 'doc') { + return 'link'; + } + } else { + return dropEffect?.(data); + } + return; + }, + [dropEffect] + ); + + const handleDropOnPlaceholder = useAsyncCallback( + async (data: DropTargetDropEvent) => { + if (data.source.data.entity?.type === 'doc') { + // TODO(eyhn): timeout&error handling + await docsService.addLinkedDoc(docId, data.source.data.entity.id); + } else { + toast(t['com.affine.rootAppSidebar.doc.link-doc-only']()); + } + }, + [docId, docsService, t] + ); + + const handleCanDrop = useMemo['canDrop']>( + () => args => { + const entityType = args.source.data.entity?.type; + return args.treeInstruction?.type !== 'make-child' + ? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true) + : entityType === 'doc'; + }, + [canDrop] + ); + + const [enableInfoModal, setEnableInfoModal] = useState(false); + const operations = useExplorerDocNodeOperations( + docId, + useMemo( + () => ({ + openInfoModal: () => setEnableInfoModal(true), + openNodeCollapsed: () => setCollapsed(false), + }), + [] + ) + ); + + const finalOperations = useMemo(() => { + if (additionalOperations) { + return [...operations, ...additionalOperations]; + } + return operations; + }, [additionalOperations, operations]); + + if (isInTrash || !docRecord) { + return null; + } + + return ( + <> + +
+ +
+ + ) + } + reorderable={reorderable} + onRename={handleRename} + childrenPlaceholder={} + operations={finalOperations} + dropEffect={handleDropEffectOnDoc} + data-testid={`explorer-doc-${docId}`} + > + {children?.map(child => ( + + ))} +
+ {enableInfoModal && ( + + )} + + ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx new file mode 100644 index 000000000..2430ebd8b --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/doc/operations.tsx @@ -0,0 +1,200 @@ +import { + MenuIcon, + MenuItem, + MenuSeparator, + toast, + useConfirmModal, +} 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 { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import { + DeleteIcon, + FavoritedIcon, + FavoriteIcon, + InformationIcon, + LinkedPageIcon, + SplitViewIcon, +} from '@blocksuite/icons/rc'; +import { DocsService, useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +import type { NodeOperation } from '../../tree/types'; + +export const useExplorerDocNodeOperations = ( + docId: string, + options: { + openInfoModal: () => void; + openNodeCollapsed: () => void; + } +): NodeOperation[] => { + const t = useI18n(); + const { appSettings } = useAppSettingHelper(); + const { workbenchService, docsService, favoriteItemsAdapter } = useServices({ + DocsService, + WorkbenchService, + FavoriteItemsAdapter, + }); + const { openConfirmModal } = useConfirmModal(); + + const docRecord = useLiveData(docsService.list.doc$(docId)); + + const favorite = useLiveData( + useMemo( + () => favoriteItemsAdapter.isFavorite$(docId, 'doc'), + [docId, favoriteItemsAdapter] + ) + ); + + const handleMoveToTrash = useCallback(() => { + if (!docRecord) { + return; + } + openConfirmModal({ + title: t['com.affine.moveToTrash.title'](), + description: t['com.affine.moveToTrash.confirmModal.description']({ + title: docRecord.title$.value, + }), + confirmText: t['com.affine.moveToTrash.confirmModal.confirm'](), + cancelText: t['com.affine.moveToTrash.confirmModal.cancel'](), + confirmButtonOptions: { + type: 'error', + }, + onConfirm() { + docRecord.moveToTrash(); + toast(t['com.affine.toastMessage.movedTrash']()); + }, + }); + }, [docRecord, openConfirmModal, t]); + + const handleOpenInSplitView = useCallback(() => { + workbenchService.workbench.openDoc(docId, { + at: 'beside', + }); + }, [docId, workbenchService]); + + const handleAddLinkedPage = useAsyncCallback(async () => { + const newDoc = docsService.createDoc(); + // TODO: handle timeout & error + await docsService.addLinkedDoc(docId, newDoc.id); + workbenchService.workbench.openDoc(newDoc.id); + options.openNodeCollapsed(); + }, [docId, options, docsService, workbenchService.workbench]); + + const handleToggleFavoriteDoc = useCallback(() => { + favoriteItemsAdapter.toggle(docId, 'doc'); + }, [favoriteItemsAdapter, docId]); + + return useMemo( + () => [ + ...(runtimeConfig.enableInfoModal + ? [ + { + index: 50, + view: ( + + + + } + onClick={options.openInfoModal} + > + {t['com.affine.page-properties.page-info.view']()} + + ), + }, + ] + : []), + { + index: 99, + view: ( + + + + } + onClick={handleAddLinkedPage} + > + {t['com.affine.page-operation.add-linked-page']()} + + ), + }, + ...(appSettings.enableMultiView + ? [ + { + index: 100, + view: ( + + + + } + onClick={handleOpenInSplitView} + > + {t['com.affine.workbench.split-view.page-menu-open']()} + + ), + }, + ] + : []), + { + index: 199, + view: ( + + {favorite ? ( + + ) : ( + + )} + + } + onClick={handleToggleFavoriteDoc} + > + {favorite + ? t['com.affine.favoritePageOperation.remove']() + : t['com.affine.favoritePageOperation.add']()} + + ), + }, + { + index: 9999, + view: , + }, + { + index: 10000, + view: ( + + + + } + onClick={handleMoveToTrash} + > + {t['com.affine.moveToTrash.title']()} + + ), + }, + ], + [ + appSettings.enableMultiView, + favorite, + handleAddLinkedPage, + handleMoveToTrash, + handleOpenInSplitView, + handleToggleFavoriteDoc, + options.openInfoModal, + t, + ] + ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/doc/styles.css.ts b/packages/frontend/core/src/modules/explorer/views/nodes/doc/styles.css.ts new file mode 100644 index 000000000..6a4ebb875 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/doc/styles.css.ts @@ -0,0 +1,7 @@ +import { style } from '@vanilla-extract/css'; + +export const loadingIcon = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/folder/empty.css.ts b/packages/frontend/core/src/modules/explorer/views/nodes/folder/empty.css.ts new file mode 100644 index 000000000..d0bb8ae9e --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/folder/empty.css.ts @@ -0,0 +1,44 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const content = style({ + 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'), +}); + +export const draggedOverHighlight = style({ + selectors: { + '&[data-dragged-over="true"]': { + background: cssVar('--affine-hover-color'), + borderRadius: '4px', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/folder/empty.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/folder/empty.tsx new file mode 100644 index 000000000..f7e6c1f2d --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/folder/empty.tsx @@ -0,0 +1,53 @@ +import { + Button, + 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 clsx from 'clsx'; + +import * as styles from './empty.css'; + +export const FolderEmpty = ({ + onClickCreate, + className, + canDrop, + onDrop, +}: { + onClickCreate?: () => void; + onDrop?: (data: DropTargetDropEvent) => void; + canDrop?: DropTargetOptions['canDrop']; + className?: string; +}) => { + const { dropTargetRef } = useDropTarget( + () => ({ + onDrop, + canDrop, + }), + [onDrop, canDrop] + ); + + const t = useI18n(); + return ( +
+
+ +
+
+ {t['com.affine.rootAppSidebar.organize.empty-folder']()} +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx new file mode 100644 index 000000000..15125aeb7 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx @@ -0,0 +1,612 @@ +import { + AnimatedFolderIcon, + type DropTargetDropEvent, + type DropTargetOptions, + IconButton, + MenuIcon, + MenuItem, + MenuSeparator, +} from '@affine/component'; +import { + type FolderNode, + OrganizeService, +} from '@affine/core/modules/organize'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { Unreachable } from '@affine/env/constant'; +import { useI18n } from '@affine/i18n'; +import { + DeleteIcon, + FolderIcon, + PlusIcon, + RemoveFolderIcon, +} from '@blocksuite/icons/rc'; +import { DocsService, useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useMemo, useState } from 'react'; + +import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree'; +import type { NodeOperation } from '../../tree/types'; +import { ExplorerCollectionNode } from '../collection'; +import { ExplorerDocNode } from '../doc'; +import { ExplorerTagNode } from '../tag'; +import type { GenericExplorerNode } from '../types'; +import { FolderEmpty } from './empty'; + +export const ExplorerFolderNode = ({ + nodeId, + onDrop, + defaultRenaming, + operations, + location, + dropEffect, + canDrop, + reorderable, +}: { + defaultRenaming?: boolean; + nodeId: string; + onDrop?: (data: DropTargetDropEvent, node: FolderNode) => void; + operations?: + | NodeOperation[] + | ((type: string, node: FolderNode) => NodeOperation[]); +} & Omit) => { + const { organizeService } = useServices({ OrganizeService }); + const node = useLiveData(organizeService.folderTree.folderNode$(nodeId)); + const type = useLiveData(node?.type$); + const data = useLiveData(node?.data$); + const handleDrop = useCallback( + (data: DropTargetDropEvent) => { + if (!node) { + return; + } + onDrop?.(data, node); + }, + [node, onDrop] + ); + const additionalOperations = useMemo(() => { + if (!type || !node) { + return; + } + if (typeof operations === 'function') { + return operations(type, node); + } + return operations; + }, [node, operations, type]); + + if (!node) { + return; + } + + if (type === 'folder') { + return ( + + ); + } else if (type === 'doc') { + return ( + data && ( + + ) + ); + } else if (type === 'collection') { + return ( + data && ( + + ) + ); + } else if (type === 'tag') { + return ( + data && ( + + ) + ); + } + + return; +}; + +export const ExplorerFolderNodeFolder = ({ + node, + onDrop, + defaultRenaming, + location, + operations: additionalOperations, + canDrop, + dropEffect, + reorderable, +}: { + defaultRenaming?: boolean; + node: FolderNode; +} & GenericExplorerNode) => { + const t = useI18n(); + const { docsService, workbenchService } = useServices({ + DocsService, + WorkbenchService, + }); + const name = useLiveData(node.name$); + const [collapsed, setCollapsed] = useState(true); + const [newFolderId, setNewFolderId] = useState(null); + + const handleDelete = useCallback(() => { + node.delete(); + }, [node]); + + const children = useLiveData(node.sortedChildren$); + + const dndData = useMemo(() => { + if (!node.id) { + throw new Unreachable(); + } + return { + draggable: { + entity: { + type: 'folder', + id: node.id, + }, + from: location, + }, + dropTarget: { + at: 'explorer:organize:folder', + }, + } satisfies AffineDNDData; + }, [location, node.id]); + + const handleRename = useCallback( + (newName: string) => { + node.rename(newName); + }, + [node] + ); + + const handleDropOnFolder = useCallback( + (data: DropTargetDropEvent) => { + if (data.treeInstruction?.type === 'make-child') { + if (data.source.data.entity?.type === 'folder') { + if ( + node.id === data.source.data.entity.id || + node.beChildOf(data.source.data.entity.id) + ) { + return; + } + node.moveHere(data.source.data.entity.id, node.indexAt('before')); + } else if ( + data.source.data.from?.at === 'explorer:organize:folder-node' + ) { + node.moveHere(data.source.data.from.nodeId, node.indexAt('before')); + } else if ( + data.source.data.entity?.type === 'collection' || + data.source.data.entity?.type === 'doc' || + data.source.data.entity?.type === 'tag' + ) { + node.createLink( + data.source.data.entity?.type, + data.source.data.entity.id, + node.indexAt('before') + ); + } + } else { + onDrop?.(data); + } + }, + [node, onDrop] + ); + + const handleDropEffect = useCallback( + data => { + if (data.treeInstruction?.type === 'make-child') { + if (data.source.data.entity?.type === 'folder') { + if ( + node.id === data.source.data.entity.id || + node.beChildOf(data.source.data.entity.id) + ) { + return; + } + return 'move'; + } else if ( + data.source.data.from?.at === 'explorer:organize:folder-node' + ) { + return 'move'; + } else if ( + data.source.data.entity?.type === 'collection' || + data.source.data.entity?.type === 'doc' || + data.source.data.entity?.type === 'tag' + ) { + return 'link'; + } + } else { + return dropEffect?.(data); + } + return; + }, + [dropEffect, node] + ); + + const handleDropOnPlaceholder = useCallback( + (data: DropTargetDropEvent) => { + if (data.source.data.entity?.type === 'folder') { + if ( + node.id === data.source.data.entity.id || + node.beChildOf(data.source.data.entity.id) + ) { + return; + } + node.moveHere(data.source.data.entity.id, node.indexAt('before')); + } else if ( + data.source.data.from?.at === 'explorer:organize:folder-node' + ) { + node.moveHere(data.source.data.from.nodeId, node.indexAt('before')); + } else if ( + data.source.data.entity?.type === 'collection' || + data.source.data.entity?.type === 'doc' || + data.source.data.entity?.type === 'tag' + ) { + node.createLink( + data.source.data.entity?.type, + data.source.data.entity.id, + node.indexAt('before') + ); + } + }, + [node] + ); + + const handleDropOnChildren = useCallback( + (data: DropTargetDropEvent, dropAtNode?: FolderNode) => { + if (!dropAtNode || !dropAtNode.id) { + return; + } + if ( + data.treeInstruction?.type === 'reorder-above' || + data.treeInstruction?.type === 'reorder-below' + ) { + const at = + data.treeInstruction?.type === 'reorder-below' ? 'after' : 'before'; + if (data.source.data.entity?.type === 'folder') { + if ( + node.id === data.source.data.entity.id || + node.beChildOf(data.source.data.entity.id) + ) { + return; + } + node.moveHere( + data.source.data.entity.id, + node.indexAt(at, dropAtNode.id) + ); + } else if ( + data.source.data.from?.at === 'explorer:organize:folder-node' + ) { + node.moveHere( + data.source.data.from.nodeId, + node.indexAt(at, dropAtNode.id) + ); + } else if ( + data.source.data.entity?.type === 'collection' || + data.source.data.entity?.type === 'doc' || + data.source.data.entity?.type === 'tag' + ) { + node.createLink( + data.source.data.entity?.type, + data.source.data.entity.id, + node.indexAt(at, dropAtNode.id) + ); + } + } else if (data.treeInstruction?.type === 'reparent') { + const currentLevel = data.treeInstruction.currentLevel; + const desiredLevel = data.treeInstruction.desiredLevel; + if (currentLevel === desiredLevel + 1) { + onDrop?.({ + ...data, + treeInstruction: { + type: 'reorder-below', + currentLevel, + indentPerLevel: data.treeInstruction.indentPerLevel, + }, + }); + return; + } else { + onDrop?.({ + ...data, + treeInstruction: { + ...data.treeInstruction, + currentLevel: currentLevel - 1, + }, + }); + } + } + }, + [node, onDrop] + ); + + const handleDropEffectOnChildren = useCallback( + data => { + if ( + data.treeInstruction?.type === 'reorder-above' || + data.treeInstruction?.type === 'reorder-below' + ) { + if (data.source.data.entity?.type === 'folder') { + if ( + node.id === data.source.data.entity.id || + node.beChildOf(data.source.data.entity.id) + ) { + return; + } + return 'move'; + } else if ( + data.source.data.from?.at === 'explorer:organize:folder-node' + ) { + return 'move'; + } else if ( + data.source.data.entity?.type === 'collection' || + data.source.data.entity?.type === 'doc' || + data.source.data.entity?.type === 'tag' + ) { + return 'link'; + } + } else if (data.treeInstruction?.type === 'reparent') { + const currentLevel = data.treeInstruction.currentLevel; + const desiredLevel = data.treeInstruction.desiredLevel; + if (currentLevel === desiredLevel + 1) { + dropEffect?.({ + ...data, + treeInstruction: { + type: 'reorder-below', + currentLevel, + indentPerLevel: data.treeInstruction.indentPerLevel, + }, + }); + return; + } else { + dropEffect?.({ + ...data, + treeInstruction: { + ...data.treeInstruction, + currentLevel: currentLevel - 1, + }, + }); + } + } + return; + }, + [dropEffect, node] + ); + + const handleCanDrop = useMemo['canDrop']>( + () => args => { + const entityType = args.source.data.entity?.type; + if (args.treeInstruction && args.treeInstruction?.type !== 'make-child') { + return ( + (typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true + ); + } + + if (args.source.data.entity?.type === 'folder') { + if ( + node.id === args.source.data.entity.id || + node.beChildOf(args.source.data.entity.id) + ) { + return false; + } + return true; + } else if ( + args.source.data.from?.at === 'explorer:organize:folder-node' + ) { + return true; + } else if ( + entityType === 'collection' || + entityType === 'doc' || + entityType === 'tag' + ) { + return true; + } + return false; + }, + [canDrop, node] + ); + + const handleChildrenCanDrop = useMemo< + DropTargetOptions['canDrop'] + >( + () => args => { + const entityType = args.source.data.entity?.type; + + if (args.source.data.entity?.type === 'folder') { + if ( + node.id === args.source.data.entity.id || + node.beChildOf(args.source.data.entity.id) + ) { + return false; + } + return true; + } else if ( + args.source.data.from?.at === 'explorer:organize:folder-node' + ) { + return true; + } else if ( + entityType === 'collection' || + entityType === 'doc' || + entityType === 'tag' + ) { + return true; + } + return false; + }, + [node] + ); + + const handleNewDoc = useCallback(() => { + const newDoc = docsService.createDoc(); + node.createLink('doc', newDoc.id, node.indexAt('before')); + workbenchService.workbench.openDoc(newDoc.id); + setCollapsed(false); + }, [docsService, node, workbenchService.workbench]); + + const handleCreateSubfolder = useCallback(() => { + const newFolderId = node.createFolder( + t['com.affine.rootAppSidebar.organize.new-folders'](), + node.indexAt('before') + ); + setCollapsed(false); + setNewFolderId(newFolderId); + }, [node, t]); + + const folderOperations = useMemo(() => { + return [ + { + index: 0, + inline: true, + view: ( + + + + ), + }, + { + index: 100, + view: ( + + + + } + onClick={handleCreateSubfolder} + > + {t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()} + + ), + }, + { + index: 9999, + view: , + }, + { + index: 10000, + view: ( + + + + } + onClick={handleDelete} + > + {t['com.affine.rootAppSidebar.organize.delete']()} + + ), + }, + ]; + }, [handleCreateSubfolder, handleDelete, handleNewDoc, t]); + + const finalOperations = useMemo(() => { + if (additionalOperations) { + return [...additionalOperations, ...folderOperations]; + } + return folderOperations; + }, [additionalOperations, folderOperations]); + + const handleDeleteChildren = useCallback((node: FolderNode) => { + node.delete(); + }, []); + + const childrenOperations = useCallback( + // eslint-disable-next-line @typescript-eslint/ban-types + (type: string, node: FolderNode) => { + if (type === 'doc' || type === 'collection' || type === 'tag') { + return [ + { + index: 999, + view: ( + + + + } + onClick={() => handleDeleteChildren(node)} + > + {t['com.affine.rootAppSidebar.organize.delete-from-folder']()} + + ), + }, + ] satisfies NodeOperation[]; + } + return []; + }, + [handleDeleteChildren, t] + ); + + return ( + ( + + )} + name={name} + dndData={dndData} + onDrop={handleDropOnFolder} + defaultRenaming={defaultRenaming} + renameable + reorderable={reorderable} + collapsed={collapsed} + setCollapsed={setCollapsed} + onRename={handleRename} + operations={finalOperations} + canDrop={handleCanDrop} + childrenPlaceholder={ + + } + dropEffect={handleDropEffect} + data-testid={`explorer-folder-${node.id}`} + > + {children.map(child => ( + + ))} + + ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/tag/empty.css.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/tag/empty.css.tsx new file mode 100644 index 000000000..049d07b21 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/tag/empty.css.tsx @@ -0,0 +1,19 @@ +import { cssVar } from '@toeverything/theme'; +import { fallbackVar, style } from '@vanilla-extract/css'; + +import { levelIndent } from '../../tree/node.css'; + +export const noReferences = style({ + fontSize: cssVar('fontSm'), + textAlign: 'left', + padding: '4px 0 4px 32px', + color: cssVar('black30'), + userSelect: 'none', + paddingLeft: `calc(${fallbackVar(levelIndent, '20px')} + 32px)`, + selectors: { + '&[data-dragged-over="true"]': { + background: cssVar('--affine-hover-color'), + borderRadius: '4px', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/tag/empty.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/tag/empty.tsx new file mode 100644 index 000000000..127c62eb7 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/tag/empty.tsx @@ -0,0 +1,24 @@ +import { type DropTargetDropEvent, useDropTarget } from '@affine/component'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; + +import * as styles from './empty.css'; + +export const Empty = ({ + onDrop, +}: { + onDrop: (data: DropTargetDropEvent) => void; +}) => { + const { dropTargetRef } = useDropTarget( + () => ({ + onDrop, + }), + [onDrop] + ); + const t = useI18n(); + return ( +
+ {t['com.affine.rootAppSidebar.tags.no-doc']()} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/tag/index.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/tag/index.tsx new file mode 100644 index 000000000..3da8ecac1 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/tag/index.tsx @@ -0,0 +1,196 @@ +import { + type DropTargetDropEvent, + type DropTargetOptions, + toast, +} from '@affine/component'; +import { TagService } from '@affine/core/modules/tag'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; +import { + GlobalContextService, + useLiveData, + useServices, +} from '@toeverything/infra'; +import clsx from 'clsx'; +import { useCallback, useMemo, useState } from 'react'; + +import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree'; +import { ExplorerDocNode } from '../doc'; +import type { GenericExplorerNode } from '../types'; +import { Empty } from './empty'; +import { useExplorerTagNodeOperations } from './operations'; +import * as styles from './styles.css'; + +export const ExplorerTagNode = ({ + tagId, + onDrop, + location, + reorderable, + operations: additionalOperations, + dropEffect, + canDrop, + defaultRenaming, +}: { + tagId: string; + defaultRenaming?: boolean; +} & GenericExplorerNode) => { + const t = useI18n(); + const { tagService, globalContextService } = useServices({ + TagService, + GlobalContextService, + }); + const active = + useLiveData(globalContextService.globalContext.tagId.$) === tagId; + const [collapsed, setCollapsed] = useState(true); + + const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId)); + const tagColor = useLiveData(tagRecord?.color$); + const tagName = useLiveData(tagRecord?.value$); + const tagDocIds = useLiveData(tagRecord?.pageIds$); + + const Icon = useCallback( + ({ className }: { className?: string }) => { + return ( +
+
+
+ ); + }, + [tagColor] + ); + + const dndData = useMemo(() => { + return { + draggable: { + entity: { + type: 'tag', + id: tagId, + }, + from: location, + }, + dropTarget: { + at: 'explorer:tag', + }, + } satisfies AffineDNDData; + }, [location, tagId]); + + const handleRename = useCallback( + (newName: string) => { + if (tagRecord) { + tagRecord.rename(newName); + } + }, + [tagRecord] + ); + + const handleDropOnTag = useCallback( + (data: DropTargetDropEvent) => { + if (data.treeInstruction?.type === 'make-child' && tagRecord) { + if (data.source.data.entity?.type === 'doc') { + tagRecord.tag(data.source.data.entity.id); + } else { + toast(t['com.affine.rootAppSidebar.tag.doc-only']()); + } + } else { + onDrop?.(data); + } + }, + [onDrop, t, tagRecord] + ); + + const handleDropEffectOnTag = useCallback( + data => { + if (data.treeInstruction?.type === 'make-child') { + if (data.source.data.entity?.type === 'doc') { + return 'link'; + } + } else { + return dropEffect?.(data); + } + return; + }, + [dropEffect] + ); + + const handleDropOnPlaceholder = useCallback( + (data: DropTargetDropEvent) => { + if (tagRecord) { + if (data.source.data.entity?.type === 'doc') { + tagRecord.tag(data.source.data.entity.id); + } else { + toast(t['com.affine.rootAppSidebar.tag.doc-only']()); + } + } + }, + [t, tagRecord] + ); + + const handleCanDrop = useMemo['canDrop']>( + () => args => { + const entityType = args.source.data.entity?.type; + return args.treeInstruction?.type !== 'make-child' + ? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true) + : entityType === 'doc'; + }, + [canDrop] + ); + + const operations = useExplorerTagNodeOperations( + tagId, + useMemo( + () => ({ + openNodeCollapsed: () => setCollapsed(false), + }), + [] + ) + ); + + const finalOperations = useMemo(() => { + if (additionalOperations) { + return [...operations, ...additionalOperations]; + } + return operations; + }, [additionalOperations, operations]); + + if (!tagRecord) { + return null; + } + + return ( + } + operations={finalOperations} + dropEffect={handleDropEffectOnTag} + data-testid={`explorer-tag-${tagId}`} + > + {tagDocIds?.map(docId => ( + + ))} + + ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx new file mode 100644 index 000000000..d7d429cbd --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/tag/operations.tsx @@ -0,0 +1,115 @@ +import { + IconButton, + MenuIcon, + MenuItem, + MenuSeparator, + toast, +} from '@affine/component'; +import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; +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 { DocsService, useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +import type { NodeOperation } from '../../tree/types'; + +export const useExplorerTagNodeOperations = ( + tagId: string, + { + openNodeCollapsed, + }: { + openNodeCollapsed: () => void; + } +): NodeOperation[] => { + const t = useI18n(); + const { appSettings } = useAppSettingHelper(); + const { docsService, workbenchService, tagService } = useServices({ + WorkbenchService, + TagService, + DocsService, + }); + + const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId)); + + const handleNewDoc = useCallback(() => { + if (tagRecord) { + const newDoc = docsService.createDoc(); + tagRecord?.tag(newDoc.id); + workbenchService.workbench.openDoc(newDoc.id); + openNodeCollapsed(); + } + }, [docsService, openNodeCollapsed, tagRecord, workbenchService.workbench]); + + const handleMoveToTrash = useCallback(() => { + tagService.tagList.deleteTag(tagId); + toast(t['com.affine.tags.delete-tags.toast']()); + }, [t, tagId, tagService.tagList]); + + const handleOpenInSplitView = useCallback(() => { + workbenchService.workbench.openTag(tagId, { + at: 'beside', + }); + }, [tagId, workbenchService]); + + return useMemo( + () => [ + { + index: 0, + inline: true, + view: ( + + + + ), + }, + ...(appSettings.enableMultiView + ? [ + { + index: 100, + view: ( + + + + } + onClick={handleOpenInSplitView} + > + {t['com.affine.workbench.split-view.page-menu-open']()} + + ), + }, + ] + : []), + { + index: 9999, + view: , + }, + { + index: 10000, + view: ( + + + + } + onClick={handleMoveToTrash} + > + {t['Delete']()} + + ), + }, + ], + [ + appSettings.enableMultiView, + handleMoveToTrash, + handleNewDoc, + handleOpenInSplitView, + t, + ] + ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/tag/styles.css.ts b/packages/frontend/core/src/modules/explorer/views/nodes/tag/styles.css.ts new file mode 100644 index 000000000..5a96a08bb --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/tag/styles.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; + +export const tagIcon = style({ + borderRadius: '50%', + height: '8px', + width: '8px', +}); + +export const tagIconContainer = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '1em', + height: '1em', +}); diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/types.ts b/packages/frontend/core/src/modules/explorer/views/nodes/types.ts new file mode 100644 index 000000000..9a14d0141 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/nodes/types.ts @@ -0,0 +1,49 @@ +import type { DropTargetDropEvent, DropTargetOptions } from '@affine/component'; +import type { AffineDNDData } from '@affine/core/types/dnd'; + +import type { ExplorerTreeNodeDropEffect } from '../tree'; +import type { NodeOperation } from '../tree/types'; + +/** + * The interface for a generic explorer node. + * + * # Drop controlled area + * + * When an element is dragged over the node, there are two controlled areas depending on the mouse position. + * + * **Make Child Area**: + * When the mouse is in the center area of the node, it is in `Make Child Area`, + * `canDrop`, `onDrop`, and `dropEffect` are handled by the node itself. + * + * **Edge Area**: + * When the mouse is at the upper edge, lower edge, or front of a node, it is located in the `Edge Area`, + * and all drop events are handled by the node's parent, which callbacks in this interface. + * + * The controlled area can be distinguished by `data.treeInstruction.type` in the callback parameter. + */ +export interface GenericExplorerNode { + /** + * Tell the node and dropTarget where the node is located in the tree + */ + location?: AffineDNDData['draggable']['from']; + /** + * Whether the node is allowed to reorder with its sibling nodes + */ + reorderable?: boolean; + /** + * Additional operations to be displayed in the node + */ + operations?: NodeOperation[]; + /** + * Control whether drop is allowed, the callback will be called when dragging. + */ + canDrop?: DropTargetOptions['canDrop']; + /** + * Called when an element is dropped over the node. + */ + onDrop?: (data: DropTargetDropEvent) => void; + /** + * The drop effect to be used when an element is dropped over the node. + */ + dropEffect?: ExplorerTreeNodeDropEffect; +} diff --git a/packages/frontend/core/src/modules/explorer/views/sections/collections/empty.css.ts b/packages/frontend/core/src/modules/explorer/views/sections/collections/empty.css.ts new file mode 100644 index 000000000..ca05081fa --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/collections/empty.css.ts @@ -0,0 +1,35 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const content = style({ + 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'), +}); diff --git a/packages/frontend/core/src/modules/explorer/views/sections/collections/empty.tsx b/packages/frontend/core/src/modules/explorer/views/sections/collections/empty.tsx new file mode 100644 index 000000000..6d45ad1c8 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/collections/empty.tsx @@ -0,0 +1,30 @@ +import { Button } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { ViewLayersIcon } from '@blocksuite/icons/rc'; + +import * as styles from './empty.css'; + +export const RootEmpty = ({ + onClickCreate, +}: { + onClickCreate?: () => void; +}) => { + const t = useI18n(); + + return ( +
+
+ +
+
+ {t['com.affine.collections.empty.message']()} +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/sections/collections/index.tsx b/packages/frontend/core/src/modules/explorer/views/sections/collections/index.tsx new file mode 100644 index 000000000..c4da803f9 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/collections/index.tsx @@ -0,0 +1,72 @@ +import { IconButton } from '@affine/component'; +import { CategoryDivider } from '@affine/core/components/app-sidebar'; +import { useEditCollectionName } from '@affine/core/components/page-list'; +import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager'; +import { CollectionService } from '@affine/core/modules/collection'; +import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree'; +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useI18n } from '@affine/i18n'; +import { PlusIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useServices } from '@toeverything/infra'; +import { nanoid } from 'nanoid'; +import { useCallback } from 'react'; + +import { ExplorerCollectionNode } from '../../nodes/collection'; +import { RootEmpty } from './empty'; +import * as styles from './styles.css'; + +export const ExplorerCollections = () => { + const t = useI18n(); + const { collectionService, workbenchService } = useServices({ + CollectionService, + WorkbenchService, + }); + const collections = useLiveData(collectionService.collections$); + const { node, open: openCreateCollectionModel } = useEditCollectionName({ + title: t['com.affine.editCollection.createCollection'](), + showTips: true, + }); + + const handleCreateCollection = useCallback(() => { + openCreateCollectionModel('') + .then(name => { + const id = nanoid(); + collectionService.addCollection(createEmptyCollection(id, { name })); + workbenchService.workbench.openCollection(id); + }) + .catch(err => { + console.error(err); + }); + }, [collectionService, openCreateCollectionModel, workbenchService]); + + return ( + <> +
+ + + + + + } + > + {collections.map(collection => ( + + ))} + +
+ {node} + + ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/sections/collections/styles.css.ts b/packages/frontend/core/src/modules/explorer/views/sections/collections/styles.css.ts new file mode 100644 index 000000000..c69d8ce56 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/collections/styles.css.ts @@ -0,0 +1,5 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + marginTop: '16px', +}); diff --git a/packages/frontend/core/src/modules/explorer/views/sections/favorites/empty.css.ts b/packages/frontend/core/src/modules/explorer/views/sections/favorites/empty.css.ts new file mode 100644 index 000000000..c9fb078fe --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/favorites/empty.css.ts @@ -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'), +}); diff --git a/packages/frontend/core/src/modules/explorer/views/sections/favorites/empty.tsx b/packages/frontend/core/src/modules/explorer/views/sections/favorites/empty.tsx new file mode 100644 index 000000000..9dc9b8caf --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/favorites/empty.tsx @@ -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) => void; + canDrop?: DropTargetOptions['canDrop']; + dropEffect?: ExplorerTreeNodeDropEffect; +}) => { + const t = useI18n(); + + const { dropTargetRef, draggedOverDraggable, draggedOverPosition } = + useDropTarget( + () => ({ + data: { + at: 'explorer:favorite:root', + }, + onDrop: onDrop, + canDrop: canDrop, + }), + [onDrop, canDrop] + ); + + return ( +
+
+ +
+
+ {t['com.affine.rootAppSidebar.organize.empty']()} +
+ {dropEffect && draggedOverDraggable && ( + + )} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/sections/favorites/index.tsx b/packages/frontend/core/src/modules/explorer/views/sections/favorites/index.tsx new file mode 100644 index 000000000..dd28d4b98 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/favorites/index.tsx @@ -0,0 +1,286 @@ +import { + type DropTargetDropEvent, + type DropTargetOptions, + IconButton, + useDropTarget, +} from '@affine/component'; +import { CategoryDivider } from '@affine/core/components/app-sidebar'; +import { + DropEffect, + 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'; + +export const ExplorerFavorites = () => { + 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) => { + 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(data => { + if ( + data.source.data.entity?.type === 'doc' || + data.source.data.entity?.type === 'collection' + ) { + return 'link'; + } + return; + }, []); + + const handleCanDrop = useMemo['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 + ) => { + if ( + data.treeInstruction?.type === 'reorder-above' || + 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') + ) { + // 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( + data => { + if ( + data.treeInstruction?.type === 'reorder-above' || + 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') + ) { + 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['canDrop'] + >( + () => args => + args.source.data.entity?.type === 'doc' || + args.source.data.entity?.type === 'collection', + [] + ); + + const { dropTargetRef, draggedOverDraggable, draggedOverPosition } = + useDropTarget( + () => ({ + data: { + at: 'explorer:favorite:root', + }, + onDrop: handleDrop, + canDrop: handleCanDrop, + }), + [handleCanDrop, handleDrop] + ); + + return ( +
+ + + + + {draggedOverDraggable && ( + + )} + + + } + > + {favorites.map(favorite => ( + + ))} + +
+ ); +}; + +const childLocation = { + at: 'explorer:favorite:items' as const, +}; +const ExplorerFavoriteNode = ({ + favorite, + onDrop, + canDrop, + dropEffect, +}: { + favorite: { + id: string; + type: 'collection' | 'doc'; + }; + canDrop?: DropTargetOptions['canDrop']; + onDrop: ( + favorite: { + id: string; + type: 'collection' | 'doc'; + }, + data: DropTargetDropEvent + ) => void; + dropEffect: ExplorerTreeNodeDropEffect; +}) => { + const handleOnChildrenDrop = useCallback( + (data: DropTargetDropEvent) => { + onDrop(favorite, data); + }, + [favorite, onDrop] + ); + return favorite.type === 'doc' ? ( + + ) : ( + + ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/sections/favorites/styles.css.ts b/packages/frontend/core/src/modules/explorer/views/sections/favorites/styles.css.ts new file mode 100644 index 000000000..f7fa0fe23 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/favorites/styles.css.ts @@ -0,0 +1,16 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const container = style({ + marginTop: '16px', +}); + +export const draggedOverHighlight = style({ + position: 'relative', + selectors: { + '&[data-dragged-over="true"]': { + background: cssVar('--affine-hover-color'), + borderRadius: '4px', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/sections/organize/empty.css.ts b/packages/frontend/core/src/modules/explorer/views/sections/organize/empty.css.ts new file mode 100644 index 000000000..1d7c13992 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/organize/empty.css.ts @@ -0,0 +1,36 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const content = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 6, + padding: '9px 20px 25px 21px', + position: 'relative', +}); +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'), +}); diff --git a/packages/frontend/core/src/modules/explorer/views/sections/organize/empty.tsx b/packages/frontend/core/src/modules/explorer/views/sections/organize/empty.tsx new file mode 100644 index 000000000..5c4f9a384 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/organize/empty.tsx @@ -0,0 +1,30 @@ +import { Button } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { FolderIcon } from '@blocksuite/icons/rc'; + +import * as styles from './empty.css'; + +export const RootEmpty = ({ + onClickCreate, +}: { + onClickCreate?: () => void; +}) => { + const t = useI18n(); + + return ( +
+
+ +
+
+ {t['com.affine.rootAppSidebar.organize.empty']()} +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/sections/organize/index.tsx b/packages/frontend/core/src/modules/explorer/views/sections/organize/index.tsx new file mode 100644 index 000000000..4db878218 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/organize/index.tsx @@ -0,0 +1,125 @@ +import { + type DropTargetDropEvent, + type DropTargetOptions, + IconButton, + toast, +} from '@affine/component'; +import { CategoryDivider } from '@affine/core/components/app-sidebar'; +import { + type ExplorerTreeNodeDropEffect, + ExplorerTreeRoot, +} from '@affine/core/modules/explorer/views/tree'; +import { + type FolderNode, + OrganizeService, +} from '@affine/core/modules/organize'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; +import { PlusIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useMemo, useState } from 'react'; + +import { ExplorerFolderNode } from '../../nodes/folder'; +import { RootEmpty } from './empty'; +import * as styles from './styles.css'; + +export const ExplorerOrganize = () => { + const { organizeService } = useServices({ OrganizeService }); + const [newFolderId, setNewFolderId] = useState(null); + + const t = useI18n(); + + const rootFolder = organizeService.folderTree.rootFolder; + + const folders = useLiveData(rootFolder.sortedChildren$); + + const handleCreateFolder = useCallback(() => { + const newFolderId = rootFolder.createFolder( + 'New Folder', + rootFolder.indexAt('before') + ); + setNewFolderId(newFolderId); + }, [rootFolder]); + + const handleOnChildrenDrop = useCallback( + (data: DropTargetDropEvent, node?: FolderNode) => { + if (!node || !node.id) { + return; // never happens + } + if ( + data.treeInstruction?.type === 'reorder-above' || + data.treeInstruction?.type === 'reorder-below' + ) { + const at = + data.treeInstruction?.type === 'reorder-below' ? 'after' : 'before'; + if (data.source.data.entity?.type === 'folder') { + rootFolder.moveHere( + data.source.data.entity.id, + rootFolder.indexAt(at, node.id) + ); + } else { + toast(t['com.affine.rootAppSidebar.organize.root-folder-only']()); + } + } else { + return; // not supported + } + }, + [rootFolder, t] + ); + + const handleChildrenDropEffect = useCallback( + data => { + if ( + data.treeInstruction?.type === 'reorder-above' || + data.treeInstruction?.type === 'reorder-below' + ) { + if (data.source.data.entity?.type === 'folder') { + return 'move'; + } + } else { + return; // not supported + } + return; + }, + [] + ); + + const handleChildrenCanDrop = useMemo< + DropTargetOptions['canDrop'] + >(() => args => args.source.data.entity?.type === 'folder', []); + + return ( +
+ + + + + + } + > + {folders.map(child => ( + + ))} + +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/sections/organize/styles.css.ts b/packages/frontend/core/src/modules/explorer/views/sections/organize/styles.css.ts new file mode 100644 index 000000000..574b2653a --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/organize/styles.css.ts @@ -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', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/sections/tags/empty.css.ts b/packages/frontend/core/src/modules/explorer/views/sections/tags/empty.css.ts new file mode 100644 index 000000000..1d7c13992 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/tags/empty.css.ts @@ -0,0 +1,36 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const content = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 6, + padding: '9px 20px 25px 21px', + position: 'relative', +}); +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'), +}); diff --git a/packages/frontend/core/src/modules/explorer/views/sections/tags/empty.tsx b/packages/frontend/core/src/modules/explorer/views/sections/tags/empty.tsx new file mode 100644 index 000000000..4625b5e41 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/tags/empty.tsx @@ -0,0 +1,30 @@ +import { Button } from '@affine/component'; +import { useI18n } from '@affine/i18n'; +import { TagIcon } from '@blocksuite/icons/rc'; + +import * as styles from './empty.css'; + +export const RootEmpty = ({ + onClickCreate, +}: { + onClickCreate?: () => void; +}) => { + const t = useI18n(); + + return ( +
+
+ +
+
+ {t['com.affine.rootAppSidebar.tags.empty']()} +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/sections/tags/index.tsx b/packages/frontend/core/src/modules/explorer/views/sections/tags/index.tsx new file mode 100644 index 000000000..f8379daa4 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/tags/index.tsx @@ -0,0 +1,64 @@ +import { IconButton } from '@affine/component'; +import { CategoryDivider } from '@affine/core/components/app-sidebar'; +import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree'; +import type { Tag } from '@affine/core/modules/tag'; +import { TagService } from '@affine/core/modules/tag'; +import { useI18n } from '@affine/i18n'; +import { PlusIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useState } from 'react'; + +import { ExplorerTagNode } from '../../nodes/tag'; +import { RootEmpty } from './empty'; +import * as styles from './styles.css'; + +export const ExplorerTags = () => { + const { tagService } = useServices({ + TagService, + }); + const [createdTag, setCreatedTag] = useState(null); + + const tags = useLiveData(tagService.tagList.tags$); + + const t = useI18n(); + + const handleCreateNewFavoriteDoc = useCallback(() => { + const newTags = tagService.tagList.createTag( + t['com.affine.rootAppSidebar.tags.new-tag'](), + tagService.randomTagColor() + ); + setCreatedTag(newTags); + }, [t, tagService]); + + return ( +
+ + + + + + } + > + {tags.map(tag => ( + + ))} + +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/sections/tags/styles.css.ts b/packages/frontend/core/src/modules/explorer/views/sections/tags/styles.css.ts new file mode 100644 index 000000000..574b2653a --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/sections/tags/styles.css.ts @@ -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', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/tree/context.tsx b/packages/frontend/core/src/modules/explorer/views/tree/context.tsx new file mode 100644 index 000000000..584cf16a8 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/context.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export interface ExplorerTreeContextData { + /** + * The level of the current tree node. + */ + level: number; +} + +export const ExplorerTreeContext = + React.createContext(null); diff --git a/packages/frontend/core/src/modules/explorer/views/tree/drop-effect.css.ts b/packages/frontend/core/src/modules/explorer/views/tree/drop-effect.css.ts new file mode 100644 index 000000000..f54abe938 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/drop-effect.css.ts @@ -0,0 +1,25 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const dropEffect = style({ + zIndex: 99999, + position: 'absolute', + left: '0px', + top: '-34px', + opacity: 0.9, + background: cssVar('--affine-background-primary-color'), + boxShadow: cssVar('--affine-toolbar-shadow'), + padding: '0px 4px', + fontSize: '12px', + borderRadius: '4px', + lineHeight: 1.4, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '4px', +}); + +export const icon = style({ + width: '12px', + height: '12px', +}); diff --git a/packages/frontend/core/src/modules/explorer/views/tree/drop-effect.tsx b/packages/frontend/core/src/modules/explorer/views/tree/drop-effect.tsx new file mode 100644 index 000000000..f8b4ed8b9 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/drop-effect.tsx @@ -0,0 +1,36 @@ +import { useI18n } from '@affine/i18n'; +import { CopyIcon, LinkIcon, MoveToIcon } from '@blocksuite/icons/rc'; + +import * as styles from './drop-effect.css'; + +export const DropEffect = ({ + dropEffect, + position, +}: { + dropEffect?: 'copy' | 'move' | 'link' | undefined; + position: { x: number; y: number }; +}) => { + const t = useI18n(); + if (dropEffect === undefined) return null; + return ( +
+ {dropEffect === 'copy' ? ( + + ) : dropEffect === 'move' ? ( + + ) : ( + + )} + {dropEffect === 'copy' + ? t['com.affine.rootAppSidebar.explorer.drop-effect.copy']() + : dropEffect === 'move' + ? t['com.affine.rootAppSidebar.explorer.drop-effect.move']() + : t['com.affine.rootAppSidebar.explorer.drop-effect.link']()} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/tree/index.tsx b/packages/frontend/core/src/modules/explorer/views/tree/index.tsx new file mode 100644 index 000000000..a31d02c0c --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/index.tsx @@ -0,0 +1,7 @@ +export { DropEffect } from './drop-effect'; +export type { + ExplorerTreeNodeDropEffect, + ExplorerTreeNodeDropEffectData, +} from './node'; +export { ExplorerTreeNode } from './node'; +export { ExplorerTreeRoot } from './root'; diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts b/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts new file mode 100644 index 000000000..992810d51 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts @@ -0,0 +1,166 @@ +import { cssVar } from '@toeverything/theme'; +import { createVar, keyframes, style } from '@vanilla-extract/css'; +export const levelIndent = createVar(); +export const linkItemRoot = style({ + color: 'inherit', +}); +export const itemRoot = style({ + display: 'inline-flex', + alignItems: 'center', + borderRadius: '4px', + textAlign: 'left', + color: 'inherit', + width: '100%', + minHeight: '30px', + userSelect: 'none', + cursor: 'pointer', + padding: '0 4px', + fontSize: cssVar('fontSm'), + position: 'relative', + marginTop: '0px', + selectors: { + '&:hover': { + background: cssVar('hoverColor'), + }, + '&[data-active="true"]': { + background: cssVar('hoverColor'), + }, + '&[data-disabled="true"]': { + cursor: 'default', + color: cssVar('textSecondaryColor'), + pointerEvents: 'none', + }, + '&[data-dragging="true"]': { + opacity: 0.5, + }, + }, +}); +export const itemContent = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: 'flex', + alignItems: 'center', + flex: 1, +}); +export const postfix = style({ + display: 'flex', + alignItems: 'center', + right: '4px', + position: 'absolute', + opacity: 0, + pointerEvents: 'none', + selectors: { + [`${itemRoot}:hover &`]: { + justifySelf: 'flex-end', + position: 'initial', + opacity: 1, + pointerEvents: 'all', + }, + }, +}); +export const icon = style({ + color: cssVar('iconColor'), + fontSize: '20px', +}); +export const collapsedIconContainer = style({ + width: '16px', + height: '16px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '2px', + transition: 'transform 0.2s', + color: 'inherit', + selectors: { + '&[data-collapsed="true"]': { + transform: 'rotate(-90deg)', + }, + '&[data-disabled="true"]': { + opacity: 0.3, + pointerEvents: 'none', + }, + '&:hover': { + background: cssVar('hoverColor'), + }, + }, +}); +export const iconsContainer = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + width: '44px', + flexShrink: 0, +}); +export const collapsedIcon = style({ + transition: 'transform 0.2s ease-in-out', + selectors: { + '&[data-collapsed="true"]': { + transform: 'rotate(-90deg)', + }, + }, +}); + +export const collapseContentPlaceholder = style({ + display: 'none', + selectors: { + '&:only-child': { + display: 'initial', + }, + }, +}); + +const draggedOverAnimation = keyframes({ + '0%': { + opacity: 1, + }, + '60%': { + opacity: 1, + }, + '70%': { + opacity: 0, + }, + '80%': { + opacity: 1, + }, + '90%': { + opacity: 0, + }, + '100%': { + opacity: 1, + }, +}); + +export const contentContainer = style({ + paddingLeft: levelIndent, + position: 'relative', +}); + +export const draggingContainer = style({ + background: cssVar('--affine-background-primary-color'), + boxShadow: cssVar('--affine-toolbar-shadow'), + width: '200px', + borderRadius: '6px', +}); + +export const draggedOverEffect = style({ + position: 'relative', + selectors: { + '&[data-tree-instruction="make-child"][data-self-dragged-over="false"]:after': + { + display: 'block', + content: '""', + position: 'absolute', + zIndex: 1, + background: cssVar('--affine-hover-color'), + left: levelIndent, + top: 0, + width: `calc(100% - ${levelIndent})`, + height: '100%', + }, + '&[data-tree-instruction="make-child"][data-self-dragged-over="false"][data-open="false"]:after': + { + animation: `${draggedOverAnimation} 1s infinite linear`, + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx new file mode 100644 index 000000000..237e73bf5 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx @@ -0,0 +1,422 @@ +import { + DropIndicator, + type DropTargetDropEvent, + type DropTargetOptions, + type DropTargetTreeInstruction, + IconButton, + Menu, + MenuIcon, + MenuItem, + useDraggable, + useDropTarget, +} from '@affine/component'; +import { RenameModal } from '@affine/component/rename-modal'; +import { WorkbenchLink } from '@affine/core/modules/workbench'; +import type { AffineDNDData } from '@affine/core/types/dnd'; +import { useI18n } from '@affine/i18n'; +import { + ArrowDownSmallIcon, + EditIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/rc'; +import * as Collapsible from '@radix-ui/react-collapsible'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import clsx from 'clsx'; +import type { To } from 'history'; +import { + type Dispatch, + Fragment, + type RefAttributes, + type SetStateAction, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { ExplorerTreeContext } from './context'; +import { DropEffect } from './drop-effect'; +import * as styles from './node.css'; +import type { NodeOperation } from './types'; + +export type ExplorerTreeNodeDropEffectData = { + source: { data: AffineDNDData['draggable'] }; + treeInstruction: DropTargetTreeInstruction | null; +}; +export type ExplorerTreeNodeDropEffect = ( + data: ExplorerTreeNodeDropEffectData +) => 'copy' | 'move' | 'link' | undefined; + +export const ExplorerTreeNode = ({ + children, + icon: Icon, + name, + onClick, + to, + active, + defaultRenaming, + renameable, + onRename, + disabled, + collapsed, + setCollapsed, + canDrop, + reorderable = true, + operations = [], + postfix, + childrenOperations = [], + childrenPlaceholder, + linkComponent: LinkComponent = WorkbenchLink, + dndData, + onDrop, + dropEffect, + ...otherProps +}: { + name?: string; + icon?: React.ComponentType<{ + className?: string; + draggedOver?: boolean; + treeInstruction?: DropTargetTreeInstruction | null; + }>; + children?: React.ReactNode; + active?: boolean; + reorderable?: boolean; + defaultRenaming?: boolean; + collapsed: boolean; + setCollapsed: Dispatch>; + renameable?: boolean; + onRename?: (newName: string) => void; + disabled?: boolean; + onClick?: () => void; + to?: To; + postfix?: React.ReactNode; + canDrop?: DropTargetOptions['canDrop']; + operations?: NodeOperation[]; + childrenOperations?: NodeOperation[]; + childrenPlaceholder?: React.ReactNode; + linkComponent?: React.ComponentType< + React.PropsWithChildren<{ to: To; className?: string }> & RefAttributes + >; + dndData?: AffineDNDData; + onDrop?: (data: DropTargetDropEvent) => void; + dropEffect?: ExplorerTreeNodeDropEffect; +} & { [key in `data-${string}`]?: any }) => { + const t = useI18n(); + const cid = useId(); + const context = useContext(ExplorerTreeContext); + const level = context?.level ?? 0; + // If no onClick or to is provided, clicking on the node will toggle the collapse state + const clickForCollapse = !onClick && !to && !disabled; + const [childCount, setChildCount] = useState(0); + const [renaming, setRenaming] = useState(defaultRenaming); + const [lastInGroup, setLastInGroup] = useState(false); + const rootRef = useRef(null); + const { dragRef, dragging, CustomDragPreview } = useDraggable< + AffineDNDData & { draggable: { __cid: string } } + >( + () => ({ + data: { ...dndData?.draggable, __cid: cid }, + dragPreviewPosition: 'pointer-outside', + }), + [cid, dndData] + ); + const handleCanDrop = useMemo['canDrop']>( + () => args => { + if (!reorderable && args.treeInstruction?.type !== 'make-child') { + return false; + } + return (typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true; + }, + [canDrop, reorderable] + ); + const { + dropTargetRef, + treeInstruction, + draggedOverDraggable, + draggedOver, + draggedOverPosition, + } = useDropTarget( + () => ({ + data: dndData?.dropTarget, + treeInstruction: { + currentLevel: level, + indentPerLevel: 20, + mode: !collapsed + ? 'expanded' + : lastInGroup + ? 'last-in-group' + : 'standard', + block: + reorderable === false + ? ['reorder-above', 'reorder-below', 'reparent'] + : [], + }, + onDrop: data => { + if ( + data.source.data.__cid === cid && + data.treeInstruction?.type !== 'reparent' + ) { + // Do nothing if dropped on self + return; + } + onDrop?.(data); + if (data.treeInstruction?.type === 'make-child') { + setCollapsed(false); + } + }, + canDrop: handleCanDrop, + }), + [ + dndData?.dropTarget, + level, + collapsed, + lastInGroup, + reorderable, + handleCanDrop, + cid, + onDrop, + setCollapsed, + ] + ); + const isSelfDraggedOver = draggedOverDraggable?.data.__cid === cid; + + useEffect(() => { + if ( + draggedOver && + treeInstruction?.type === 'make-child' && + !isSelfDraggedOver + ) { + // auto expand when dragged over + const timeout = setTimeout(() => { + setCollapsed(false); + }, 1000); + return () => clearTimeout(timeout); + } + return; + }, [draggedOver, isSelfDraggedOver, setCollapsed, treeInstruction?.type]); + + useEffect(() => { + if (rootRef.current) { + const parent = rootRef.current.parentElement; + if (parent) { + const updateLastInGroup = () => { + setLastInGroup(parent?.lastElementChild === rootRef.current); + }; + updateLastInGroup(); + const observer = new MutationObserver(updateLastInGroup); + observer.observe(parent, { + childList: true, + }); + return () => observer.disconnect(); + } + } + return; + }, []); + + const presetOperations = useMemo( + () => + ( + [ + renameable + ? { + index: 0, + view: ( + + + + } + onClick={() => setRenaming(true)} + > + {t['com.affine.menu.rename']()} + + ), + } + : null, + ] as (NodeOperation | null)[] + ).filter((t): t is NodeOperation => t !== null), + [renameable, t] + ); + + const { menuOperations, inlineOperations } = useMemo(() => { + const sorted = [...presetOperations, ...operations].sort( + (a, b) => a.index - b.index + ); + return { + menuOperations: sorted.filter(({ inline }) => !inline), + inlineOperations: sorted.filter(({ inline }) => !!inline), + }; + }, [presetOperations, operations]); + + const contextValue = useMemo(() => { + return { + operations: childrenOperations, + level: (context?.level ?? 0) + 1, + registerChild: () => { + setChildCount(c => c + 1); + return () => setChildCount(c => c - 1); + }, + }; + }, [childrenOperations, context?.level]); + + const handleCollapsedChange = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); // for links + setCollapsed(!collapsed); + }, + [collapsed, setCollapsed] + ); + + const handleRename = useCallback( + (newName: string) => { + onRename?.(newName); + }, + [onRename] + ); + + const handleClick = useCallback(() => { + if (!clickForCollapse) { + onClick?.(); + } else { + setCollapsed(prev => !prev); + } + }, [clickForCollapse, onClick, setCollapsed]); + + const content = ( +
+ {Icon && ( +
+
+ +
+ +
+ )} + {renameable && renaming && ( + + )} + +
{name}
+ + {postfix} +
{ + // prevent jump to page + e.stopPropagation(); + e.preventDefault(); + }} + > + {inlineOperations.map(({ view }, index) => ( + {view} + ))} + {menuOperations.length > 0 && ( + ( + {view} + ))} + > + + + + + )} +
+
+ ); + + return ( + +
+ {to ? ( + + {content} + + ) : ( +
{content}
+ )} + +
{content}
+
+ {treeInstruction && + // Do not show drop indicator for self dragged over + !(treeInstruction.type !== 'reparent' && isSelfDraggedOver) && + treeInstruction.type !== 'instruction-blocked' && ( + + )} + {draggedOver && + dropEffect && + draggedOverPosition && + !isSelfDraggedOver && + draggedOverDraggable && ( + + )} +
+ + {/* For lastInGroup check, the placeholder must be placed above all children in the dom */} +
+ {childCount === 0 && !collapsed && childrenPlaceholder} +
+ + {collapsed ? null : children} + +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/tree/root.css.ts b/packages/frontend/core/src/modules/explorer/views/tree/root.css.ts new file mode 100644 index 000000000..2db3cdf97 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/root.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; + +export const placeholder = style({ + display: 'none', + selectors: { + '&:only-child': { + display: 'initial', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/explorer/views/tree/root.tsx b/packages/frontend/core/src/modules/explorer/views/tree/root.tsx new file mode 100644 index 000000000..bf6677197 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/root.tsx @@ -0,0 +1,41 @@ +import { useMemo, useState } from 'react'; + +import { ExplorerTreeContext } from './context'; +import * as styles from './root.css'; +import type { NodeOperation } from './types'; + +export const ExplorerTreeRoot = ({ + children, + childrenOperations = [], + placeholder, +}: { + children?: React.ReactNode; + childrenOperations?: NodeOperation[]; + className?: string; + placeholder?: React.ReactNode; +}) => { + const [childCount, setChildCount] = useState(0); + const contextValue = useMemo(() => { + return { + operations: childrenOperations, + level: 0, + registerChild: () => { + setChildCount(c => c + 1); + return () => setChildCount(c => c - 1); + }, + }; + }, [childrenOperations]); + + return ( + //
is for placeholder:last-child selector +
+ {/* For lastInGroup check, the placeholder must be placed above all children in the dom */} +
+ {childCount === 0 && placeholder} +
+ + {children} + +
+ ); +}; diff --git a/packages/frontend/core/src/modules/explorer/views/tree/types.ts b/packages/frontend/core/src/modules/explorer/views/tree/types.ts new file mode 100644 index 000000000..f5603e916 --- /dev/null +++ b/packages/frontend/core/src/modules/explorer/views/tree/types.ts @@ -0,0 +1,5 @@ +export type NodeOperation = { + index: number; + view: React.ReactNode; + inline?: boolean; +}; diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 7d5860b7f..5a56c2464 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -7,6 +7,7 @@ import { configureDocLinksModule } from './doc-link'; import { configureDocsSearchModule } from './docs-search'; import { configureFindInPageModule } from './find-in-page'; import { configureNavigationModule } from './navigation'; +import { configureOrganizeModule } from './organize'; import { configurePeekViewModule } from './peek-view'; import { configurePermissionsModule } from './permissions'; import { configureWorkspacePropertiesModule } from './properties'; @@ -33,4 +34,5 @@ export function configureCommonModules(framework: Framework) { configureQuickSearchModule(framework); configureDocsSearchModule(framework); configureDocLinksModule(framework); + configureOrganizeModule(framework); } diff --git a/packages/frontend/core/src/modules/organize/entities/folder-node.ts b/packages/frontend/core/src/modules/organize/entities/folder-node.ts new file mode 100644 index 000000000..3bdb75b45 --- /dev/null +++ b/packages/frontend/core/src/modules/organize/entities/folder-node.ts @@ -0,0 +1,168 @@ +import { generateFractionalIndexingKeyBetween } from '@affine/core/utils'; +import { Entity, LiveData } from '@toeverything/infra'; +import { map, of, switchMap } from 'rxjs'; + +import type { FolderStore } from '../stores/folder'; + +export class FolderNode extends Entity<{ + id: string | null; +}> { + id = this.props.id; + + info$ = LiveData.from<{ + data: string; + // eslint-disable-next-line @typescript-eslint/ban-types + type: (string & {}) | 'folder' | 'doc' | 'tag' | 'collection'; + index: string; + id: string; + parentId?: string | null; + } | null>(this.store.watchNodeInfo(this.id ?? ''), null); + type$ = this.info$.map(info => + this.id === null ? 'folder' : (info?.type ?? '') + ); + data$ = this.info$.map(info => info?.data); + name$ = this.info$.map(info => (info?.type === 'folder' ? info.data : '')); + children$ = LiveData.from( + // watch children if this is a folder, otherwise return empty array + this.type$.pipe( + switchMap(type => + type === 'folder' + ? this.store + .watchNodeChildren(this.id) + .pipe( + map(children => + children + .filter(e => this.filterInvalidChildren(e)) + .map(child => + this.framework.createEntity(FolderNode, child) + ) + ) + ) + .pipe() + : of([]) + ) + ), + [] + ); + sortedChildren$ = LiveData.computed(get => { + return get(this.children$) + .map(node => [node, get(node.index$)] as const) + .sort((a, b) => (a[1] > b[1] ? 1 : -1)) + .map(([node]) => node); + }); + index$ = this.info$.map(info => info?.index ?? ''); + + constructor(readonly store: FolderStore) { + super(); + } + + contains(childId: string | null): boolean { + if (!this.id) { + return true; + } + if (!childId) { + return false; + } + return this.store.isAncestor(childId, this.id); + } + + beChildOf(parentId: string | null): boolean { + if (!this.id) { + return false; + } + if (!parentId) { + return true; + } + return this.store.isAncestor(this.id, parentId); + } + + filterInvalidChildren(child: { type: string }): boolean { + if (this.id === null && child.type !== 'folder') { + return false; // root node can only have folders + } + return true; + } + + createFolder(name: string, index: string) { + if (this.type$.value !== 'folder') { + throw new Error('Cannot create folder on non-folder node'); + } + return this.store.createFolder(this.id, name, index); + } + + createLink( + type: 'doc' | 'tag' | 'collection', + targetId: string, + index: string + ) { + if (this.id === null) { + throw new Error('Cannot create link on root node'); + } + if (this.type$.value !== 'folder') { + throw new Error('Cannot create link on non-folder node'); + } + this.store.createLink(this.id, type, targetId, index); + } + + delete() { + if (this.id === null) { + throw new Error('Cannot delete root node'); + } + if (this.type$.value === 'folder') { + this.store.removeFolder(this.id); + } else { + this.store.removeLink(this.id); + } + } + + moveHere(childId: string, index: string) { + this.store.moveNode(childId, this.id, index); + } + + rename(name: string) { + if (this.id === null) { + throw new Error('Cannot rename root node'); + } + this.store.renameNode(this.id, name); + } + + indexAt(at: 'before' | 'after', targetId?: string) { + if (!targetId) { + if (at === 'before') { + const first = this.sortedChildren$.value.at(0); + return generateFractionalIndexingKeyBetween( + null, + first?.index$.value || null + ); + } else { + const last = this.sortedChildren$.value.at(-1); + return generateFractionalIndexingKeyBetween( + last?.index$.value || null, + null + ); + } + } else { + const sortedChildren = this.sortedChildren$.value; + const targetIndex = sortedChildren.findIndex( + node => node.id === targetId + ); + if (targetIndex === -1) { + throw new Error('Target node not found'); + } + const target = sortedChildren[targetIndex]; + const before: FolderNode | null = sortedChildren[targetIndex - 1] || null; + const after: FolderNode | null = sortedChildren[targetIndex + 1] || null; + if (at === 'before') { + return generateFractionalIndexingKeyBetween( + before?.index$.value || null, + target.index$.value + ); + } else { + return generateFractionalIndexingKeyBetween( + target.index$.value, + after?.index$.value || null + ); + } + } + } +} diff --git a/packages/frontend/core/src/modules/organize/entities/folder-tree.ts b/packages/frontend/core/src/modules/organize/entities/folder-tree.ts new file mode 100644 index 000000000..aeffc737e --- /dev/null +++ b/packages/frontend/core/src/modules/organize/entities/folder-tree.ts @@ -0,0 +1,32 @@ +import { Entity, LiveData } from '@toeverything/infra'; +import { map } from 'rxjs'; + +import type { FolderStore } from '../stores/folder'; +import { FolderNode } from './folder-node'; + +export class FolderTree extends Entity { + constructor(private readonly folderStore: FolderStore) { + super(); + } + + readonly rootFolder = this.framework.createEntity(FolderNode, { + id: null, + }); + + // get folder by id + folderNode$(id: string) { + return LiveData.from( + this.folderStore.watchNodeInfo(id).pipe( + map(info => { + if (!info) { + return null; + } + return this.framework.createEntity(FolderNode, { + id, + }); + }) + ), + null + ); + } +} diff --git a/packages/frontend/core/src/modules/organize/index.ts b/packages/frontend/core/src/modules/organize/index.ts new file mode 100644 index 000000000..ebab9aa56 --- /dev/null +++ b/packages/frontend/core/src/modules/organize/index.ts @@ -0,0 +1,18 @@ +import { DBService, type Framework, WorkspaceScope } from '@toeverything/infra'; + +import { FolderNode } from './entities/folder-node'; +import { FolderTree } from './entities/folder-tree'; +import { OrganizeService } from './services/organize'; +import { FolderStore } from './stores/folder'; + +export type { FolderNode } from './entities/folder-node'; +export { OrganizeService } from './services/organize'; + +export function configureOrganizeModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(OrganizeService) + .entity(FolderTree, [FolderStore]) + .entity(FolderNode, [FolderStore]) + .store(FolderStore, [DBService]); +} diff --git a/packages/frontend/core/src/modules/organize/services/organize.ts b/packages/frontend/core/src/modules/organize/services/organize.ts new file mode 100644 index 000000000..42ba7d86f --- /dev/null +++ b/packages/frontend/core/src/modules/organize/services/organize.ts @@ -0,0 +1,6 @@ +import { Service } from '@toeverything/infra'; + +import { FolderTree } from '../entities/folder-tree'; +export class OrganizeService extends Service { + folderTree = this.framework.createEntity(FolderTree); +} diff --git a/packages/frontend/core/src/modules/organize/stores/folder.ts b/packages/frontend/core/src/modules/organize/stores/folder.ts new file mode 100644 index 000000000..be4d927cd --- /dev/null +++ b/packages/frontend/core/src/modules/organize/stores/folder.ts @@ -0,0 +1,148 @@ +import type { DBService } from '@toeverything/infra'; +import { Store } from '@toeverything/infra'; + +export class FolderStore extends Store { + constructor(private readonly dbService: DBService) { + super(); + } + + watchNodeInfo(nodeId: string) { + return this.dbService.db.folders.get$(nodeId); + } + + watchNodeChildren(parentId: string | null) { + return this.dbService.db.folders.find$({ + parentId: parentId, + }); + } + + isAncestor(childId: string, ancestorId: string): boolean { + if (childId === ancestorId) { + return false; + } + const history = new Set([childId]); + let current: string = childId; + while (current) { + const info = this.dbService.db.folders.get(current); + if (info === null || !info.parentId) { + return false; + } + current = info.parentId; + if (history.has(current)) { + return false; // loop detected + } + history.add(current); + if (current === ancestorId) { + return true; + } + } + return false; + } + + createLink( + parentId: string, + type: 'doc' | 'tag' | 'collection', + nodeId: string, + index: string + ) { + const parent = this.dbService.db.folders.get(parentId); + if (parent === null || parent.type !== 'folder') { + throw new Error('Parent folder not found'); + } + + this.dbService.db.folders.create({ + parentId, + type, + data: nodeId, + index: index, + }); + } + + renameNode(nodeId: string, name: string) { + const node = this.dbService.db.folders.get(nodeId); + if (node === null) { + throw new Error('Node not found'); + } + if (node.type !== 'folder') { + throw new Error('Cannot rename non-folder node'); + } + this.dbService.db.folders.update(nodeId, { + data: name, + }); + } + + createFolder(parentId: string | null, name: string, index: string) { + if (parentId) { + const parent = this.dbService.db.folders.get(parentId); + if (parent === null || parent.type !== 'folder') { + throw new Error('Parent folder not found'); + } + } + + return this.dbService.db.folders.create({ + parentId: parentId, + type: 'folder', + data: name, + index: index, + }).id; + } + + removeFolder(folderId: string) { + const info = this.dbService.db.folders.get(folderId); + if (info === null || info.type !== 'folder') { + throw new Error('Folder not found'); + } + const stack = [info]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + if (current.type !== 'folder') { + this.dbService.db.folders.delete(current.id); + } else { + const children = this.dbService.db.folders.find({ + parentId: current.id, + }); + stack.push(...children); + this.dbService.db.folders.delete(current.id); + } + } + } + + removeLink(linkId: string) { + const link = this.dbService.db.folders.get(linkId); + if (link === null || link.type === 'folder') { + throw new Error('Link not found'); + } + this.dbService.db.folders.delete(linkId); + } + + moveNode(nodeId: string, parentId: string | null, index: string) { + const node = this.dbService.db.folders.get(nodeId); + if (node === null) { + throw new Error('Node not found'); + } + + if (parentId) { + if (nodeId === parentId) { + throw new Error('Cannot move a node to itself'); + } + if (this.isAncestor(parentId, nodeId)) { + throw new Error('Cannot move a node to its descendant'); + } + const parent = this.dbService.db.folders.get(parentId); + if (parent === null || parent.type !== 'folder') { + throw new Error('Parent folder not found'); + } + } else { + if (node.type !== 'folder') { + throw new Error('Root node can only have folders'); + } + } + this.dbService.db.folders.update(nodeId, { + parentId, + index, + }); + } +} diff --git a/packages/frontend/core/src/modules/organize/types.ts b/packages/frontend/core/src/modules/organize/types.ts new file mode 100644 index 000000000..af5a9b96b --- /dev/null +++ b/packages/frontend/core/src/modules/organize/types.ts @@ -0,0 +1,7 @@ +export interface NodeInfo { + id: string; + parentId: string | null; + type: 'folder' | 'doc' | 'tag' | 'collection'; + data: string; + index: string; +} diff --git a/packages/frontend/core/src/modules/tag/service/tag.ts b/packages/frontend/core/src/modules/tag/service/tag.ts index b589caf35..b59c630a9 100644 --- a/packages/frontend/core/src/modules/tag/service/tag.ts +++ b/packages/frontend/core/src/modules/tag/service/tag.ts @@ -1,7 +1,32 @@ import { Service } from '@toeverything/infra'; +import { cssVar } from '@toeverything/theme'; import { TagList } from '../entities/tag-list'; +type TagColorHelper = T extends `paletteLine${infer Color}` ? Color : never; +export type TagColorName = TagColorHelper[0]>; + +const tagColorIds: TagColorName[] = [ + 'Red', + 'Magenta', + 'Orange', + 'Yellow', + 'Green', + 'Teal', + 'Blue', + 'Purple', + 'Grey', +]; + export class TagService extends Service { tagList = this.framework.createEntity(TagList); + + tagColors = tagColorIds.map( + color => [color, cssVar(`paletteLine${color}`)] as const + ); + + randomTagColor() { + const randomIndex = Math.floor(Math.random() * this.tagColors.length); + return this.tagColors[randomIndex][1]; + } } diff --git a/packages/frontend/core/src/pages/workspace/collection/index.tsx b/packages/frontend/core/src/pages/workspace/collection/index.tsx index 253bf798e..f4705f2ae 100644 --- a/packages/frontend/core/src/pages/workspace/collection/index.tsx +++ b/packages/frontend/core/src/pages/workspace/collection/index.tsx @@ -14,12 +14,22 @@ import { PageIcon, ViewLayersIcon, } from '@blocksuite/icons/rc'; -import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +import { + GlobalContextService, + useLiveData, + useService, + useServices, + WorkspaceService, +} from '@toeverything/infra'; import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; -import { ViewBody, ViewHeader } from '../../../modules/workbench'; +import { + useIsActiveView, + ViewBody, + ViewHeader, +} from '../../../modules/workbench'; import { WorkspaceSubPath } from '../../../shared'; import * as styles from './collection.css'; import { CollectionDetailHeader } from './header'; @@ -58,13 +68,18 @@ export const CollectionDetail = ({ }; export const Component = function CollectionPage() { - const collectionService = useService(CollectionService); + const { collectionService, globalContextService } = useServices({ + CollectionService, + GlobalContextService, + }); + const globalContext = globalContextService.globalContext; const collections = useLiveData(collectionService.collections$); const navigate = useNavigateHelper(); const params = useParams(); const workspace = useService(WorkspaceService).workspace; const collection = collections.find(v => v.id === params.collectionId); + const isActiveView = useIsActiveView(); const notifyCollectionDeleted = useCallback(() => { navigate.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); @@ -82,6 +97,19 @@ export const Component = function CollectionPage() { return notify.error({ title: text }); }, [collectionService, navigate, params.collectionId, workspace.id]); + useEffect(() => { + if (isActiveView && collection) { + globalContext.collectionId.set(collection.id); + globalContext.isCollection.set(true); + + return () => { + globalContext.collectionId.set(null); + globalContext.isCollection.set(false); + }; + } + return; + }, [collection, globalContext, isActiveView]); + useEffect(() => { if (!collection) { notifyCollectionDeleted(); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx index bb81f8018..a72f7e53b 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx @@ -176,8 +176,7 @@ export function DetailPageHeader(props: PageHeaderProps) { ); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index 9d7fdbe0c..a7047b8c2 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -95,7 +95,6 @@ const DetailPageImpl = memo(function DetailPageImpl() { useEffect(() => { const disposable = AIProvider.slots.requestOpenWithChat.on(params => { - console.log(params); workbench.openSidebar(); view.activeSidebarTab('chat'); @@ -110,9 +109,11 @@ const DetailPageImpl = memo(function DetailPageImpl() { useEffect(() => { if (isActiveView) { globalContext.docId.set(doc.id); + globalContext.isDoc.set(true); return () => { globalContext.docId.set(null); + globalContext.isDoc.set(false); }; } return; diff --git a/packages/frontend/core/src/pages/workspace/tag/index.tsx b/packages/frontend/core/src/pages/workspace/tag/index.tsx index 295f36a87..9253139c7 100644 --- a/packages/frontend/core/src/pages/workspace/tag/index.tsx +++ b/packages/frontend/core/src/pages/workspace/tag/index.tsx @@ -4,9 +4,18 @@ import { } from '@affine/core/components/page-list'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { TagService } from '@affine/core/modules/tag'; -import { ViewBody, ViewHeader } from '@affine/core/modules/workbench'; -import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; -import { useMemo } from 'react'; +import { + useIsActiveView, + ViewBody, + ViewHeader, +} from '@affine/core/modules/workbench'; +import { + GlobalContextService, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; +import { useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { PageNotFound } from '../../404'; @@ -15,6 +24,7 @@ import { TagDetailHeader } from './header'; import * as styles from './index.css'; export const TagDetail = ({ tagId }: { tagId?: string }) => { + const globalContext = useService(GlobalContextService).globalContext; const currentWorkspace = useService(WorkspaceService).workspace; const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); @@ -28,6 +38,21 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => { return pageMetas.filter(page => pageIdsSet.has(page.id)); }, [pageIds, pageMetas]); + const isActiveView = useIsActiveView(); + + useEffect(() => { + if (isActiveView && currentTag) { + globalContext.tagId.set(currentTag.id); + globalContext.isTag.set(true); + + return () => { + globalContext.tagId.set(null); + globalContext.isTag.set(false); + }; + } + return; + }, [currentTag, globalContext, isActiveView]); + if (!currentTag) { return ; } diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index 7fdfe5e04..1dd0a9a8d 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -7,9 +7,14 @@ import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-me import { useI18n } from '@affine/i18n'; import { assertExists } from '@blocksuite/global/utils'; import { DeleteIcon } from '@blocksuite/icons/rc'; -import { useService, WorkspaceService } from '@toeverything/infra'; +import { + GlobalContextService, + useService, + WorkspaceService, +} from '@toeverything/infra'; +import { useEffect } from 'react'; -import { ViewBody, ViewHeader } from '../../modules/workbench'; +import { useIsActiveView, ViewBody, ViewHeader } from '../../modules/workbench'; import { EmptyPageList } from './page-list-empty'; import * as styles from './trash-page.css'; @@ -28,6 +33,7 @@ const TrashHeader = () => { }; export const TrashPage = () => { + const globalContextService = useService(GlobalContextService); const currentWorkspace = useService(WorkspaceService).workspace; const docCollection = currentWorkspace.docCollection; assertExists(docCollection); @@ -37,6 +43,19 @@ export const TrashPage = () => { trash: true, }); + const isActiveView = useIsActiveView(); + + useEffect(() => { + if (isActiveView) { + globalContextService.globalContext.isTrash.set(true); + + return () => { + globalContextService.globalContext.isTrash.set(false); + }; + } + return; + }, [globalContextService.globalContext.isTrash, isActiveView]); + return ( <> diff --git a/packages/frontend/core/src/types/dnd.ts b/packages/frontend/core/src/types/dnd.ts new file mode 100644 index 000000000..878127d18 --- /dev/null +++ b/packages/frontend/core/src/types/dnd.ts @@ -0,0 +1,77 @@ +import type { DNDData } from '@affine/component'; + +export interface AffineDNDData extends DNDData { + draggable: { + entity?: + | { + type: 'doc'; + id: string; + } + | { + type: 'folder'; + id: string; + } + | { + type: 'collection'; + id: string; + } + | { + type: 'tag'; + id: string; + }; + from?: + | { + at: 'explorer:organize:folder-node'; + nodeId: string; + } + | { + at: 'explorer:collection:list'; + } + | { + at: 'explorer:doc:linked-docs'; + docId: string; + } + | { + at: 'explorer:collection:filtered-docs'; + collectionId: string; + } + | { + at: 'explorer:favorite:items'; + } + | { + at: 'all-docs:list'; + } + | { + at: 'all-tags:list'; + } + | { + at: 'all-collections:list'; + } + | { + at: 'explorer:tags:list'; + } + | { + at: 'explorer:tags:docs'; + }; + }; + dropTarget: + | { + at: 'explorer:organize:root'; + } + | { + at: 'explorer:favorite:root'; + } + | { + at: 'explorer:organize:folder'; + } + | { + at: 'explorer:doc'; + } + | { + at: 'app-sidebar:trash'; + } + | { + at: 'explorer:tag'; + } + | Record; +} diff --git a/packages/frontend/core/src/utils/__tests__/fractional-indexing.spec.ts b/packages/frontend/core/src/utils/__tests__/fractional-indexing.spec.ts new file mode 100644 index 000000000..b5ed42853 --- /dev/null +++ b/packages/frontend/core/src/utils/__tests__/fractional-indexing.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'vitest'; + +import { generateFractionalIndexingKeyBetween } from '../fractional-indexing'; + +function gen(a: string | null, b: string | null) { + const result = generateFractionalIndexingKeyBetween(a, b); + + expect( + a === null || b === null || (a < result && result < b), + `${a} ${b} ${result}` + ).toBe(true); + return result; +} + +test('fractional-indexing', () => { + for (let i = 0; i < 100; i++) { + const set = new Set(); + let a = null; + let b = null; + for (let i = 0; i < 100; i++) { + const s1 = gen(a, b); + expect(a === null || b === null || (a < s1 && s1 < b)).toBe(true); + const s2 = gen(a, b); + expect(a === null || b === null || (a < s2 && s2 < b)).toBe(true); + + if (set.has(s1) || set.has(s2) || s1 === s2) { + throw new Error('Duplicate key, ' + set.size + ', ' + s1 + ', ' + s2); + break; + } + set.add(s1); + set.add(s2); + if (s1 < s2) { + a = s1; + b = s2; + } else { + a = s2; + b = s1; + } + } + } +}); diff --git a/packages/frontend/core/src/utils/fractional-indexing.ts b/packages/frontend/core/src/utils/fractional-indexing.ts index 30b5a5aab..7e0cf0d91 100644 --- a/packages/frontend/core/src/utils/fractional-indexing.ts +++ b/packages/frontend/core/src/utils/fractional-indexing.ts @@ -8,6 +8,9 @@ export interface SortableProvider { } // Using fractional-indexing managing orders of items in a list +/** + * @deprecated + */ export function createFractionalIndexingSortableHelper< T, K extends string | number, @@ -60,6 +63,35 @@ export function createFractionalIndexingSortableHelper< provider.setItemOrder(fromItem, generateKeyBetween(...args)); } + function moveTo(fromId: K, toId: K, position: 'before' | 'after') { + const items = getOrderedItems(); + const from = items.findIndex(i => provider.getItemId(i) === fromId); + const to = items.findIndex(i => provider.getItemId(i) === toId); + const fromItem = items[from] as T | undefined; + if (fromItem === undefined) return; + const toItem = items[to] as T | undefined; + const toItemPrev = items[to - 1] as T | undefined; + const toItemNext = items[to + 1] as T | undefined; + const toItemOrder = toItem ? provider.getItemOrder(toItem) : null; + const toItemPrevOrder = toItemPrev + ? provider.getItemOrder(toItemPrev) + : null; + const toItemNextOrder = toItemNext + ? provider.getItemOrder(toItemNext) + : null; + if (position === 'before') { + provider.setItemOrder( + fromItem, + generateKeyBetween(toItemPrevOrder, toItemOrder) + ); + } else { + provider.setItemOrder( + fromItem, + generateKeyBetween(toItemOrder, toItemNextOrder) + ); + } + } + /** * Cases example: * Imagine we have the following items, | a | b | c | @@ -108,6 +140,74 @@ export function createFractionalIndexingSortableHelper< getSmallestOrder, getNewItemOrder, move, + moveTo, insertBefore, }; } + +/** + * generate a key between a and b, the result key is always satisfied with a < result < b. + * the key always has a random suffix, so there is no need to worry about collision. + * + * make sure a and b are generated by this function. + */ +export function generateFractionalIndexingKeyBetween( + a: string | null, + b: string | null +) { + const randomSize = 32; + function randomString(length: number = randomSize) { + const values = new Uint8Array(length); + crypto.getRandomValues(values); + const chars = + '123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(values[i] % chars.length); + } + return result; + } + + if (a !== null && b !== null && a >= b) { + throw new Error('a should be smaller than b'); + } + + // get the subkey in full key + // e.g. + // a0xxxx -> a + // a0x0xxxx -> a0x + function subkey(key: string | null) { + if (key === null) { + return null; + } + if (key.length <= randomSize + 1) { + // no subkey + return key; + } + const splitAt = key.substring(0, key.length - randomSize - 1); + return splitAt; + } + + const aSubkey = subkey(a); + const bSubkey = subkey(b); + + if (aSubkey === null && bSubkey === null) { + // generate a new key + return generateKeyBetween(null, null) + '0' + randomString(); + } else if (aSubkey === null && bSubkey !== null) { + // generate a key before b + return generateKeyBetween(null, bSubkey) + '0' + randomString(); + } else if (bSubkey === null && aSubkey !== null) { + // generate a key after a + return generateKeyBetween(aSubkey, null) + '0' + randomString(); + } else if (aSubkey !== null && bSubkey !== null) { + // generate a key between a and b + if (aSubkey === bSubkey && a !== null && b !== null) { + // conflict, if the subkeys are the same, generate a key between fullkeys + return generateKeyBetween(a, b) + '0' + randomString(); + } else { + return generateKeyBetween(aSubkey, bSubkey) + '0' + randomString(); + } + } + throw new Error('Never reach here'); +} diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index f19367501..37eb704f2 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -619,6 +619,7 @@ "com.affine.collection.helpInfo": "HELP INFO", "com.affine.collection.menu.edit": "Edit Collection", "com.affine.collection.menu.rename": "Rename", + "com.affine.menu.rename": "Rename", "com.affine.collection.removePage.success": "Removed successfully", "com.affine.collection.toolbar.selected": "<0>{{count}} selected", "com.affine.collection.toolbar.selected_one": "<0>{{count}} collection selected", @@ -808,6 +809,8 @@ "com.affine.moveToTrash.confirmModal.description.multiple": "{{ number }} docs will be moved to Trash", "com.affine.moveToTrash.confirmModal.title": "Delete doc?", "com.affine.moveToTrash.confirmModal.title.multiple": "Delete {{ number }} docs?", + "com.affine.moveToTrash.confirmModal.confirm": "Delete", + "com.affine.moveToTrash.confirmModal.cancel": "Cancel", "com.affine.moveToTrash.title": "Move to Trash", "com.affine.nameWorkspace.affine-cloud.description": "Enabling AFFiNE Cloud allows you to synchronise and backup data, as well as support multi-user collaboration and content publishing.", "com.affine.nameWorkspace.affine-cloud.title": "Sync across devices with AFFiNE Cloud", @@ -1112,6 +1115,26 @@ "com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.", "com.affine.rootAppSidebar.collections": "Collections", "com.affine.rootAppSidebar.favorites": "Favourites", + "com.affine.rootAppSidebar.organize": "Organize", + "com.affine.rootAppSidebar.organize.empty": "No Folders", + "com.affine.rootAppSidebar.organize.empty-folder": "Empty Folder", + "com.affine.rootAppSidebar.organize.folder.create-subfolder": "Create a subfolder", + "com.affine.rootAppSidebar.organize.empty-folder.add-pages": "Add pages", + "com.affine.rootAppSidebar.organize.empty.new-folders-button": "New Folder", + "com.affine.rootAppSidebar.organize.new-folders": "New Folder", + "com.affine.rootAppSidebar.organize.delete": "Delete", + "com.affine.rootAppSidebar.organize.delete-from-folder": "Remove from folder", + "com.affine.rootAppSidebar.organize.root-folder-only": "Only folder can be placed on here", + "com.affine.rootAppSidebar.tags": "Tags", + "com.affine.rootAppSidebar.tags.empty": "No Tags", + "com.affine.rootAppSidebar.tags.new-tag": "New Tag", + "com.affine.rootAppSidebar.tags.no-doc": "No docs", + "com.affine.rootAppSidebar.tags.empty.new-tag-button": "New Tag", + "com.affine.rootAppSidebar.tag.doc-only": "Only doc can be placed on here", + "com.affine.rootAppSidebar.doc.link-doc-only": "Only doc can be placed on here", + "com.affine.rootAppSidebar.explorer.drop-effect.link": "Link", + "com.affine.rootAppSidebar.explorer.drop-effect.move": "Move", + "com.affine.rootAppSidebar.explorer.drop-effect.copy": "Copy", "com.affine.rootAppSidebar.docs.no-subdoc": "No linked docs", "com.affine.rootAppSidebar.favorites.empty": "You can add documents to your favourites", "com.affine.rootAppSidebar.others": "Others", diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index d9b355ef0..3451a0607 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -118,7 +118,7 @@ test('can sync collections between different browser', async ({ page ); await enableCloudWorkspace(page); - await page.getByTestId('slider-bar-add-collection-button').click(); + await page.getByTestId('explorer-bar-add-collection-button').click(); const title = page.getByTestId('input-collection-title'); await title.isVisible(); await title.fill('test collection'); @@ -130,7 +130,7 @@ test('can sync collections between different browser', async ({ const page2 = await context.newPage(); await loginUser(page2, user.email); await page2.goto(page.url()); - const collections = page2.getByTestId('collections'); + const collections = page2.getByTestId('explorer-collections'); await expect(collections.getByText('test collection')).toBeVisible(); } }); diff --git a/tests/affine-local/e2e/doc-info-modal.spec.ts b/tests/affine-local/e2e/doc-info-modal.spec.ts index 44399cc3d..0f5ecab12 100644 --- a/tests/affine-local/e2e/doc-info-modal.spec.ts +++ b/tests/affine-local/e2e/doc-info-modal.spec.ts @@ -89,15 +89,15 @@ test('New a page and add to favourites, then open info modal from sidebar', asyn await page.getByTestId('editor-option-menu-favorite').click(); await page.getByTestId('all-pages').click(); - const favoriteListItemInSidebar = page.getByTestId( - 'favourite-page-' + newPageId + const favoriteListItemInSidebar = page.locator( + `[data-testid="explorer-favorites"] [data-testid="explorer-doc-${newPageId}"]` ); expect(await favoriteListItemInSidebar.textContent()).toBe( 'this is a new page' ); await favoriteListItemInSidebar.hover(); await favoriteListItemInSidebar - .getByTestId('left-sidebar-page-operation-button') + .getByTestId('explorer-tree-node-operation-button') .click(); const infoBtn = page.getByText('View Info'); await infoBtn.click(); diff --git a/tests/affine-local/e2e/drag-page.spec.ts b/tests/affine-local/e2e/drag-page.spec.ts index aa16ec943..44ffa1ceb 100644 --- a/tests/affine-local/e2e/drag-page.spec.ts +++ b/tests/affine-local/e2e/drag-page.spec.ts @@ -15,32 +15,25 @@ const dragToFavourites = async ( page: Page, dragItem: Locator, id: string, - type: 'page' | 'collection' = 'page' + type: 'doc' | 'collection' | 'tag' | 'folder' = 'doc' ) => { - const favourites = page.getByTestId('favourites'); + const favourites = page.getByTestId('explorer-favorite-category-divider'); await dragTo(page, dragItem, favourites); - if (type === 'collection') { - const collection = page - .getByTestId(`favourites`) - .locator(`[data-collection-id="${id}"]`); - await expect(collection).toBeVisible(); - return collection; - } else { - const favouritePage = page.getByTestId(`favourite-page-${id}`); - await expect(favouritePage).toBeVisible(); - return favouritePage; - } + const item = page + .getByTestId(`explorer-favorites`) + .locator(`[data-testid="explorer-${type}-${id}"]`); + await expect(item).toBeVisible(); + return item; }; const createCollection = async (page: Page, name: string) => { - await page.getByTestId('slider-bar-add-collection-button').click(); + await page.getByTestId('explorer-bar-add-collection-button').click(); const input = page.getByTestId('input-collection-title'); await expect(input).toBeVisible(); await input.fill(name); await page.getByTestId('save-collection').click(); - const collection = page.locator( - `[data-testid=collection-item]:has-text("${name}")` - ); + const newCollectionId = page.url().split('/').reverse()[0]; + const collection = page.getByTestId(`explorer-collection-${newCollectionId}`); await expect(collection).toBeVisible(); return collection; }; @@ -55,8 +48,7 @@ const dragToCollection = async (page: Page, dragItem: Locator) => { await clickSideBarAllPageButton(page); await dragTo(page, dragItem, collection); await page.waitForTimeout(500); - await collection.getByTestId('fav-collapsed-button').click(); - const collectionPage = page.getByTestId('collection-page'); + const collectionPage = collection.locator('[data-testid^="explorer-doc-"]'); await expect(collectionPage).toBeVisible(); return collectionPage; }; @@ -99,7 +91,7 @@ test('drag a page from "All pages" list to favourites, then drag to trash', asyn const favouritePage = await dragToFavourites( page, - page.locator(`[role="button"]:has-text("${title}")`), + page.locator(`[data-testid="page-list-item"]:has-text("${title}")`), pageId ); @@ -117,7 +109,7 @@ test('drag a page from "All pages" list to collections, then drag to trash', asy const collectionPage = await dragToCollection( page, - page.locator(`[role="button"]:has-text("${title}")`) + page.locator(`[data-testid="page-list-item"]:has-text("${title}")`) ); await dragToTrash(page, title, collectionPage); @@ -133,7 +125,7 @@ test('drag a page from "All pages" list to trash', async ({ page }) => { await dragToTrash( page, title, - page.locator(`[role="button"]:has-text("${title}")`) + page.locator(`[data-testid="page-list-item"]:has-text("${title}")`) ); }); @@ -148,7 +140,7 @@ test('drag a page from favourites to collection', async ({ page }) => { // drag to favourites const favouritePage = await dragToFavourites( page, - page.locator(`[role="button"]:has-text("${title}")`), + page.locator(`[data-testid="page-list-item"]:has-text("${title}")`), pageId ); @@ -160,9 +152,7 @@ test('drag a collection to favourites', async ({ page }) => { await clickSideBarAllPageButton(page); await page.waitForTimeout(500); const collection = await createCollection(page, 'test collection'); - const collectionId = (await collection.getAttribute( - 'data-collection-id' - )) as string; + const collectionId = page.url().split('/').reverse()[0]; await dragToFavourites(page, collection, collectionId, 'collection'); }); @@ -177,46 +167,44 @@ test('items in favourites can be reordered by dragging', async ({ page }) => { { const collection = await createCollection(page, 'test collection'); - const collectionId = (await collection.getAttribute( - 'data-collection-id' - )) as string; + const collectionId = page.url().split('/').reverse()[0]; await dragToFavourites(page, collection, collectionId, 'collection'); } // assert the order of the items in favourites await expect( - page.getByTestId('favourites').locator('[data-draggable]') + page.getByTestId('explorer-favorites').locator('[draggable]') ).toHaveCount(3); await expect( - page.getByTestId('favourites').locator('[data-draggable]').first() + page.getByTestId('explorer-favorites').locator('[draggable]').first() ).toHaveText(title0); await expect( - page.getByTestId('favourites').locator('[data-draggable]').last() + page.getByTestId('explorer-favorites').locator('[draggable]').last() ).toHaveText('test collection'); // drag the first item to the last const firstItem = page - .getByTestId('favourites') - .locator('[data-draggable]') + .getByTestId('explorer-favorites') + .locator('[draggable]') .first(); const lastItem = page - .getByTestId('favourites') - .locator('[data-draggable]') + .getByTestId('explorer-favorites') + .locator('[draggable]') .last(); - await dragTo(page, firstItem, lastItem); + await dragTo(page, firstItem, lastItem, 'bottom'); // now check the order again await expect( - page.getByTestId('favourites').locator('[data-draggable]') + page.getByTestId('explorer-favorites').locator('[draggable]') ).toHaveCount(3); await expect( - page.getByTestId('favourites').locator('[data-draggable]').first() + page.getByTestId('explorer-favorites').locator('[draggable]').first() ).toHaveText(title1); await expect( - page.getByTestId('favourites').locator('[data-draggable]').last() + page.getByTestId('explorer-favorites').locator('[draggable]').last() ).toHaveText(title0); }); diff --git a/tests/affine-local/e2e/local-first-collections-items.spec.ts b/tests/affine-local/e2e/local-first-collections-items.spec.ts index c4be5b5e1..2d4abaaeb 100644 --- a/tests/affine-local/e2e/local-first-collections-items.spec.ts +++ b/tests/affine-local/e2e/local-first-collections-items.spec.ts @@ -64,24 +64,24 @@ const createAndPinCollection = async ( test('Show collections items in sidebar', async ({ page }) => { await removeOnboardingPages(page); await createAndPinCollection(page); - const collections = page.getByTestId('collections'); - const items = collections.getByTestId('collection-item'); + const collections = page.getByTestId('explorer-collections'); + const items = collections.locator('[data-testid^="explorer-collection-"]'); expect(await items.count()).toBe(1); const first = items.first(); expect(await first.textContent()).toBe('test collection'); - await first.getByTestId('fav-collapsed-button').click(); - const collectionPage = collections.getByTestId('collection-page').nth(0); + await first.getByTestId('explorer-collapsed-button').click(); + const collectionPage = first.locator('[data-testid^="explorer-doc-"]').nth(0); expect(await collectionPage.textContent()).toBe('test page'); await collectionPage.hover(); await collectionPage - .getByTestId('left-sidebar-page-operation-button') + .getByTestId('explorer-tree-node-operation-button') .click(); const deletePage = page.getByText('Move to Trash'); await deletePage.click(); - await page.getByTestId('confirm-delete-page').click(); - expect(await collections.getByTestId('collection-page').count()).toBe(0); + await page.getByTestId('confirm-modal-confirm').click(); + expect(await first.locator('[data-testid^="explorer-doc-"]').count()).toBe(0); await first.hover(); - await first.getByTestId('collection-options').click(); + await first.getByTestId('explorer-tree-node-operation-button').click(); const deleteCollection = page.getByText('Delete'); await deleteCollection.click(); await page.waitForTimeout(50); @@ -104,12 +104,12 @@ test('Show collections items in sidebar', async ({ page }) => { test('edit collection', async ({ page }) => { await removeOnboardingPages(page); await createAndPinCollection(page); - const collections = page.getByTestId('collections'); - const items = collections.getByTestId('collection-item'); + const collections = page.getByTestId('explorer-collections'); + const items = collections.locator('[data-testid^="explorer-collection-"]'); expect(await items.count()).toBe(1); const first = items.first(); await first.hover(); - await first.getByTestId('collection-options').click(); + await first.getByTestId('explorer-tree-node-operation-button').click(); const editCollection = page.getByText('Rename'); await editCollection.click(); await page.getByTestId('rename-modal-input').fill('123'); @@ -121,15 +121,13 @@ test('edit collection', async ({ page }) => { test('edit collection and change filter date', async ({ page }) => { await removeOnboardingPages(page); await createAndPinCollection(page); - const collections = page.getByTestId('collections'); - const items = collections.getByTestId('collection-item'); + const collections = page.getByTestId('explorer-collections'); + const items = collections.locator('[data-testid^="explorer-collection-"]'); expect(await items.count()).toBe(1); const first = items.first(); await first.hover(); - await first.getByTestId('collection-options').click(); - const editCollection = page - .getByTestId('collection-option') - .getByText('Rename'); + await first.getByTestId('explorer-tree-node-operation-button').click(); + const editCollection = page.getByText('Rename'); await editCollection.click(); await page.getByTestId('rename-modal-input').fill('123'); await page.keyboard.press('Enter'); @@ -146,17 +144,17 @@ test('add collection from sidebar', async ({ page }) => { const cell = page.getByTestId('page-list-item-title').getByText('test page'); await expect(cell).toBeVisible(); const nullCollection = page.getByTestId( - 'slider-bar-collection-null-description' + 'slider-bar-collection-empty-message' ); await expect(nullCollection).toBeVisible(); - await page.getByTestId('slider-bar-add-collection-button').click(); + await page.getByTestId('explorer-bar-add-collection-button').click(); const title = page.getByTestId('input-collection-title'); await expect(title).toBeVisible(); await title.fill('test collection'); await page.getByTestId('save-collection').click(); await page.waitForTimeout(100); - const collections = page.getByTestId('collections'); - const items = collections.getByTestId('collection-item'); + const collections = page.getByTestId('explorer-collections'); + const items = collections.locator('[data-testid^="explorer-collection-"]'); expect(await items.count()).toBe(1); await expect(nullCollection).not.toBeVisible(); }); diff --git a/tests/affine-local/e2e/local-first-favorites-items.spec.ts b/tests/affine-local/e2e/local-first-favorites-items.spec.ts index 717b956e7..6bbc8443f 100644 --- a/tests/affine-local/e2e/local-first-favorites-items.spec.ts +++ b/tests/affine-local/e2e/local-first-favorites-items.spec.ts @@ -28,7 +28,7 @@ test('Show favorite items in sidebar', async ({ page, workspace }) => { const favoriteBtn = page.getByTestId('editor-option-menu-favorite'); await favoriteBtn.click(); const favoriteListItemInSidebar = page.getByTestId( - 'favourite-page-' + newPageId + 'explorer-doc-' + newPageId ); expect(await favoriteListItemInSidebar.textContent()).toBe( 'this is a new page to favorite' @@ -57,21 +57,23 @@ test('Show favorite reference in sidebar', async ({ page, workspace }) => { const favoriteBtn = page.getByTestId('editor-option-menu-favorite'); await favoriteBtn.click(); - const favItemTestId = 'favourite-page-' + newPageId; + const favItemTestId = 'explorer-doc-' + newPageId; const favoriteListItemInSidebar = page.getByTestId(favItemTestId); expect(await favoriteListItemInSidebar.textContent()).toBe( 'this is a new page to favorite' ); - const collapseButton = favoriteListItemInSidebar.locator( - '[data-testid="fav-collapsed-button"]' + const collapseButton = favoriteListItemInSidebar.getByTestId( + 'explorer-collapsed-button' ); await expect(collapseButton).toBeVisible(); await collapseButton.click(); await expect( - page.locator('[data-type="reference-page"] >> text=Another page') + favoriteListItemInSidebar.locator( + '[data-testid^="explorer-doc-"]:has-text("Another page")' + ) ).toBeVisible(); const currentWorkspace = await workspace.current(); @@ -80,7 +82,6 @@ test('Show favorite reference in sidebar', async ({ page, workspace }) => { test("Deleted page's reference will not be shown in sidebar", async ({ page, - workspace, }) => { await openHomePage(page); await waitForEditorLoad(page); @@ -109,7 +110,7 @@ test("Deleted page's reference will not be shown in sidebar", async ({ const anotherPageId = page.url().split('/').reverse()[0]; - const favItemTestId = 'favourite-page-' + newPageId; + const favItemTestId = 'explorer-doc-' + newPageId; await expect(page.getByTestId(favItemTestId)).toHaveText( 'this is a new page to favorite' @@ -117,12 +118,14 @@ test("Deleted page's reference will not be shown in sidebar", async ({ await page .getByTestId(favItemTestId) - .locator('[data-testid="fav-collapsed-button"]') + .getByTestId('explorer-collapsed-button') .click(); - const favItemAnotherPageTestId = 'reference-page-' + anotherPageId; + const favItemAnotherPageTestId = 'explorer-doc-' + anotherPageId; - await expect(page.getByTestId(favItemAnotherPageTestId)).toBeVisible(); + await expect( + page.getByTestId(favItemTestId).getByTestId(favItemAnotherPageTestId) + ).toBeVisible(); // delete the page await clickPageMoreActions(page); @@ -133,17 +136,16 @@ test("Deleted page's reference will not be shown in sidebar", async ({ // confirm delete await page.locator('button >> text=Delete').click(); - await expect(page.getByTestId(favItemAnotherPageTestId)).toBeVisible(); - const currentWorkspace = await workspace.current(); - - expect(currentWorkspace.meta.flavour).toContain('local'); + await expect( + page.getByTestId(favItemTestId).getByTestId(favItemAnotherPageTestId) + ).toBeHidden(); }); test('Add new favorite page via sidebar', async ({ page }) => { await openHomePage(page); await waitForEditorLoad(page); - await page.getByTestId('slider-bar-add-favorite-button').first().click(); + await page.getByTestId('explorer-bar-add-favorite-button').first().click(); await clickPageModeButton(page); await waitForEmptyEditor(page); @@ -151,7 +153,7 @@ test('Add new favorite page via sidebar', async ({ page }) => { await getBlockSuiteEditorTitle(page).fill('this is a new fav page'); // check if the page title is shown in the favorite list const favItem = page - .getByTestId('favourites') - .locator('[data-draggable] >> text=this is a new fav page'); + .getByTestId('explorer-favorites') + .locator('[draggable] >> text=this is a new fav page'); await expect(favItem).toBeVisible(); }); diff --git a/tests/kit/utils/editor.ts b/tests/kit/utils/editor.ts index 51e12537d..2e8d49b53 100644 --- a/tests/kit/utils/editor.ts +++ b/tests/kit/utils/editor.ts @@ -12,7 +12,7 @@ export async function clickEdgelessModeButton(page: Page) { } export async function clickPageModeButton(page: Page) { - page.getByTestId('switch-page-mode-button').click({ + await page.getByTestId('switch-page-mode-button').click({ delay: 50, }); await expect( diff --git a/tests/kit/utils/page-logic.ts b/tests/kit/utils/page-logic.ts index bc4fe57af..d32cf9011 100644 --- a/tests/kit/utils/page-logic.ts +++ b/tests/kit/utils/page-logic.ts @@ -71,16 +71,36 @@ export const getPageByTitle = (page: Page, title: string) => { return page.getByTestId('page-list-item').getByText(title); }; -export const dragTo = async (page: Page, locator: Locator, target: Locator) => { +export const dragTo = async ( + page: Page, + locator: Locator, + target: Locator, + location: 'top-left' | 'top' | 'bottom' | 'center' = 'center' +) => { await locator.hover(); await page.mouse.down(); - await page.waitForTimeout(1000); + await page.mouse.move(1, 1); + const targetElement = await target.boundingBox(); if (!targetElement) { throw new Error('target element not found'); } - await page.mouse.move(targetElement.x, targetElement.y); - await target.hover(); + const position = + location === 'center' + ? { + x: targetElement.width / 2, + y: targetElement.height / 2, + } + : location === 'top-left' + ? { x: 1, y: 1 } + : location === 'top' + ? { x: targetElement.width / 2, y: 1 } + : location === 'bottom' + ? { x: targetElement.width / 2, y: targetElement.height - 1 } + : { x: 1, y: 1 }; + await target.hover({ + position: position, + }); await page.mouse.up(); }; diff --git a/tools/cli/src/webpack/runtime-config.ts b/tools/cli/src/webpack/runtime-config.ts index 9ca9915b9..ab69e8421 100644 --- a/tools/cli/src/webpack/runtime-config.ts +++ b/tools/cli/src/webpack/runtime-config.ts @@ -21,6 +21,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enableExperimentalFeature: true, allowLocalWorkspace: buildFlags.distribution === 'desktop' ? true : false, + enableOrganize: false, // TODO(@forehalo): enable in next release and canary enableInfoModal: false, @@ -54,6 +55,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { serverUrlPrefix: 'https://affine.fail', changelogUrl: 'https://github.com/toeverything/AFFiNE/releases', enableInfoModal: true, + enableOrganize: true, }; }, }; diff --git a/yarn.lock b/yarn.lock index e5e1efddd..85cf6c42c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -388,7 +388,7 @@ __metadata: "@blocksuite/block-std": "npm:0.16.0-canary-202407250137-267cd23" "@blocksuite/blocks": "npm:0.16.0-canary-202407250137-267cd23" "@blocksuite/global": "npm:0.16.0-canary-202407250137-267cd23" - "@blocksuite/icons": "npm:2.1.59" + "@blocksuite/icons": "npm:2.1.60" "@blocksuite/inline": "npm:0.16.0-canary-202407250137-267cd23" "@blocksuite/presets": "npm:0.16.0-canary-202407250137-267cd23" "@blocksuite/store": "npm:0.16.0-canary-202407250137-267cd23" @@ -440,6 +440,7 @@ __metadata: file-type: "npm:^19.1.0" foxact: "npm:^0.2.33" fractional-indexing: "npm:^3.2.0" + fractional-indexing-jittered: "npm:^0.9.1" fuse.js: "npm:^7.0.0" graphql: "npm:^16.8.1" history: "npm:^5.3.0" @@ -3543,6 +3544,22 @@ __metadata: languageName: node linkType: hard +"@blocksuite/icons@npm:2.1.60": + version: 2.1.60 + resolution: "@blocksuite/icons@npm:2.1.60" + peerDependencies: + "@types/react": ^18.0.25 + lit: ^2.6.1 + react: ^18.2.0 + peerDependenciesMeta: + lit: + optional: true + react: + optional: true + checksum: 10/72302c4fd28252ede791cc97aa9c4977ee1e60f17b46043b1837b7ca7c72bdefb68b91bb847bcabb80ce489b0902fc6605ffe345ed321fdbf80f5a73c19ef33f + languageName: node + linkType: hard + "@blocksuite/inline@npm:0.16.0-canary-202407250137-267cd23": version: 0.16.0-canary-202407250137-267cd23 resolution: "@blocksuite/inline@npm:0.16.0-canary-202407250137-267cd23" @@ -23574,6 +23591,13 @@ __metadata: languageName: node linkType: hard +"fractional-indexing-jittered@npm:^0.9.1": + version: 0.9.1 + resolution: "fractional-indexing-jittered@npm:0.9.1" + checksum: 10/2908f1109ba52e7dfc96cf212c1fb95aebfe3a0dabd4e1b7663fe8d855f74d4554e3ebf600f47af28acdbc307bb149eaf78b975208a44a0a85a5c993471b32a4 + languageName: node + linkType: hard + "fractional-indexing@npm:^3.2.0": version: 3.2.0 resolution: "fractional-indexing@npm:3.2.0"