From 488a8e182daa5d0d8709153b87e3256c4c7239b5 Mon Sep 17 00:00:00 2001 From: huanhuanwa <44698191+huanhuanwa@users.noreply.github.com> Date: Thu, 25 Jul 2024 18:52:09 +0800 Subject: [PATCH] feat: add shared demo (#15) --- package-lock.json | 362 +++++++++++++++++- package.json | 4 +- packages/grid/src/core/utils/queries.ts | 20 +- src/app/app.component.html | 2 - src/app/app.component.ts | 64 +++- src/app/share/apply-to-table/array-event.ts | 67 ++++ src/app/share/apply-to-table/index.ts | 29 ++ src/app/share/apply-to-yjs/add-field.ts | 20 + src/app/share/apply-to-yjs/add-record.ts | 12 + src/app/share/apply-to-yjs/index.ts | 33 ++ .../share/apply-to-yjs/update-field-value.ts | 15 + src/app/share/provider.ts | 8 + src/app/share/shared.ts | 75 ++++ src/app/share/utils/translate-to-table.ts | 31 ++ src/app/share/yjs-table.ts | 57 +++ 15 files changed, 787 insertions(+), 12 deletions(-) create mode 100644 src/app/share/apply-to-table/array-event.ts create mode 100644 src/app/share/apply-to-table/index.ts create mode 100644 src/app/share/apply-to-yjs/add-field.ts create mode 100644 src/app/share/apply-to-yjs/add-record.ts create mode 100644 src/app/share/apply-to-yjs/index.ts create mode 100644 src/app/share/apply-to-yjs/update-field-value.ts create mode 100644 src/app/share/provider.ts create mode 100644 src/app/share/shared.ts create mode 100644 src/app/share/utils/translate-to-table.ts create mode 100644 src/app/share/yjs-table.ts diff --git a/package-lock.json b/package-lock.json index a6d6d4f9..7a1baf93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,9 @@ "karma-jasmine-html-reporter": "~2.1.0", "ng-packagr": "^18.0.0", "prettier": "^3.3.2", - "typescript": "~5.4.2" + "typescript": "~5.4.2", + "y-websocket": "^2.0.3", + "yjs": "^13.6.16" } }, "node_modules/@ai-table/grid": { @@ -4048,6 +4050,23 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abstract-leveldown": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", + "integrity": "sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==", + "dev": true, + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "immediate": "^3.2.3", + "level-concat-iterator": "~2.0.0", + "level-supports": "~1.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/accepts": { "version": "1.3.8", "dev": true, @@ -4348,6 +4367,13 @@ ], "license": "MIT" }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "dev": true, + "optional": true + }, "node_modules/asynckit": { "version": "0.4.0", "dev": true, @@ -7321,6 +7347,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deferred-leveldown": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-5.3.0.tgz", + "integrity": "sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==", + "dev": true, + "optional": true, + "dependencies": { + "abstract-leveldown": "~6.2.1", + "inherits": "^2.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "dev": true, @@ -7632,6 +7672,22 @@ "iconv-lite": "^0.6.2" } }, + "node_modules/encoding-down": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/encoding-down/-/encoding-down-6.3.0.tgz", + "integrity": "sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==", + "dev": true, + "optional": true, + "dependencies": { + "abstract-leveldown": "^6.2.1", + "inherits": "^2.0.3", + "level-codec": "^9.0.0", + "level-errors": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/encoding/node_modules/iconv-lite": { "version": "0.6.3", "dev": true, @@ -9568,6 +9624,13 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz", + "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", + "dev": true, + "optional": true + }, "node_modules/immer": { "version": "10.1.1", "license": "MIT", @@ -10077,6 +10140,16 @@ "node": ">=0.10.0" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "dev": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "dev": true, @@ -10705,6 +10778,182 @@ "node": ">=0.10.0" } }, + "node_modules/level": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz", + "integrity": "sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==", + "dev": true, + "optional": true, + "dependencies": { + "level-js": "^5.0.0", + "level-packager": "^5.1.0", + "leveldown": "^5.4.0" + }, + "engines": { + "node": ">=8.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, + "node_modules/level-codec": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-9.0.2.tgz", + "integrity": "sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==", + "dev": true, + "optional": true, + "dependencies": { + "buffer": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-concat-iterator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz", + "integrity": "sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==", + "dev": true, + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/level-errors/-/level-errors-2.0.1.tgz", + "integrity": "sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==", + "dev": true, + "optional": true, + "dependencies": { + "errno": "~0.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-iterator-stream": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/level-iterator-stream/-/level-iterator-stream-4.0.2.tgz", + "integrity": "sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==", + "dev": true, + "optional": true, + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^3.4.0", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-js": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/level-js/-/level-js-5.0.2.tgz", + "integrity": "sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==", + "dev": true, + "optional": true, + "dependencies": { + "abstract-leveldown": "~6.2.3", + "buffer": "^5.5.0", + "inherits": "^2.0.3", + "ltgt": "^2.1.2" + } + }, + "node_modules/level-packager": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/level-packager/-/level-packager-5.1.1.tgz", + "integrity": "sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==", + "dev": true, + "optional": true, + "dependencies": { + "encoding-down": "^6.3.0", + "levelup": "^4.3.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/level-supports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-1.0.1.tgz", + "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", + "dev": true, + "optional": true, + "dependencies": { + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leveldown": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/leveldown/-/leveldown-5.6.0.tgz", + "integrity": "sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "abstract-leveldown": "~6.2.1", + "napi-macros": "~2.0.0", + "node-gyp-build": "~4.1.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/leveldown/node_modules/node-gyp-build": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz", + "integrity": "sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==", + "dev": true, + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/levelup": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/levelup/-/levelup-4.4.0.tgz", + "integrity": "sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==", + "dev": true, + "optional": true, + "dependencies": { + "deferred-leveldown": "~5.3.0", + "level-errors": "~2.0.0", + "level-iterator-stream": "~4.0.0", + "level-supports": "~1.0.0", + "xtend": "~4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lib0": { + "version": "0.2.94", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.94.tgz", + "integrity": "sha512-hZ3p54jL4Wpu7IOg26uC7dnEWiMyNlUrb9KoG7+xYs45WkQwpVvKFndVq2+pqLYKe1u8Fp3+zAfZHVvTK34PvQ==", + "dev": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/license-webpack-plugin": { "version": "4.0.2", "dev": true, @@ -10936,6 +11185,13 @@ "yallist": "^3.0.2" } }, + "node_modules/ltgt": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", + "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", + "dev": true, + "optional": true + }, "node_modules/magic-string": { "version": "0.30.10", "dev": true, @@ -11552,6 +11808,13 @@ "node": ">=0.10.0" } }, + "node_modules/napi-macros": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.0.0.tgz", + "integrity": "sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==", + "dev": true, + "optional": true + }, "node_modules/needle": { "version": "3.3.1", "dev": true, @@ -18621,6 +18884,84 @@ "node": ">=0.4" } }, + "node_modules/y-leveldb": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz", + "integrity": "sha512-6ulEn5AXfXJYi89rXPEg2mMHAyyw8+ZfeMMdOtBbV8FJpQ1NOrcgi6DTAcXof0dap84NjHPT2+9d0rb6cFsjEg==", + "dev": true, + "optional": true, + "dependencies": { + "level": "^6.0.1", + "lib0": "^0.2.31" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "dev": true, + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-websocket": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-2.0.4.tgz", + "integrity": "sha512-UbrkOU4GPNFFTDlJYAxAmzZhia8EPxHkngZ6qjrxgIYCN3gI2l+zzLzA9p4LQJ0IswzpioeIgmzekWe7HoBBjg==", + "dev": true, + "dependencies": { + "lib0": "^0.2.52", + "lodash.debounce": "^4.0.8", + "y-protocols": "^1.0.5" + }, + "bin": { + "y-websocket": "bin/server.cjs", + "y-websocket-server": "bin/server.cjs" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^6.2.1", + "y-leveldb": "^0.1.0" + }, + "peerDependencies": { + "yjs": "^13.5.6" + } + }, + "node_modules/y-websocket/node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "dev": true, + "optional": true, + "dependencies": { + "async-limiter": "~1.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "dev": true, @@ -18670,6 +19011,23 @@ "node": ">=12" } }, + "node_modules/yjs": { + "version": "13.6.18", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.18.tgz", + "integrity": "sha512-GBTjO4QCmv2HFKFkYIJl7U77hIB1o22vSCSQD1Ge8ZxWbIbn8AltI4gyXbtL+g5/GJep67HCMq3Y5AmNwDSyEg==", + "dev": true, + "dependencies": { + "lib0": "^0.2.86" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "1.0.0", "dev": true, @@ -18687,7 +19045,7 @@ }, "packages/grid": { "name": "@ai-table/grid", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "tslib": "^2.3.0" }, diff --git a/package.json b/package.json index 2286d40c..53476aec 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "ng-packagr": "^18.0.0", "cpx": "^1.5.0", "@worktile/pkg-manager": "^0.1.0", - "chalk": "^2.4.2" + "chalk": "^2.4.2", + "y-websocket": "^2.0.3", + "yjs": "^13.6.16" } } diff --git a/packages/grid/src/core/utils/queries.ts b/packages/grid/src/core/utils/queries.ts index 83792fea..ae96d4bc 100644 --- a/packages/grid/src/core/utils/queries.ts +++ b/packages/grid/src/core/utils/queries.ts @@ -14,13 +14,23 @@ export const AITableQueries = { if (!isUndefinedOrNull(fieldIndex) && fieldIndex > -1) { return [fieldIndex] as AIFieldPath; } - throw new Error(`Unable to find the path: ${JSON.stringify({ ...(field || {}), ...(record || {}) })}`); + throw new Error(`can not find the path: ${JSON.stringify({ ...(field || {}), ...(record || {}) })}`); }, getFieldValue(aiTable: AITable, path: [number, number]): any { - if (!aiTable || !aiTable.records() || !aiTable.fields()) { - throw new Error(`Cannot find a descendant at path [${path}]`); + if (!aiTable) { + throw new Error(`aiTable does not exist [${path}]`); } - const fieldId = aiTable.fields()[path[1]].id; - return aiTable.records()[path[0]].value[fieldId]; + if (!aiTable.records()) { + throw new Error(`aiTable has no records [${path}]`); + } + if (!aiTable.fields()) { + throw new Error(`aiTable has no fields [${path}]`); + } + + const field = aiTable.fields()[path[1]]; + if (!field) { + throw new Error(`can not find field at path [${path}]`); + } + return aiTable.records()[path[0]].value[field.id]; } }; diff --git a/src/app/app.component.html b/src/app/app.component.html index db42c502..99655fec 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -5,5 +5,3 @@ [aiFieldConfig]="aiFieldConfig" (aiTableInitialized)="aiTableInitialized($event)" > - - diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e5376f9e..86c04166 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, OnInit, Signal, signal, WritableSignal } from '@angular/core'; +import { AfterViewInit, Component, OnDestroy, OnInit, Signal, signal, WritableSignal } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { RouterOutlet } from '@angular/router'; import { @@ -15,6 +15,13 @@ import { import { ThyIconRegistry } from 'ngx-tethys/icon'; import { ThyPopover, ThyPopoverModule } from 'ngx-tethys/popover'; import { FieldPropertyEditor } from './component/field-property-editor/field-property-editor.component'; +import { WebsocketProvider } from 'y-websocket'; +import { connectProvider } from './share/provider'; +import { SharedType, getSharedType } from './share/shared'; +import { YjsAITable } from './share/yjs-table'; +import applyActionOps from './share/apply-to-yjs'; +import { applyYjsEvents } from './share/apply-to-table'; +import { translateSharedTypeToTable } from './share/utils/translate-to-table'; const LOCAL_STORAGE_KEY = 'ai-table-data'; @@ -121,13 +128,17 @@ const initValue = { templateUrl: './app.component.html', styleUrl: './app.component.scss' }) -export class AppComponent implements OnInit, AfterViewInit { +export class AppComponent implements OnInit, AfterViewInit, OnDestroy { records!: WritableSignal; fields!: WritableSignal; aiTable!: AITable; + sharedType!: SharedType | null; + + provider!: WebsocketProvider | null; + aiFieldConfig: AIFieldConfig = { fieldPropertyEditor: FieldPropertyEditor, fieldMenus: [ @@ -156,9 +167,39 @@ export class AppComponent implements OnInit, AfterViewInit { const value = this.getLocalStorage(); this.records = signal(value.records); this.fields = signal(value.fields); + this.initSharedType(); console.time('render'); } + initSharedType() { + const isInitializeSharedType = localStorage.getItem('ai-table-shared-type'); + this.sharedType = getSharedType( + { + records: this.records(), + fields: this.fields() + }, + !!isInitializeSharedType + ); + let isInitialized = false; + this.provider = connectProvider(this.sharedType.doc!); + this.sharedType.observeDeep((events: any) => { + if (!YjsAITable.isLocal(this.aiTable)) { + if (!isInitialized) { + const data = translateSharedTypeToTable(this.sharedType!); + console.log(123, data); + this.records.set(data.records); + this.fields.set(data.fields); + isInitialized = true; + } else { + applyYjsEvents(this.aiTable, events); + } + } + }); + if (!isInitializeSharedType) { + localStorage.setItem('ai-table-shared-type', 'true'); + } + } + registryIcon() { this.iconRegistry.addSvgIconSet(this.sanitizer.bypassSecurityTrustResourceUrl('assets/icons/defs/svg/sprite.defs.svg')); } @@ -175,6 +216,13 @@ export class AppComponent implements OnInit, AfterViewInit { records: data.records }) ); + if (this.provider) { + if (!YjsAITable.isRemote(this.aiTable) && !YjsAITable.isUndo(this.aiTable)) { + YjsAITable.asLocal(this.aiTable, () => { + applyActionOps(this.sharedType!, data.actions, this.aiTable); + }); + } + } } aiTableInitialized(aiTable: AITable) { @@ -189,4 +237,16 @@ export class AppComponent implements OnInit, AfterViewInit { const data = localStorage.getItem(`${LOCAL_STORAGE_KEY}`); return data ? JSON.parse(data) : initValue; } + + + disconnect() { + if (this.provider) { + this.provider.disconnect(); + this.provider = null; + } + } + + ngOnDestroy(): void { + this.disconnect(); + } } diff --git a/src/app/share/apply-to-table/array-event.ts b/src/app/share/apply-to-table/array-event.ts new file mode 100644 index 00000000..3263fc90 --- /dev/null +++ b/src/app/share/apply-to-table/array-event.ts @@ -0,0 +1,67 @@ +import { ActionName, AIFieldValuePath, AITable, AITableAction, AITableField, AITableQueries } from '@ai-table/grid'; +import * as Y from 'yjs'; +import { toTablePath, translateRecord } from '../utils/translate-to-table'; +import { isArray } from 'ngx-tethys/util'; + +export default function translateArrayEvent(aiTable: AITable, event: Y.YEvent): AITableAction[] { + const actions: AITableAction[] = []; + let offset = 0; + let targetPath = toTablePath(event.path); + const isRecordsTranslate = event.path.includes('records'); + const isFieldsTranslate = event.path.includes('fields'); + + event.changes.delta.forEach((delta) => { + if ('retain' in delta) { + offset += delta.retain ?? 0; + } + if ('insert' in delta) { + if (isArray(delta.insert)) { + if (isRecordsTranslate) { + if (targetPath.length) { + try { + delta.insert?.map((item: any) => { + const path = [targetPath[0], offset] as AIFieldValuePath; + const fieldValue = AITableQueries.getFieldValue(aiTable, path); + // To exclude insert triggered by field inserts. + if (fieldValue !== item) { + actions.push({ + type: ActionName.UpdateFieldValue, + path, + fieldValue, + newFieldValue: item + }); + } + }); + } catch (error) {} + } else { + delta.insert?.map((item: Y.Array, index) => { + const data = item.toJSON(); + const [fixedField, customField] = data; + actions.push({ + type: ActionName.AddRecord, + path: [offset + index], + record: { + id: fixedField[0], + value: translateRecord(customField, aiTable.fields()) + } + }); + }); + } + } + if (isFieldsTranslate) { + delta.insert?.map((item: Y.Map, index) => { + const data = item.toJSON(); + if (event.path.includes('fields')) { + actions.push({ + type: ActionName.AddField, + path: [offset + index], + field: data as AITableField + }); + } + }); + } + } + } + }); + return actions; +} diff --git a/src/app/share/apply-to-table/index.ts b/src/app/share/apply-to-table/index.ts new file mode 100644 index 00000000..3c6b382f --- /dev/null +++ b/src/app/share/apply-to-table/index.ts @@ -0,0 +1,29 @@ +import * as Y from 'yjs'; +import { AITable, AITableAction } from '@ai-table/grid'; +import translateArrayEvent from './array-event'; +import { YjsAITable } from '../yjs-table'; + +export function translateYjsEvent(aiTable: AITable, event: Y.YEvent): AITableAction[] { + if (event instanceof Y.YArrayEvent) { + return translateArrayEvent(aiTable, event); + } + return []; +} + +export function applyYjsEvents(aiTable: AITable, events: Y.YEvent[]): void { + if (YjsAITable.isUndo(aiTable)) { + events.forEach((event) => + translateYjsEvent(aiTable, event).forEach((item) => { + aiTable.apply(item); + }) + ); + } else { + YjsAITable.asRemote(aiTable, () => { + events.forEach((event) => + translateYjsEvent(aiTable, event).forEach((item) => { + aiTable.apply(item); + }) + ); + }); + } +} diff --git a/src/app/share/apply-to-yjs/add-field.ts b/src/app/share/apply-to-yjs/add-field.ts new file mode 100644 index 00000000..2072f839 --- /dev/null +++ b/src/app/share/apply-to-yjs/add-field.ts @@ -0,0 +1,20 @@ +import { SharedType, SyncArrayElement, toSyncElement } from '../shared'; +import { AddFieldAction, getDefaultFieldValue } from '@ai-table/grid'; + +export default function addField(sharedType: SharedType, action: AddFieldAction): SharedType { + const fields = sharedType.get('fields'); + const path = action.path[0]; + if (fields) { + fields.insert(path, [toSyncElement(action.field)]); + } + const records = sharedType.get('records') as SyncArrayElement; + if (records) { + for (let value of records) { + const newRecord = getDefaultFieldValue(action.field.type); + const customField = value.get(1); + customField.insert(path, [newRecord]); + } + } + + return sharedType; +} diff --git a/src/app/share/apply-to-yjs/add-record.ts b/src/app/share/apply-to-yjs/add-record.ts new file mode 100644 index 00000000..c83da905 --- /dev/null +++ b/src/app/share/apply-to-yjs/add-record.ts @@ -0,0 +1,12 @@ +import { SharedType, toRecordSyncElement } from '../shared'; +import { AddRecordAction } from '@ai-table/grid'; + +export default function addRecord(sharedType: SharedType, action: AddRecordAction): SharedType { + const records = sharedType.get('records'); + if (records) { + const path = action.path[0]; + records.insert(path, [toRecordSyncElement(action.record)]); + } + + return sharedType; +} diff --git a/src/app/share/apply-to-yjs/index.ts b/src/app/share/apply-to-yjs/index.ts new file mode 100644 index 00000000..83eb2ca6 --- /dev/null +++ b/src/app/share/apply-to-yjs/index.ts @@ -0,0 +1,33 @@ +import { AITable, AITableAction } from '@ai-table/grid'; +import { SharedType } from '../shared'; +import updateFieldValue from './update-field-value'; +import addRecord from './add-record'; +import addField from './add-field'; + +export type ActionMapper = { + [K in O['type']]: O extends { type: K } ? ApplyFunc : never; +}; + +export type ApplyFunc = (sharedType: SharedType, op: O) => SharedType; + +export const actionMappers: Partial> = { + update_field_value: updateFieldValue, + add_record: addRecord, + add_field: addField +}; + +export default function applyActionOps(sharedType: SharedType, actions: AITableAction[], aiTable: AITable): SharedType { + if (actions.length > 0) { + sharedType.doc!.transact(() => { + actions.forEach((action) => { + const apply = actionMappers[action.type] as ApplyFunc; + if (apply) { + return apply(sharedType, action); + } + return null; + }); + }, aiTable); + } + + return sharedType; +} diff --git a/src/app/share/apply-to-yjs/update-field-value.ts b/src/app/share/apply-to-yjs/update-field-value.ts new file mode 100644 index 00000000..d7f78955 --- /dev/null +++ b/src/app/share/apply-to-yjs/update-field-value.ts @@ -0,0 +1,15 @@ +import { SharedType, SyncArrayElement } from '../shared'; +import { UpdateFieldValueAction } from '@ai-table/grid'; + +export default function updateFieldValue(sharedType: SharedType, action: UpdateFieldValueAction): SharedType { + const records = sharedType.get('records'); + if (records) { + const record = records?.get(action.path[0]) as SyncArrayElement; + const customField = record.get(1); + const index = action.path[1]; + customField.delete(index); + customField.insert(index, [action.newFieldValue]); + } + + return sharedType; +} diff --git a/src/app/share/provider.ts b/src/app/share/provider.ts new file mode 100644 index 00000000..3bd6caba --- /dev/null +++ b/src/app/share/provider.ts @@ -0,0 +1,8 @@ +import { WebsocketProvider } from 'y-websocket'; +import * as Y from 'yjs'; + +export const connectProvider = (doc: Y.Doc) => { + const provider = new WebsocketProvider('wss://demos.yjs.dev/ws', 'ai-table-demo-2024/7/25', doc); + provider.connect(); + return provider; +}; diff --git a/src/app/share/shared.ts b/src/app/share/shared.ts new file mode 100644 index 00000000..800cdf46 --- /dev/null +++ b/src/app/share/shared.ts @@ -0,0 +1,75 @@ +import { AITableFields, AITableRecord, AITableRecords } from '@ai-table/grid'; +import { isArray, isObject } from 'ngx-tethys/util'; +import * as Y from 'yjs'; + +export type SyncMapElement = Y.Map; +export type SyncArrayElement = Y.Array>; +export type SyncElement = Y.Array; +export type SharedType = Y.Map; + +export const getSharedType = ( + initializeValue: { + fields: AITableFields; + records: AITableRecords; + }, + isInitializeSharedType: boolean +) => { + const doc = new Y.Doc(); + const sharedType = doc.getMap('ai-table'); + if (!isInitializeSharedType) { + toSharedType(sharedType, initializeValue); + } + return sharedType; +}; + +export function toSharedType( + sharedType: Y.Map, + data: { + fields: AITableFields; + records: AITableRecords; + } +): void { + const fieldSharedType = new Y.Array(); + sharedType.set('fields', fieldSharedType); + fieldSharedType.insert(0, data.fields.map(toSyncElement)); + + const recordSharedType = new Y.Array>(); + sharedType.set('records', recordSharedType); + recordSharedType.insert(0, data.records.map(toRecordSyncElement)); +} + +export function toSyncElement(node: any): SyncMapElement { + const element: SyncMapElement = new Y.Map(); + for (const key in node) { + if (isArray(node[key])) { + const arrayElement = new Y.Array(); + element.set(key, arrayElement); + const arrayContainer = node[key].map(toSyncElement); + arrayElement.insert(0, arrayContainer); + } else if (isObject(node[key])) { + const mapElement = toSyncElement(node[key]); + element.set(key, mapElement); + } else { + element.set(key, node[key]); + } + } + + return element; +} + +export function toRecordSyncElement(record: AITableRecord): Y.Array> { + const fixedFieldArray = new Y.Array(); + fixedFieldArray.insert(0, [record['id']]); + + const customFieldArray = new Y.Array(); + const customFields = []; + for (const fieldId in record['value']) { + customFields.push(record['value'][fieldId]); + } + customFieldArray.insert(0, customFields); + + // To save memory, convert map to array. + const element = new Y.Array>(); + element.insert(0, [fixedFieldArray, customFieldArray]); + return element; +} diff --git a/src/app/share/utils/translate-to-table.ts b/src/app/share/utils/translate-to-table.ts new file mode 100644 index 00000000..27954481 --- /dev/null +++ b/src/app/share/utils/translate-to-table.ts @@ -0,0 +1,31 @@ +import { AITableFields, AITableRecords, Path } from '@ai-table/grid'; +import { SharedType } from '../shared'; + +export const translateRecord = (arrayRecord: any[], fields: AITableFields) => { + const fieldIds = fields.map((item) => item.id); + const recordValue: Record = {}; + fieldIds.forEach((item, index) => { + recordValue[item] = arrayRecord[index] || ''; + }); + return recordValue; +}; + +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; + return { + id: fixedField[0], + value: translateRecord(customField, fields) + }; + }); + return { + records, + fields + }; +}; + +export function toTablePath(path: (string | number)[]): Path { + return path.filter((node) => typeof node === 'number') as Path; +} diff --git a/src/app/share/yjs-table.ts b/src/app/share/yjs-table.ts new file mode 100644 index 00000000..cbafc730 --- /dev/null +++ b/src/app/share/yjs-table.ts @@ -0,0 +1,57 @@ +import { AITable } from '@ai-table/grid'; + +const IS_LOCAL: WeakSet = new WeakSet(); +const IS_REMOTE: WeakSet = new WeakSet(); +const IS_UNDO: WeakSet = new WeakSet(); + +export interface YjsAITable { + isLocal: () => boolean; + asLocal: () => void; +} + +export const YjsAITable = { + isLocal: (aiTable: AITable): boolean => { + return IS_LOCAL.has(aiTable); + }, + + asLocal: (aiTable: AITable, fn: () => void): void => { + const wasLocal = YjsAITable.isLocal(aiTable); + IS_LOCAL.add(aiTable); + + fn(); + + if (!wasLocal) { + IS_LOCAL.delete(aiTable); + } + }, + + isRemote: (aiTable: AITable): boolean => { + return IS_REMOTE.has(aiTable); + }, + + asRemote: (aiTable: AITable, fn: () => void): void => { + const wasRemote = YjsAITable.isRemote(aiTable); + IS_REMOTE.add(aiTable); + + fn(); + + if (!wasRemote) { + Promise.resolve().then(() => IS_REMOTE.delete(aiTable)); + } + }, + + isUndo: (aiTable: AITable): boolean => { + return IS_UNDO.has(aiTable); + }, + + asUndo: (aiTable: AITable, fn: () => void): void => { + const wasUndo = YjsAITable.isUndo(aiTable); + IS_UNDO.add(aiTable); + + fn(); + + if (!wasUndo) { + Promise.resolve().then(() => IS_UNDO.delete(aiTable)); + } + } +};