diff --git a/blocksuite/affine/all/src/__tests__/database/database.unit.spec.ts b/blocksuite/affine/all/src/__tests__/database/database.unit.spec.ts index 879b7815f..ae835234a 100644 --- a/blocksuite/affine/all/src/__tests__/database/database.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/database/database.unit.spec.ts @@ -1,15 +1,15 @@ import { addProperty, copyCellsByProperty, - databaseBlockColumns, + databaseBlockProperties, deleteColumn, getCell, getProperty, updateCell, } from '@blocksuite/affine-block-database'; import { - type Cell, - type Column, + type CellDataType, + type ColumnDataType, type DatabaseBlockModel, DatabaseBlockSchemaExtension, NoteBlockSchemaExtension, @@ -55,9 +55,9 @@ describe('DatabaseManager', () => { let databaseBlockId: BlockModel['id']; let p1: BlockModel['id']; let p2: BlockModel['id']; - let col1: Column['id']; - let col2: Column['id']; - let col3: Column['id']; + let col1: ColumnDataType['id']; + let col2: ColumnDataType['id']; + let col3: ColumnDataType['id']; const selection = [ { id: '1', value: 'Done', color: 'var(--affine-tag-white)' }, @@ -90,7 +90,7 @@ describe('DatabaseManager', () => { col1 = addProperty( db, 'end', - databaseBlockColumns.numberColumnConfig.create('Number') + databaseBlockProperties.numberColumnConfig.create('Number') ); col2 = addProperty( db, @@ -102,7 +102,7 @@ describe('DatabaseManager', () => { col3 = addProperty( db, 'end', - databaseBlockColumns.richTextColumnConfig.create('Rich Text') + databaseBlockProperties.richTextColumnConfig.create('Rich Text') ); doc.updateBlock(databaseModel, { @@ -136,7 +136,7 @@ describe('DatabaseManager', () => { test('getColumn', () => { const column = { - ...databaseBlockColumns.numberColumnConfig.create('testColumnId'), + ...databaseBlockProperties.numberColumnConfig.create('testColumnId'), id: 'testColumnId', }; addProperty(db, 'end', column); @@ -147,7 +147,7 @@ describe('DatabaseManager', () => { test('addColumn', () => { const column = - databaseBlockColumns.numberColumnConfig.create('Test Column'); + databaseBlockProperties.numberColumnConfig.create('Test Column'); const id = addProperty(db, 'end', column); const result = getProperty(db, id); @@ -157,7 +157,7 @@ describe('DatabaseManager', () => { test('deleteColumn', () => { const column = { - ...databaseBlockColumns.numberColumnConfig.create('Test Column'), + ...databaseBlockProperties.numberColumnConfig.create('Test Column'), id: 'testColumnId', }; addProperty(db, 'end', column); @@ -176,10 +176,10 @@ describe('DatabaseManager', () => { noteBlockId ); const column = { - ...databaseBlockColumns.numberColumnConfig.create('Test Column'), + ...databaseBlockProperties.numberColumnConfig.create('Test Column'), id: 'testColumnId', }; - const cell: Cell = { + const cell: CellDataType = { columnId: column.id, value: 42, }; diff --git a/blocksuite/affine/blocks/block-data-view/src/data-source.ts b/blocksuite/affine/blocks/block-data-view/src/data-source.ts index 2b7434e6f..af3b136c9 100644 --- a/blocksuite/affine/blocks/block-data-view/src/data-source.ts +++ b/blocksuite/affine/blocks/block-data-view/src/data-source.ts @@ -1,8 +1,8 @@ import { - databaseBlockAllPropertyMap, + DatabaseBlockDataSource, databasePropertyConverts, } from '@blocksuite/affine-block-database'; -import type { Column } from '@blocksuite/affine-model'; +import type { ColumnDataType } from '@blocksuite/affine-model'; import { insertPositionToIndex, type InsertToPosition, @@ -171,7 +171,7 @@ export class BlockQueryDataSource extends DataSourceBase { ): string { const doc = this.block.doc; doc.captureSync(); - const column = databaseBlockAllPropertyMap[ + const column = DatabaseBlockDataSource.propertiesMap.value[ type ?? propertyPresets.multiSelectPropertyConfig.type ].create(this.newColumnName()); @@ -180,7 +180,7 @@ export class BlockQueryDataSource extends DataSourceBase { return id; } doc.transact(() => { - const col: Column = { + const col: ColumnDataType = { ...column, id, }; @@ -287,7 +287,9 @@ export class BlockQueryDataSource extends DataSourceBase { currentCells as any ) ?? { property: - databaseBlockAllPropertyMap[toType].config.propertyData.default(), + DatabaseBlockDataSource.propertiesMap.value[ + toType + ].config.propertyData.default(), cells: currentCells.map(() => undefined), }; this.block.doc.captureSync(); diff --git a/blocksuite/affine/blocks/block-data-view/src/data-view-model.ts b/blocksuite/affine/blocks/block-data-view/src/data-view-model.ts index 4aad47c20..e2bf7c69e 100644 --- a/blocksuite/affine/blocks/block-data-view/src/data-view-model.ts +++ b/blocksuite/affine/blocks/block-data-view/src/data-view-model.ts @@ -1,4 +1,4 @@ -import type { Column } from '@blocksuite/affine-model'; +import type { ColumnDataType } from '@blocksuite/affine-model'; import { arrayMove, insertPositionToIndex, @@ -14,7 +14,7 @@ import { type Props = { title: string; views: DataViewDataType[]; - columns: Column[]; + columns: ColumnDataType[]; cells: Record>; }; diff --git a/blocksuite/affine/blocks/block-database/src/adapters/html.ts b/blocksuite/affine/blocks/block-database/src/adapters/html.ts index 9a000406a..a14bc5d0b 100644 --- a/blocksuite/affine/blocks/block-database/src/adapters/html.ts +++ b/blocksuite/affine/blocks/block-database/src/adapters/html.ts @@ -1,5 +1,5 @@ import { - type Column, + type ColumnDataType, DatabaseBlockSchema, type SerializedCells, } from '@blocksuite/affine-model'; @@ -20,7 +20,7 @@ export const databaseBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { fromBlockSnapshot: { enter: (o, context) => { const { walkerContext } = context; - const columns = o.node.props.columns as Array; + const columns = o.node.props.columns as Array; const children = o.node.children; const cells = o.node.props.cells as SerializedCells; const table = processTable(columns, children, cells); diff --git a/blocksuite/affine/blocks/block-database/src/adapters/markdown.ts b/blocksuite/affine/blocks/block-database/src/adapters/markdown.ts index ec059a73e..49d082cc9 100644 --- a/blocksuite/affine/blocks/block-database/src/adapters/markdown.ts +++ b/blocksuite/affine/blocks/block-database/src/adapters/markdown.ts @@ -1,5 +1,5 @@ import { - type Column, + type ColumnDataType, DatabaseBlockSchema, type SerializedCells, } from '@blocksuite/affine-model'; @@ -27,7 +27,7 @@ export const databaseBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = enter: (o, context) => { const { walkerContext, deltaConverter } = context; const rows: TableRow[] = []; - const columns = o.node.props.columns as Array; + const columns = o.node.props.columns as Array; const children = o.node.children; const cells = o.node.props.cells as SerializedCells; const table = processTable(columns, children, cells); diff --git a/blocksuite/affine/blocks/block-database/src/adapters/plain-text.ts b/blocksuite/affine/blocks/block-database/src/adapters/plain-text.ts index bfcaed2b4..9e12c20b5 100644 --- a/blocksuite/affine/blocks/block-database/src/adapters/plain-text.ts +++ b/blocksuite/affine/blocks/block-database/src/adapters/plain-text.ts @@ -1,5 +1,5 @@ import { - type Column, + type ColumnDataType, DatabaseBlockSchema, type SerializedCells, } from '@blocksuite/affine-model'; @@ -20,7 +20,7 @@ export const databaseBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher enter: (o, context) => { const { walkerContext, deltaConverter } = context; const rows: string[][] = []; - const columns = o.node.props.columns as Array; + const columns = o.node.props.columns as Array; const children = o.node.children; const cells = o.node.props.cells as SerializedCells; const table = processTable(columns, children, cells); diff --git a/blocksuite/affine/blocks/block-database/src/adapters/utils.ts b/blocksuite/affine/blocks/block-database/src/adapters/utils.ts index c1299d768..0807b2ad0 100644 --- a/blocksuite/affine/blocks/block-database/src/adapters/utils.ts +++ b/blocksuite/affine/blocks/block-database/src/adapters/utils.ts @@ -1,4 +1,4 @@ -import type { Column, SerializedCells } from '@blocksuite/affine-model'; +import type { ColumnDataType, SerializedCells } from '@blocksuite/affine-model'; import type { DeltaInsert } from '@blocksuite/inline'; import type { BlockSnapshot } from '@blocksuite/store'; @@ -45,7 +45,7 @@ export const isDelta = (value: unknown): value is { delta: DeltaInsert[] } => { return false; }; type Table = { - headers: Column[]; + headers: ColumnDataType[]; rows: Row[]; }; type Row = { @@ -55,7 +55,7 @@ type Cell = { value: string | { delta: DeltaInsert[] }; }; export const processTable = ( - columns: Column[], + columns: ColumnDataType[], children: BlockSnapshot[], cells: SerializedCells ): Table => { diff --git a/blocksuite/affine/blocks/block-database/src/context/index.ts b/blocksuite/affine/blocks/block-database/src/context/index.ts new file mode 100644 index 000000000..4975c0324 --- /dev/null +++ b/blocksuite/affine/blocks/block-database/src/context/index.ts @@ -0,0 +1 @@ +export * from './host-context'; diff --git a/blocksuite/affine/blocks/block-database/src/data-source.ts b/blocksuite/affine/blocks/block-database/src/data-source.ts index 8a657bcf8..f16e236d5 100644 --- a/blocksuite/affine/blocks/block-database/src/data-source.ts +++ b/blocksuite/affine/blocks/block-database/src/data-source.ts @@ -1,5 +1,5 @@ import type { - Column, + ColumnDataType, ColumnUpdater, DatabaseBlockModel, } from '@blocksuite/affine-model'; @@ -24,12 +24,11 @@ import { propertyPresets } from '@blocksuite/data-view/property-presets'; import { IS_MOBILE } from '@blocksuite/global/env'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { type BlockModel } from '@blocksuite/store'; -import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; import { getIcon } from './block-icons.js'; import { - databaseBlockAllPropertyMap, - databaseBlockPropertyList, + databaseBlockProperties, databasePropertyConverts, } from './properties/index.js'; import { @@ -53,6 +52,19 @@ import { } from './views/index.js'; export class DatabaseBlockDataSource extends DataSourceBase { + static externalProperties = signal([]); + static propertiesList = computed(() => { + return [ + ...Object.values(databaseBlockProperties), + ...this.externalProperties.value, + ]; + }); + static propertiesMap = computed(() => { + return Object.fromEntries( + this.propertiesList.value.map(v => [v.type, v as PropertyMetaConfig]) + ); + }); + private _batch = 0; private readonly _model: DatabaseBlockModel; @@ -108,7 +120,7 @@ export class DatabaseBlockDataSource extends DataSourceBase { } allPropertyMetas$ = computed[]>(() => { - return databaseBlockPropertyList; + return DatabaseBlockDataSource.propertiesList.value; }); propertyMetas$ = computed(() => { @@ -153,6 +165,9 @@ export class DatabaseBlockDataSource extends DataSourceBase { this._runCapture(); const type = this.propertyTypeGet(propertyId); + if (type == null) { + return; + } const update = this.propertyMetaGet(type)?.config.rawValue.setValue; const old = this.cellValueGet(rowId, propertyId); const updateFn = @@ -185,6 +200,9 @@ export class DatabaseBlockDataSource extends DataSourceBase { return getIcon(model); } const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } if (type === 'title') { const model = this.getModelById(rowId); return model?.text; @@ -225,7 +243,7 @@ export class DatabaseBlockDataSource extends DataSourceBase { protected override getNormalPropertyAndIndex(propertyId: string): | { - column: Column>; + column: ColumnDataType>; index: number; } | undefined { @@ -247,7 +265,7 @@ export class DatabaseBlockDataSource extends DataSourceBase { private getPropertyAndIndex(propertyId: string): | { - column: Column>; + column: ColumnDataType>; index: number; } | undefined { @@ -368,7 +386,7 @@ export class DatabaseBlockDataSource extends DataSourceBase { } propertyMetaGet(type: string): PropertyMetaConfig | undefined { - return databaseBlockAllPropertyMap[type]; + return DatabaseBlockDataSource.propertiesMap.value[type]; } propertyNameGet(propertyId: string): string { @@ -392,13 +410,13 @@ export class DatabaseBlockDataSource extends DataSourceBase { return false; } - propertyTypeGet(propertyId: string): string { + propertyTypeGet(propertyId: string): string | undefined { if (propertyId === 'type') { return 'image'; } const result = this.getPropertyAndIndex(propertyId); if (!result) { - return ''; + return; } return result.column.type; } diff --git a/blocksuite/affine/blocks/block-database/src/index.ts b/blocksuite/affine/blocks/block-database/src/index.ts index 29054837f..e3f4689f5 100644 --- a/blocksuite/affine/blocks/block-database/src/index.ts +++ b/blocksuite/affine/blocks/block-database/src/index.ts @@ -1,6 +1,7 @@ export * from './adapters'; export * from './commands'; export * from './config'; +export * from './context'; export * from './data-source'; export * from './database-block'; export * from './database-spec'; @@ -9,4 +10,6 @@ export * from './detail-panel/note-renderer'; export * from './properties'; export * from './properties/rich-text/cell-renderer'; export * from './selection.js'; +export * from './service'; export * from './utils/block-utils'; +export * from '@blocksuite/data-view'; diff --git a/blocksuite/affine/blocks/block-database/src/properties/index.ts b/blocksuite/affine/blocks/block-database/src/properties/index.ts index ecabd3e17..16be391c2 100644 --- a/blocksuite/affine/blocks/block-database/src/properties/index.ts +++ b/blocksuite/affine/blocks/block-database/src/properties/index.ts @@ -1,4 +1,3 @@ -import type { PropertyMetaConfig } from '@blocksuite/data-view'; import { propertyPresets } from '@blocksuite/data-view/property-presets'; import { linkColumnConfig } from './link/cell-renderer.js'; @@ -14,7 +13,7 @@ const { progressPropertyConfig, selectPropertyConfig, } = propertyPresets; -export const databaseBlockColumns = { +export const databaseBlockProperties = { checkboxColumnConfig: checkboxPropertyConfig, dateColumnConfig: datePropertyConfig, multiSelectColumnConfig: multiSelectPropertyConfig, @@ -26,7 +25,3 @@ export const databaseBlockColumns = { richTextColumnConfig, titleColumnConfig, }; -export const databaseBlockPropertyList = Object.values(databaseBlockColumns); -export const databaseBlockAllPropertyMap = Object.fromEntries( - databaseBlockPropertyList.map(v => [v.type, v as PropertyMetaConfig]) -); diff --git a/blocksuite/affine/blocks/block-database/src/service/index.ts b/blocksuite/affine/blocks/block-database/src/service/index.ts new file mode 100644 index 000000000..555921a5f --- /dev/null +++ b/blocksuite/affine/blocks/block-database/src/service/index.ts @@ -0,0 +1,11 @@ +import type { PropertyMetaConfig } from '@blocksuite/data-view'; +import { createIdentifier } from '@blocksuite/global/di'; + +export interface DatabaseBlockConfigService { + propertiesPresets: PropertyMetaConfig[]; +} + +export const DatabaseBlockConfigService = + createIdentifier( + 'AffineDatabaseBlockConfigService' + ); diff --git a/blocksuite/affine/blocks/block-database/src/utils/block-utils.ts b/blocksuite/affine/blocks/block-database/src/utils/block-utils.ts index 8b0fb0fe6..580e6e4d4 100644 --- a/blocksuite/affine/blocks/block-database/src/utils/block-utils.ts +++ b/blocksuite/affine/blocks/block-database/src/utils/block-utils.ts @@ -1,6 +1,6 @@ import type { - Cell, - Column, + CellDataType, + ColumnDataType, ColumnUpdater, DatabaseBlockModel, ViewBasicDataType, @@ -15,7 +15,7 @@ import type { BlockModel } from '@blocksuite/store'; export function addProperty( model: DatabaseBlockModel, position: InsertToPosition, - column: Omit & { + column: Omit & { id?: string; } ): string { @@ -24,7 +24,7 @@ export function addProperty( return id; } model.doc.transact(() => { - const col: Column = { + const col: ColumnDataType = { ...column, id, }; @@ -39,8 +39,8 @@ export function addProperty( export function copyCellsByProperty( model: DatabaseBlockModel, - fromId: Column['id'], - toId: Column['id'] + fromId: ColumnDataType['id'], + toId: ColumnDataType['id'] ) { model.doc.transact(() => { Object.keys(model.props.cells).forEach(rowId => { @@ -57,7 +57,7 @@ export function copyCellsByProperty( export function deleteColumn( model: DatabaseBlockModel, - columnId: Column['id'] + columnId: ColumnDataType['id'] ) { const index = model.props.columns.findIndex(v => v.id === columnId); if (index < 0) return; @@ -101,8 +101,8 @@ export function duplicateView(model: DatabaseBlockModel, id: string): string { export function getCell( model: DatabaseBlockModel, rowId: BlockModel['id'], - columnId: Column['id'] -): Cell | null { + columnId: ColumnDataType['id'] +): CellDataType | null { if (columnId === 'title') { return { columnId: 'title', @@ -121,8 +121,8 @@ export function getCell( export function getProperty( model: DatabaseBlockModel, - id: Column['id'] -): Column | undefined { + id: ColumnDataType['id'] +): ColumnDataType | undefined { return model.props.columns.find(v => v.id === id); } @@ -143,7 +143,7 @@ export function moveViewTo( export function updateCell( model: DatabaseBlockModel, rowId: string, - cell: Cell + cell: CellDataType ) { model.doc.transact(() => { const columnId = cell.columnId; diff --git a/blocksuite/affine/data-view/src/core/data-source/base.ts b/blocksuite/affine/data-view/src/core/data-source/base.ts index 5de1190fb..78f1911c3 100644 --- a/blocksuite/affine/data-view/src/core/data-source/base.ts +++ b/blocksuite/affine/data-view/src/core/data-source/base.ts @@ -1,4 +1,4 @@ -import type { Column } from '@blocksuite/affine-model'; +import type { ColumnDataType } from '@blocksuite/affine-model'; import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; import { computed, type ReadonlySignal } from '@preact/signals-core'; @@ -256,7 +256,7 @@ export abstract class DataSourceBase implements DataSource { protected abstract getNormalPropertyAndIndex(propertyId: string): | { - column: Column>; + column: ColumnDataType>; index: number; } | undefined; diff --git a/blocksuite/affine/data-view/src/core/property/types.ts b/blocksuite/affine/data-view/src/core/property/types.ts index 351704d4a..63ca5e52a 100644 --- a/blocksuite/affine/data-view/src/core/property/types.ts +++ b/blocksuite/affine/data-view/src/core/property/types.ts @@ -39,7 +39,7 @@ export type PropertyConfig = { data: Data; }> ) => JsonValue; - fromJson: ( + fromJson?: ( config: WithCommonPropertyConfig<{ value: JsonValue; data: Data; diff --git a/blocksuite/affine/data-view/src/core/view-manager/index.ts b/blocksuite/affine/data-view/src/core/view-manager/index.ts index 98a19b6eb..ac4d24cac 100644 --- a/blocksuite/affine/data-view/src/core/view-manager/index.ts +++ b/blocksuite/affine/data-view/src/core/view-manager/index.ts @@ -1,2 +1,5 @@ +export * from './cell'; +export * from './property'; +export * from './row'; export * from './single-view.js'; export * from './view-manager.js'; diff --git a/blocksuite/affine/model/src/blocks/database/database-model.ts b/blocksuite/affine/model/src/blocks/database/database-model.ts index 35d1f860c..33fdeb1a7 100644 --- a/blocksuite/affine/model/src/blocks/database/database-model.ts +++ b/blocksuite/affine/model/src/blocks/database/database-model.ts @@ -5,13 +5,17 @@ import { defineBlockSchema, } from '@blocksuite/store'; -import type { Column, SerializedCells, ViewBasicDataType } from './types.js'; +import type { + ColumnDataType, + SerializedCells, + ViewBasicDataType, +} from './types.js'; export type DatabaseBlockProps = { views: ViewBasicDataType[]; title: Text; cells: SerializedCells; - columns: Array; + columns: Array; }; export class DatabaseBlockModel extends BlockModel {} diff --git a/blocksuite/affine/model/src/blocks/database/types.ts b/blocksuite/affine/model/src/blocks/database/types.ts index b0e5aa4c4..9a9c24964 100644 --- a/blocksuite/affine/model/src/blocks/database/types.ts +++ b/blocksuite/affine/model/src/blocks/database/types.ts @@ -1,4 +1,4 @@ -export interface Column< +export interface ColumnDataType< Data extends Record = Record, > { id: string; @@ -7,13 +7,15 @@ export interface Column< data: Data; } -export type ColumnUpdater = (data: T) => Partial; -export type Cell = { - columnId: Column['id']; +export type ColumnUpdater = ( + data: T +) => Partial; +export type CellDataType = { + columnId: ColumnDataType['id']; value: ValueType; }; -export type SerializedCells = Record>; +export type SerializedCells = Record>; export type ViewBasicDataType = { id: string; name: string; diff --git a/blocksuite/affine/shared/src/services/user-service/user-list-service.ts b/blocksuite/affine/shared/src/services/user-service/user-list-service.ts index ddf898f57..a4b43b527 100644 --- a/blocksuite/affine/shared/src/services/user-service/user-list-service.ts +++ b/blocksuite/affine/shared/src/services/user-service/user-list-service.ts @@ -1,12 +1,14 @@ import { createIdentifier } from '@blocksuite/global/di'; import type { ExtensionType } from '@blocksuite/store'; -import type { Signal } from '@preact/signals-core'; +import type { ReadonlySignal } from '@preact/signals-core'; import type { AffineUserInfo } from './types'; export interface UserListService { - users$: Signal; - hasMore$: Signal; + users$: ReadonlySignal; + isLoading$: ReadonlySignal; + searchText$: ReadonlySignal; + hasMore$: ReadonlySignal; loadMore(): void; search(keyword: string): void; } diff --git a/blocksuite/playground/apps/starter/data/database.ts b/blocksuite/playground/apps/starter/data/database.ts index a4d5a64ba..97a8459a6 100644 --- a/blocksuite/playground/apps/starter/data/database.ts +++ b/blocksuite/playground/apps/starter/data/database.ts @@ -1,6 +1,6 @@ import { - databaseBlockColumns, DatabaseBlockDataSource, + databaseBlockProperties, } from '@blocksuite/affine/blocks/database'; import { type DatabaseBlockModel, @@ -46,13 +46,13 @@ export const database: InitFn = (collection: Workspace, id: string) => { database.props.title = new Text(title); const richTextId = datasource.propertyAdd( 'end', - databaseBlockColumns.richTextColumnConfig.type + databaseBlockProperties.richTextColumnConfig.type ); Object.values([ propertyPresets.multiSelectPropertyConfig, propertyPresets.datePropertyConfig, propertyPresets.numberPropertyConfig, - databaseBlockColumns.linkColumnConfig, + databaseBlockProperties.linkColumnConfig, propertyPresets.checkboxPropertyConfig, propertyPresets.progressPropertyConfig, ]).forEach(column => { diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index c0abe6122..f60860766 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -51,6 +51,7 @@ import { DocPropertiesTable, } from '../../components/doc-properties'; import { patchForAttachmentEmbedViews } from '../extensions/attachment-embed-view'; +import { patchDatabaseBlockConfigService } from '../extensions/database-block-config-service'; import { patchDocModeService } from '../extensions/doc-mode-service'; import { patchDocUrlExtensions } from '../extensions/doc-url'; import { EdgelessClipboardWatcher } from '../extensions/edgeless-clipboard'; @@ -168,6 +169,7 @@ const usePatchSpecs = (mode: DocMode) => { patchUserExtensions(publicUserService), ] : [], + patchDatabaseBlockConfigService(), mode === 'edgeless' && enableTurboRenderer ? patchTurboRendererExtension() : [], diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/file/define.ts b/packages/frontend/core/src/blocksuite/database-block/properties/file/define.ts new file mode 100644 index 000000000..2b3a5ee5a --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/file/define.ts @@ -0,0 +1,45 @@ +import { propertyType, t } from '@blocksuite/affine/blocks/database'; +import zod from 'zod'; + +export const fileColumnType = propertyType('file'); + +export const FileItemSchema = zod.object({ + id: zod.string(), + name: zod.string(), + mime: zod.string().optional(), + order: zod.string(), +}); + +export type FileItemType = zod.TypeOf; +const FileCellRawValueTypeSchema = zod.record(zod.string(), FileItemSchema); +export const FileCellJsonValueTypeSchema = zod.array(zod.string()); +export type FileCellRawValueType = zod.TypeOf< + typeof FileCellRawValueTypeSchema +>; +export type FileCellJsonValueType = zod.TypeOf< + typeof FileCellJsonValueTypeSchema +>; +export const filePropertyModelConfig = fileColumnType.modelConfig({ + name: 'File', + propertyData: { + schema: zod.object({}), + default: () => ({}), + }, + rawValue: { + schema: FileCellRawValueTypeSchema, + default: () => ({}) as FileCellRawValueType, + fromString: () => ({ + value: {}, + }), + toString: ({ value }) => + Object.values(value ?? {}) + ?.map(v => v.name) + .join(',') ?? '', + toJson: ({ value }) => Object.values(value ?? {}).map(v => v.name), + }, + jsonValue: { + schema: FileCellJsonValueTypeSchema, + type: () => t.array.instance(t.string.instance()), + isEmpty: ({ value }) => value.length === 0, + }, +}); diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/file/style.css.ts b/packages/frontend/core/src/blocksuite/database-block/properties/file/style.css.ts new file mode 100644 index 000000000..757e15af4 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/file/style.css.ts @@ -0,0 +1,174 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const filePopoverContainer = style({ + padding: '8px 0 0 0', + width: '415px', +}); + +export const filePopoverContent = style({ + padding: '0', +}); + +export const uploadButton = style({ + width: '100%', + fontSize: '16px', +}); + +export const fileInfoContainer = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '14px', + gap: '8px', + overflow: 'hidden', +}); + +export const fileSizeInfo = style({ + color: cssVarV2('text/secondary'), +}); + +export const upgradeLink = style({ + color: '#1E96F0', + textDecoration: 'none', + fontWeight: 500, + whiteSpace: 'nowrap', +}); + +export const fileListContainer = style({ + display: 'flex', + flexDirection: 'column', +}); + +export const fileItem = style({ + display: 'flex', + justifyContent: 'space-between', + padding: '4px 8px', + gap: '8px', + overflow: 'hidden', +}); + +export const fileItemContent = style({ + display: 'flex', + alignItems: 'center', + gap: '4px', + overflow: 'hidden', +}); + +export const fileName = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '14px', +}); + +export const menuButton = style({ + display: 'flex', + height: '20px', + width: '20px', + flexShrink: 0, + justifyContent: 'center', + alignItems: 'center', + borderRadius: '2px', + cursor: 'pointer', + color: cssVarV2.icon.primary, + ':hover': { + backgroundColor: cssVarV2.layer.background.hoverOverlay, + }, +}); + +export const cellContainer = style({ + width: '100%', + position: 'relative', + gap: '6px', + display: 'flex', + flexWrap: 'wrap', + overflow: 'hidden', +}); + +export const fileItemCell = style({ + display: 'flex', + alignItems: 'center', + overflow: 'hidden', + height: '24px', +}); + +export const fileItemImagePreview = style({ + height: '136px', + borderRadius: '2px', +}); + +export const progressIconContainer = style({ + position: 'relative', + width: '24px', + height: '24px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const progressSvg = style({ + transform: 'rotate(-90deg)', +}); + +export const progressCircle = style({ + transition: 'stroke-dasharray 0.3s ease-in-out', +}); + +export const imagePreviewIcon = style({ + borderRadius: '2px', + height: '100%', +}); + +export const filePreviewContainer = style({ + width: '100%', + height: '100%', + backgroundColor: cssVarV2.database.attachment.fileSolidBackground, + padding: '1px 4px', + fontSize: '14px', + lineHeight: '22px', + borderRadius: '2px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const imagePreviewContainer = style({ + height: '24px', + borderRadius: '2px', + border: 'none', + overflow: 'hidden', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const uploadContainer = style({ + display: 'flex', + alignItems: 'center', + padding: '8px 16px', + borderTop: '1px solid', + borderColor: cssVarV2.layer.insideBorder.border, +}); + +export const uploadButtonStyle = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '4px', + cursor: 'pointer', +}); + +export const uploadPopoverContainer = style({ + padding: '12px', + width: '415px', + display: 'flex', + flexDirection: 'column', + gap: '10px', +}); + +export const fileNameStyle = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/file/view.tsx b/packages/frontend/core/src/blocksuite/database-block/properties/file/view.tsx new file mode 100644 index 000000000..d0e60b561 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/file/view.tsx @@ -0,0 +1,633 @@ +import { Popover, uniReactRoot } from '@affine/component'; +import { Button } from '@affine/component/ui/button'; +import { Menu, MenuItem } from '@affine/component/ui/menu'; +import { + type Cell, + type CellRenderProps, + createIcon, + type DataViewCellLifeCycle, + HostContextKey, +} from '@blocksuite/affine/blocks/database'; +import { openFileOrFiles } from '@blocksuite/affine/shared/utils'; +import type { BlobEngine } from '@blocksuite/affine/sync'; +import { + DeleteIcon, + DownloadIcon, + FileIcon, + MoreHorizontalIcon, + PlusIcon, +} from '@blocksuite/icons/rc'; +import { + computed, + type ReadonlySignal, + type Signal, + signal, +} from '@preact/signals-core'; +import { generateFractionalIndexingKeyBetween } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { fileTypeFromBuffer, type FileTypeResult } from 'file-type'; +import { nanoid } from 'nanoid'; +import type { ForwardRefRenderFunction, MouseEvent, ReactNode } from 'react'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, +} from 'react'; + +import { useSignalValue } from '../../../../modules/doc-info/utils'; +import type { + FileCellJsonValueType, + FileCellRawValueType, + FileItemType, +} from './define'; +import { filePropertyModelConfig } from './define'; +import * as styles from './style.css'; + +interface FileUploadProgress { + name: string; + progress: number; +} + +interface FileLoadData { + blob: Blob; + url: string; + fileType?: FileTypeResult; +} + +class FileUploadManager { + private readonly uploadProgressMap: Map> = + new Map(); + private readonly fileLoadMap: Map> = + new Map(); + + constructor(private readonly blobSync: BlobEngine) {} + + uploadFile(file: File, onComplete: (blobId?: string) => void): string { + const tempId = nanoid(); + + const progress = signal({ + progress: 0, + name: file.name, + }); + this.uploadProgressMap.set(tempId, progress); + this.startUpload(file, tempId) + .then(blobId => { + this.uploadProgressMap.delete(tempId); + onComplete?.(blobId); + }) + .catch(() => { + this.uploadProgressMap.delete(tempId); + onComplete?.(); + }); + return tempId; + } + + async startUpload(file: File, fileId: string): Promise { + let progress = this.uploadProgressMap.get(fileId); + if (!progress) { + return; + } + progress.value = { + ...progress.value, + progress: 10, + }; + + const arrayBuffer = await file.arrayBuffer(); + progress = this.uploadProgressMap.get(fileId); + if (!progress) { + return; + } + progress.value = { + ...progress.value, + progress: 30, + }; + + const blob = new Blob([arrayBuffer], { + type: file.type, + }); + + this.simulateUploadProgress(fileId); + + const uploadedId = await this.blobSync.set(blob); + progress = this.uploadProgressMap.get(fileId); + if (!progress) { + return; + } + progress.value = { + ...progress.value, + progress: 100, + }; + + return uploadedId; + } + + getUploadProgress( + fileId: string + ): ReadonlySignal | undefined { + return this.uploadProgressMap.get(fileId); + } + + async getFileBlob(blobId: string): Promise { + return this.blobSync?.get(blobId); + } + + getFileInfo(blobId: string): ReadonlySignal { + let fileLoadData = this.fileLoadMap.get(blobId); + if (fileLoadData) { + return fileLoadData; + } + const blobPromise = this.getFileBlob(blobId); + fileLoadData = signal(undefined); + this.fileLoadMap.set(blobId, fileLoadData); + blobPromise + .then(async blob => { + if (!blob) { + return; + } + const fileType = await fileTypeFromBuffer(await blob.arrayBuffer()); + fileLoadData.value = { + blob, + url: URL.createObjectURL(blob), + fileType, + }; + }) + .catch(() => {}); + return fileLoadData; + } + + private simulateUploadProgress(fileId: string): void { + setTimeout(() => { + const progress = this.uploadProgressMap.get(fileId); + if (!progress || progress.value.progress >= 100) return; + const next = + (100 - progress.value.progress) / 10 + progress.value.progress; + progress.value = { + ...progress.value, + progress: Math.min(next, 100), + }; + this.simulateUploadProgress(fileId); + }, 10); + } + + dispose(): void { + this.fileLoadMap.forEach(fileLoadData => { + const url = fileLoadData.value?.url; + if (url) { + URL.revokeObjectURL(url); + } + }); + + this.uploadProgressMap.clear(); + this.fileLoadMap.clear(); + } +} + +type FileItemDoneType = FileItemType & { + type: 'done'; +}; +type FileItemUploadingType = { + id: string; + type: 'uploading'; + name: string; + order: string; +}; +type FileItemRenderType = FileItemDoneType | FileItemUploadingType; +const CircularProgress = ({ progress }: { progress: number }) => { + const circumference = 2 * Math.PI * 10; + + return ( + + + + + ); +}; + +class FileCellManager { + private readonly cell: Cell; + readonly selectCurrentCell: (editing: boolean) => void; + private readonly blobSync?: BlobEngine; + private readonly uploadingFiles = signal< + Record + >({}); + readonly isEditing: ReadonlySignal; + readonly fileUploadManager: FileUploadManager | undefined; + doneFiles = computed(() => this.cell.value$.value ?? {}); + + get readonly() { + return this.cell.property.readonly$; + } + + constructor( + props: CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType> + ) { + this.cell = props.cell; + this.selectCurrentCell = props.selectCurrentCell; + this.isEditing = props.isEditing$; + this.blobSync = this.cell?.view?.contextGet + ? this.cell.view.contextGet(HostContextKey)?.doc.blobSync + : undefined; + + this.fileUploadManager = this.blobSync + ? new FileUploadManager(this.blobSync) + : undefined; + } + + dispose(): void { + this.fileUploadManager?.dispose(); + } + + removeFile = (file: FileItemRenderType, e?: MouseEvent): void => { + e?.stopPropagation(); + + if (file.type === 'uploading') { + const newTemp = { ...this.uploadingFiles.value }; + delete newTemp[file.id]; + this.uploadingFiles.value = newTemp; + return; + } + + const value = { ...this.cell.value$.value }; + delete value[file.id]; + this.cell.valueSet(value); + }; + + uploadFile = (file: File): void => { + if (!this.fileUploadManager) { + return; + } + const lastFile = this.fileList.value[this.fileList.value.length - 1]; + const order = generateFractionalIndexingKeyBetween( + lastFile?.order || null, + null + ); + + const fileId = this.fileUploadManager.uploadFile(file, blobId => { + if (blobId) { + this.cell.valueSet({ + ...this.cell.value$.value, + [blobId]: { + name: file.name, + id: blobId, + order, + mime: this.fileUploadManager?.getFileInfo(blobId).value?.fileType + ?.mime, + }, + }); + } + this.removeFile(tempFile); + }); + const tempFile: FileItemUploadingType = { + id: fileId, + type: 'uploading', + name: file.name, + order, + }; + this.uploadingFiles.value = { + ...this.uploadingFiles.value, + [fileId]: tempFile, + }; + }; + + fileList = computed(() => { + const uploadingList = Object.values(this.uploadingFiles.value); + const doneList = Object.values(this.doneFiles.value).map( + file => ({ + ...file, + type: 'done', + }) + ); + return [...doneList, ...uploadingList].sort((a, b) => + a.order > b.order ? 1 : -1 + ); + }); +} + +const FileCellComponent: ForwardRefRenderFunction< + DataViewCellLifeCycle, + CellRenderProps<{}, FileCellRawValueType, FileCellJsonValueType> +> = (props, ref): ReactNode => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const manager = useMemo(() => new FileCellManager(props), []); + + useEffect(() => { + return () => { + manager.dispose(); + }; + }, [manager]); + + useImperativeHandle( + ref, + () => ({ + beforeEnterEditMode: () => { + return true; + }, + beforeExitEditingMode: () => {}, + afterEnterEditingMode: () => {}, + focusCell: () => true, + blurCell: () => true, + forceUpdate: () => {}, + }), + [] + ); + const fileList = useSignalValue(manager.fileList); + const isEditing = useSignalValue(manager.isEditing); + const renderPopoverContent = () => { + if (fileList.length === 0) { + return ( +
+ + +
+
+ The maximum size per file is 100MB +
+ e.stopPropagation()} + > + Upgrade to Pro + +
+
+ ); + } + return ( +
+
+ {fileList.map(file => ( + + ))} +
+
+
{ + openFileOrFiles({ multiple: true }) + .then(files => { + files?.forEach(file => { + manager.uploadFile(file); + }); + }) + .catch(e => { + console.error(e); + }); + }} + className={styles.uploadButtonStyle} + > + + Add a file or image +
+
+
+ ); + }; + + return ( +
+ { + manager.selectCurrentCell(open); + }} + contentOptions={{ + className: styles.filePopoverContent, + }} + content={renderPopoverContent()} + > +
+
+
+ {fileList.map(file => ( +
+ +
+ ))} +
+
+ ); +}; + +const useFilePreview = ( + file: FileItemRenderType, + fileUploadManager?: FileUploadManager +): { + preview: ReactNode; + fileType: 'uploading' | 'loading' | 'image' | 'file'; +} => { + const uploadProgress = useSignalValue( + file.type === 'uploading' + ? fileUploadManager?.getUploadProgress(file.id) + : undefined + ); + const loadFileData = useSignalValue( + file.type === 'done' ? fileUploadManager?.getFileInfo(file.id) : undefined + ); + if (uploadProgress != null) { + return { + preview: ( +
+ +
+ ), + fileType: 'uploading', + }; + } + const mime = + loadFileData?.fileType?.mime ?? + (file.type === 'done' ? file.mime : undefined); + if (mime?.startsWith('image/')) { + if (loadFileData == null) { + return { + preview: null, + fileType: 'loading', + }; + } + return { + preview: ( + {file.name} + ), + fileType: 'image', + }; + } + + return { + preview: , + fileType: 'file', + }; +}; + +export const FileListItem = (props: { + file: FileItemRenderType; + handleRemoveFile: (file: FileItemRenderType, e?: MouseEvent) => void; + fileUploadManager?: FileUploadManager; +}) => { + const { file, handleRemoveFile, fileUploadManager } = props; + + const { preview, fileType } = useFilePreview(file, fileUploadManager); + + const handleDownloadFile = useCallback( + async (fileId: string, e?: MouseEvent) => { + e?.stopPropagation(); + + try { + const blob = await fileUploadManager?.getFileBlob(fileId); + if (!blob) { + console.error('Failed to download: blob not found'); + return; + } + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = file.name; + document.body.append(a); + a.click(); + + setTimeout(() => { + a.remove(); + URL.revokeObjectURL(url); + }, 100); + } catch (error) { + console.error('Download failed', error); + } + }, + [fileUploadManager, file.name] + ); + const menuItems = ( + <> + {/* {fileType === 'image' && ( + { + console.log('Preview image:', file.id); + }} + prefixIcon={} + > + Preview + + )} */} + {(fileType === 'file' || fileType === 'image') && ( + { + void handleDownloadFile(file.id, e).catch(error => { + console.error('Download failed:', error); + }); + }} + prefixIcon={} + > + Download + + )} + { + handleRemoveFile(file, e); + }} + prefixIcon={} + > + Delete + + + ); + + return ( +
+
+ {fileType === 'image' ? ( +
{preview}
+ ) : ( + <> + {preview} +
{file.name}
+ + )} +
+ +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > + +
+
+
+ ); +}; + +const FilePreview = (props: { + file: FileItemRenderType; + fileUploadManager?: FileUploadManager; +}) => { + const { file, fileUploadManager } = props; + const { preview, fileType } = useFilePreview(file, fileUploadManager); + if (fileType === 'file') { + return
{file.name}
; + } + if (fileType === 'image') { + return
{preview}
; + } + return preview; +}; + +const FileCell = forwardRef(FileCellComponent); + +export const filePropertyConfig = filePropertyModelConfig.createPropertyMeta({ + icon: createIcon('FileIcon'), + cellRenderer: { + view: uniReactRoot.createUniComponent(FileCell), + }, +}); diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/index.ts b/packages/frontend/core/src/blocksuite/database-block/properties/index.ts new file mode 100644 index 000000000..d77e6ab42 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/index.ts @@ -0,0 +1,9 @@ +import type { PropertyMetaConfig } from '@blocksuite/affine/blocks/database'; + +import { filePropertyConfig } from './file/view'; +import { memberPropertyConfig } from './member/view'; + +export const propertiesPresets: PropertyMetaConfig[] = [ + filePropertyConfig, + memberPropertyConfig, +]; diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/member/define.ts b/packages/frontend/core/src/blocksuite/database-block/properties/member/define.ts new file mode 100644 index 000000000..3462e54f4 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/member/define.ts @@ -0,0 +1,37 @@ +import { propertyType, t } from '@blocksuite/affine/blocks/database'; +import zod from 'zod'; + +export const memberColumnType = propertyType('member'); + +export const MemberItemSchema = zod.string(); + +export type MemberItemType = zod.TypeOf; +const MemberCellRawValueTypeSchema = zod.array(MemberItemSchema); +export const MemberCellJsonValueTypeSchema = zod.array(zod.string()); +export type MemberCellRawValueType = zod.TypeOf< + typeof MemberCellRawValueTypeSchema +>; +export type MemberCellJsonValueType = zod.TypeOf< + typeof MemberCellJsonValueTypeSchema +>; +export const memberPropertyModelConfig = memberColumnType.modelConfig({ + name: 'Member', + propertyData: { + schema: zod.object({}), + default: () => ({}), + }, + rawValue: { + schema: MemberCellRawValueTypeSchema, + default: () => [] as MemberCellRawValueType, + fromString: () => ({ + value: [], + }), + toString: ({ value }) => value.join(',') ?? '', + toJson: ({ value }) => value, + }, + jsonValue: { + schema: MemberCellJsonValueTypeSchema, + type: () => t.array.instance(t.string.instance()), + isEmpty: ({ value }) => value.length === 0, + }, +}); diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/member/multi-member-select/index.tsx b/packages/frontend/core/src/blocksuite/database-block/properties/member/multi-member-select/index.tsx new file mode 100644 index 000000000..ab794d8bc --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/member/multi-member-select/index.tsx @@ -0,0 +1,341 @@ +import { Avatar, notify } from '@affine/component'; +import { + type ExistedUserInfo, + type UserListService, + type UserService, +} from '@blocksuite/affine/shared/services'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; +import clsx from 'clsx'; +import { + type ChangeEvent, + type MouseEvent, + useEffect, + useMemo, + useRef, +} from 'react'; + +import { useSignalValue } from '../../../../../modules/doc-info/utils'; +import * as styles from './style.css'; + +type BaseOptions = { + userService: UserService; + userListService: UserListService; + onComplete: () => void; +}; + +export type MemberManagerOptions = + | ({ + multiple: true; + value: ReadonlySignal; + onChange: (value: string[]) => void; + } & BaseOptions) + | ({ + multiple: false; + value: ReadonlySignal; + onChange: (value?: string) => void; + } & BaseOptions); + +class MemberManager { + selectedMembers = computed(() => { + if (this.ops.multiple) { + return this.ops.value.value; + } + return this.ops.value.value ? [this.ops.value.value] : []; + }); + + selectedMemberId = signal(null); + + filteredMembers = computed(() => { + return this.ops.userListService.users$.value.filter( + member => + !member.removed && !this.selectedMembers.value.includes(member.id) + ); + }); + + constructor(private readonly ops: MemberManagerOptions) {} + + get userService() { + return this.ops.userService; + } + + get userListService() { + return this.ops.userListService; + } + search = (searchText: string): void => { + this.userListService.search(searchText); + }; + + selectMember = (memberId: string): void => { + if (this.ops.multiple) { + if (this.selectedMembers.value.includes(memberId)) { + notify.error({ + title: 'Member already exists', + message: 'The member has already been selected', + }); + return; + } + this.ops.onChange([...this.selectedMembers.value, memberId]); + this.moveSelectionAfterSelect(memberId); + } else { + this.ops.onChange(memberId); + } + }; + + moveSelectionAfterSelect = (selectedId: string): void => { + const members = this.filteredMembers.value; + const currentIndex = members.findIndex(member => member.id === selectedId); + + if (currentIndex === -1) { + return; + } + + const updatedMembers = this.filteredMembers.value; + const nextMember = updatedMembers[currentIndex + 1]; + if (nextMember) { + this.selectedMemberId.value = nextMember.id; + return; + } + const prevMember = updatedMembers[currentIndex - 1]; + if (prevMember) { + this.selectedMemberId.value = prevMember.id; + return; + } + + this.selectedMemberId.value = null; + }; + + removeMember = (memberId: string, e?: MouseEvent): void => { + e?.stopPropagation(); + if (this.ops.multiple) { + this.ops.onChange(this.ops.value.value.filter(id => id !== memberId)); + } else { + this.ops.onChange(undefined); + } + }; + + complete = (): void => { + this.ops.onComplete(); + }; + + getSelectedIndex = (): number => { + if (!this.selectedMemberId.value) return -1; + + const members = this.filteredMembers.value; + return members.findIndex( + member => member.id === this.selectedMemberId.value + ); + }; + + moveSelectionUp = (): void => { + const members = this.filteredMembers.value; + if (members.length === 0) return; + + const currentIndex = this.getSelectedIndex(); + let newIndex = currentIndex > 0 ? currentIndex - 1 : members.length - 1; + this.selectedMemberId.value = members[newIndex].id; + }; + + moveSelectionDown = (): void => { + const members = this.filteredMembers.value; + if (members.length === 0) return; + + const currentIndex = this.getSelectedIndex(); + let newIndex = currentIndex < members.length - 1 ? currentIndex + 1 : 0; + this.selectedMemberId.value = members[newIndex].id; + }; + + scrollSelectedIntoView = ( + memberListRef: React.RefObject + ): void => { + if (!memberListRef.current) return; + + const selectedElement = memberListRef.current.querySelector( + `[data-selected="true"]` + ); + if (selectedElement) { + selectedElement.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + } + }; + + confirmSelection = (): void => { + if ( + this.selectedMemberId.value && + this.filteredMembers.value.some(v => v.id === this.selectedMemberId.value) + ) { + this.selectMember(this.selectedMemberId.value); + } + }; +} + +export const useMemberInfo = (id: string, memberManager: MemberManager) => { + useEffect(() => { + memberManager.userService?.revalidateUserInfo(id); + }, [id, memberManager.userService]); + return useSignalValue(memberManager.userService?.userInfo$(id)); +}; + +export const MemberListItem = (props: { + member: ExistedUserInfo; + memberManager: MemberManager; + isSelected?: boolean; +}) => { + const { member, memberManager, isSelected } = props; + + const handleClick = () => { + memberManager.selectMember(member.id); + }; + + const handleMouseEnter = () => { + memberManager.selectedMemberId.value = member.id; + }; + + return ( +
+
+ +
+
{member.name}
+
+ ); +}; + +export const MemberPreview = ({ + memberId, + memberManager, + onDelete, +}: { + memberId: string; + memberManager: MemberManager; + onDelete?: () => void; +}) => { + const userInfo = useMemberInfo(memberId, memberManager); + if (!userInfo) { + return null; + } + return ( +
+ +
+ {userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'} +
+ {onDelete && ( +
+ ✕ +
+ )} +
+ ); +}; + +export const MultiMemberSelect: React.FC = props => { + const inputRef = useRef(null); + const memberListRef = useRef(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + const memberManager = useMemo(() => new MemberManager(props), []); + + const isLoading = useSignalValue(memberManager.userListService.isLoading$); + const selectedMembers = useSignalValue(memberManager.selectedMembers); + const filteredMemberList = useSignalValue(memberManager.filteredMembers); + const selectedMemberId = useSignalValue(memberManager.selectedMemberId); + + useEffect(() => { + memberManager.search(''); + const input = inputRef.current; + if (input) { + input.focus(); + const handleKeyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + memberManager.moveSelectionDown(); + memberManager.scrollSelectedIntoView(memberListRef); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + memberManager.moveSelectionUp(); + memberManager.scrollSelectedIntoView(memberListRef); + } else if (e.key === 'Enter') { + e.preventDefault(); + memberManager.confirmSelection(); + } else if ( + e.key === 'Backspace' && + memberManager.userListService.searchText$.value === '' + ) { + const selectedMembers = memberManager.selectedMembers.value; + const lastId = selectedMembers[selectedMembers.length - 1]; + if (lastId) { + memberManager.removeMember(lastId); + } + } else if (e.key === 'Escape') { + memberManager.complete(); + } + }; + + input.addEventListener('keydown', handleKeyDown); + return () => { + input.removeEventListener('keydown', handleKeyDown); + }; + } + return; + }, [memberManager]); + + const handleInputChange = (event: ChangeEvent) => { + memberManager.search(event.target.value); + }; + + return ( +
inputRef.current?.focus()} + > +
+ {selectedMembers.map(memberId => ( + memberManager.removeMember(memberId)} + /> + ))} + +
+
+ {isLoading ? ( +
Loading...
+ ) : filteredMemberList.length === 0 ? ( +
No results
+ ) : ( + filteredMemberList.map(member => ( + + )) + )} +
+
+ ); +}; diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/member/multi-member-select/style.css.ts b/packages/frontend/core/src/blocksuite/database-block/properties/member/multi-member-select/style.css.ts new file mode 100644 index 000000000..0937d1646 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/member/multi-member-select/style.css.ts @@ -0,0 +1,133 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const multiMemberSelectContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', + position: 'relative', + padding: '8px', + width: '214px', +}); + +export const memberInputContainer = style({ + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + padding: '4px', + borderRadius: '4px', + alignItems: 'center', + border: `1px solid transparent`, + borderColor: cssVarV2.layer.insideBorder.blackBorder, + ':focus-within': { + borderColor: cssVarV2.layer.insideBorder.primaryBorder, + boxShadow: cssVar('activeShadow'), + }, +}); + +export const memberSearchInput = style({ + flex: '1', + minWidth: '100px', + border: 'none', + outline: 'none', + backgroundColor: 'transparent', + fontSize: '14px', + lineHeight: '22px', + padding: '0', + color: cssVarV2.text.primary, +}); + +export const memberListContainer = style({ + display: 'flex', + flexDirection: 'column', + maxHeight: '300px', + overflow: 'auto', +}); + +export const memberSelectedItem = style({ + backgroundColor: cssVarV2.layer.background.hoverOverlay, +}); + +export const memberDeleteIcon = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '16px', + height: '16px', + color: cssVarV2.icon.primary, + fontSize: '16px', + cursor: 'pointer', + borderRadius: '2px', + ':hover': { + backgroundColor: cssVarV2.layer.background.tertiary, + }, +}); + +export const memberPreviewContainer = style({ + display: 'flex', + alignItems: 'center', + overflow: 'hidden', + padding: '2px', + borderRadius: '2px', + border: `1px solid transparent`, + borderColor: cssVarV2.layer.insideBorder.blackBorder, + backgroundColor: cssVarV2.button.secondary, +}); + +export const memberPreview = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '14px', + lineHeight: '22px', +}); + +export const memberItem = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + overflow: 'hidden', + padding: '4px', + borderRadius: '4px', +}); + +export const memberItemContent = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + overflow: 'hidden', +}); + +export const memberName = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '12px', + lineHeight: '20px', + padding: '0 4px', + flex: 1, +}); + +export const avatar = style({ + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const loadingContainer = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + padding: '16px', +}); + +export const noResultContainer = style({ + display: 'flex', + alignItems: 'center', + padding: '4px', + color: cssVarV2.text.primary, + fontSize: '14px', + lineHeight: '22px', +}); diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/member/style.css.ts b/packages/frontend/core/src/blocksuite/database-block/properties/member/style.css.ts new file mode 100644 index 000000000..8fa71ba77 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/member/style.css.ts @@ -0,0 +1,93 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const memberPopoverContainer = style({ + padding: '8px 0 0 0', + width: '415px', +}); + +export const memberPopoverContent = style({ + padding: '0', +}); + +export const searchContainer = style({ + padding: '12px 12px 8px 12px', +}); + +export const searchInput = style({ + width: '100%', +}); + +export const memberListContainer = style({ + display: 'flex', + flexDirection: 'column', + maxHeight: '300px', + overflow: 'auto', +}); + +export const memberItem = style({ + display: 'flex', + justifyContent: 'space-between', + padding: '8px 12px', + gap: '8px', + overflow: 'hidden', + cursor: 'pointer', + borderRadius: '4px', + transition: 'background-color 0.2s ease', + ':hover': { + backgroundColor: cssVarV2.layer.background.hoverOverlay, + }, + ':active': { + backgroundColor: cssVarV2.layer.background.secondary, + }, +}); + +export const memberItemContent = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + overflow: 'hidden', +}); + +export const memberName = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '14px', + lineHeight: '22px', +}); + +export const cellContainer = style({ + width: '100%', + position: 'relative', + gap: '6px', + display: 'flex', + flexWrap: 'wrap', + overflow: 'hidden', +}); + +export const avatar = style({ + flexShrink: 0, +}); + +export const loadingContainer = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + padding: '16px', +}); + +export const noResultContainer = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + padding: '16px', + color: cssVarV2.text.secondary, +}); + +export const memberPreviewContainer = style({ + display: 'flex', + alignItems: 'center', + gap: '4px', + overflow: 'hidden', +}); diff --git a/packages/frontend/core/src/blocksuite/database-block/properties/member/view.tsx b/packages/frontend/core/src/blocksuite/database-block/properties/member/view.tsx new file mode 100644 index 000000000..bc8d18ed0 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/database-block/properties/member/view.tsx @@ -0,0 +1,183 @@ +import { Avatar, Popover, uniReactRoot } from '@affine/component'; +import { + type Cell, + type CellRenderProps, + createIcon, + type DataViewCellLifeCycle, + HostContextKey, +} from '@blocksuite/affine/blocks/database'; +import { + UserListProvider, + type UserListService, + UserProvider, + type UserService, +} from '@blocksuite/affine/shared/services'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { + forwardRef, + type ForwardRefRenderFunction, + type ReactNode, + useEffect, + useImperativeHandle, + useMemo, +} from 'react'; + +import { useSignalValue } from '../../../../modules/doc-info/utils'; +import type { + MemberCellJsonValueType, + MemberCellRawValueType, + MemberItemType, +} from './define'; +import { memberPropertyModelConfig } from './define'; +import { MultiMemberSelect } from './multi-member-select'; +import * as styles from './style.css'; + +class MemberManager { + private readonly cell: Cell< + MemberCellRawValueType, + MemberCellJsonValueType, + {} + >; + readonly selectCurrentCell: (editing: boolean) => void; + readonly isEditing: ReadonlySignal; + public readonly userService?: UserService | null; + public readonly userListService?: UserListService | null; + + memberList = computed(() => this.cell.value$.value ?? []); + + get readonly() { + return this.cell.property.readonly$; + } + + constructor( + props: CellRenderProps<{}, MemberCellRawValueType, MemberCellJsonValueType> + ) { + this.cell = props.cell; + this.selectCurrentCell = props.selectCurrentCell; + this.isEditing = props.isEditing$; + const host = this.cell.view.contextGet(HostContextKey); + this.userService = host?.std.getOptional(UserProvider); + this.userListService = host?.std.getOptional(UserListProvider); + } + + setMemberList = (memberList: MemberItemType[]): void => { + this.cell.valueSet(memberList); + }; +} + +const MemberCellComponent: ForwardRefRenderFunction< + DataViewCellLifeCycle, + CellRenderProps<{}, MemberCellRawValueType, MemberCellJsonValueType> +> = (props, ref): ReactNode => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const manager = useMemo(() => new MemberManager(props), []); + + useImperativeHandle( + ref, + () => ({ + beforeEnterEditMode: () => { + return true; + }, + beforeExitEditingMode: () => {}, + afterEnterEditingMode: () => {}, + focusCell: () => true, + blurCell: () => true, + forceUpdate: () => {}, + }), + [] + ); + + const memberList = useSignalValue(manager.memberList); + const isEditing = useSignalValue(manager.isEditing); + + const renderPopoverContent = () => { + if (!manager.userService || !manager.userListService) { + return ( +
+ member list only works in cloud +
+ ); + } + return ( + { + manager.setMemberList(newIds); + }} + userService={manager.userService} + userListService={manager.userListService} + onComplete={() => { + // manager.selectCurrentCell(false); + }} + /> + ); + }; + return ( +
+ { + manager.selectCurrentCell(open); + }} + contentOptions={{ + className: styles.memberPopoverContent, + }} + content={renderPopoverContent()} + > +
+
+
+ {memberList.map(memberId => ( + + ))} +
+
+ ); +}; + +const useMemberInfo = (id: string, memberManager: MemberManager) => { + useEffect(() => { + memberManager.userService?.revalidateUserInfo(id); + }, [id, memberManager.userService]); + return useSignalValue(memberManager.userService?.userInfo$(id)); +}; + +const MemberPreview = ({ + memberId, + memberManager, +}: { + memberId: string; + memberManager: MemberManager; +}) => { + const userInfo = useMemberInfo(memberId, memberManager); + if (!userInfo) { + return null; + } + return ( +
+ +
+ {userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'} +
+
+ ); +}; + +const MemberCell = forwardRef(MemberCellComponent); + +export const memberPropertyConfig = + memberPropertyModelConfig.createPropertyMeta({ + icon: createIcon('MultiPeopleIcon'), + cellRenderer: { + view: uniReactRoot.createUniComponent(MemberCell), + }, + }); diff --git a/packages/frontend/core/src/blocksuite/extensions/database-block-config-service.ts b/packages/frontend/core/src/blocksuite/extensions/database-block-config-service.ts new file mode 100644 index 000000000..7463ea8e1 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/database-block-config-service.ts @@ -0,0 +1,12 @@ +import { DatabaseBlockDataSource } from '@blocksuite/affine/blocks/database'; +import type { ExtensionType } from '@blocksuite/affine/store'; + +import { propertiesPresets } from '../database-block/properties'; + +export function patchDatabaseBlockConfigService(): ExtensionType { + //TODO use service + DatabaseBlockDataSource.externalProperties.value = propertiesPresets; + return { + setup: () => {}, + }; +} diff --git a/packages/frontend/core/src/blocksuite/extensions/user-list.ts b/packages/frontend/core/src/blocksuite/extensions/user-list.ts index 9dc20f137..c200575c4 100644 --- a/packages/frontend/core/src/blocksuite/extensions/user-list.ts +++ b/packages/frontend/core/src/blocksuite/extensions/user-list.ts @@ -8,6 +8,10 @@ export function patchUserListExtensions(memberSearch: MemberSearchService) { loadMore() { memberSearch.loadMore(); }, + // eslint-disable-next-line rxjs/finnish + isLoading$: memberSearch.isLoading$.signal, + // eslint-disable-next-line rxjs/finnish + searchText$: memberSearch.searchText$.signal, search(keyword) { memberSearch.search(keyword); }, diff --git a/packages/frontend/core/src/modules/doc-info/utils.ts b/packages/frontend/core/src/modules/doc-info/utils.ts index 42437634d..1778dc6d4 100644 --- a/packages/frontend/core/src/modules/doc-info/utils.ts +++ b/packages/frontend/core/src/modules/doc-info/utils.ts @@ -2,12 +2,13 @@ import { DebugLogger } from '@affine/debug'; import { BlockStdScope } from '@blocksuite/affine/block-std'; import { PageEditorBlockSpecs } from '@blocksuite/affine/extensions'; import type { Store } from '@blocksuite/affine/store'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Observable } from 'rxjs'; const logger = new DebugLogger('doc-info'); interface ReadonlySignal { + value: T; subscribe: (fn: (value: T) => void) => () => void; } @@ -23,6 +24,20 @@ export function signalToObservable( }; }); } +export function useSignalValue(signal: ReadonlySignal): T; +export function useSignalValue(signal?: ReadonlySignal): T | undefined; +export function useSignalValue(signal?: ReadonlySignal): T | undefined { + const [value, setValue] = useState(signal?.value); + useEffect(() => { + if (signal == null) { + return; + } + return signal.subscribe(value => { + setValue(value); + }); + }, [signal]); + return value; +} // todo(pengx17): use rc pool? export function createBlockStdScope(doc: Store) { diff --git a/packages/frontend/core/src/modules/permissions/services/member-search.ts b/packages/frontend/core/src/modules/permissions/services/member-search.ts index f743667d8..4f4cf8921 100644 --- a/packages/frontend/core/src/modules/permissions/services/member-search.ts +++ b/packages/frontend/core/src/modules/permissions/services/member-search.ts @@ -68,9 +68,6 @@ export class MemberSearchService extends Service { } search(searchText?: string) { - if (this.searchText$.value === searchText) { - return; - } this.reset(); this.searchText$.setValue(searchText ?? ''); this.loadMore();