mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 23:56:36 +08:00
feat(editor): support file column and member column for database block (#10932)
close: BS-2630, BS-2631, BS-2629, BS-2632, BS-2635
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Column>;
|
||||
const columns = o.node.props.columns as Array<ColumnDataType>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
|
||||
@@ -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<Column>;
|
||||
const columns = o.node.props.columns as Array<ColumnDataType>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
|
||||
@@ -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<Column>;
|
||||
const columns = o.node.props.columns as Array<ColumnDataType>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './host-context';
|
||||
@@ -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<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;
|
||||
@@ -108,7 +120,7 @@ export class DatabaseBlockDataSource extends DataSourceBase {
|
||||
}
|
||||
|
||||
allPropertyMetas$ = computed<PropertyMetaConfig<any, any, any, any>[]>(() => {
|
||||
return databaseBlockPropertyList;
|
||||
return DatabaseBlockDataSource.propertiesList.value;
|
||||
});
|
||||
|
||||
propertyMetas$ = computed<PropertyMetaConfig[]>(() => {
|
||||
@@ -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<Record<string, unknown>>;
|
||||
column: ColumnDataType<Record<string, unknown>>;
|
||||
index: number;
|
||||
}
|
||||
| undefined {
|
||||
@@ -247,7 +265,7 @@ export class DatabaseBlockDataSource extends DataSourceBase {
|
||||
|
||||
private getPropertyAndIndex(propertyId: string):
|
||||
| {
|
||||
column: Column<Record<string, unknown>>;
|
||||
column: ColumnDataType<Record<string, unknown>>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
|
||||
@@ -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<DatabaseBlockConfigService>(
|
||||
'AffineDatabaseBlockConfigService'
|
||||
);
|
||||
@@ -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, 'id'> & {
|
||||
column: Omit<ColumnDataType, 'id'> & {
|
||||
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;
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
column: ColumnDataType<Record<string, unknown>>;
|
||||
index: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -39,7 +39,7 @@ export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
|
||||
data: Data;
|
||||
}>
|
||||
) => JsonValue;
|
||||
fromJson: (
|
||||
fromJson?: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value: JsonValue;
|
||||
data: Data;
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './cell';
|
||||
export * from './property';
|
||||
export * from './row';
|
||||
export * from './single-view.js';
|
||||
export * from './view-manager.js';
|
||||
|
||||
@@ -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<Column>;
|
||||
columns: Array<ColumnDataType>;
|
||||
};
|
||||
|
||||
export class DatabaseBlockModel extends BlockModel<DatabaseBlockProps> {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface Column<
|
||||
export interface ColumnDataType<
|
||||
Data extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
id: string;
|
||||
@@ -7,13 +7,15 @@ export interface Column<
|
||||
data: Data;
|
||||
}
|
||||
|
||||
export type ColumnUpdater<T extends Column = Column> = (data: T) => Partial<T>;
|
||||
export type Cell<ValueType = unknown> = {
|
||||
columnId: Column['id'];
|
||||
export type ColumnUpdater<T extends ColumnDataType = ColumnDataType> = (
|
||||
data: T
|
||||
) => Partial<T>;
|
||||
export type CellDataType<ValueType = unknown> = {
|
||||
columnId: ColumnDataType['id'];
|
||||
value: ValueType;
|
||||
};
|
||||
|
||||
export type SerializedCells = Record<string, Record<string, Cell>>;
|
||||
export type SerializedCells = Record<string, Record<string, CellDataType>>;
|
||||
export type ViewBasicDataType = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -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<AffineUserInfo[]>;
|
||||
hasMore$: Signal<boolean>;
|
||||
users$: ReadonlySignal<AffineUserInfo[]>;
|
||||
isLoading$: ReadonlySignal<boolean>;
|
||||
searchText$: ReadonlySignal<string>;
|
||||
hasMore$: ReadonlySignal<boolean>;
|
||||
loadMore(): void;
|
||||
search(keyword: string): void;
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
: [],
|
||||
|
||||
@@ -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<typeof FileItemSchema>;
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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<string, Signal<FileUploadProgress>> =
|
||||
new Map();
|
||||
private readonly fileLoadMap: Map<string, Signal<FileLoadData | undefined>> =
|
||||
new Map();
|
||||
|
||||
constructor(private readonly blobSync: BlobEngine) {}
|
||||
|
||||
uploadFile(file: File, onComplete: (blobId?: string) => void): string {
|
||||
const tempId = nanoid();
|
||||
|
||||
const progress = signal<FileUploadProgress>({
|
||||
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<string | undefined> {
|
||||
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<FileUploadProgress> | undefined {
|
||||
return this.uploadProgressMap.get(fileId);
|
||||
}
|
||||
|
||||
async getFileBlob(blobId: string): Promise<Blob | null> {
|
||||
return this.blobSync?.get(blobId);
|
||||
}
|
||||
|
||||
getFileInfo(blobId: string): ReadonlySignal<FileLoadData | undefined> {
|
||||
let fileLoadData = this.fileLoadMap.get(blobId);
|
||||
if (fileLoadData) {
|
||||
return fileLoadData;
|
||||
}
|
||||
const blobPromise = this.getFileBlob(blobId);
|
||||
fileLoadData = signal<FileLoadData | undefined>(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 (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
className={styles.progressSvg}
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke={cssVarV2.loading.background}
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke={cssVarV2.loading.foreground}
|
||||
strokeWidth="4"
|
||||
strokeDasharray={`${(progress / 100) * circumference} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
className={styles.progressCircle}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
class FileCellManager {
|
||||
private readonly cell: Cell<FileCellRawValueType, FileCellJsonValueType, {}>;
|
||||
readonly selectCurrentCell: (editing: boolean) => void;
|
||||
private readonly blobSync?: BlobEngine;
|
||||
private readonly uploadingFiles = signal<
|
||||
Record<string, FileItemUploadingType>
|
||||
>({});
|
||||
readonly isEditing: ReadonlySignal<boolean>;
|
||||
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<FileItemDoneType>(
|
||||
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 (
|
||||
<div className={styles.uploadPopoverContainer}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
openFileOrFiles({ multiple: true })
|
||||
.then(files => {
|
||||
files?.forEach(file => {
|
||||
manager.uploadFile(file);
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
}}
|
||||
variant="primary"
|
||||
className={styles.uploadButton}
|
||||
>
|
||||
Choose a file
|
||||
</Button>
|
||||
|
||||
<div className={styles.fileInfoContainer}>
|
||||
<div className={styles.fileSizeInfo}>
|
||||
The maximum size per file is 100MB
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
className={styles.upgradeLink}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.filePopoverContainer}>
|
||||
<div className={styles.fileListContainer}>
|
||||
{fileList.map(file => (
|
||||
<FileListItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
handleRemoveFile={manager.removeFile}
|
||||
fileUploadManager={manager.fileUploadManager}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.uploadContainer}>
|
||||
<div
|
||||
onClick={() => {
|
||||
openFileOrFiles({ multiple: true })
|
||||
.then(files => {
|
||||
files?.forEach(file => {
|
||||
manager.uploadFile(file);
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
}}
|
||||
className={styles.uploadButtonStyle}
|
||||
>
|
||||
<PlusIcon width={20} height={20} />
|
||||
<span>Add a file or image</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<Popover
|
||||
open={isEditing}
|
||||
onOpenChange={open => {
|
||||
manager.selectCurrentCell(open);
|
||||
}}
|
||||
contentOptions={{
|
||||
className: styles.filePopoverContent,
|
||||
}}
|
||||
content={renderPopoverContent()}
|
||||
>
|
||||
<div></div>
|
||||
</Popover>
|
||||
<div className={styles.cellContainer}>
|
||||
{fileList.map(file => (
|
||||
<div key={file.id} className={styles.fileItemCell}>
|
||||
<FilePreview
|
||||
file={file}
|
||||
fileUploadManager={manager.fileUploadManager}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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: (
|
||||
<div className={styles.progressIconContainer}>
|
||||
<CircularProgress progress={uploadProgress.progress} />
|
||||
</div>
|
||||
),
|
||||
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: (
|
||||
<img
|
||||
className={styles.imagePreviewIcon}
|
||||
src={loadFileData.url}
|
||||
alt={file.name}
|
||||
/>
|
||||
),
|
||||
fileType: 'image',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
preview: <FileIcon width={18} height={18} />,
|
||||
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' && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
console.log('Preview image:', file.id);
|
||||
}}
|
||||
prefixIcon={<FileIcon width={20} height={20} />}
|
||||
>
|
||||
Preview
|
||||
</MenuItem>
|
||||
)} */}
|
||||
{(fileType === 'file' || fileType === 'image') && (
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
void handleDownloadFile(file.id, e).catch(error => {
|
||||
console.error('Download failed:', error);
|
||||
});
|
||||
}}
|
||||
prefixIcon={<DownloadIcon width={20} height={20} />}
|
||||
>
|
||||
Download
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
handleRemoveFile(file, e);
|
||||
}}
|
||||
prefixIcon={<DeleteIcon width={20} height={20} />}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.fileItem}>
|
||||
<div className={styles.fileItemContent}>
|
||||
{fileType === 'image' ? (
|
||||
<div className={styles.fileItemImagePreview}>{preview}</div>
|
||||
) : (
|
||||
<>
|
||||
{preview}
|
||||
<div className={styles.fileNameStyle}>{file.name}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Menu items={menuItems} rootOptions={{ modal: false }}>
|
||||
<div
|
||||
className={styles.menuButton}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<MoreHorizontalIcon width={16} height={16} />
|
||||
</div>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FilePreview = (props: {
|
||||
file: FileItemRenderType;
|
||||
fileUploadManager?: FileUploadManager;
|
||||
}) => {
|
||||
const { file, fileUploadManager } = props;
|
||||
const { preview, fileType } = useFilePreview(file, fileUploadManager);
|
||||
if (fileType === 'file') {
|
||||
return <div className={styles.filePreviewContainer}>{file.name}</div>;
|
||||
}
|
||||
if (fileType === 'image') {
|
||||
return <div className={styles.imagePreviewContainer}>{preview}</div>;
|
||||
}
|
||||
return preview;
|
||||
};
|
||||
|
||||
const FileCell = forwardRef(FileCellComponent);
|
||||
|
||||
export const filePropertyConfig = filePropertyModelConfig.createPropertyMeta({
|
||||
icon: createIcon('FileIcon'),
|
||||
cellRenderer: {
|
||||
view: uniReactRoot.createUniComponent(FileCell),
|
||||
},
|
||||
});
|
||||
@@ -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<string, any, any, any>[] = [
|
||||
filePropertyConfig,
|
||||
memberPropertyConfig,
|
||||
];
|
||||
@@ -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<typeof MemberItemSchema>;
|
||||
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,
|
||||
},
|
||||
});
|
||||
+341
@@ -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<string[]>;
|
||||
onChange: (value: string[]) => void;
|
||||
} & BaseOptions)
|
||||
| ({
|
||||
multiple: false;
|
||||
value: ReadonlySignal<string>;
|
||||
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<string | null>(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<HTMLDivElement | null>
|
||||
): 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 (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.memberItem,
|
||||
isSelected && styles.memberSelectedItem
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
data-selected={isSelected ? 'true' : 'false'}
|
||||
>
|
||||
<div className={styles.avatar}>
|
||||
<Avatar url={member.avatar} size={24} />
|
||||
</div>
|
||||
<div className={styles.memberName}>{member.name}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MemberPreview = ({
|
||||
memberId,
|
||||
memberManager,
|
||||
onDelete,
|
||||
}: {
|
||||
memberId: string;
|
||||
memberManager: MemberManager;
|
||||
onDelete?: () => void;
|
||||
}) => {
|
||||
const userInfo = useMemberInfo(memberId, memberManager);
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.memberPreviewContainer}>
|
||||
<Avatar
|
||||
className={styles.avatar}
|
||||
url={!userInfo.removed ? userInfo.avatar : undefined}
|
||||
size={16}
|
||||
/>
|
||||
<div className={styles.memberName}>
|
||||
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
|
||||
</div>
|
||||
{onDelete && (
|
||||
<div className={styles.memberDeleteIcon} onClick={onDelete}>
|
||||
✕
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiMemberSelect: React.FC<MemberManagerOptions> = props => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const memberListRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
memberManager.search(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.multiMemberSelectContainer}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<div className={styles.memberInputContainer}>
|
||||
{selectedMembers.map(memberId => (
|
||||
<MemberPreview
|
||||
key={memberId}
|
||||
memberId={memberId}
|
||||
memberManager={memberManager}
|
||||
onDelete={() => memberManager.removeMember(memberId)}
|
||||
/>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.memberSearchInput}
|
||||
placeholder="Search members..."
|
||||
value={memberManager.userListService.searchText$.value}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.memberListContainer} ref={memberListRef}>
|
||||
{isLoading ? (
|
||||
<div className={styles.loadingContainer}>Loading...</div>
|
||||
) : filteredMemberList.length === 0 ? (
|
||||
<div className={styles.noResultContainer}>No results</div>
|
||||
) : (
|
||||
filteredMemberList.map(member => (
|
||||
<MemberListItem
|
||||
key={member.id}
|
||||
member={member as ExistedUserInfo}
|
||||
memberManager={memberManager}
|
||||
isSelected={member.id === selectedMemberId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+133
@@ -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',
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
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 (
|
||||
<div className={styles.memberPopoverContainer}>
|
||||
member list only works in cloud
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MultiMemberSelect
|
||||
multiple
|
||||
value={manager.memberList}
|
||||
onChange={newIds => {
|
||||
manager.setMemberList(newIds);
|
||||
}}
|
||||
userService={manager.userService}
|
||||
userListService={manager.userListService}
|
||||
onComplete={() => {
|
||||
// manager.selectCurrentCell(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<Popover
|
||||
open={isEditing}
|
||||
onOpenChange={open => {
|
||||
manager.selectCurrentCell(open);
|
||||
}}
|
||||
contentOptions={{
|
||||
className: styles.memberPopoverContent,
|
||||
}}
|
||||
content={renderPopoverContent()}
|
||||
>
|
||||
<div></div>
|
||||
</Popover>
|
||||
<div className={styles.cellContainer}>
|
||||
{memberList.map(memberId => (
|
||||
<MemberPreview
|
||||
key={memberId}
|
||||
memberId={memberId}
|
||||
memberManager={manager}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={styles.memberPreviewContainer}>
|
||||
<Avatar
|
||||
className={styles.avatar}
|
||||
url={!userInfo.removed ? userInfo.avatar : undefined}
|
||||
size={24}
|
||||
/>
|
||||
<div className={styles.memberName}>
|
||||
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberCell = forwardRef(MemberCellComponent);
|
||||
|
||||
export const memberPropertyConfig =
|
||||
memberPropertyModelConfig.createPropertyMeta({
|
||||
icon: createIcon('MultiPeopleIcon'),
|
||||
cellRenderer: {
|
||||
view: uniReactRoot.createUniComponent(MemberCell),
|
||||
},
|
||||
});
|
||||
@@ -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: () => {},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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<T> {
|
||||
value: T;
|
||||
subscribe: (fn: (value: T) => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -23,6 +24,20 @@ export function signalToObservable<T>(
|
||||
};
|
||||
});
|
||||
}
|
||||
export function useSignalValue<T>(signal: ReadonlySignal<T>): T;
|
||||
export function useSignalValue<T>(signal?: ReadonlySignal<T>): T | undefined;
|
||||
export function useSignalValue<T>(signal?: ReadonlySignal<T>): T | undefined {
|
||||
const [value, setValue] = useState<T | undefined>(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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user