import type { ColumnDataType, ColumnUpdater, DatabaseBlockModel, } from '@blocksuite/affine-model'; import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands'; import { FeatureFlagService } from '@blocksuite/affine-shared/services'; import { insertPositionToIndex, type InsertToPosition, } from '@blocksuite/affine-shared/utils'; import { type DatabaseFlags, DataSourceBase, type DataViewDataType, type PropertyMetaConfig, type TypeInstance, type ViewManager, ViewManagerBase, type ViewMeta, } from '@blocksuite/data-view'; import { propertyPresets } from '@blocksuite/data-view/property-presets'; import { IS_MOBILE } from '@blocksuite/global/env'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import type { EditorHost } from '@blocksuite/std'; import { type BlockModel } from '@blocksuite/store'; import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; import { getIcon } from './block-icons.js'; import { databaseBlockProperties, databasePropertyConverts, } from './properties/index.js'; import { addProperty, copyCellsByProperty, deleteRows, deleteView, duplicateView, getCell, getProperty, moveViewTo, updateCell, updateCells, updateProperty, updateView, } from './utils/block-utils.js'; import { databaseBlockViewConverts, databaseBlockViewMap, databaseBlockViews, } 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; override featureFlags$: ReadonlySignal = computed(() => { const featureFlagService = this.doc.get(FeatureFlagService); const flag = featureFlagService.getFlag( 'enable_database_number_formatting' ); return { enable_number_formatting: flag ?? false, }; }); properties$: ReadonlySignal = computed(() => { const fixedPropertiesSet = new Set(this.fixedProperties$.value); const properties: string[] = []; this._model.props.columns$.value.forEach(column => { if (fixedPropertiesSet.has(column.type)) { fixedPropertiesSet.delete(column.type); } properties.push(column.id); }); const result = [...fixedPropertiesSet, ...properties]; return result; }); readonly$: ReadonlySignal = computed(() => { return ( this._model.doc.readonly || // TODO(@L-Sun): use block level readonly IS_MOBILE ); }); rows$: ReadonlySignal = computed(() => { return this._model.children.map(v => v.id); }); viewConverts = databaseBlockViewConverts; viewDataList$: ReadonlySignal = computed(() => { return this._model.props.views$.value as DataViewDataType[]; }); override viewManager: ViewManager = new ViewManagerBase(this); viewMetas = databaseBlockViews; get doc() { return this._model.doc; } allPropertyMetas$ = computed[]>(() => { return DatabaseBlockDataSource.propertiesList.value; }); propertyMetas$ = computed(() => { return this.allPropertyMetas$.value.filter( v => !v.config.fixed && !v.config.hide ); }); constructor(model: DatabaseBlockModel) { super(); this._model = model; } private _runCapture() { if (this._batch) { return; } this._batch = requestAnimationFrame(() => { this.doc.captureSync(); this._batch = 0; }); } private getModelById(rowId: string): BlockModel | undefined { return this._model.children[this._model.childMap.value.get(rowId) ?? -1]; } private newPropertyName() { let i = 1; while ( this._model.props.columns$.value.some( column => column.name === `Column ${i}` ) ) { i++; } return `Column ${i}`; } cellValueChange(rowId: string, propertyId: string, value: unknown): void { 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 = update ?? (({ setValue, newValue }) => { setValue(newValue); }); updateFn({ value: old, data: this.propertyDataGet(propertyId), dataSource: this, newValue: value, setValue: newValue => { if (this._model.props.columns$.value.some(v => v.id === propertyId)) { updateCell(this._model, rowId, { columnId: propertyId, value: newValue, }); } }, }); } cellValueGet(rowId: string, propertyId: string): unknown { if (propertyId === 'type') { const model = this.getModelById(rowId); if (!model) { return; } return getIcon(model); } const type = this.propertyTypeGet(propertyId); if (!type) { return; } if (type === 'title') { const model = this.getModelById(rowId); return model?.text; } const meta = this.propertyMetaGet(type); if (!meta) { return; } const rawValue = getCell(this._model, rowId, propertyId)?.value ?? meta.config.rawValue.default(); const schema = meta.config.rawValue.schema; const result = schema.safeParse(rawValue); if (result.success) { return result.data; } return; } propertyAdd( insertToPosition: InsertToPosition, type?: string ): string | undefined { this.doc.captureSync(); const property = this.propertyMetaGet( type ?? propertyPresets.multiSelectPropertyConfig.type ); if (!property) { return; } const result = addProperty( this._model, insertToPosition, property.create(this.newPropertyName()) ); return result; } protected override getNormalPropertyAndIndex(propertyId: string): | { column: ColumnDataType>; index: number; } | undefined { const index = this._model.props.columns$.value.findIndex( v => v.id === propertyId ); if (index >= 0) { const column = this._model.props.columns$.value[index]; if (!column) { return; } return { column, index, }; } return; } private getPropertyAndIndex(propertyId: string): | { column: ColumnDataType>; index: number; } | undefined { const result = this.getNormalPropertyAndIndex(propertyId); if (result) { return result; } if (this.isFixedProperty(propertyId)) { const meta = this.propertyMetaGet(propertyId); if (!meta) { return; } const defaultData = meta.config.fixed?.defaultData ?? {}; return { column: { data: defaultData, id: propertyId, type: propertyId, name: meta.config.name, }, index: -1, }; } return undefined; } private updateProperty(id: string, updater: ColumnUpdater) { const result = this.getPropertyAndIndex(id); if (!result) { return; } const { column: prevColumn, index } = result; this._model.doc.transact(() => { if (index >= 0) { const result = updater(prevColumn); this._model.props.columns[index] = { ...prevColumn, ...result }; } else { const result = updater(prevColumn); this._model.props.columns = [ ...this._model.props.columns, { ...prevColumn, ...result }, ]; } }); return id; } propertyDataGet(propertyId: string): Record { const result = this.getPropertyAndIndex(propertyId); if (!result) { return {}; } return result.column.data; } propertyDataSet(propertyId: string, data: Record): void { this._runCapture(); this.updateProperty(propertyId, () => ({ data })); } propertyDataTypeGet(propertyId: string): TypeInstance | undefined { const result = this.getPropertyAndIndex(propertyId); if (!result) { return; } const { column } = result; const meta = this.propertyMetaGet(column.type); if (!meta) { return; } return meta.config?.jsonValue.type({ data: column.data, dataSource: this, }); } propertyDelete(id: string): void { if (this.isFixedProperty(id)) { return; } this.doc.captureSync(); const index = this._model.props.columns.findIndex(v => v.id === id); if (index < 0) return; this.doc.transact(() => { this._model.props.columns = this._model.props.columns.filter( (_, i) => i !== index ); }); } propertyDuplicate(propertyId: string): string | undefined { if (this.isFixedProperty(propertyId)) { return; } this.doc.captureSync(); const currentSchema = getProperty(this._model, propertyId); if (!currentSchema) { return; } const { id: copyId, ...nonIdProps } = currentSchema; const names = new Set(this._model.props.columns$.value.map(v => v.name)); let index = 1; while (names.has(`${nonIdProps.name}(${index})`)) { index++; } const schema = { ...nonIdProps, name: `${nonIdProps.name}(${index})` }; const id = addProperty( this._model, { before: false, id: propertyId, }, schema ); copyCellsByProperty(this._model, copyId, id); return id; } propertyMetaGet(type: string): PropertyMetaConfig | undefined { return DatabaseBlockDataSource.propertiesMap.value[type]; } propertyNameGet(propertyId: string): string { if (propertyId === 'type') { return 'Block Type'; } const result = this.getPropertyAndIndex(propertyId); if (!result) { return ''; } return result.column.name; } propertyNameSet(propertyId: string, name: string): void { this.doc.captureSync(); this.updateProperty(propertyId, () => ({ name })); } override propertyReadonlyGet(propertyId: string): boolean { if (propertyId === 'type') return true; return false; } propertyTypeGet(propertyId: string): string | undefined { if (propertyId === 'type') { return 'image'; } const result = this.getPropertyAndIndex(propertyId); if (!result) { return; } return result.column.type; } propertyTypeSet(propertyId: string, toType: string): void { if (this.isFixedProperty(propertyId)) { return; } const meta = this.propertyMetaGet(toType); if (!meta) { return; } const currentType = this.propertyTypeGet(propertyId); const currentData = this.propertyDataGet(propertyId); const rows = this.rows$.value; const currentCells = rows.map(rowId => this.cellValueGet(rowId, propertyId) ); const convertFunction = databasePropertyConverts.find( v => v.from === currentType && v.to === toType )?.convert; const result = convertFunction?.( currentData as any, currentCells as any ) ?? { property: meta.config.propertyData.default(), cells: currentCells.map(() => undefined), }; this.doc.captureSync(); updateProperty(this._model, propertyId, () => ({ type: toType, data: result.property, })); const cells: Record = {}; currentCells.forEach((value, i) => { if (value != null || result.cells[i] != null) { const rowId = rows[i]; if (rowId) { cells[rowId] = result.cells[i]; } } }); updateCells(this._model, propertyId, cells); } rowAdd(insertPosition: InsertToPosition | number): string { this.doc.captureSync(); const index = typeof insertPosition === 'number' ? insertPosition : insertPositionToIndex(insertPosition, this._model.children); return this.doc.addBlock('affine:paragraph', {}, this._model.id, index); } rowDelete(ids: string[]): void { this.doc.captureSync(); for (const id of ids) { const block = this.doc.getBlock(id); if (block) { this.doc.deleteBlock(block.model); } } deleteRows(this._model, ids); } rowMove(rowId: string, position: InsertToPosition): void { const model = this.doc.getModelById(rowId); if (model) { const index = insertPositionToIndex(position, this._model.children); const target = this._model.children[index]; if (target?.id === rowId) { return; } this.doc.moveBlocks([model], this._model, target); } } viewDataAdd(viewData: DataViewDataType): string { this._model.doc.captureSync(); this._model.doc.transact(() => { this._model.props.views = [...this._model.props.views, viewData]; }); return viewData.id; } viewDataDelete(viewId: string): void { this._model.doc.captureSync(); deleteView(this._model, viewId); } viewDataDuplicate(id: string): string { return duplicateView(this._model, id); } viewDataGet(viewId: string): DataViewDataType | undefined { return this.viewDataList$.value.find(data => data.id === viewId)!; } viewDataMoveTo(id: string, position: InsertToPosition): void { moveViewTo(this._model, id, position); } viewDataUpdate( id: string, updater: (data: ViewData) => Partial ): void { updateView(this._model, id, updater); } viewMetaGet(type: string): ViewMeta { const view = databaseBlockViewMap[type]; if (!view) { throw new BlockSuiteError( ErrorCode.DatabaseBlockError, `Unknown view type: ${type}` ); } return view; } viewMetaGetById(viewId: string): ViewMeta | undefined { const view = this.viewDataGet(viewId); if (!view) { return; } return this.viewMetaGet(view.mode); } } export const databaseViewInitTemplate = ( datasource: DatabaseBlockDataSource, viewType: string ) => { Array.from({ length: 3 }).forEach(() => { datasource.rowAdd('end'); }); datasource.viewManager.viewAdd(viewType); }; export const convertToDatabase = (host: EditorHost, viewType: string) => { const [_, ctx] = host.std.command.exec(getSelectedModelsCommand, { types: ['block', 'text'], }); const { selectedModels } = ctx; const firstModel = selectedModels?.[0]; if (!firstModel) return; host.doc.captureSync(); const parentModel = host.doc.getParent(firstModel); if (!parentModel) { return; } const id = host.doc.addBlock( 'affine:database', {}, parentModel, parentModel.children.indexOf(firstModel) ); const databaseModel = host.doc.getBlock(id)?.model as | DatabaseBlockModel | undefined; if (!databaseModel) { return; } const datasource = new DatabaseBlockDataSource(databaseModel); datasource.viewManager.viewAdd(viewType); host.doc.moveBlocks(selectedModels, databaseModel); const selectionManager = host.selection; selectionManager.clear(); };