From 88968087cbf6979544e9c7c3a3f7d7dbfeb6c8fa Mon Sep 17 00:00:00 2001 From: huanhuanwa <44698191+huanhuanwa@users.noreply.github.com> Date: Mon, 5 Aug 2024 15:24:30 +0800 Subject: [PATCH] feat(positions): build data by positions and active views #WIK-16218 (#29) --- packages/grid/src/grid.component.ts | 2 +- ...ection.servive.ts => selection.service.ts} | 0 src/app/action/view.ts | 47 +-- src/app/app.component.scss | 2 +- src/app/app.routes.ts | 19 +- src/app/component/basic/basic.component.html | 14 - src/app/component/basic/basic.component.ts | 14 - src/app/component/common/common.component.ts | 301 ------------------ .../common/content/content.component.html | 16 + .../common/content/content.component.ts | 178 +++++++++++ src/app/component/share/share.component.html | 20 -- src/app/component/share/share.component.ts | 100 ------ src/app/component/table.component.html | 15 + src/app/component/table.component.ts | 50 +++ src/app/service/table.service.ts | 108 +++++++ src/app/share/apply-to-yjs/add-record.ts | 3 +- src/app/share/provider.ts | 3 +- src/app/share/shared.ts | 67 ++-- src/app/share/utils/translate-to-table.ts | 16 +- src/app/types/index.ts | 19 ++ src/app/types/view.ts | 13 +- src/app/utils/utils.ts | 183 +++++++++++ 22 files changed, 661 insertions(+), 529 deletions(-) rename packages/grid/src/services/{selection.servive.ts => selection.service.ts} (100%) delete mode 100644 src/app/component/basic/basic.component.html delete mode 100644 src/app/component/basic/basic.component.ts delete mode 100644 src/app/component/common/common.component.ts create mode 100644 src/app/component/common/content/content.component.html create mode 100644 src/app/component/common/content/content.component.ts delete mode 100644 src/app/component/share/share.component.html delete mode 100644 src/app/component/share/share.component.ts create mode 100644 src/app/component/table.component.html create mode 100644 src/app/component/table.component.ts create mode 100644 src/app/service/table.service.ts create mode 100644 src/app/types/index.ts create mode 100644 src/app/utils/utils.ts diff --git a/packages/grid/src/grid.component.ts b/packages/grid/src/grid.component.ts index c3a609d0..f6f00848 100644 --- a/packages/grid/src/grid.component.ts +++ b/packages/grid/src/grid.component.ts @@ -46,7 +46,7 @@ import { import { SelectOptionPipe } from './pipes/grid'; import { AITableGridEventService } from './services/event.service'; import { AI_TABLE_GRID_FIELD_SERVICE_MAP, AITableGridFieldService } from './services/field.service'; -import { AITableGridSelectionService } from './services/selection.servive'; +import { AITableGridSelectionService } from './services/selection.service'; import { AIFieldConfig, AITableFieldMenuItem, AITableRowHeight } from './types'; import { buildGridData } from './utils'; diff --git a/packages/grid/src/services/selection.servive.ts b/packages/grid/src/services/selection.service.ts similarity index 100% rename from packages/grid/src/services/selection.servive.ts rename to packages/grid/src/services/selection.service.ts diff --git a/src/app/action/view.ts b/src/app/action/view.ts index 48e16d64..9d2b0d34 100644 --- a/src/app/action/view.ts +++ b/src/app/action/view.ts @@ -1,4 +1,5 @@ -import { AITableView, AIViewAction, ViewActionName } from '../types/view'; +import { AITableRecord, AITableRecords } from '@ai-table/grid'; +import { AITableView, AIViewAction, Direction, ViewActionName } from '../types/view'; import { AIViewTable } from '../types/view'; import { createDraft, finishDraft } from 'immer'; @@ -23,37 +24,43 @@ export function setView(aiTable: AIViewTable, value: Partial, path: path }; aiTable.viewApply(operation); - } export const GeneralActions = { transform(aiTable: AIViewTable, op: AIViewAction): void { const views = createDraft(aiTable.views()); - applyView(aiTable, views, op); + const records = createDraft(aiTable.records()); + applyView(aiTable, views, records, op); aiTable.views.update(() => { return finishDraft(views); }); + aiTable.records.update(() => { + return finishDraft(records); + }); } }; -export const applyView = (aiTable: AIViewTable, views: AITableView[], options: AIViewAction) => { - const [viewIndex] = options.path; - const targetView: AITableView = views[viewIndex] - Object.entries(options.newView).forEach(([k, value]) => { - const key = k as keyof AITableView; - if (value == null) { - delete targetView[key] - } else { - targetView[key] = value as never - } - }); - Object.entries(options.view).forEach(([k, value]) => { - if (!options.newView.hasOwnProperty(k)) { - const key = k as keyof AITableView; - delete targetView[key] +export const applyView = (aiTable: AIViewTable, views: AITableView[], records: AITableRecords, options: AIViewAction) => { + switch (options.type) { + case ViewActionName.setView: { + const [viewIndex] = options.path; + if (viewIndex > -1) { + views[viewIndex] = { + ...views[viewIndex], + ...options.newView + }; + if (options.newView.sortCondition) { + const { sortCondition } = options.newView; + const { sortBy, direction } = sortCondition.conditions[0]; + records = records.sort((a: AITableRecord, b: AITableRecord) => { + return direction === Direction.ascending + ? a.values[sortBy] - b.values[sortBy] + : b.values[sortBy] - a.values[sortBy]; + }); + } + } } - }); - return views; + } }; export const ViewActions = { diff --git a/src/app/app.component.scss b/src/app/app.component.scss index fcd4b246..eea78d8c 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -1,6 +1,6 @@ app-root { display: block; - margin: 20px; + margin: 0 20px; margin-bottom: 0; overflow-x: auto; height: calc(100vh - 20px); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c6ae8e78..81bcb6c6 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,14 +1,17 @@ import { Routes } from '@angular/router'; -import { BasicComponent } from './component/basic/basic.component'; -import { ShareComponent } from './component/share/share.component'; +import { DemoTableContent } from './component/common/content/content.component'; +import { DemoTable } from './component/table.component'; export const routes: Routes = [ { path: '', - component: BasicComponent - }, - { - path: 'share', - component: ShareComponent - }, + component: DemoTable, + children:[ + { + path: ':viewId', + component: DemoTableContent, + } + ] + } + ]; diff --git a/src/app/component/basic/basic.component.html b/src/app/component/basic/basic.component.html deleted file mode 100644 index f495fc6b..00000000 --- a/src/app/component/basic/basic.component.html +++ /dev/null @@ -1,14 +0,0 @@ -删除行 -移动选中行到第三行 -移动选中列到第三列 -排序 - - - diff --git a/src/app/component/basic/basic.component.ts b/src/app/component/basic/basic.component.ts deleted file mode 100644 index e5324f3b..00000000 --- a/src/app/component/basic/basic.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; -import { AITableGrid } from '@ai-table/grid'; - -import { CommonComponent } from '../common/common.component'; -import { ThyAction } from 'ngx-tethys/action'; - -@Component({ - selector: 'ai-table-basic', - standalone: true, - imports: [RouterOutlet, AITableGrid, CommonComponent, ThyAction], - templateUrl: './basic.component.html' -}) -export class BasicComponent extends CommonComponent {} diff --git a/src/app/component/common/common.component.ts b/src/app/component/common/common.component.ts deleted file mode 100644 index c284a707..00000000 --- a/src/app/component/common/common.component.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { - Actions, - AIFieldConfig, - AIFieldPath, - AIRecordPath, - AITable, - AITableField, - AITableFields, - AITableFieldType, - AITableGrid, - AITableQueries, - AITableRecord, - AITableRecords, - DividerMenuItem, - EditFieldPropertyItem, - RemoveFieldItem -} from '@ai-table/grid'; -import { AfterViewInit, Component, computed, OnDestroy, OnInit, output, Signal, signal, WritableSignal } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; -import { RouterOutlet } from '@angular/router'; -import { ThyAction } from 'ngx-tethys/action'; -import { ThyIconRegistry } from 'ngx-tethys/icon'; -import { ThyPopoverModule } from 'ngx-tethys/popover'; -import { CustomActions } from '../../action'; -import { withCustomApply } from '../../plugins/custom-action.plugin'; -import { AITableView, AIViewTable, Direction, RowHeight } from '../../types/view'; -import { FieldPropertyEditor } from './field-property-editor/field-property-editor.component'; - -const LOCAL_STORAGE_KEY = 'ai-table-data'; - -const initValue = { - records: [ - { - id: 'row-1', - values: { - 'column-1': '文本 1-1', - 'column-2': '1', - 'column-3': { - url: 'https://www.baidu.com', - text: '百度链接' - }, - 'column-4': 3, - 'column-5': 10 - } - }, - { - id: 'row-2', - values: { - 'column-1': '文本 2-1', - 'column-2': '2', - 'column-3': {}, - 'column-4': 1, - 'column-5': 20 - } - }, - { - id: 'row-3', - values: { - 'column-1': '文本 3-1', - 'column-2': '3', - 'column-3': {}, - 'column-4': 2, - 'column-5': 50 - } - } - ], - fields: [ - { - id: 'column-1', - name: '文本', - type: AITableFieldType.text - }, - { - id: 'column-2', - name: '单选', - type: AITableFieldType.select, - options: [ - { - id: '1', - name: '开始', - color: '#5dcfff' - }, - { - id: '2', - name: '进行中', - color: '#ffcd5d' - }, - { - id: '3', - name: '已完成', - color: '#73d897' - } - ] - }, - { - id: 'column-3', - name: '链接', - type: AITableFieldType.link - }, - { - id: 'column-4', - name: '评分', - type: AITableFieldType.rate - }, - { - id: 'column-5', - name: '进度', - type: AITableFieldType.progress - } - ] -}; - -// console.time('build data'); -// initValue.fields = []; -// for (let index = 0; index < 5; index++) { -// initValue.fields.push({ -// id: `column-${index}`, -// name: "文本", -// type: AITableFieldType.text, -// }); -// } -// initValue.records = []; -// for (let index = 0; index < 40 * 3 * 2*30; index++) { -// const value: any = {}; -// initValue.fields.forEach((column, columnIndex) => { -// value[`${column.id}`] = `text-${index}-${columnIndex}`; -// }); -// initValue.records.push({ -// id: `row-${index + 1}`, -// value: value, -// }); -// } -// console.timeEnd('build data'); - -@Component({ - selector: 'ai-table-common', - standalone: true, - imports: [RouterOutlet, AITableGrid, ThyPopoverModule, FieldPropertyEditor, ThyAction], - template: '' -}) -export class CommonComponent implements OnInit, AfterViewInit, OnDestroy { - records!: WritableSignal; - - fields!: WritableSignal; - - aiTable!: AITable; - - views = signal([ - { rowHeight: RowHeight.short, id: '3', name: '表格视图3' }, - { rowHeight: RowHeight.short, id: 'view1', name: '表格1', isActive: true }, - ]); - - plugins = [withCustomApply]; - - listOfOption = [ - { - value: 'short', - text: 'short' - }, - { - value: 'medium', - text: 'medium' - }, - { - value: 'tall', - text: 'tall' - } - ]; - - aiFieldConfig: AIFieldConfig = { - fieldPropertyEditor: FieldPropertyEditor, - fieldMenus: [ - EditFieldPropertyItem, - DividerMenuItem, - { - id: 'filterFields', - name: '按本列筛选', - icon: 'filter-line', - exec: (aiTable: AITable, field: Signal) => {}, - hidden: (aiTable: AITable, field: Signal) => false, - disabled: (aiTable: AITable, field: Signal) => false - }, - DividerMenuItem, - RemoveFieldItem - ] - }; - - activeView = computed(() => { - return { ...this.views().find((view) => view?.isActive) } as AITableView; - }); - - tableInitialized = output(); - - constructor( - private iconRegistry: ThyIconRegistry, - private sanitizer: DomSanitizer - ) { - this.registryIcon(); - } - - ngOnInit(): void { - const value = this.getLocalStorage(); - this.records = signal(value.records); - this.fields = signal(value.fields); - console.time('render'); - } - - registryIcon() { - this.iconRegistry.addSvgIconSet(this.sanitizer.bypassSecurityTrustResourceUrl('assets/icons/defs/svg/sprite.defs.svg')); - } - - ngAfterViewInit() { - console.timeEnd('render'); - } - - onChange(data: any) { - localStorage.setItem( - `${LOCAL_STORAGE_KEY}`, - JSON.stringify({ - fields: data.fields, - records: data.records - }) - ); - if(data.actions[0].type === 'set_view'){ - const sortCondition = this.activeView().sortCondition; - if(sortCondition){ - const {sortBy, direction} = sortCondition.conditions[0] - const records = this.records().sort((a:AITableRecord,b:AITableRecord)=>{ return direction === Direction.ascending ? a.values[sortBy] - b.values[sortBy] : b.values[sortBy] - a.values[sortBy] }); - this.records.set([...records]) - } - } - } - - aiTableInitialized(aiTable: AITable) { - this.aiTable = aiTable; - (this.aiTable as AIViewTable).views = this.views; - this.tableInitialized.emit(this.aiTable); - } - - setLocalData(data: string) { - localStorage.setItem(`${LOCAL_STORAGE_KEY}`, data); - } - - getLocalStorage() { - const data = localStorage.getItem(`${LOCAL_STORAGE_KEY}`); - return data ? JSON.parse(data) : initValue; - } - - changeRowHeight(event: string) { - CustomActions.setView(this.aiTable as any, this.activeView(), [0]); - } - - removeRecord() { - const recordIds = [...this.aiTable.selection().selectedRecords.keys()]; - recordIds.forEach((item) => { - const path = this.aiTable.records().findIndex((record) => record.id === item); - Actions.removeRecord(this.aiTable, [path]); - }); - } - - moveField() { - const newIndex = 2; - const selectedFieldIds = [...this.aiTable.selection().selectedFields.keys()]; - const selectedRecords = this.aiTable.fields().filter((item) => selectedFieldIds.includes(item.id)); - selectedRecords.forEach((item) => { - const path = AITableQueries.findPath(this.aiTable, item) as AIFieldPath; - Actions.moveField(this.aiTable, path, [newIndex]); - }); - } - - moveRecord() { - const selectedRecordIds = [...this.aiTable.selection().selectedRecords.keys()]; - const selectedRecords = this.aiTable.records().filter((item) => selectedRecordIds.includes(item.id)); - const selectedRecordsAfterNewPath: AITableRecord[] = []; - let offset = 0; - const newIndex = 2; - selectedRecords.forEach((item) => { - const path = AITableQueries.findPath(this.aiTable, undefined, item) as AIRecordPath; - if (path[0] < newIndex) { - Actions.moveRecord(this.aiTable, path, [newIndex]); - offset = 1; - } else { - selectedRecordsAfterNewPath.push(item); - } - }); - - selectedRecordsAfterNewPath.reverse().forEach((item) => { - const newPath = [newIndex + offset] as AIRecordPath; - const path = AITableQueries.findPath(this.aiTable, undefined, item) as AIRecordPath; - Actions.moveRecord(this.aiTable, path, newPath); - }); - } - - sort(){ - const direction = this.activeView().sortCondition?.conditions[0].direction - const sortCondition = { keepSort:true , conditions:[{sortBy: 'column-4', direction: direction=== Direction.ascending ? Direction.descending: Direction.ascending}]} - CustomActions.setView(this.aiTable as any, {sortCondition}, [1]); - } - - ngOnDestroy(): void {} -} diff --git a/src/app/component/common/content/content.component.html b/src/app/component/common/content/content.component.html new file mode 100644 index 00000000..58be1a07 --- /dev/null +++ b/src/app/component/common/content/content.component.html @@ -0,0 +1,16 @@ +@if (tableService.activeView()) { + + +} \ No newline at end of file diff --git a/src/app/component/common/content/content.component.ts b/src/app/component/common/content/content.component.ts new file mode 100644 index 00000000..cd5bfa46 --- /dev/null +++ b/src/app/component/common/content/content.component.ts @@ -0,0 +1,178 @@ +import { + Actions, + AIFieldConfig, + AIFieldPath, + AIRecordPath, + AITable, + AITableField, + AITableGrid, + AITableQueries, + AITableRecord, + DividerMenuItem, + EditFieldPropertyItem, + RemoveFieldItem +} from '@ai-table/grid'; +import { Component, inject, Signal } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { ThyAction } from 'ngx-tethys/action'; +import { ThyPopoverModule } from 'ngx-tethys/popover'; +import { FormsModule } from '@angular/forms'; +import { ThyInputDirective } from 'ngx-tethys/input'; +import { DomSanitizer } from '@angular/platform-browser'; +import { ThyIconRegistry } from 'ngx-tethys/icon'; +import { withCustomApply } from '../../../plugins/custom-action.plugin'; +import { TableService } from '../../../service/table.service'; +import applyActionOps from '../../../share/apply-to-yjs'; +import { YjsAITable } from '../../../share/yjs-table'; +import { AIViewAction, AIViewTable, Direction } from '../../../types/view'; +import { getDefaultValue } from '../../../utils/utils'; +import { FieldPropertyEditor } from '../field-property-editor/field-property-editor.component'; +import { CustomActions } from '../../../action'; + +@Component({ + selector: 'demo-table-content', + standalone: true, + imports: [RouterOutlet, AITableGrid, ThyPopoverModule, FieldPropertyEditor, ThyAction, FormsModule, ThyInputDirective], + templateUrl: './content.component.html' +}) +export class DemoTableContent { + aiTable!: AITable; + + plugins = [withCustomApply]; + + listOfOption = [ + { + value: 'short', + text: 'short' + }, + { + value: 'medium', + text: 'medium' + }, + { + value: 'tall', + text: 'tall' + } + ]; + + aiFieldConfig: AIFieldConfig = { + fieldPropertyEditor: FieldPropertyEditor, + fieldMenus: [ + EditFieldPropertyItem, + DividerMenuItem, + { + id: 'filterFields', + name: '按本列筛选', + icon: 'filter-line', + exec: (aiTable: AITable, field: Signal) => {}, + hidden: (aiTable: AITable, field: Signal) => false, + disabled: (aiTable: AITable, field: Signal) => false + }, + DividerMenuItem, + RemoveFieldItem + ] + }; + + iconRegistry = inject(ThyIconRegistry); + + sanitizer = inject(DomSanitizer); + + tableService = inject(TableService); + + constructor() { + this.registryIcon(); + } + + ngOnInit(): void { + if (this.tableService.sharedType) { + this.tableService.buildRenderRecords(); + this.tableService.buildRenderFields(); + } else { + const value = getDefaultValue(); + this.tableService.buildRenderRecords(value.records); + this.tableService.buildRenderFields(value.fields); + } + console.time('render'); + } + + ngAfterViewInit() { + console.timeEnd('render'); + } + + registryIcon() { + this.iconRegistry.addSvgIconSet(this.sanitizer.bypassSecurityTrustResourceUrl('assets/icons/defs/svg/sprite.defs.svg')); + } + + onChange(data: any) { + // TODO:获取当前的 view 和 path,转换为 sharedType 中原数据的 path + if (this.tableService.sharedType) { + if (!YjsAITable.isRemote(this.aiTable) && !YjsAITable.isUndo(this.aiTable)) { + YjsAITable.asLocal(this.aiTable, () => { + applyActionOps(this.tableService.sharedType!, data.actions, this.aiTable); + }); + } + } + } + + sort() { + const direction = this.tableService.activeView().sortCondition?.conditions[0].direction; + const sortCondition = { + keepSort: false, + conditions: [{ sortBy: 'column-4', direction: direction === Direction.ascending ? Direction.descending : Direction.ascending }] + }; + const index = this.tableService.views().indexOf(this.tableService.activeView()); + CustomActions.setView(this.aiTable as any, { sortCondition }, [index]); + } + + prevent(event: Event) { + event.stopPropagation(); + event.preventDefault(); + } + + aiTableInitialized(aiTable: AITable) { + this.aiTable = aiTable; + (this.aiTable as AIViewTable).views = this.tableService.views; + this.tableService.setAITable(aiTable); + } + + removeRecord() { + const recordIds = [...this.aiTable.selection().selectedRecords.keys()]; + recordIds.forEach((item) => { + const path = this.aiTable.records().findIndex((record) => record.id === item); + Actions.removeRecord(this.aiTable, [path]); + }); + } + + moveField() { + const newIndex = 2; + const selectedFieldIds = [...this.aiTable.selection().selectedFields.keys()]; + const selectedRecords = this.aiTable.fields().filter((item) => selectedFieldIds.includes(item.id)); + selectedRecords.forEach((item) => { + const path = AITableQueries.findPath(this.aiTable, item) as AIFieldPath; + Actions.moveField(this.aiTable, path, [newIndex]); + }); + } + + moveRecord() { + const selectedRecordIds = [...this.aiTable.selection().selectedRecords.keys()]; + const selectedRecords = this.aiTable.records().filter((item) => selectedRecordIds.includes(item.id)); + const selectedRecordsAfterNewPath: AITableRecord[] = []; + let offset = 0; + const newIndex = 2; + selectedRecords.forEach((item) => { + const path = AITableQueries.findPath(this.aiTable, undefined, item) as AIRecordPath; + if (path[0] < newIndex) { + Actions.moveRecord(this.aiTable, path, [newIndex]); + offset = 1; + } else { + selectedRecordsAfterNewPath.push(item); + } + }); + + selectedRecordsAfterNewPath.reverse().forEach((item) => { + const newPath = [newIndex + offset] as AIRecordPath; + const path = AITableQueries.findPath(this.aiTable, undefined, item) as AIRecordPath; + Actions.moveRecord(this.aiTable, path, newPath); + }); + } +} diff --git a/src/app/component/share/share.component.html b/src/app/component/share/share.component.html deleted file mode 100644 index 0d935d41..00000000 --- a/src/app/component/share/share.component.html +++ /dev/null @@ -1,20 +0,0 @@ - -
-删除行 -移动选中行到第三行 -移动选中列到第三列 -排序 - - diff --git a/src/app/component/share/share.component.ts b/src/app/component/share/share.component.ts deleted file mode 100644 index 449b06d1..00000000 --- a/src/app/component/share/share.component.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { AfterViewInit, Component, OnDestroy, OnInit, signal, isDevMode } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; -import { WebsocketProvider } from 'y-websocket'; -import { applyYjsEvents } from '../../share/apply-to-table'; -import applyActionOps from '../../share/apply-to-yjs'; -import { connectProvider } from '../../share/provider'; -import { SharedType, getSharedType } from '../../share/shared'; -import { translateSharedTypeToTable } from '../../share/utils/translate-to-table'; -import { YjsAITable } from '../../share/yjs-table'; -import { CommonComponent } from '../common/common.component'; -import { ThyAction } from 'ngx-tethys/action'; -import { AITable, AITableGrid } from '@ai-table/grid'; -import { ThyInputDirective } from 'ngx-tethys/input'; -import { FormsModule } from '@angular/forms'; - -@Component({ - selector: 'ai-table-share', - standalone: true, - imports: [RouterOutlet, CommonComponent, AITableGrid, ThyAction, FormsModule, ThyInputDirective], - templateUrl: './share.component.html' -}) -export class ShareComponent extends CommonComponent implements OnInit, AfterViewInit, OnDestroy { - sharedType!: SharedType | null; - - provider!: WebsocketProvider | null; - - room = 'room-1'; - - override ngOnInit(): void { - const value = this.getLocalStorage(); - this.records = signal(value.records); - this.fields = signal(value.fields); - console.time('shared-render'); - } - - override ngAfterViewInit() { - console.timeEnd('shared-render'); - } - - initialized(aiTable: AITable) { - this.aiTable = aiTable; - } - - handleShared() { - if (this.provider) { - this.disconnect(); - return; - } - const isInitializeSharedType = false; - this.sharedType = getSharedType( - { - records: this.records(), - fields: this.fields(), - views: this.views() - }, - !!isInitializeSharedType - ); - let isInitialized = false; - this.provider = connectProvider(this.sharedType.doc!, this.room, isDevMode()); - this.sharedType.observeDeep((events: any) => { - if (!YjsAITable.isLocal(this.aiTable)) { - if (!isInitialized) { - const data = translateSharedTypeToTable(this.sharedType!); - this.records.set(data.records); - this.fields.set(data.fields); - this.views.set(data.views); - isInitialized = true; - console.log(this.fields()); - } else { - applyYjsEvents(this.aiTable, events); - } - } - }); - if (!isInitializeSharedType) { - localStorage.setItem('ai-table-shared-type', 'true'); - } - } - - override onChange(data: any) { - super.onChange(data); - if (this.provider) { - if (!YjsAITable.isRemote(this.aiTable) && !YjsAITable.isUndo(this.aiTable)) { - YjsAITable.asLocal(this.aiTable, () => { - applyActionOps(this.sharedType!, data.actions, this.aiTable); - }); - } - } - } - - disconnect() { - if (this.provider) { - this.provider.disconnect(); - this.provider = null; - } - } - - override ngOnDestroy(): void { - this.disconnect(); - } -} diff --git a/src/app/component/table.component.html b/src/app/component/table.component.html new file mode 100644 index 00000000..da3f296f --- /dev/null +++ b/src/app/component/table.component.html @@ -0,0 +1,15 @@ + + + @for (item of tableService.views(); track $index) { + + @if (item.isActive) { + + } + + } + diff --git a/src/app/component/table.component.ts b/src/app/component/table.component.ts new file mode 100644 index 00000000..27a0021b --- /dev/null +++ b/src/app/component/table.component.ts @@ -0,0 +1,50 @@ +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Router, RouterOutlet } from '@angular/router'; +import { WebsocketProvider } from 'y-websocket'; +import { ThyAction } from 'ngx-tethys/action'; +import { AITableGrid } from '@ai-table/grid'; +import { FormsModule } from '@angular/forms'; +import { ThyPopoverModule } from 'ngx-tethys/popover'; +import { ThyTabs, ThyTab } from 'ngx-tethys/tabs'; +import { ThyInputDirective } from 'ngx-tethys/input'; +import { TableService } from '../service/table.service'; + +const initViews = [ + { id: 'view1', name: '表格视图1', isActive: true }, + { id: 'view2', name: '表格视图2' } +]; + +@Component({ + selector: 'demo-ai-table', + standalone: true, + imports: [RouterOutlet, AITableGrid, ThyAction, ThyTabs, ThyTab, ThyPopoverModule, FormsModule, ThyInputDirective], + templateUrl: './table.component.html', + providers: [TableService] +}) +export class DemoTable implements OnInit, OnDestroy { + provider!: WebsocketProvider | null; + + room = 'share-room-demo-1'; + + router = inject(Router); + + tableService = inject(TableService); + + ngOnInit(): void { + this.router.navigate(['/view1']); + this.tableService.initData(initViews); + } + + activeTabChange(data: any) { + this.tableService.updateActiveView(data); + this.router.navigateByUrl(`/${this.tableService.activeView().id}`); + } + + handleShared() { + this.tableService.handleShared(this.room); + } + + ngOnDestroy(): void { + this.tableService.disconnect(); + } +} diff --git a/src/app/service/table.service.ts b/src/app/service/table.service.ts new file mode 100644 index 00000000..b17358f3 --- /dev/null +++ b/src/app/service/table.service.ts @@ -0,0 +1,108 @@ +import { computed, Injectable, isDevMode, signal, WritableSignal } from '@angular/core'; +import { createSharedType, initSharedType, SharedType } from '../share/shared'; +import { WebsocketProvider } from 'y-websocket'; +import { getProvider } from '../share/provider'; +import { DemoAIField, DemoAIRecord } from '../types'; +import { getDefaultValue, sortDataByView } from '../utils/utils'; +import { applyYjsEvents } from '../share/apply-to-table'; +import { translateSharedTypeToTable } from '../share/utils/translate-to-table'; +import { YjsAITable } from '../share/yjs-table'; +import { AITable } from '@ai-table/grid'; +import { AITableView } from '../types/view'; +import { createDraft, finishDraft } from 'immer'; + +@Injectable() +export class TableService { + views!: WritableSignal; + + records!: WritableSignal; + + fields!: WritableSignal; + + aiTable!: AITable; + + provider!: WebsocketProvider | null; + + sharedType!: SharedType | null; + + activeView = computed(() => { + return this.views().find((view) => view?.isActive) as AITableView; + }); + + initData(views: AITableView[]) { + this.views = signal(views); + } + + updateActiveView(activeViewId: string) { + this.views.update((value) => { + const draftViews = createDraft(value); + draftViews.forEach((item) => { + if (item.isActive && item.id !== activeViewId) { + item.isActive = false; + } + if (!item.isActive && item.id === activeViewId) { + item.isActive = true; + } + }); + return finishDraft(draftViews); + }); + } + + setAITable(aiTable: AITable) { + this.aiTable = aiTable; + } + + buildRenderRecords(records?: DemoAIRecord[]) { + this.records = signal(sortDataByView(records ?? this.records(), this.activeView().id) as DemoAIRecord[]); + } + + buildRenderFields(fields?: DemoAIField[]) { + this.fields = signal(sortDataByView(fields ?? this.fields(), this.activeView().id) as DemoAIField[]); + } + + handleShared(room: string) { + if (this.provider) { + this.disconnect(); + return; + } + + let isInitialized = false; + if (!this.sharedType) { + this.sharedType = createSharedType(); + this.sharedType.observeDeep((events: any) => { + if (!YjsAITable.isLocal(this.aiTable)) { + if (!isInitialized) { + const data = translateSharedTypeToTable(this.sharedType!); + this.buildRenderRecords(data.records); + this.buildRenderFields(data.fields); + this.views.set(data.views); + isInitialized = true; + } else { + applyYjsEvents(this.aiTable, events); + } + } + }); + } + this.provider = getProvider(this.sharedType.doc!, room, isDevMode()); + this.provider.connect(); + this.provider.once('synced', () => { + if (this.provider!.synced && [...this.sharedType!.doc!.store.clients.keys()].length === 0) { + console.log('init shared type'); + const value = getDefaultValue(); + initSharedType(this.sharedType!.doc!, { + records: value.records, + fields: value.fields, + views: this.views() + }); + } + }); + } + + disconnect() { + if (this.provider) { + this.provider.disconnect(); + this.provider = null; + this.sharedType = null; + } + } +} diff --git a/src/app/share/apply-to-yjs/add-record.ts b/src/app/share/apply-to-yjs/add-record.ts index c83da905..0c905f73 100644 --- a/src/app/share/apply-to-yjs/add-record.ts +++ b/src/app/share/apply-to-yjs/add-record.ts @@ -1,3 +1,4 @@ +import { DemoAIRecord } from '../../types'; import { SharedType, toRecordSyncElement } from '../shared'; import { AddRecordAction } from '@ai-table/grid'; @@ -5,7 +6,7 @@ export default function addRecord(sharedType: SharedType, action: AddRecordActio const records = sharedType.get('records'); if (records) { const path = action.path[0]; - records.insert(path, [toRecordSyncElement(action.record)]); + records.insert(path, [toRecordSyncElement(action.record as DemoAIRecord)]); } return sharedType; diff --git a/src/app/share/provider.ts b/src/app/share/provider.ts index 7b507fe3..6a585655 100644 --- a/src/app/share/provider.ts +++ b/src/app/share/provider.ts @@ -1,11 +1,10 @@ import { WebsocketProvider } from 'y-websocket'; import * as Y from 'yjs'; -export const connectProvider = (doc: Y.Doc, room: string, isDev: boolean) => { +export const getProvider = (doc: Y.Doc, room: string, isDev: boolean) => { // 在线地址:wss://demos.yjs.dev/ws const prodUrl = `ws${location.protocol.slice(4)}//${location.host}/collaboration`; const devUrl = `ws${location.protocol.slice(4)}//${location.hostname}:3000`; const provider = new WebsocketProvider(isDev ? devUrl : prodUrl, room, doc); - provider.connect(); return provider; }; \ No newline at end of file diff --git a/src/app/share/shared.ts b/src/app/share/shared.ts index 698d7776..66f32720 100644 --- a/src/app/share/shared.ts +++ b/src/app/share/shared.ts @@ -1,6 +1,6 @@ -import { AITableFields, AITableRecord, AITableRecords } from '@ai-table/grid'; import { isArray, isObject } from 'ngx-tethys/util'; import * as Y from 'yjs'; +import { DemoAIField, DemoAIRecord, Positions } from '../types'; import { AITableView } from '../types/view'; export type SyncMapElement = Y.Map; @@ -8,41 +8,46 @@ export type SyncArrayElement = Y.Array>; export type SyncElement = Y.Array; export type SharedType = Y.Map; -export const getSharedType = ( - initializeValue: { - fields: AITableFields; - records: AITableRecords; - views: AITableView[] - }, - isInitializeSharedType: boolean -) => { +export const createSharedType = () => { const doc = new Y.Doc(); const sharedType = doc.getMap('ai-table'); - if (!isInitializeSharedType) { - toSharedType(sharedType, initializeValue); + return sharedType; +}; + +export const initSharedType = ( + doc: Y.Doc, + initializeValue: { + fields: DemoAIField[]; + records: DemoAIRecord[]; + views: AITableView[]; } +) => { + const sharedType = doc.getMap('ai-table'); + toSharedType(sharedType, initializeValue); return sharedType; }; export function toSharedType( sharedType: Y.Map, data: { - fields: AITableFields; - records: AITableRecords; - views: AITableView[] + fields: DemoAIField[]; + records: DemoAIRecord[]; + views: AITableView[]; } ): void { - const fieldSharedType = new Y.Array(); - sharedType.set('fields', fieldSharedType); - fieldSharedType.insert(0, data.fields.map(toSyncElement)); + sharedType.doc!.transact(() => { + const fieldSharedType = new Y.Array(); + fieldSharedType.insert(0, data.fields.map(toSyncElement)); + sharedType.set('fields', fieldSharedType); - const recordSharedType = new Y.Array>(); - sharedType.set('records', recordSharedType); - recordSharedType.insert(0, data.records.map(toRecordSyncElement)); + const recordSharedType = new Y.Array>(); + sharedType.set('records', recordSharedType); + recordSharedType.insert(0, data.records.map(toRecordSyncElement)); - const viewsSharedType = new Y.Array(); - sharedType.set('views', viewsSharedType); - viewsSharedType.insert(0, data.views.map(toSyncElement)) + const viewsSharedType = new Y.Array(); + sharedType.set('views', viewsSharedType); + viewsSharedType.insert(0, data.views.map(toSyncElement)); + }); } export function toSyncElement(node: any): SyncMapElement { @@ -64,19 +69,19 @@ export function toSyncElement(node: any): SyncMapElement { return element; } -export function toRecordSyncElement(record: AITableRecord): Y.Array> { - const fixedFieldArray = new Y.Array(); - fixedFieldArray.insert(0, [record['id']]); +export function toRecordSyncElement(record: DemoAIRecord): Y.Array> { + const nonEditableArray = new Y.Array(); + nonEditableArray.insert(0, [record['id']]); - const customFieldArray = new Y.Array(); - const customFields = []; + const editableArray = new Y.Array(); + const editableFields = []; for (const fieldId in record['values']) { - customFields.push(record['values'][fieldId]); + editableFields.push(record['values'][fieldId]); } - customFieldArray.insert(0, customFields); + editableArray.insert(0, [...editableFields, record['positions']]); // To save memory, convert map to array. const element = new Y.Array>(); - element.insert(0, [fixedFieldArray, customFieldArray]); + element.insert(0, [nonEditableArray, editableArray]); return element; } diff --git a/src/app/share/utils/translate-to-table.ts b/src/app/share/utils/translate-to-table.ts index 8e2f3939..b9e8a96f 100644 --- a/src/app/share/utils/translate-to-table.ts +++ b/src/app/share/utils/translate-to-table.ts @@ -1,5 +1,6 @@ -import { AITableFields, AITableRecords, Path } from '@ai-table/grid'; +import { AITableFields, Path } from '@ai-table/grid'; import { SharedType } from '../shared'; +import { DemoAIField, DemoAIRecord } from '../../types'; export const translateRecord = (arrayRecord: any[], fields: AITableFields) => { const fieldIds = fields.map((item) => item.id); @@ -12,15 +13,16 @@ export const translateRecord = (arrayRecord: any[], fields: AITableFields) => { export const translateSharedTypeToTable = (sharedType: SharedType) => { const data = sharedType.toJSON(); - const fields: AITableFields = data['fields']; - const records: AITableRecords = data['records'].map((record: any) => { - const [fixedField, customField] = record; + const fields: DemoAIField[] = data['fields']; + const records: DemoAIRecord[] = data['records'].map((record: any) => { + const [nonEditableArray, editableArray] = record; return { - id: fixedField[0], - values: translateRecord(customField, fields) + id: nonEditableArray[0], + positions: editableArray[editableArray.length - 1], + values: translateRecord(editableArray.slice(0, editableArray.length - 1), fields) }; }); - const views = data['views'] + const views = data['views']; return { records, fields, diff --git a/src/app/types/index.ts b/src/app/types/index.ts new file mode 100644 index 00000000..8eeb07c9 --- /dev/null +++ b/src/app/types/index.ts @@ -0,0 +1,19 @@ +import { ActionName, AITableField, AITableRecord } from '@ai-table/grid'; + +export class Positions { + [view_id: string]: number; +} + +export interface DemoAIField extends AITableField { + positions: Positions; +} + + +export interface DemoAIRecord extends AITableRecord { + positions: Positions; +} + + +export const UpdateRecordTypes = [ActionName.AddRecord, ActionName.RemoveRecord, ActionName.MoveRecord]; + +export const UpdateFieldTypes = [ActionName.AddField, ActionName.RemoveField, ActionName.MoveField]; \ No newline at end of file diff --git a/src/app/types/view.ts b/src/app/types/view.ts index aea10d3d..675a2be8 100644 --- a/src/app/types/view.ts +++ b/src/app/types/view.ts @@ -1,11 +1,5 @@ import { AITable } from '@ai-table/grid'; -import { Signal, WritableSignal } from '@angular/core'; - -export enum RowHeight { - short = 'short', - medium = 'medium', - tall = 'tall' -} +import { WritableSignal } from '@angular/core'; export enum Direction { default = 0, @@ -18,7 +12,6 @@ export interface AITableView { name: string; emoji_icon?: string; isActive?: boolean; - rowHeight?: RowHeight; sortCondition?: { keepSort: boolean; conditions: { @@ -39,13 +32,15 @@ export enum ViewActionName { setView = 'set_view' } -export interface AIViewAction { +export interface SetAIViewAction { type: ViewActionName.setView; view: Partial; newView: Partial; path: [number]; } +export type AIViewAction = SetAIViewAction; + export interface AIViewTable extends AITable { views: WritableSignal; viewApply: (action: AIViewAction) => void; diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts new file mode 100644 index 00000000..f14c7f6d --- /dev/null +++ b/src/app/utils/utils.ts @@ -0,0 +1,183 @@ +import { AITableFieldType } from '@ai-table/grid'; +import { DemoAIField, DemoAIRecord } from '../types'; +const LOCAL_STORAGE_KEY = 'ai-table-data'; + +export function sortDataByView(data: DemoAIRecord[] | DemoAIField[], activeViewId: string) { + const hasPositions = data.every((item) => item.positions && item.positions); + if (hasPositions) { + return [...data].sort((a, b) => a.positions[activeViewId] - b.positions[activeViewId]); + } + return data; +} + +export function getSortFieldsAndRecordsByPositions(records: DemoAIRecord[], fields: DemoAIField[], activeViewId: string) { + const newRecords = sortDataByView(records, activeViewId) as DemoAIRecord[]; + const newFields = sortDataByView(fields, activeViewId) as DemoAIField[]; + return { + records: newRecords, + fields: newFields + }; +} + +export function setActiveViewPositions(value: DemoAIRecord[] | DemoAIField[], activeViewId: string) { + return value.map((item, index) => { + return { + ...item, + positions: { + ...(item.positions || {}), + [activeViewId]: index + } + }; + }); +} + +export function getLocalStorage() { + const data = localStorage.getItem(`${LOCAL_STORAGE_KEY}`); + return data ? JSON.parse(data) : getDefaultValue(); +} + +export function setLocalData(data: string) { + localStorage.setItem(`${LOCAL_STORAGE_KEY}`, data); +} + +export function getDefaultValue() { + const initValue: { + records: DemoAIRecord[]; + fields: DemoAIField[]; + } = { + records: [ + { + id: 'row-1', + positions: { + view1: 0, + view2: 1 + }, + values: { + 'column-1': '文本 1-1', + 'column-2': '1', + 'column-3': { + url: 'https://www.baidu.com', + text: '百度链接' + }, + 'column-4': 3, + 'column-5': 10 + } + }, + { + id: 'row-2', + positions: { + view1: 1, + view2: 2 + }, + values: { + 'column-1': '文本 2-1', + 'column-2': '2', + 'column-3': {}, + 'column-4': 1, + 'column-5': 50 + } + }, + { + id: 'row-3', + positions: { + view1: 2, + view2: 0 + }, + values: { + 'column-1': '文本 3-1', + 'column-2': '3', + 'column-3': {}, + 'column-4': 1, + 'column-5': 100 + } + } + ], + fields: [ + { + id: 'column-1', + name: '文本', + positions: { + view1: 0, + view2: 1 + }, + type: AITableFieldType.text + }, + { + id: 'column-2', + name: '单选', + positions: { + view1: 1, + view2: 3 + }, + type: AITableFieldType.select, + options: [ + { + id: '1', + name: '开始', + color: '#5dcfff' + }, + { + id: '2', + name: '进行中', + color: '#ffcd5d' + }, + { + id: '3', + name: '已完成', + color: '#73d897' + } + ] + }, + { + id: 'column-3', + name: '链接', + positions: { + view1: 2, + view2: 2 + }, + type: AITableFieldType.link + }, + { + id: 'column-4', + name: '评分', + positions: { + view1: 3, + view2: 4 + }, + type: AITableFieldType.rate + }, + { + id: 'column-5', + name: '进度', + positions:{ + view1: 4, + view2: 0 + }, + type: AITableFieldType.progress + } + ] + }; + + // console.time('build data'); + // initValue.fields = []; + // for (let index = 0; index < 5; index++) { + // initValue.fields.push({ + // id: `column-${index}`, + // name: "文本", + // type: AITableFieldType.text, + // }); + // } + // initValue.records = []; + // for (let index = 0; index < 40 * 3 * 2*30; index++) { + // const value: any = {}; + // initValue.fields.forEach((column, columnIndex) => { + // value[`${column.id}`] = `text-${index}-${columnIndex}`; + // }); + // initValue.records.push({ + // id: `row-${index + 1}`, + // value: value, + // }); + // } + // console.timeEnd('build data'); + return initValue; +}