diff --git a/.env.example b/.env.example index 68c3964e..eb46e341 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,2 @@ -# supabase url -VITE_SUPABASE_URL= -# supabase anon key -VITE_SUPABASE_ANON_KEY= # default excel id -VITE_DEFAULT_EXCEL_ID= \ No newline at end of file +VITE_DEFAULT_EXCEL_ID= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a9c54459..e36f94ee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,8 +15,6 @@ concurrency: cancel-in-progress: true env: - VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }} - VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }} VITE_DEFAULT_EXCEL_ID: ${{ vars.VITE_DEFAULT_EXCEL_ID }} jobs: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index aa6c80ce..5e2f17ce 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,6 @@ jobs: run: | npm i -g pnpm pnpm i - pnpm exec playwright install --with-deps npm run type-check npm run lint npm run build @@ -29,5 +28,3 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - - name: E2E test - run: npm run e2e diff --git a/.gitignore b/.gitignore index f26ddf3d..c2dd6108 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ vite.config.mts.*.mjs diff.txt .nx pnpm-lock.yaml +levelDB \ No newline at end of file diff --git a/README.md b/README.md index 4a856b1e..70dab08a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![GitHub](https://img.shields.io/github/license/nusr/excel.svg) ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/nusr/excel.svg) -[online demo](https://stackblitz.com/edit/nusr-excel-collaboration) +[online demo](https://nusr.github.io/excel) ![demo](./scripts/demo.gif) @@ -31,33 +31,7 @@ cd excel npm i -g pnpm pnpm i -npm run start -``` - -## Environment - -Create an `.env` file and modify it as the `.env.example` file - -## Supbase - -Collaborative editing uses [Supabase](https://supabase.com/) as backend. -You need to configure `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY`. -With Row Level Security (RLS) disabled, anonymous users will be able to read/write data in the table. - -```sql -CREATE TABLE IF NOT EXISTS history ( - id SERIAL PRIMARY KEY, - doc_id UUID, - update TEXT, - create_time TIMESTAMP WITH TIME ZONE NULL DEFAULT NOW(), -); - --- document table need to enable real time -CREATE TABLE IF NOT EXISTS document ( - id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, - name VARCHAR(20), - create_time TIMESTAMP WITH TIME ZONE NULL DEFAULT NOW(), -); +npm run dev ``` ## Supported Features diff --git a/demo/index.tsx b/demo/index.tsx index f849aa32..bb5bfafe 100644 --- a/demo/index.tsx +++ b/demo/index.tsx @@ -8,15 +8,12 @@ import { type WorkerMethod, Excel, version, + UserItem, useUserInfo, } from '../src'; import Worker from './worker?worker'; import './sentry'; -import { CollaborationProvider } from './server'; import { WebsocketProvider } from 'y-websocket'; -import { WebrtcProvider } from 'y-webrtc'; - -const workerInstance = wrap(new Worker()); const docId = import.meta.env.VITE_DEFAULT_EXCEL_ID || @@ -24,62 +21,45 @@ const docId = const doc = initDoc({ guid: docId }); location.hash = `#${docId}`; -const providerType = new URLSearchParams(location.search).get('providerType'); - -let provider: CollaborationProvider | undefined = undefined; -if (providerType === 'websocket') { - const websocketProvider = new WebsocketProvider( - 'ws://localhost:1234', - doc.guid, - doc, - ); - - websocketProvider.on('status', (...args) => { - console.log('status:', ...args); - }); - websocketProvider.on('sync', (...args) => { - console.log('sync:', ...args); - }); - console.log('websocketProvider', websocketProvider); -} else if (providerType === 'webrtc') { - const webrtcProvider = new WebrtcProvider(doc.guid, doc, { - signaling: ['ws://localhost:4444'], - }); - - webrtcProvider.on('synced', (...args) => { - console.log('synced:', ...args); - }); - webrtcProvider.on('peers', (...args) => { - console.log('peers:', ...args); - }); - webrtcProvider.on('status', (...args) => { - console.log('status:', ...args); - }); - console.log('webrtcProvider', webrtcProvider); -} else { - provider = new CollaborationProvider({ - doc, - supabaseUrl: import.meta.env.VITE_SUPABASE_URL, - supabaseAnonKey: import.meta.env.VITE_SUPABASE_ANON_KEY, - enableIndexDb: false, - }); +const provider = new WebsocketProvider('ws://localhost:1234', doc.guid, doc, { + connect: false, +}); - provider.setAwarenessChangeCallback((users) => { - useUserInfo.getState().setUsers(users); - }); -} +provider.connect(); +provider.awareness.on('update', () => { + const list: UserItem[] = []; + for (const item of provider.awareness.getStates().entries()) { + const [key, value] = item; + if (!value.range || key === doc.clientID) { + continue; + } + list.push({ clientId: key, range: value.range }); + } + console.log(list) + useUserInfo.getState().setUsers(list); +}); +const workerInstance = wrap(new Worker()); const controller = initController({ worker: workerInstance, doc, }); + +controller.on('rangeChange', (range) => { + provider.awareness.setLocalStateField('range', range); +}); + +doc.on('update', () => { + controller.emit('renderChange', { changeSet: new Set(['rangeMap']) }); +}); + (window as any).controller = controller; (window as any).doc = doc; (window as any).version = version; createRoot(document.getElementById('root')!).render( - + , diff --git a/demo/local.ts b/demo/local.ts deleted file mode 100644 index 280bdf20..00000000 --- a/demo/local.ts +++ /dev/null @@ -1,86 +0,0 @@ -import Dexie, { Table } from 'dexie'; -import { uint8ArrayToString, DocumentItem, HistoryItem } from '../src'; - -function sortByCreateTime( - a: HistoryItem | DocumentItem, - b: HistoryItem | DocumentItem, - isDesc = false, -) { - const bTime = new Date(b.create_time).getTime(); - const aTime = new Date(a.create_time).getTime(); - return isDesc ? bTime - aTime : aTime - bTime; -} - -export class DocumentDB extends Dexie { - readonly document!: Table; - readonly history!: Table; - constructor(version = 1) { - super('excel'); - this.version(version).stores({ - document: 'id', - history: '++id, doc_id', - }); - this.on('populate', () => { - this.resetDatabase(); - }); - } - - deleteDocument(docId: string) { - return this.transaction('rw', this.history, this.document, () => { - this.history.where({ doc_id: docId }).delete(); - this.document.delete(docId); - }); - } - updateDocument(docId: string, name: string) { - return this.document.update(docId, { name }); - } - getDocument(docId: string) { - return this.document.get(docId); - } - addDocument(docId: string, name: string, sync = false) { - return this.document.add({ - id: docId, - name, - create_time: new Date().toISOString(), - sync, - }); - } - addHistory(docId: string, update: Uint8Array, sync = false) { - if (update.length > 65535) { - return Promise.resolve(0); - } - const str = uint8ArrayToString(update); - return this.history.add({ - doc_id: docId, - update: str, - create_time: new Date().toISOString(), - sync, - }); - } - async getAllHistory(docId: string) { - const list = await this.history.where({ doc_id: docId }).toArray(); - list.sort((a, b) => sortByCreateTime(a, b, true)); - return list; - } - syncData( - callback: ( - documentList: DocumentItem[], - historyList: HistoryItem[], - ) => Promise, - ) { - return this.transaction('rw', this.document, this.history, async () => { - const documentList = await this.document.where({ sync: false }).toArray(); - const historyList = await this.history.where({ sync: false }).toArray(); - documentList.sort(sortByCreateTime); - historyList.sort(sortByCreateTime); - await callback(documentList, historyList); - await this.history.where({ sync: false }).modify({ sync: true }); - await this.document.where({ sync: false }).modify({ sync: true }); - }); - } - resetDatabase() { - return this.transaction('rw', this.document, this.history, async () => { - await Promise.all(this.tables.map((table) => table.clear())); - }); - } -} diff --git a/demo/server.ts b/demo/server.ts deleted file mode 100644 index 263e1b69..00000000 --- a/demo/server.ts +++ /dev/null @@ -1,345 +0,0 @@ -import type { RealtimeChannel } from '@supabase/supabase-js'; -import { SupabaseClient } from '@supabase/supabase-js'; -import { - SYNC_FLAG, - UserItem, - ICollaborationProvider, - DocumentItem, - collaborationLog, - omit, - uint8ArrayToString, - stringToUint8Array, - shouldSkipUpdate, - HistoryItem, -} from '../src'; -import * as Y from 'yjs'; -import { DocumentDB } from './local'; -import { v4 as uuidV4 } from 'uuid'; -import * as awarenessProtocol from 'y-protocols/awareness'; - -interface Database { - public: { - Tables: { - document: { - Row: Required; - Insert: { - id?: string; - name: string; - create_time?: never; - }; - Update: { - id?: string; - name?: string; - create_time?: never; - }; - }; - history: { - Row: Required; - Insert: { - id?: number; - doc_id: string; - update: string; - create_time?: never; - }; - }; - }; - }; -} - -type CollaborationOptions = { - doc: Y.Doc; - supabaseUrl?: string; - supabaseAnonKey?: string; - enableIndexDb?: boolean; -}; - -const DIRECTORY = 'supabase/'; - -type EventType = 'message' | 'awareness'; - -const BROADCAST = 'broadcast'; - -export class CollaborationProvider implements ICollaborationProvider { - private readonly doc: Y.Doc; - private readonly remoteDB: SupabaseClient | null = null; - private readonly channel: RealtimeChannel | null = null; - private readonly localDB: DocumentDB | null = null; - private readonly broadcastChannel: BroadcastChannel; - private readonly awareness: awarenessProtocol.Awareness; - private awarenessChangeCallback: ((users: UserItem[]) => void) | null = null; - constructor(options: CollaborationOptions) { - const { doc } = options; - if (options.supabaseUrl && options.supabaseAnonKey) { - this.remoteDB = new SupabaseClient( - options.supabaseUrl, - options.supabaseAnonKey, - ); - this.channel = this.remoteDB.channel(doc.guid, { - config: { broadcast: { ack: false } }, - }); - } - this.doc = doc; - if (typeof indexedDB !== 'undefined' && options.enableIndexDb) { - this.localDB = new DocumentDB(2); - } - this.broadcastChannel = new BroadcastChannel(this.docId); - this.awareness = new awarenessProtocol.Awareness(doc); - this.subscribe(); - } - getDoc() { - return this.doc; - } - private get clientID() { - return this.doc.clientID; - } - private get docId() { - return this.doc.guid; - } - - setAwarenessChangeCallback(callback: (users: UserItem[]) => void): void { - this.awarenessChangeCallback = callback; - } - - private isOnline() { - return navigator.onLine && this.remoteDB !== null; - } - - private subscribe() { - this.awareness.on('update', this.onAwarenessUpdate); - this.broadcastChannel.addEventListener( - 'message', - ( - event: MessageEvent<{ - update: Uint8Array; - type: EventType; - clientID: number; - }>, - ) => { - const { update, type, clientID } = event.data; - if (this.isOnline() || this.clientID === clientID) { - return; - } - collaborationLog('onmessage', event); - this.applyUpdate(update, type); - }, - ); - - this.channel - ?.on(BROADCAST, { event: this.docId }, (payload) => { - const { update, clientID, type } = payload?.payload || {}; - if (!update || this.clientID === clientID) { - return; - } - collaborationLog('subscribe:', payload); - const updateData = stringToUint8Array(update); - this.applyUpdate(updateData, type); - }) - .subscribe((status, error) => { - if (error) { - collaborationLog('subscribe error:', error, status); - } - if (status === 'SUBSCRIBED') { - collaborationLog('subscribe connect success'); - } - }); - window.addEventListener('beforeunload', this.onDisconnect); - - this.awareness.on('change', () => { - const list = this.awareness.getStates(); - if (!list) { - return; - } - const users: UserItem[] = []; - for (const [key, value] of list.entries()) { - if (key === this.clientID || !value) { - continue; - } - const userData = value as Pick< - UserItem, - 'range' | 'userId' | 'userName' - >; - users.push({ - range: userData.range, - clientId: key, - userName: userData.userName, - userId: userData.userId, - }); - } - collaborationLog('awareness', users); - this.awarenessChangeCallback?.(users); - }); - - this.doc.on('update', (update: Uint8Array, _b, _c, tran) => { - if (shouldSkipUpdate(tran)) { - return; - } - collaborationLog('doc update', tran.doc.clientID, tran); - this.addHistory(update); - }); - - this.onConnect(); - } - - private applyUpdate(update: Uint8Array, type: EventType) { - if (type === 'message') { - Y.applyUpdate(this.doc, update, SYNC_FLAG.SKIP_UPDATE); - } else if (type === 'awareness') { - awarenessProtocol.applyAwarenessUpdate(this.awareness, update, this); - } - } - private onConnect() { - if (this.awareness.getLocalState() !== null) { - const awarenessUpdate = awarenessProtocol.encodeAwarenessUpdate( - this.awareness, - [this.clientID], - ); - this.broadcastChannel.postMessage({ - update: awarenessUpdate, - type: 'awareness', - clientId: this.clientID, - }); - } - } - private readonly onDisconnect = () => { - awarenessProtocol.removeAwarenessStates( - this.awareness, - [this.clientID], - this, - ); - }; - - private readonly onAwarenessUpdate = ({ added, updated, removed }: any) => { - const changedClients = added.concat(updated).concat(removed); - const awarenessUpdate = awarenessProtocol.encodeAwarenessUpdate( - this.awareness, - changedClients, - ); - this.postMessage(awarenessUpdate, 'awareness'); - }; - - private async postMessage(update: Uint8Array, type: EventType) { - if (!this.isOnline()) { - this.broadcastChannel.postMessage({ - update, - clientID: this.clientID, - type, - }); - return; - } - const result = uint8ArrayToString(update); - const real = await this.channel?.send({ - type: BROADCAST, - event: this.docId, - payload: { update: result, clientID: this.clientID, type }, - }); - collaborationLog('channel send', real); - } - - private async addHistory(update: Uint8Array) { - this.postMessage(update, 'message'); - if (!this.isOnline()) { - await this.localDB?.addHistory(this.docId, update); - return; - } - const result = uint8ArrayToString(update); - const r = await this.remoteDB - ?.from('history') - .insert([{ doc_id: this.docId, update: result }]); - collaborationLog('db insert', r); - await this.localDB?.addHistory(this.docId, update, true); - } - - async retrieveHistory(): Promise { - if (!this.isOnline()) { - const list = await this.localDB?.getAllHistory(this.docId); - return list?.map((item) => stringToUint8Array(item.update)) || []; - } - const result = await this.remoteDB - ?.from('history') - .select('*') - .eq('doc_id', this.docId) - .order('create_time', { ascending: false }); - collaborationLog('retrieveHistory', result); - const list = (result?.data || []).map((v) => stringToUint8Array(v.update)); - return list; - } - uploadFile = async (file: File, _base64: string): Promise => { - if (!this.isOnline()) { - return _base64; - } - const filePath = `${DIRECTORY}${file.name}`; - const result = await this.remoteDB?.storage - .from('avatars') - .upload(filePath, file); - collaborationLog('updateFile', result); - return result?.data?.path ?? ''; - }; - downloadFile = async (filePath: string): Promise => { - if (!this.isOnline()) { - return filePath; - } - if (!filePath?.startsWith(DIRECTORY)) { - return filePath; - } - const result = await this.remoteDB?.storage - .from('avatars') - .download(filePath); - collaborationLog('downloadFile', result); - if (result?.data) { - return URL.createObjectURL(result.data); - } - return ''; - }; - async syncData() { - if (!this.isOnline()) { - return; - } - await this.localDB?.syncData(async (documentList, historyList) => { - await this.remoteDB - ?.from('document') - .upsert(documentList.map((v) => omit(v, ['sync']))); - await this.remoteDB - ?.from('history') - .insert(historyList.map((v) => omit(v, ['id', 'sync']))); - return true; - }); - } - async addDocument(): Promise { - if (!this.isOnline()) { - const docId = uuidV4(); - await this.localDB?.addDocument(docId, ''); - return docId; - } - const r = await this.remoteDB - ?.from('document') - .insert([{ name: '' }]) - .select(); - collaborationLog('db addDocument', r); - // @ts-ignore - const docId = r.data?.[0].id as string; - return docId; - } - async updateDocument(docId: string, name: string): Promise { - if (!this.isOnline()) { - await this.localDB?.updateDocument(docId || this.docId, name); - return; - } - await this.remoteDB - ?.from('document') - .update({ name }) - .eq('id', docId || this.docId); - } - async getDocument(): Promise { - if (!this.isOnline()) { - return await this.localDB?.getDocument(this.docId); - } - const result = await this.remoteDB - ?.from('document') - .select('*') - .eq('id', this.docId); - return result?.data?.[0]; - } - syncRange(data: Pick) { - this.awareness.setLocalState(data); - } -} diff --git a/index.html b/index.html index 7d20b12f..744d1b42 100644 --- a/index.html +++ b/index.html @@ -10,41 +10,43 @@ margin: 0; padding: 0; } + .title { text-align: center; margin: 0; font-size: 18px; padding: 10px 0; } + .container { display: flex; flex-direction: column; } + .extra { padding-bottom: 10px; text-align: center; } + .content { flex: 1; display: flex; } + .html { border: 2px dashed black; - height: calc(100vh - 110px); + height: calc(100vh - 80px); flex: 1; } + .enable { text-align: center; } +

Excel Collaborative Example (see iframes below)

-
- - - -
@@ -64,35 +66,5 @@

Excel Collaborative Example (see iframes below)

class="html" > - diff --git a/package.json b/package.json index f56a5f15..2f32c5e1 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ }, "scripts": { "start": "vite --force --host --port 3000", - "dev": "run-p start server:*", - "server:websocket": "npx y-websocket", - "server:webrtc": "npx y-webrtc-signaling", + "dev": "run-p start server", + "server": "cross-env YPERSISTENCE=./levelDB npx y-websocket", + "preview": "vite preview", "type-check": "tsc", "e2e": "playwright test", "e2e:ui": "npm run e2e -- --ui", @@ -47,7 +47,6 @@ "@release-it/bumper": "6.0.1", "@release-it/conventional-changelog": "9.0.4", "@sentry/react": "8.47.0", - "@supabase/supabase-js": "2.47.10", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", @@ -58,7 +57,7 @@ "@types/react-dom": "19.0.2", "@vitejs/plugin-react": "4.3.4", "canvas": "2.11.2", - "dexie": "4.0.10", + "cross-env": "7.0.3", "husky": "9.1.7", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", @@ -73,9 +72,6 @@ "typescript": "5.7.2", "vite": "6.0.6", "vite-plugin-dts": "4.4.0", - "vite-plugin-pwa": "0.21.1", - "y-protocols": "1.0.6", - "y-webrtc": "10.3.0", "y-websocket": "2.1.0" }, "dependencies": { diff --git a/src/canvas/event.ts b/src/canvas/event.ts index 0d160117..9e7b8f95 100644 --- a/src/canvas/event.ts +++ b/src/canvas/event.ts @@ -3,7 +3,6 @@ import { isTestEnv, throttle, CUSTOM_FORMAT, - eventEmitter, deepEqual, paste, } from '../util'; @@ -107,7 +106,7 @@ export function registerGlobalEvent( } if (!deepEqual(newRange, oldRange)) { controller.setCopyRange(newRange); - eventEmitter.emit('renderChange', { + controller.emit('renderChange', { changeSet: new Set(['cellStyle']), }); } diff --git a/src/containers/MenuBar/File.tsx b/src/containers/MenuBar/File.tsx index b95b50c0..432f27af 100644 --- a/src/containers/MenuBar/File.tsx +++ b/src/containers/MenuBar/File.tsx @@ -15,7 +15,7 @@ type Props = { }; export const File: FunctionComponent = ({ visible, setVisible }) => { - const { provider } = useExcel(); + const { provider, controller } = useExcel(); const [value, setValue] = useState(''); const fileName = useUserInfo((s) => s.fileName); const setFileName = useUserInfo((s) => s.setFileName); @@ -35,10 +35,10 @@ export const File: FunctionComponent = ({ visible, setVisible }) => { if (!value) { return; } - provider?.updateDocument?.(provider?.getDoc?.().guid || '', value); + provider?.updateDocument?.(controller.getHooks().doc.guid, value); setFileName(value); setVisible(false); - }, [value, provider]); + }, [value, provider, controller]); return (
diff --git a/src/containers/MenuBar/Theme.tsx b/src/containers/MenuBar/Theme.tsx index a6bf948f..94d36f04 100644 --- a/src/containers/MenuBar/Theme.tsx +++ b/src/containers/MenuBar/Theme.tsx @@ -9,7 +9,7 @@ import { getTheme, } from '../../theme'; import { ThemeType } from '../../types'; -import { eventEmitter } from '../../util'; +import { useExcel } from '../store'; function setCssVariable(data: Record) { const keyList = Object.keys(data); @@ -29,6 +29,7 @@ function updateCssVariable(value: ThemeType) { } export const Theme: React.FunctionComponent = memo(() => { + const { controller } = useExcel(); const [themeData, setThemeData] = useState('light'); useEffect(() => { setCssVariable(sizeConfig); @@ -47,10 +48,10 @@ export const Theme: React.FunctionComponent = memo(() => { useEffect(() => { setTheme(themeData); updateCssVariable(themeData); - eventEmitter.emit('renderChange', { + controller.emit('renderChange', { changeSet: new Set(['cellStyle']), }); - }, [themeData]); + }, [themeData, controller]); const handleClick = useCallback(() => { setThemeData((oldTheme) => { diff --git a/src/containers/MenuBar/User.tsx b/src/containers/MenuBar/User.tsx index 05176c1d..3a01a4f1 100644 --- a/src/containers/MenuBar/User.tsx +++ b/src/containers/MenuBar/User.tsx @@ -9,20 +9,9 @@ export const User: FunctionComponent<{ providerStatus: ProviderStatus; }> = ({ providerStatus }) => { const clientId = useUserInfo((s) => s.clientId); - const userName = useUserInfo((s) => s.userName); - const userId = useUserInfo((s) => s.userId); return (
-
- {userName || `${$('user-name')} ${clientId}`} -
- {userId && ( - {userName} - )} +
{`${$('user-name')} ${clientId}`}
diff --git a/src/containers/canvas/Collaboration.tsx b/src/containers/canvas/Collaboration.tsx index b13e92fb..5329aa83 100644 --- a/src/containers/canvas/Collaboration.tsx +++ b/src/containers/canvas/Collaboration.tsx @@ -31,7 +31,7 @@ export const Collaboration = () => { export const User: React.FunctionComponent< UserItem & { row: number; col: number } -> = ({ range, clientId, row, col, userName }) => { +> = ({ range, clientId, row, col }) => { const { controller } = useExcel(); const color = useMemo(() => { const list = getBytesFromUint32(clientId); @@ -59,7 +59,7 @@ export const User: React.FunctionComponent< style={{ ...position, border: `2px solid ${color}` }} >
- {userName || `${$('user-name')} ${clientId}`} + {`${$('user-name')} ${clientId}`}
); diff --git a/src/containers/canvas/util.ts b/src/containers/canvas/util.ts index 30109906..6c5b987c 100644 --- a/src/containers/canvas/util.ts +++ b/src/containers/canvas/util.ts @@ -9,7 +9,6 @@ import { import { DEFAULT_FONT_SIZE, HIDE_CELL, - eventEmitter, MERGE_CELL_LINE_BREAK, LINE_BREAK, DEFAULT_FORMAT_CODE, @@ -26,6 +25,7 @@ import { useScrollStore, FloatElementItem, useStyleStore, + useUserInfo, } from '../../containers/store'; import { MainCanvas, @@ -34,6 +34,7 @@ import { } from '../../canvas'; import { numberFormat as numberFormatUtil, isDateFormat } from '../../formula'; import { initFontFamilyList } from './isSupportFontFamily'; +import { toast } from '../../components'; function getChartData( range: IRange, @@ -317,6 +318,8 @@ export function initCanvas( canvas: HTMLCanvasElement, ): () => void { useCoreStore.getState().setFontFamilies(initFontFamilyList()); + useUserInfo.getState().setClientId(controller.getHooks().doc.clientID); + const mainCanvas = MainCanvas.instance || (MainCanvas.instance = new MainCanvas(controller, canvas)); @@ -331,12 +334,12 @@ export function initCanvas( const resize = () => { renderCanvas(new Set(['customWidth'])); }; - const offEvent = eventEmitter.on('renderChange', ({ changeSet }) => { + const offRenderChange = controller.on('renderChange', ({ changeSet }) => { handleStateChange(changeSet, controller); mainCanvas.render({ changeSet }); }); - const removeEvent = registerGlobalEvent(controller, resize); + const offGlobalEvent = registerGlobalEvent(controller, resize); const changeSet = new Set([ ...KEY_LIST, @@ -354,8 +357,23 @@ export function initCanvas( renderCanvas(changeSet); }, 0); } + + const offToastMessage = controller.on( + 'toastMessage', + ({ type, message, duration = 5, testId }) => { + toast({ type, message, duration, testId: testId ?? `${type}-toast` }); + }, + ); + const offModelToastMessage = controller.model.on( + 'toastMessage', + ({ type, message, duration = 5, testId }) => { + toast({ type, message, duration, testId: testId ?? `${type}-toast` }); + }, + ); return () => { - removeEvent(); - offEvent(); + offGlobalEvent(); + offRenderChange(); + offToastMessage(); + offModelToastMessage(); }; } diff --git a/src/containers/index.tsx b/src/containers/index.tsx index bce0b6a5..a6a49129 100644 --- a/src/containers/index.tsx +++ b/src/containers/index.tsx @@ -6,13 +6,13 @@ import CanvasContainer from './canvas'; import SheetBarContainer from './SheetBar'; import MenuBarContainer from './MenuBar'; import { useExcel, useUserInfo } from './store'; -import { Loading, toast } from '../components'; -import { eventEmitter, applyUpdate } from '../util'; +import { Loading } from '../components'; +import { applyUpdate } from '../util'; import { ProviderStatus } from '../types'; function useCollaboration() { const [isLoading, setIsLoading] = useState(true); - const setClientId = useUserInfo((s) => s.setClientId); + const setFileInfo = useUserInfo((s) => s.setFileInfo); const [providerStatus, setProviderStatus] = useState( ProviderStatus.LOCAL, ); @@ -28,12 +28,8 @@ function useCollaboration() { } setIsLoading(true); const file = await provider?.getDocument?.(); - const doc = provider?.getDoc?.(); - useUserInfo.setState({ - fileId: file?.id ?? '', - fileName: file?.name ?? '', - clientId: doc?.clientID, - }); + const doc = controller.getHooks().doc; + setFileInfo(file?.id ?? '', file?.name ?? ''); const result = await provider?.retrieveHistory?.(); if (result && result.length > 0 && doc) { applyUpdate(doc, result); @@ -61,32 +57,13 @@ function useCollaboration() { } window.addEventListener('online', handleEvent); window.addEventListener('offline', handleEvent); - setClientId(provider?.getDoc?.().clientID ?? 0); - eventEmitter.on('rangeChange', ({ range }) => { - const user = useUserInfo.getState(); - provider?.syncRange?.({ - range, - userId: user.userId, - userName: user.userName, - }); - }); - eventEmitter.on( - 'toastMessage', - ({ type, message, duration = 5, testId }) => { - toast({ type, message, duration, testId: testId ?? `${type}-toast` }); - }, - ); return () => { window.removeEventListener('online', handleEvent); window.removeEventListener('offline', handleEvent); - eventEmitter.off('renderChange'); - eventEmitter.off('rangeChange'); - eventEmitter.off('modelChange'); - eventEmitter.off('toastMessage'); }; }, []); return { - isLoading: isLoading && Boolean(provider), + isLoading, providerStatus, }; } diff --git a/src/containers/store/user.ts b/src/containers/store/user.ts index ee5029a6..13fcddbe 100644 --- a/src/containers/store/user.ts +++ b/src/containers/store/user.ts @@ -4,30 +4,23 @@ import { create } from 'zustand'; export type UserInfo = { clientId: number; - userId: string; - userName: string; users: UserItem[]; fileId: string; fileName: string; }; type Action = { - setUserInfo(userId: string, userName: string): void; setUsers(users: UserItem[]): void; setClientId(clientId: number): void; setFileName(name: string): void; + setFileInfo(id: string, name: string): void; }; export const useUserInfo = create((set) => ({ clientId: 0, - userId: '', - userName: '', users: [], fileId: '', fileName: '', - setUserInfo(id, name) { - set({ userId: id, userName: name }); - }, setUsers(users) { set({ users }); }, @@ -37,4 +30,7 @@ export const useUserInfo = create((set) => ({ setFileName(name) { set({ fileName: name }); }, + setFileInfo(id, name) { + set({ fileId: id, fileName: name }); + }, })); diff --git a/src/controller/Controller.ts b/src/controller/Controller.ts index 4a4e467e..af067f41 100644 --- a/src/controller/Controller.ts +++ b/src/controller/Controller.ts @@ -20,6 +20,7 @@ import { type AutoFilterItem, type CanvasOverlayPosition, SYNC_FLAG, + type ControllerEventEmitterType, } from '../types'; import { PLAIN_FORMAT, @@ -40,10 +41,10 @@ import { convertBase64toBlob, IMAGE_FORMAT, formatCustomData, - eventEmitter, controllerLog, copyOrCut, paste, + EventEmitter, } from '../util'; import { numberFormat, isDateFormat, convertDateToNumber } from '../formula'; import { transaction } from './decorator'; @@ -56,9 +57,12 @@ const defaultScrollValue: Omit = { scrollTop: 0, }; -export class Controller implements IController { +export class Controller + extends EventEmitter + implements IController +{ private scrollValue: Record> = {}; - private readonly model: IModel; + readonly model: IModel; private changeSet = new Set(); private floatElementUuid = ''; private copyRange: IRange | undefined = undefined; @@ -73,6 +77,7 @@ export class Controller implements IController { height: 0, }; constructor(model: IModel, hooks: IHooks) { + super(); this.model = model; this.hooks = hooks; } @@ -172,7 +177,7 @@ export class Controller implements IController { } if (changeSet.size > 0) { controllerLog('emitChange', changeSet); - eventEmitter.emit('renderChange', { changeSet }); + this.emit('renderChange', { changeSet }); } } getActiveRange(r?: IRange) { @@ -236,15 +241,14 @@ export class Controller implements IController { setActiveRange(range: IRange): void { this.model.setActiveRange(range); this.changeSet.add('rangeMap'); - eventEmitter.emit('rangeChange', { - range: { - row: range.row, - col: range.col, - sheetId: range.sheetId, - rowCount: range.rowCount, - colCount: range.colCount, - }, - }); + const r = { + row: range.row, + col: range.col, + sheetId: range.sheetId || this.model.getCurrentSheetId(), + rowCount: range.rowCount, + colCount: range.colCount, + }; + this.emit('rangeChange', r); this.emitChange(); } setCurrentSheetId(id: string): void { diff --git a/src/controller/decorator.ts b/src/controller/decorator.ts index 2f388981..7f517630 100644 --- a/src/controller/decorator.ts +++ b/src/controller/decorator.ts @@ -1,5 +1,5 @@ import { SYNC_FLAG, IController } from '../types'; -import { eventEmitter, controllerLog } from '../util'; +import { controllerLog } from '../util'; import { $ } from '../i18n'; export function transaction(origin: SYNC_FLAG = SYNC_FLAG.MODEL) { @@ -10,7 +10,7 @@ export function transaction(origin: SYNC_FLAG = SYNC_FLAG.MODEL) { const self = this as IController; if (self.getReadOnly()) { controllerLog('no auth method:', key); - return eventEmitter.emit('toastMessage', { + return self.emit('toastMessage', { type: 'error', message: $('no-login-editing'), }); diff --git a/src/model/Model.ts b/src/model/Model.ts index 5217c343..6cc61cb0 100644 --- a/src/model/Model.ts +++ b/src/model/Model.ts @@ -15,11 +15,12 @@ import { ModelRoot, ModelScroll, SYNC_FLAG, + ModelEventEmitterType, } from '../types'; import { XLSX_MAX_ROW_COUNT, XLSX_MAX_COL_COUNT, - eventEmitter, + EventEmitter, containRange, KEY_LIST, } from '../util'; @@ -35,7 +36,10 @@ import { FilterManger } from './filter'; import { ScrollManager } from './scroll'; import * as Y from 'yjs'; -export class Model implements IModel { +export class Model + extends EventEmitter + implements IModel +{ private readonly workbookManager: Workbook; private readonly rangeMapManager: RangeMap; private readonly drawingsManager: Drawing; @@ -49,11 +53,12 @@ export class Model implements IModel { private readonly doc: Y.Doc; private readonly undoManager: Y.UndoManager; constructor(hooks: Pick) { + super(); const { doc, worker } = hooks; this.doc = doc; const root = this.getRoot(); root.observeDeep((event) => { - eventEmitter.emit('modelChange', { event }); + this.emit('modelChange', { event }); }); this.undoManager = new Y.UndoManager(root, { trackedOrigins: new Set([SYNC_FLAG.MODEL, SYNC_FLAG.SKIP_UPDATE]), diff --git a/src/model/drawing.ts b/src/model/drawing.ts index 215a93bf..dea5689c 100644 --- a/src/model/drawing.ts +++ b/src/model/drawing.ts @@ -7,7 +7,7 @@ import type { YjsModelJson, TypedMap, } from '../types'; -import { CHART_TYPE_LIST, iterateRange, toIRange, eventEmitter } from '../util'; +import { CHART_TYPE_LIST, iterateRange, toIRange } from '../util'; import { $ } from '../i18n'; import * as Y from 'yjs'; @@ -69,7 +69,7 @@ export class Drawing implements IDrawings { addDrawing(data: DrawingElement) { const oldData = this.drawings?.get(data.uuid); if (oldData) { - return eventEmitter.emit('toastMessage', { + return this.model.emit('toastMessage', { type: 'error', message: $('uuid-is-duplicate'), }); @@ -82,7 +82,7 @@ export class Drawing implements IDrawings { let check = false; const info = this.model.getSheetInfo(range.sheetId); if (!info) { - return eventEmitter.emit('toastMessage', { + return this.model.emit('toastMessage', { type: 'error', message: $('sheet-is-not-exist'), }); @@ -108,7 +108,7 @@ export class Drawing implements IDrawings { }, ); if (!check) { - return eventEmitter.emit('toastMessage', { + return this.model.emit('toastMessage', { type: 'error', message: $('cells-must-contain-data'), }); @@ -142,7 +142,7 @@ export class Drawing implements IDrawings { (v) => v.value === value.chartType!, ); if (index < 0) { - return eventEmitter.emit('toastMessage', { + return this.model.emit('toastMessage', { type: 'error', message: $('unsupported-chart-types'), }); @@ -169,7 +169,7 @@ export class Drawing implements IDrawings { } const sheetInfo = this.model.getSheetInfo(); if (!sheetInfo) { - return + return; } const colCount = sheetInfo.colCount; for (const [uuid, v] of this.drawings.entries()) { diff --git a/src/model/mergeCell.ts b/src/model/mergeCell.ts index 911853da..c3a048b7 100644 --- a/src/model/mergeCell.ts +++ b/src/model/mergeCell.ts @@ -5,7 +5,7 @@ import type { IModel, YjsModelJson, } from '../types'; -import { convertToReference, toIRange, eventEmitter } from '../util'; +import { convertToReference, toIRange } from '../util'; import { $ } from '../i18n'; import * as Y from 'yjs'; @@ -51,7 +51,7 @@ export class MergeCell implements IMergeCell { this.convertSheetIdToName, ); if (this.mergeCells?.get(ref)) { - eventEmitter.emit('toastMessage', { + this.model.emit('toastMessage', { type: 'error', message: $('merging-cell-is-duplicate'), }); diff --git a/src/model/workbook.ts b/src/model/workbook.ts index 72091658..b3bbe487 100644 --- a/src/model/workbook.ts +++ b/src/model/workbook.ts @@ -12,7 +12,6 @@ import { DEFAULT_COL_COUNT, XLSX_MAX_COL_COUNT, XLSX_MAX_ROW_COUNT, - eventEmitter, } from '../util'; import { $ } from '../i18n'; import * as Y from 'yjs'; @@ -130,7 +129,7 @@ export class Workbook implements IWorkbook { } renameSheet(sheetName: string, sheetId?: string): void { if (!sheetName) { - eventEmitter.emit('toastMessage', { + this.model.emit('toastMessage', { type: 'error', message: $('the-value-cannot-be-empty'), }); @@ -143,7 +142,7 @@ export class Workbook implements IWorkbook { if (item.sheetId === id) { return; } - eventEmitter.emit('toastMessage', { + this.model.emit('toastMessage', { type: 'error', message: $('sheet-name-is-duplicate'), }); @@ -190,7 +189,7 @@ export class Workbook implements IWorkbook { const sheetList = this.getSheetList(); const list = sheetList.filter((v) => !v.isHide); if (list.length < 2) { - eventEmitter.emit('toastMessage', { + this.model.emit('toastMessage', { type: 'error', message: $('a-workbook-must-contains-at-least-one-visible-worksheet'), }); diff --git a/src/types/controller.ts b/src/types/controller.ts index c0850982..c30d4178 100644 --- a/src/types/controller.ts +++ b/src/types/controller.ts @@ -1,13 +1,31 @@ -import { IBaseModel, ScrollValue, WorksheetType, UserItem } from './model'; +import { + IBaseModel, + ScrollValue, + WorksheetType, + IEventEmitter, + ChangeEventType, + ModelEventEmitterType, + IModel, +} from './model'; import { CanvasOverlayPosition, IHooks, DocumentItem } from './components'; import { IRange } from './range'; import { IWindowSize, IPosition } from './event'; -import type { Doc } from 'yjs'; + +export type ControllerEventEmitterType = { + renderChange: { + changeSet: Set; + }; + rangeChange: IRange; + toastMessage: ModelEventEmitterType['toastMessage']; +}; /** * Interface representing a controller with various methods for managing and interacting with a spreadsheet. */ -export interface IController extends IBaseModel { +export interface IController + extends IBaseModel, + IEventEmitter { + readonly model: IModel; /** * Retrieves the hooks associated with the controller. * @returns {IHooks} The hooks. @@ -154,8 +172,6 @@ export interface IController extends IBaseModel { * Interface representing a collaboration provider for document handling and synchronization. */ export interface ICollaborationProvider { - getDoc(): Doc; - /** * Retrieves the history of updates from the collaboration provider. * @returns A promise that resolves to an array of Uint8Array representing the history. @@ -199,9 +215,4 @@ export interface ICollaborationProvider { * @returns A promise that resolves to a DocumentItem or undefined if not found. */ getDocument(): Promise; - - /** - * Synchronizes a specific range of data with the collaboration provider. - */ - syncRange(data: Pick): void; } diff --git a/src/types/model.ts b/src/types/model.ts index 43a41cb4..5f52348a 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -764,13 +764,45 @@ export interface IBaseModel clearHistory(): void; } +export interface IEventEmitter< + EventType extends Record = Record, +> { + getEventLength(name: T): number; + on( + name: T, + callback: (data: EventType[T]) => void, + ): VoidFunction; + emit(name: T, data: EventType[T]): void; + off( + name: T, + callback?: (data: EventType[T]) => void, + ): void; + once( + name: T, + callback: (data: EventType[T]) => void, + ): VoidFunction; +} + +export type ModelEventEmitterType = { + modelChange: { + event: YEvent[]; + }; + toastMessage: { + message: string; + type: MessageType; + duration?: number; // second + testId?: string; + }; +}; + /** * Interface representing a model with various functionalities. * */ export interface IModel extends IBaseModel, - Pick { + Pick, + IEventEmitter { /** * Pastes a range of cells. * @@ -801,27 +833,8 @@ export type NumberFormatValue = export type UserItem = { range: IRange; clientId: number; - userId: string; - userName: string; }; export type MessageType = 'success' | 'error' | 'info' | 'warning'; -export type EventEmitterType = { - renderChange: { - changeSet: Set; - }; - rangeChange: { - range: IRange; - }; - modelChange: { - event: YEvent[]; - }; - toastMessage: { - message: string; - type: MessageType; - duration?: number; // second - testId?: string; - }; -}; export enum SYNC_FLAG { MODEL = 'model', diff --git a/src/util/EventEmitter.ts b/src/util/EventEmitter.ts index 860aea18..46c2352c 100644 --- a/src/util/EventEmitter.ts +++ b/src/util/EventEmitter.ts @@ -1,25 +1,29 @@ -import { EventEmitterType } from '../types'; +import { IEventEmitter } from '../types'; export class EventEmitter< EventType extends Record = Record, -> { - protected event: Record void>> = {}; +> implements IEventEmitter +{ + private event: Record void>>; + constructor() { + this.event = {}; + } getEventLength(name: T): number { // @ts-ignore const temp = this.event[name]; - return (temp && temp.length) || 0; + return temp?.length || 0; } - on( + on = ( name: T, callback: (data: EventType[T]) => void, - ): VoidFunction { + ): VoidFunction => { // @ts-ignore this.event[name] = this.event[name] || []; // @ts-ignore this.event[name].push(callback); return () => this.off(name, callback); - } - emit(name: T, data: EventType[T]): void { + }; + emit = (name: T, data: EventType[T]): void => { // @ts-ignore const list = this.event[name]; if (!list || list.length <= 0) { @@ -28,11 +32,11 @@ export class EventEmitter< for (const item of list) { item(data); } - } - off( + }; + off = ( name: T, callback?: (data: EventType[T]) => void, - ): void { + ): void => { const result = []; // @ts-ignore const events = this.event[name]; @@ -50,19 +54,16 @@ export class EventEmitter< // @ts-ignore delete this.event[name]; } - } - once( + }; + once = ( name: T, callback: (data: EventType[T]) => void, - ): VoidFunction { + ): VoidFunction => { const listener = (data: EventType[T]) => { this.off(name, listener); callback(data); }; listener._ = callback; return this.on(name, listener); - } + }; } - - -export const eventEmitter = new EventEmitter(); diff --git a/vite.config.mts b/vite.config.mts index a6ef424c..24dfc586 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -2,7 +2,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { version } from './package.json'; import dts from 'vite-plugin-dts'; -import { VitePWA } from 'vite-plugin-pwa'; import { resolve } from 'path'; export default defineConfig((env) => { @@ -10,15 +9,7 @@ export default defineConfig((env) => { return { base: process.env.CI && !isLibrary ? '/excel/' : undefined, - plugins: isLibrary - ? [dts()] - : [ - react(), - VitePWA({ - registerType: 'autoUpdate', - manifest: { theme_color: '#217346' }, - }), - ], + plugins: isLibrary ? [dts()] : [react()], define: { 'process.env.VITE_IS_E2E': JSON.stringify(process.env.VITE_IS_E2E ?? ''), 'process.env.VERSION': JSON.stringify(version),