Files
AFFiNE-Mirror/blocksuite/affine/blocks/database/src/data-source.ts
Saul-Mirone 1f45cc5dec refactor(editor): unify directories naming (#11516)
**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
2025-04-07 12:34:40 +00:00

593 lines
15 KiB
TypeScript

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<PropertyMetaConfig[]>([]);
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<DatabaseFlags> = computed(() => {
const featureFlagService = this.doc.get(FeatureFlagService);
const flag = featureFlagService.getFlag(
'enable_database_number_formatting'
);
return {
enable_number_formatting: flag ?? false,
};
});
properties$: ReadonlySignal<string[]> = 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<boolean> = computed(() => {
return (
this._model.doc.readonly ||
// TODO(@L-Sun): use block level readonly
IS_MOBILE
);
});
rows$: ReadonlySignal<string[]> = computed(() => {
return this._model.children.map(v => v.id);
});
viewConverts = databaseBlockViewConverts;
viewDataList$: ReadonlySignal<DataViewDataType[]> = 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<PropertyMetaConfig<any, any, any, any>[]>(() => {
return DatabaseBlockDataSource.propertiesList.value;
});
propertyMetas$ = computed<PropertyMetaConfig[]>(() => {
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<string, unknown> {
const result = this.getPropertyAndIndex(propertyId);
if (!result) {
return {};
}
return result.column.data;
}
propertyDataSet(propertyId: string, data: Record<string, unknown>): 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<string, unknown> = {};
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<ViewData extends DataViewDataType>(
id: string,
updater: (data: ViewData) => Partial<ViewData>
): 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();
};