feat(sheet): support data binding in sheet (#4461)

This commit is contained in:
VicKun
2025-01-15 14:45:24 +08:00
committed by GitHub
parent cc4ad5f7f8
commit 08ed7a266e
20 changed files with 1292 additions and 7 deletions
+1
View File
@@ -52,6 +52,7 @@
"@univerjs/sheets-numfmt-ui": "workspace:*",
"@univerjs/sheets-sort": "workspace:*",
"@univerjs/sheets-sort-ui": "workspace:*",
"@univerjs/sheets-source-binding": "workspace:*",
"@univerjs/sheets-thread-comment": "workspace:*",
"@univerjs/sheets-thread-comment-ui": "workspace:*",
"@univerjs/sheets-ui": "workspace:*",
+3
View File
@@ -35,6 +35,7 @@ import { UniverSheetsHyperLinkPlugin } from '@univerjs/sheets-hyper-link';
import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt';
import { UniverSheetsNumfmtUIPlugin } from '@univerjs/sheets-numfmt-ui';
import { UniverSheetsSortPlugin } from '@univerjs/sheets-sort';
import { UniverSheetsBindingSourcePlugin } from '@univerjs/sheets-source-binding';
import { UniverSheetsThreadCommentPlugin } from '@univerjs/sheets-thread-comment';
import { UniverSheetsThreadCommentUIPlugin } from '@univerjs/sheets-thread-comment-ui';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
@@ -59,6 +60,7 @@ import '@univerjs/sheets-conditional-formatting/facade';
import '@univerjs/sheets-find-replace/facade';
import '@univerjs/sheets-drawing-ui/facade';
import '@univerjs/sheets-zen-editor/facade';
import '@univerjs/sheets-source-binding/facade';
import '../global.css';
import './styles';
@@ -118,6 +120,7 @@ univer.registerPlugin(UniverSheetsHyperLinkPlugin);
univer.registerPlugin(UniverThreadCommentUIPlugin);
univer.registerPlugin(UniverSheetsThreadCommentPlugin);
univer.registerPlugin(UniverSheetsThreadCommentUIPlugin);
univer.registerPlugin(UniverSheetsBindingSourcePlugin);
// If we are running in e2e platform, we should immediately register the debugger plugin.
if (IS_E2E) {
@@ -0,0 +1,16 @@
# @univerjs/sheets-source-binding
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-source-binding)](https://npmjs.org/packages/@univerjs/sheets-source-binding)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-source-binding)](https://img.shields.io/npm/l/@univerjs/sheets-source-binding)
## Introduction
> TODO: Introduction
## Usage
### Installation
```shell
npm i @univerjs/sheets-source-binding
```
@@ -0,0 +1,78 @@
{
"name": "@univerjs/sheets-source-binding",
"version": "0.5.3",
"private": false,
"description": "A library for connect and bind data from other sources to Univer Sheets",
"author": "DreamNum <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./facade": "./src/facade/index.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"lint:types": "tsc --noEmit",
"build": "univer-cli build"
},
"peerDependencies": {
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/sheets": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"rxjs": "^7.8.1",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,139 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICellBindingNode, ICellBindingNodeParam } from '../types';
import { Disposable, generateRandomId } from '@univerjs/core';
import { Subject } from 'rxjs';
import { SheetBindingModel } from '../model/binding-model';
import { BindingSourceChangeTypeEnum } from '../types';
interface IBindingNodeInfo {
unitId: string;
subunitId: string;
sourceId: string;
nodeId: string;
row: number;
column: number;
}
export class SheetsBindingManager extends Disposable {
modelMap: Map<string, Map<string, SheetBindingModel>> = new Map();
private _cellBindInfoUpdate$ = new Subject<IBindingNodeInfo & { changeType: BindingSourceChangeTypeEnum;oldSourceId?: string }>();
cellBindInfoUpdate$ = this._cellBindInfoUpdate$.asObservable();
constructor(
) {
super();
}
getBindingModelBySourceId(sourceId: string) {
const rs: IBindingNodeInfo[] = [];
this.modelMap.forEach((subMap, unitId) => {
subMap.forEach((model, subunitId) => {
const nodes = model.getBindingNodesBySourceId(sourceId);
if (nodes) {
for (const node of nodes) {
rs.push({
unitId,
subunitId,
sourceId,
nodeId: node.nodeId!,
row: node.row,
column: node.column,
});
}
}
});
});
return rs;
}
addModel(unitId: string, subunitId: string, model: SheetBindingModel): void {
if (!this.modelMap.has(unitId)) {
this.modelMap.set(unitId, new Map());
}
this.modelMap.get(unitId)?.set(subunitId, model);
}
getModel(unitId: string, subunitId: string): SheetBindingModel | undefined {
return this.modelMap.get(unitId)?.get(subunitId);
}
setBindingNode(unitId: string, subunitId: string, node: ICellBindingNodeParam): void {
let model = this.getModel(unitId, subunitId);
if (!model) {
model = new SheetBindingModel();
this.addModel(unitId, subunitId, model);
}
if (!node.nodeId) {
node.nodeId = generateRandomId();
}
const { row, column } = node;
if (row === undefined || column === undefined) {
throw new Error('row and column is required');
}
const oldNode = model.getBindingNode(row, column);
model.setBindingNode(row, column, { ...node, row, column } as ICellBindingNode);
this._cellBindInfoUpdate$.next({
unitId,
subunitId,
sourceId: node.sourceId,
nodeId: node.nodeId,
row,
column,
changeType: oldNode ? BindingSourceChangeTypeEnum.Update : BindingSourceChangeTypeEnum.Add,
oldSourceId: oldNode?.sourceId,
});
}
removeBindingNode(unitId: string, subunitId: string, row: number, column: number): void {
const model = this.getModel(unitId, subunitId);
if (model) {
const node = model.getBindingNode(row, column);
if (node) {
model.removeBindingNode(row, column);
this._cellBindInfoUpdate$.next({
unitId,
subunitId,
sourceId: node.sourceId,
nodeId: node.nodeId!,
row,
column,
changeType: BindingSourceChangeTypeEnum.Remove,
});
}
}
}
getBindingNode(unitId: string, subunitId: string, row: number, column: number): ICellBindingNode | undefined {
const model = this.getModel(unitId, subunitId);
if (model) {
return model.getBindingNode(row, column);
}
return undefined;
}
createModel(unitId: string, subunitId: string, json?: any): SheetBindingModel {
const model = new SheetBindingModel(json);
this.addModel(unitId, subunitId, model);
return model;
}
override dispose(): void {
this.modelMap.clear();
}
}
@@ -0,0 +1,88 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ISourceEvent } from '../types';
import { Disposable, generateRandomId } from '@univerjs/core';
import { Subject } from 'rxjs';
import { ListSourceModel, ObjectSourceModel, SourceModelBase } from '../model/source-model';
import { BindingSourceChangeTypeEnum, DataBindingNodeTypeEnum } from '../types';
export class SheetsSourceManager extends Disposable {
sourceMap: Map<string, Map<string, SourceModelBase> > = new Map();
private _sourceDataUpdate$ = new Subject<ISourceEvent>();
sourceDataUpdate$ = this._sourceDataUpdate$.asObservable();
constructor() {
super();
}
private _ensureUnitMap(unitId: string): Map<string, SourceModelBase> {
let unit = this.sourceMap.get(unitId);
if (!unit) {
unit = new Map();
this.sourceMap.set(unitId, unit);
}
return unit;
}
private _getUnitMap(unitId: string): Map<string, SourceModelBase> | undefined {
return this.sourceMap.get(unitId);
}
public getSource(unitId: string, id: string): SourceModelBase | undefined {
const unitMap = this._getUnitMap(unitId);
return unitMap?.get(id);
}
createSource(unitId: string, type: DataBindingNodeTypeEnum, isListObject?: boolean, id?: string): SourceModelBase {
const sourceId = id === undefined ? generateRandomId() : id;
let source;
switch (type) {
case DataBindingNodeTypeEnum.List:
source = new ListSourceModel(sourceId, isListObject);
break;
case DataBindingNodeTypeEnum.Object:
source = new ObjectSourceModel(sourceId);
break;
default:
throw new Error(`Invalid source type: ${type}`);
}
const unitMap = this._ensureUnitMap(unitId);
unitMap.set(sourceId, source);
return source;
}
updateSourceData(unitId: string, idOrInstance: string | SourceModelBase, data: any): void {
const unitMap = this._getUnitMap(unitId);
const sourceId = idOrInstance instanceof SourceModelBase ? idOrInstance.getId() : idOrInstance;
const source = unitMap?.get(sourceId);
if (source) {
source.setSourceData(data);
this._sourceDataUpdate$.next({ ...source.getSourceInfo(), unitId, changeType: BindingSourceChangeTypeEnum.Add });
} else {
throw new Error(`Source not found: ${sourceId}`);
}
}
removeSource(unitId: string, id: string): void {
const unitMap = this._getUnitMap(unitId);
const source = unitMap?.get(id);
if (source) {
unitMap?.delete(id);
this._sourceDataUpdate$.next({ ...source.getSourceInfo(), unitId, changeType: BindingSourceChangeTypeEnum.Remove });
}
}
}
@@ -0,0 +1,45 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FEnum } from '@univerjs/core';
import { BindModeEnum, DataBindingNodeTypeEnum } from '@univerjs/sheets-source-binding';
export interface ISourceBindingEnumMixin {
/**
* Represents the type of data binding node.{@link DataBindingNodeTypeEnum}
*/
DataBindingNodeTypeEnum: typeof DataBindingNodeTypeEnum;
/**
* Represents the type of data binding node. {@link BindModeEnum}
*/
BindModeEnum: typeof BindModeEnum;
}
class FSourceBindingEnum extends FEnum implements ISourceBindingEnumMixin {
override get DataBindingNodeTypeEnum(): typeof DataBindingNodeTypeEnum {
return DataBindingNodeTypeEnum;
}
override get BindModeEnum(): typeof BindModeEnum {
return BindModeEnum;
}
}
FEnum.extend(FSourceBindingEnum);
declare module '@univerjs/core' {
// eslint-disable-next-line ts/naming-convention
interface FEnum extends ISourceBindingEnumMixin { }
}
@@ -0,0 +1,75 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DataBindingNodeTypeEnum, SourceModelBase } from '@univerjs/sheets-source-binding';
import { SheetsSourceBindService } from '@univerjs/sheets-source-binding';
import { FWorkbook } from '@univerjs/sheets/facade';
export interface IFWorkbookSourceBindingMixin {
/**
* Create a source model with the specified type.
* @param {DataBindingNodeTypeEnum} type The source type.
* @param {boolean} [isListObject] Whether the source is a list object.
* @returns {SourceModelBase} The source data of sheet.
*/
createSource(type: DataBindingNodeTypeEnum, isListObject?: boolean): SourceModelBase;
/**
* Switch to path mode.In this mode, the path will show in cell.
*/
usePathMode(): void;
/**
* Switch to value mode.This is the default mode. In this mode, the cell value will fulfill by source values.
*/
useValueMode(): void;
/**
* Get the source model by the specified source id.
* @param {string} sourceId The source id.
*/
getSource(sourceId: string): SourceModelBase | undefined;
}
export class FWorkbookSourceBinding extends FWorkbook implements IFWorkbookSourceBindingMixin {
override createSource(type: DataBindingNodeTypeEnum, isListObject?: boolean): SourceModelBase {
const injector = this._injector;
const sheetsSourceBindService = injector.get(SheetsSourceBindService);
return sheetsSourceBindService.createSource(this.getId(), type, isListObject);
}
override getSource(sourceId: string): SourceModelBase | undefined {
const injector = this._injector;
const sheetsSourceBindService = injector.get(SheetsSourceBindService);
return sheetsSourceBindService.getSource(this.getId(), sourceId);
}
override usePathMode(): void {
const injector = this._injector;
const sheetsSourceBindService = injector.get(SheetsSourceBindService);
sheetsSourceBindService.usePathMode();
}
override useValueMode(): void {
const injector = this._injector;
const sheetsSourceBindService = injector.get(SheetsSourceBindService);
sheetsSourceBindService.useValueMode();
}
}
FWorkbook.extend(FWorkbookSourceBinding);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FWorkbook extends IFWorkbookSourceBindingMixin { }
}
@@ -0,0 +1,70 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICellBindingNode, ICellBindingNodeParam } from '@univerjs/sheets-source-binding';
import { SheetsSourceBindService } from '@univerjs/sheets-source-binding';
import { FWorksheet } from '@univerjs/sheets/facade';
export interface IFworksheetSourceBindingMixin {
/**
* Set the binding node for the specified cell.
* @param bindingNode The binding node to set. {@link ICellBindingNodeParam}
*/
setBindingNode(bindingNode: ICellBindingNodeParam): void;
/**
* Remove the binding node for the specified cell.
* @param {number} row The row index of the cell.
* @param {number} column The column index of the cell.
*/
removeBindingNode(row: number, column: number): void;
/**
* Get the binding node for the specified cell.
* @param {number} row The row index of the cell.
* @param {number} column The column index of the cell.
* @returns The binding node of the cell. {@link ICellBindingNode} or undefined if not found.
*/
getBindingNode(row: number, column: number): ICellBindingNode | undefined;
}
export class FWorksheetSourceBinding extends FWorksheet implements IFworksheetSourceBindingMixin {
override setBindingNode(bindingNode: ICellBindingNodeParam): void {
const injector = this._injector;
const sheetsSourceBindService = injector.get(SheetsSourceBindService);
const unitId = this._workbook.getUnitId();
sheetsSourceBindService.setBindingNode(unitId, this.getSheetId(), bindingNode);
}
override removeBindingNode(row: number, column: number): void {
const injector = this._injector;
const sheetsSourceBindService = injector.get(SheetsSourceBindService);
const unitId = this._workbook.getUnitId();
sheetsSourceBindService.removeBindingNode(unitId, this.getSheetId(), row, column);
}
override getBindingNode(row: number, column: number): ICellBindingNode | undefined {
const injector = this._injector;
const sheetsSourceBindService = injector.get(SheetsSourceBindService);
const unitId = this._workbook.getUnitId();
return sheetsSourceBindService.getBindingNode(unitId, this.getSheetId(), row, column);
}
}
FWorksheet.extend(FWorksheetSourceBinding);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FWorksheet extends IFworksheetSourceBindingMixin { }
}
@@ -0,0 +1,19 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './f-enum';
import './f-workbook';
import './f-worksheet';
@@ -0,0 +1,22 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { SheetBindingModel as BindingModel } from './model/binding-model';
export { SourceModelBase } from './model/source-model';
export { UniverSheetsBindingSourcePlugin } from './plugin';
export { BindModeEnum, DataBindingNodeTypeEnum } from './types';
export { SheetsSourceBindService } from './services/source-binding-service';
export type { ICellBindingNode, ICellBindingNodeParam } from './types';
@@ -0,0 +1,97 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICellBindingNode, IDataBindingModelMatrix } from '../types';
export class SheetBindingModel {
private _matrix: IDataBindingModelMatrix = {};
private _nodeMap: Map<string, ICellBindingNode> = new Map();
private _sourceIdMap: Map<string, string[]> = new Map();
constructor(json?: any) {
if (json) {
this._init(json);
}
}
_init(json: any): void {
this.fromJSON();
}
getBindingNodesBySourceId(sourceId: string): ICellBindingNode[] | undefined {
const nodeIds = this._sourceIdMap.get(sourceId);
if (nodeIds) {
return nodeIds.map((nodeId) => this._nodeMap.get(nodeId)) as ICellBindingNode[];
}
return undefined;
}
public setBindingNode(row: number, column: number, node: ICellBindingNode): void {
if (!this._matrix[row]) {
this._matrix[row] = {};
}
if (!this._matrix[row][column]) {
this._matrix[row][column] = node;
}
this._nodeMap.set(node.nodeId, node);
// update sourceId map, keep the sourceId and nodeId mapping
const nodeIds = this._sourceIdMap.get(node.sourceId);
if (nodeIds) {
nodeIds.push(node.nodeId);
} else {
this._sourceIdMap.set(node.sourceId, [node.nodeId]);
}
}
public getBindingNode(row: number, column: number): ICellBindingNode {
return this._matrix[row]?.[column];
}
public removeBindingNode(row: number, column: number): void {
const node = this._matrix[row]?.[column];
if (node) {
// @ts-ignore
this._matrix[row][column] = undefined;
this._nodeMap.delete(node.nodeId);
const nodeIds = this._sourceIdMap.get(node.sourceId);
if (nodeIds) {
const index = nodeIds.indexOf(node.nodeId);
if (index >= 0) {
nodeIds.splice(index, 1);
}
if (nodeIds.length === 0) {
this._sourceIdMap.delete(node.sourceId);
}
}
}
}
public getBindingNodeById(nodeId: string): ICellBindingNode | undefined {
return this._nodeMap.get(nodeId);
}
fromJSON(): void {
}
toJSON() {
}
}
@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICellBindingNode, IListDataBindingNode, IListSourceData, IListSourceInfo, IObjectSourceInfo } from '../types';
import { DataBindingNodeTypeEnum } from '../types';
export abstract class SourceModelBase {
protected _data: any;
readonly id: string;
private _hasData: boolean = false;
readonly type: DataBindingNodeTypeEnum;
constructor(id: string) {
this.id = id;
}
getId() {
return this.id;
}
getType(): DataBindingNodeTypeEnum {
return this.type;
}
hasData(): boolean {
return this._hasData;
}
setSourceData(data: any): void {
this._data = data;
this._hasData = true;
}
abstract getSourceInfo(): any;
abstract getData(node: ICellBindingNode, row: number, col: number): string | number | boolean | null;
}
export class ListSourceModel extends SourceModelBase {
override readonly type = DataBindingNodeTypeEnum.List;
private _isListObject: boolean;
private _fieldIndexMap: Map<string, number> = new Map();
protected override _data: IListSourceData = { fields: [], records: [] };
constructor(id: string, isListObject?: boolean) {
super(id);
this._isListObject = isListObject ?? true;
}
/**
* Toggle the list object mode. The default value is true.
* In the list object mode, the records is an array of objects. Such as [{name: 'Tom', age: 20}, {name: 'Jerry', age: 18}].
* In the list array mode, the records is an array of arrays. Such as [['Tom', 20], ['Jerry', 18]].
*/
toggleListObject(isListObject: boolean) {
this._isListObject = isListObject;
}
getData(node: IListDataBindingNode, row: number): string | number | boolean | null {
const { path, row: baseRow } = node;
const colIndex = this._fieldIndexMap.get(path)!;
const rowIndex = row - baseRow;
if (rowIndex === 0) {
return this._data.fields[colIndex];
}
if (this._isListObject) {
return (this._data.records as Record<string | number, any>)[rowIndex - 1][path];
}
return this._data.records[rowIndex - 1][colIndex];
}
override setSourceData(data: IListSourceData): void {
super.setSourceData(data);
const { fields } = data;
this._fieldIndexMap.clear();
fields.forEach((field, index) => {
this._fieldIndexMap.set(field, index);
});
}
override getSourceInfo(): IListSourceInfo {
return {
sourceId: this.id,
sourceType: this.type,
fields: this._data.fields,
recordCount: this._data.records.length,
};
}
}
export class ObjectSourceModel extends SourceModelBase {
override readonly type = DataBindingNodeTypeEnum.Object;
constructor(id: string) {
super(id);
}
getData(node: ICellBindingNode): string | number | boolean | null {
const path = node.path;
const paths = path.split('.');
let data = this._data;
for (const p of paths) {
data = data[p];
if (data === undefined) {
return null;
}
}
return data;
}
override getSourceInfo(): IObjectSourceInfo {
return {
sourceId: this.id,
sourceType: DataBindingNodeTypeEnum.Object,
};
}
}
@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import { IConfigService, Inject, Injector, Plugin, touchDependencies, UniverInstanceType } from '@univerjs/core';
import { SheetsBindingManager } from './controllers/binding-manager';
import { SheetsSourceManager } from './controllers/source-manager';
import { SheetsSourceBindService } from './services/source-binding-service';
interface IUniverSheetsBindingSourceConfig {}
export class UniverSheetsBindingSourcePlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'SHEET_BINDING_SOURCE_PLUGIN';
constructor(
private readonly _config: Partial<IUniverSheetsBindingSourceConfig> = {},
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
}
override onStarting(): void {
([
[SheetsBindingManager],
[SheetsSourceManager],
[SheetsSourceBindService],
] as Dependency[]).forEach((d) => this._injector.add(d));
}
override onReady(): void {
touchDependencies(this._injector, [
[SheetsBindingManager],
[SheetsSourceManager],
[SheetsSourceBindService],
]);
}
}
@@ -0,0 +1,250 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICellBindingNodeParam, IListSourceInfo } from '../types';
import { Disposable, Inject, InterceptorEffectEnum, RTree } from '@univerjs/core';
import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
import { SheetsBindingManager } from '../controllers/binding-manager';
import { SheetsSourceManager } from '../controllers/source-manager';
import { BindingSourceChangeTypeEnum, BindModeEnum, DataBindingNodeTypeEnum } from '../types';
export class SheetsSourceBindService extends Disposable {
private _bindingModel: BindModeEnum = BindModeEnum.Value;
private _bindModelRTreeCollection = new Map<string, RTree>();
constructor(
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@Inject(SheetsBindingManager) private _sheetsBindingManager: SheetsBindingManager,
@Inject(SheetsSourceManager) private _sheetsSourceManager: SheetsSourceManager
) {
super();
this._registerInterceptor();
this._registerSourceChange();
}
/**
* Set the binding model to path mode, in this mode, the binding path will show in the cell.
*/
usePathMode() {
this._bindingModel = BindModeEnum.Path;
}
/**
* Set the binding model to value mode, in this mode, the value of source will show in the cell.
*/
useValueMode() {
this._bindingModel = BindModeEnum.Value;
}
/**
* Get the current binding model.
* @returns the current binding model
*/
getBindingModel() {
return this._bindingModel;
}
createBindModel(unitId: string, subUnitId: string) {
return this._sheetsBindingManager.createModel(unitId, subUnitId);
}
setBindingNode(unitId: string, subUnitId: string, node: ICellBindingNodeParam) {
this._sheetsBindingManager.setBindingNode(unitId, subUnitId, node);
}
removeBindingNode(unitId: string, subUnitId: string, row: number, column: number) {
this._sheetsBindingManager.removeBindingNode(unitId, subUnitId, row, column);
}
getBindingNode(unitId: string, subUnitId: string, row: number, column: number) {
return this._sheetsBindingManager.getBindingNode(unitId, subUnitId, row, column);
}
getSource(unitId: string, id: string) {
return this._sheetsSourceManager.getSource(unitId, id);
}
createSource(unitId: string, type: DataBindingNodeTypeEnum, isListObject?: boolean, id?: string) {
return this._sheetsSourceManager.createSource(unitId, type, isListObject, id);
}
private _ensureRTreeCollection(unitId: string) {
if (!this._bindModelRTreeCollection.has(unitId)) {
this._bindModelRTreeCollection.set(unitId, new RTree());
}
return this._bindModelRTreeCollection.get(unitId) as RTree;
}
private _getRTeeCollection(unitId: string) {
return this._bindModelRTreeCollection.get(unitId);
}
// eslint-disable-next-line max-lines-per-function
private _registerSourceChange() {
// Update the RTree collection when the source data updated
this.disposeWithMe(this._sheetsSourceManager.sourceDataUpdate$.subscribe((sourceInfo) => {
const { sourceId, sourceType, unitId: sourceUnitId, changeType } = sourceInfo;
if (sourceType === DataBindingNodeTypeEnum.List) {
// Remove the old data
if (changeType === BindingSourceChangeTypeEnum.Remove) {
const nodeInfo = this._sheetsBindingManager.getBindingModelBySourceId(sourceId);
const recordCount = sourceInfo.recordCount;
for (const { unitId, subunitId, nodeId, row, column } of nodeInfo) {
const rTreeCollection = this._getRTeeCollection(sourceUnitId);
if (rTreeCollection) {
const range = { startRow: row, startColumn: column, endRow: row + recordCount, endColumn: column };
rTreeCollection.remove({ unitId, sheetId: subunitId, id: nodeId, range });
}
}
return;
}
if (changeType === BindingSourceChangeTypeEnum.Update) {
const oldRecordCount = sourceInfo.oldRecordCount!;
const nodeInfo = this._sheetsBindingManager.getBindingModelBySourceId(sourceId);
for (const { unitId, subunitId, nodeId, row, column } of nodeInfo) {
const rTreeCollection = this._getRTeeCollection(sourceUnitId);
if (rTreeCollection) {
const oldRange = { startRow: row, startColumn: column, endRow: row + oldRecordCount, endColumn: column };
const range = { startRow: row, startColumn: column, endRow: row + sourceInfo.recordCount, endColumn: column };
rTreeCollection.remove({ unitId, sheetId: subunitId, id: nodeId, range: oldRange });
rTreeCollection.insert({ unitId, sheetId: subunitId, id: nodeId, range });
}
}
return;
}
// Add the new data
const source = this._sheetsSourceManager.getSource(sourceUnitId, sourceId);
if (source && source.hasData()) {
const sourceInfo = source.getSourceInfo() as IListSourceInfo;
const recordCount = sourceInfo.recordCount;
const nodeInfo = this._sheetsBindingManager.getBindingModelBySourceId(sourceId);
for (const { unitId, subunitId, nodeId, row, column } of nodeInfo) {
const rTreeCollection = this._ensureRTreeCollection(unitId);
const range = { startRow: row, startColumn: column, endRow: row + recordCount, endColumn: column };
rTreeCollection.insert({ unitId, sheetId: subunitId, id: nodeId, range });
}
}
}
}));
// Update the RTree collection when the binding node updated
this.disposeWithMe(this._sheetsBindingManager.cellBindInfoUpdate$.subscribe((nodeInfo) => {
const { unitId, subunitId, sourceId, nodeId, row, column, changeType } = nodeInfo;
const rTreeCollection = this._ensureRTreeCollection(unitId);
const source = this._sheetsSourceManager.getSource(unitId, sourceId);
if (source && source.hasData()) {
const sourceInfo = source.getSourceInfo() as IListSourceInfo;
if (sourceInfo.sourceType === DataBindingNodeTypeEnum.List) {
const recordCount = sourceInfo.recordCount;
const range = { startRow: row, startColumn: column, endRow: row + recordCount, endColumn: column };
if (changeType === BindingSourceChangeTypeEnum.Add) {
rTreeCollection.insert({ unitId, sheetId: subunitId, id: nodeId, range });
} else if (changeType === BindingSourceChangeTypeEnum.Remove) {
rTreeCollection.remove({ unitId, sheetId: subunitId, id: nodeId, range });
} else if (changeType === BindingSourceChangeTypeEnum.Update) {
const oldSourceId = nodeInfo.oldSourceId!;
// remove the old range
const oldSource = this._sheetsSourceManager.getSource(unitId, oldSourceId);
if (oldSource && oldSource.hasData()) {
const oldSourceInfo = oldSource.getSourceInfo() as IListSourceInfo;
const oldRecordCount = oldSourceInfo.recordCount;
const oldRange = { startRow: row, startColumn: column, endRow: row + oldRecordCount, endColumn: column };
rTreeCollection.remove({ unitId, sheetId: subunitId, id: nodeId, range: oldRange });
}
rTreeCollection.insert({ unitId, sheetId: subunitId, id: nodeId, range });
}
}
}
}));
}
private _getPathModeCellValue(unitId: string, subUnitId: string, row: number, col: number) {
const model = this._sheetsBindingManager.getModel(unitId, subUnitId);
const node = model?.getBindingNode(row, col);
if (node) {
const nodeType = node.type;
if (nodeType === DataBindingNodeTypeEnum.List) {
return {
v: `#{${node.path}}`,
s: { cl: { rgb: 'blue' } },
};
} else if (nodeType === DataBindingNodeTypeEnum.Object) {
return {
v: `[${node.path}]`,
s: { cl: { rgb: 'blue' } },
};
}
}
}
private _getValueModeCellValue(unitId: string, subUnitId: string, row: number, col: number) {
const model = this._sheetsBindingManager.getModel(unitId, subUnitId);
// object mode
if (model) {
const node = model.getBindingNode(row, col);
if (node) {
const { sourceId } = node;
const source = this._sheetsSourceManager.getSource(unitId, sourceId);
if (source && source.hasData()) {
return {
v: source?.getData(node, row, col) || '',
};
}
}
}
const rTreeCollection = this._getRTeeCollection(unitId);
if (model && rTreeCollection) {
const range = { startRow: row, startColumn: col, endRow: row, endColumn: col };
const nodeIds = Array.from(rTreeCollection.bulkSearch([{ unitId, sheetId: subUnitId, range }]));
if (nodeIds.length > 0) {
const node = model.getBindingNodeById(nodeIds[0] as string);
if (node) {
const { sourceId } = node;
const source = this._sheetsSourceManager.getSource(unitId, sourceId);
if (source && source.hasData()) {
return {
v: source?.getData(node, row, col) || '',
};
}
}
}
}
}
private _registerInterceptor() {
this.disposeWithMe(this._sheetInterceptorService.intercept(INTERCEPTOR_POINT.CELL_CONTENT, {
effect: InterceptorEffectEnum.Value,
handler: (cell, context, next) => {
const { row, col, unitId, subUnitId } = context;
let value = null;
if (this._bindingModel === BindModeEnum.Path) {
value = this._getPathModeCellValue(unitId, subUnitId, row, col);
} else {
value = this._getValueModeCellValue(unitId, subUnitId, row, col);
}
if (value !== null) {
return next({ ...cell, ...value });
}
return next(cell);
},
}));
}
}
@@ -0,0 +1,164 @@
/**
* Copyright 2023-present DreamNum Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Represents the type of data binding node.Which config the array or object like data source or path.
*/
export enum DataBindingNodeTypeEnum {
/**
* The array like data source.
*/
List = 'list',
// Cell = 'cell',
/**
* The object like data source.
*/
Object = 'object',
}
export enum ListDataBindingNodeDirectionEnum {
Horizontal = 'horizontal',
Vertical = 'vertical',
}
/**
* Represents the type of data binding node.Which config show Binding path or value.
*/
export enum BindModeEnum {
/**
* The path mode, show the binding path.
*/
Path = 'path',
/**
* The value mode, show the binding value.
*/
Value = 'value',
}
export type sourceDataType = string | number | boolean | object | null;
export interface IDataSource {
id: string;
getData(row: number, column: number): sourceDataType;
}
export interface IListDataSource extends IDataSource {
getLength(): number;
getColumns(): string[];
}
export interface IDataBindingModelMatrix {
[row: number]: {
[column: number]: ICellBindingNode;
};
}
export interface IDataBindingModel {
themeName?: string;
matrix: IDataBindingModelMatrix;
setBindingNode(row: number, column: number, node: ICellBindingNode): void;
}
/**
* The binding node of cell, which config the source id, path, row, column.
*/
export interface ICellBindingNodeParam {
/**
* The binding node type, the node type should be same as provide source type.
*/
type: DataBindingNodeTypeEnum;
/**
* The path of the binding node, the path should be same as provide source path.
* @example
* for object type : the source is :{user: {name :'Tom'}}, we can set path 'user.name' to represent the Tom.
* for list type : the source is :{fields:['name', 'age'], records:[['Tom', 18],['Jerry', 20]]}, we can set path 'name' to represent the all names in data.
*/
path: string;
/**
* The source id of the binding node, the source id should be same as provide source id.
*/
sourceId: string;
/**
* The target row of the binding node.
*/
row: number;
/**
* The target column of the binding node.
*/
column: number;
nodeId?: string; // optional in ICellBindingNodeParam
}
export interface ICellBindingNode extends ICellBindingNodeParam {
/**
* The node id of ICellBindingNode, if not provide, will generate a random id.
*/
nodeId: string;
}
export interface IListDataBindingNode extends ICellBindingNode {
type: DataBindingNodeTypeEnum.List;
limit?: number;
direction?: ListDataBindingNodeDirectionEnum;
range?: { startRow: number; startColumn: number; endRow: number; endColumn: number };
displayName?: string;
}
export interface IDataBindingService {
/**
* A flag to control show path or show value
*/
isBindingMode: boolean;
/**
* Change the binding mode
* @param {BindModeEnum} mode Update the binding mode
*/
setBindingMode(mode: BindModeEnum): void;
}
export interface IListSourceInfo {
sourceId: string;
sourceType: DataBindingNodeTypeEnum.List;
fields: string[];
recordCount: number;
// range: null|{startRow:number, startColumn:number, endRow:number, endColumn:number};
}
export interface IObjectSourceInfo {
sourceId: string;
sourceType: DataBindingNodeTypeEnum.Object;
}
export type ISourceEvent = (IListSourceInfo | IObjectSourceInfo) & {
unitId: string;
changeType: BindingSourceChangeTypeEnum;
oldRecordCount?: number;
};
export interface IListSourceData {
fields: string[];
records: any[][] | Record<string | number, any>[];
}
export enum BindingSourceChangeTypeEnum {
Add = 'add',
Remove = 'remove',
Update = 'update',
}
@@ -0,0 +1,9 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"references": [{ "path": "./tsconfig.node.json" }],
"include": ["src"]
}
@@ -0,0 +1,4 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"include": ["vitest.config.ts"]
}
@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();
+30 -7
View File
@@ -376,6 +376,9 @@ importers:
'@univerjs/sheets-sort-ui':
specifier: workspace:*
version: link:../packages/sheets-sort-ui
'@univerjs/sheets-source-binding':
specifier: workspace:*
version: link:../packages-experimental/sheets-source-binding
'@univerjs/sheets-thread-comment':
specifier: workspace:*
version: link:../packages/sheets-thread-comment
@@ -749,6 +752,31 @@ importers:
specifier: ^2.1.8
version: 2.1.8(@types/node@22.10.5)(happy-dom@15.0.0)(jsdom@24.1.1)(less@4.2.1)(sass@1.77.5)(terser@5.37.0)
packages-experimental/sheets-source-binding:
dependencies:
'@univerjs/core':
specifier: workspace:*
version: link:../../packages/core
'@univerjs/sheets':
specifier: workspace:*
version: link:../../packages/sheets
devDependencies:
'@univerjs-infra/shared':
specifier: workspace:*
version: link:../../common/shared
rxjs:
specifier: ^7.8.1
version: 7.8.1
typescript:
specifier: ^5.7.2
version: 5.7.2
vite:
specifier: ^6.0.7
version: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(less@4.2.1)(sass@1.77.5)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
vitest:
specifier: ^2.1.8
version: 2.1.8(@types/node@22.10.5)(happy-dom@15.0.0)(jsdom@24.1.1)(less@4.2.1)(sass@1.77.5)(terser@5.37.0)
packages-experimental/uni-docs-ui:
dependencies:
'@univerjs/core':
@@ -10423,9 +10451,6 @@ packages:
tslib@2.5.0:
resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==}
tslib@2.6.3:
resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -18188,7 +18213,7 @@ snapshots:
regexp-util@2.0.0:
dependencies:
tslib: 2.6.3
tslib: 2.8.1
regexp.prototype.flags@1.5.3:
dependencies:
@@ -18381,7 +18406,7 @@ snapshots:
rxjs@7.8.1:
dependencies:
tslib: 2.6.3
tslib: 2.8.1
safe-array-concat@1.1.2:
dependencies:
@@ -19004,8 +19029,6 @@ snapshots:
tslib@2.5.0: {}
tslib@2.6.3: {}
tslib@2.8.1: {}
tsx@4.19.2: