diff --git a/package-lock.json b/package-lock.json index fa8b66f0..c204b35e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "@types/react-dom": "^18.0.11", "@y-presence/client": "^2.0.1", "@y-presence/react": "^2.0.1", + "browser-fs-access": "^0.35.0", "eslint": "^8.45.0", + "loglevel": "^1.8.1", "prettier": "^3.0.0", "react": "^18.2.0", "react-cookie": "^6.1.1", @@ -5643,6 +5645,11 @@ "react-dom": ">=16.8" } }, + "node_modules/@tldraw/tldraw/node_modules/browser-fs-access": { + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.31.2.tgz", + "integrity": "sha512-wZSA7UgKMwR6oxddFQeSIoD7cxiNiaZT+iuVJw4/avr9t2ROwu80gxENT0YJChsLxJ7xBbLZDGHTAXfAg3Pq5Q==" + }, "node_modules/@tldraw/vec": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@tldraw/vec/-/vec-1.9.2.tgz", @@ -7572,9 +7579,9 @@ } }, "node_modules/browser-fs-access": { - "version": "0.31.2", - "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.31.2.tgz", - "integrity": "sha512-wZSA7UgKMwR6oxddFQeSIoD7cxiNiaZT+iuVJw4/avr9t2ROwu80gxENT0YJChsLxJ7xBbLZDGHTAXfAg3Pq5Q==" + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.35.0.tgz", + "integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==" }, "node_modules/browser-process-hrtime": { "version": "1.0.0", @@ -15659,6 +15666,18 @@ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" }, + "node_modules/loglevel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", + "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/package.json b/package.json index a329315b..9c277651 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "@types/react-dom": "^18.0.11", "@y-presence/client": "^2.0.1", "@y-presence/react": "^2.0.1", + "browser-fs-access": "^0.35.0", "eslint": "^8.45.0", + "loglevel": "^1.8.1", "prettier": "^3.0.0", "react": "^18.2.0", "react-cookie": "^6.1.1", diff --git a/src/App.tsx b/src/App.tsx index d175e610..05bbc7e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,8 @@ import { awareness, roomID } from './store/store'; import './App.css'; function Editor({ roomId }: { roomId: string }) { - const { onSaveProjectAs, onSaveProject, onOpenMedia } = useFileSystem(); + const { onSaveProjectAs, onSaveProject, onOpenMedia, onOpenProject } = + useFileSystem(); const { onMount, saveUserSettings, getDarkMode, ...events } = useMultiplayerState(roomId); @@ -20,6 +21,7 @@ function Editor({ roomId }: { roomId: string }) { darkMode={getDarkMode()} showMultiplayerMenu={false} {...events} + onOpenProject={onOpenProject} onSaveProject={onSaveProject} onSaveProjectAs={onSaveProjectAs} onOpenMedia={onOpenMedia} diff --git a/src/hooks/useMultiplayerState.ts b/src/hooks/useMultiplayerState.ts index 3a004f75..e58c65b4 100644 --- a/src/hooks/useMultiplayerState.ts +++ b/src/hooks/useMultiplayerState.ts @@ -1,12 +1,3 @@ -import { - TDAsset, - TDBinding, - TDShape, - TDSnapshot, - TDUser, - TldrawApp, - TldrawPatch, -} from '@tldraw/tldraw'; import { useCallback, useEffect, useState } from 'react'; import { Room } from '@y-presence/client'; import { @@ -18,35 +9,45 @@ import { yBindings, yShapes, } from '../store/store'; +import { fileOpen } from 'browser-fs-access'; import { TldrawPresence } from '../types'; +import { + FileBuilder, + FileBuilderResult, + errorLogger, + STORAGE_SETTINGS_KEY, + getUserSettings, + castToString, +} from '../utilities'; +import { + TDAsset, + TDBinding, + TDShape, + TldrawApp, + TldrawPatch, + TDUser, +} from '@tldraw/tldraw'; export const room = new Room(awareness, {}); -const STORAGE_SETTINGS_KEY = 'sc_tldraw_settings'; - -const getUserSettings = (): TDSnapshot['settings'] | undefined => { - const settingsString = localStorage.getItem(STORAGE_SETTINGS_KEY); - return settingsString ? JSON.parse(settingsString) : undefined; -}; - -const setDefaultState = () => { - const userSettings = getUserSettings(); - if (userSettings) { - TldrawApp.defaultState.settings = userSettings; - } else { - TldrawApp.defaultState.settings.language = 'de'; - } -}; - export function useMultiplayerState(roomId: string) { const [appInstance, setAppInstance] = useState( undefined, ); const [loading, setLoading] = useState(true); + const setDefaultState = () => { + const userSettings = getUserSettings(); + if (userSettings) { + TldrawApp.defaultState.settings = userSettings; + } else { + TldrawApp.defaultState.settings.language = 'de'; + } + }; + setDefaultState(); - const getDarkMode = (): boolean | false => { + const getDarkMode = (): boolean => { const settings = getUserSettings(); return settings ? settings.isDarkMode : false; }; @@ -63,14 +64,113 @@ export function useMultiplayerState(roomId: string) { [], ); - const onMount = useCallback( - (app: TldrawApp) => { - app.loadRoom(roomId); + const openFromFileSystem = async (): Promise => { + try { + const blob = await fileOpen({ + description: 'Tldraw File', + extensions: [`.tldr`], + multiple: false, + }); + + if (!blob) throw new Error('No file selected'); + + const json: string | null = await readBlobAsText(blob); + + return FileBuilder.build(json, blob.handle ?? null); + } catch (error: any) { + errorLogger('Error opening file', error); + return null; + } + }; + + const readBlobAsText = async (blob: Blob): Promise => + new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + if (reader.readyState === FileReader.DONE) { + const result = castToString(reader.result); + resolve(result); + } + }; + reader.readAsText(blob, 'utf8'); + }); + + const updateYMapsInTransaction = ( + shapes: Record, + bindings: Record, + assets: Record, + ) => { + doc.transact(() => { + updateYShapes(shapes); + updateYBindings(bindings); + updateYAssets(assets); + }); + }; + + const updateYShapes = (shapes: Record) => { + Object.entries(shapes).forEach(([id, shape]) => { + if (!shape) { + yShapes.delete(id); + } else { + yShapes.set(shape.id, shape); + } + }); + }; + + const updateYBindings = (bindings: Record) => { + Object.entries(bindings).forEach(([id, binding]) => { + if (!binding) { + yBindings.delete(id); + } else { + yBindings.set(binding.id, binding); + } + }); + }; + + const updateYAssets = (assets: Record) => { + Object.entries(assets).forEach(([id, asset]) => { + if (!asset) { + yAssets.delete(id); + } else { + yAssets.set(asset.id, asset); + } + }); + }; + + const onMount = useCallback(async (app: TldrawApp) => { + try { + await app.loadRoom(roomId); app.pause(); setAppInstance(app); - }, - [roomId], - ); + + app.openProject = async () => { + try { + const result = await openFromFileSystem(); + if (!result || result === null) { + throw new Error('Failed to open project'); + } + + const { document } = result; + + yShapes.clear(); + yBindings.clear(); + yAssets.clear(); + + updateYMapsInTransaction( + document.pages.page.shapes, + document.pages.page.bindings, + document.assets, + ); + + app.zoomToFit(); + } catch (error: any) { + errorLogger('Error opening project', error); + } + }; + } catch (error: any) { + errorLogger('Error loading room', error); + } + }, []); const onChangePage = useCallback( ( @@ -79,30 +179,7 @@ export function useMultiplayerState(roomId: string) { bindings: Record, assets: Record, ) => { - undoManager.stopCapturing(); - doc.transact(() => { - Object.entries(shapes).forEach(([id, shape]) => { - if (!shape) { - yShapes.delete(id); - } else { - yShapes.set(shape.id, shape); - } - }); - Object.entries(bindings).forEach(([id, binding]) => { - if (!binding) { - yBindings.delete(id); - } else { - yBindings.set(binding.id, binding); - } - }); - Object.entries(assets).forEach(([id, asset]) => { - if (!asset) { - yAssets.delete(id); - } else { - yAssets.set(asset.id, asset); - } - }); - }); + updateYMapsInTransaction(shapes, bindings, assets); }, [], ); @@ -115,14 +192,14 @@ export function useMultiplayerState(roomId: string) { undoManager.redo(); }, []); - const onChangePresence = useCallback((app: TldrawApp, user: TDUser) => { - if (!app.room) return; - room.setPresence({ id: app.room.userId, tdUser: user }); - }, []); + const onChangePresence = useCallback( + (app: TldrawApp, user: TDUser) => { + if (!app.room) return; + room.setPresence({ id: app.room.userId, tdUser: user }); + }, + [room.updatePresence], + ); - /** - * Update app users whenever there is a change in the room users - */ useEffect(() => { if (!appInstance || !room) return; @@ -160,25 +237,22 @@ export function useMultiplayerState(roomId: string) { useEffect(() => { if (!appInstance) return; - function handleDisconnect() { - provider.disconnect(); - } + const handleDisconnect = () => provider.disconnect(); window.addEventListener('beforeunload', handleDisconnect); - function handleChanges() { + const handleChanges = () => appInstance?.replacePageContent( Object.fromEntries(yShapes.entries()), Object.fromEntries(yBindings.entries()), Object.fromEntries(yAssets.entries()), ); - } - async function setup() { + const setup = async () => { yShapes.observeDeep(handleChanges); handleChanges(); setLoading(false); - } + }; setup(); diff --git a/src/store/store.ts b/src/store/store.ts index e9f9cb48..fadea09c 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -49,7 +49,7 @@ export const awareness = provider.awareness; export const yShapes: Map = doc.getMap('shapes'); export const yBindings: Map = doc.getMap('bindings'); export const yAssets: Map = doc.getMap('assets'); -export const undoManager = new UndoManager([yShapes, yBindings]); +export const undoManager = new UndoManager([yShapes, yBindings, yAssets]); export function configure(options: any) { Object.assign(defaultOptions, options); diff --git a/src/utilities/fileBuilder.ts b/src/utilities/fileBuilder.ts new file mode 100644 index 00000000..be749b49 --- /dev/null +++ b/src/utilities/fileBuilder.ts @@ -0,0 +1,33 @@ +import { TDFile, TDDocument } from '@tldraw/tldraw'; +import log from 'loglevel'; + +interface FileBuilderResult { + fileHandle: FileSystemFileHandle | null; + document: TDDocument; +} + +class FileBuilder { + static build( + json: string | null, + fileHandle: FileSystemFileHandle | null, + ): FileBuilderResult | null { + if (json === null) { + log.error('Failed to cast result to string'); + return null; + } + + try { + const parsedFile: TDFile = JSON.parse(json); + + return { + fileHandle, + document: parsedFile.document, + }; + } catch (error) { + log.error('Error parsing JSON:', error); + return null; + } + } +} + +export { FileBuilder, type FileBuilderResult }; diff --git a/src/utilities/fileUtils.ts b/src/utilities/fileUtils.ts new file mode 100644 index 00000000..df97a72e --- /dev/null +++ b/src/utilities/fileUtils.ts @@ -0,0 +1,5 @@ +export const isString = (value: any): value is string => + typeof value === 'string'; + +export const castToString = (value: any): string | null => + isString(value) ? value : null; diff --git a/src/utilities/index.ts b/src/utilities/index.ts new file mode 100644 index 00000000..3c05450c --- /dev/null +++ b/src/utilities/index.ts @@ -0,0 +1,13 @@ +import { FileBuilder, FileBuilderResult } from './fileBuilder'; +import { STORAGE_SETTINGS_KEY, getUserSettings } from './localStorage'; +import { errorLogger } from './logger'; +import { castToString } from './fileUtils'; + +export { + FileBuilder, + type FileBuilderResult, + STORAGE_SETTINGS_KEY, + getUserSettings, + errorLogger, + castToString, +}; diff --git a/src/utilities/localStorage.ts b/src/utilities/localStorage.ts new file mode 100644 index 00000000..7bdd29ed --- /dev/null +++ b/src/utilities/localStorage.ts @@ -0,0 +1,8 @@ +import { TDSnapshot } from '@tldraw/tldraw'; + +export const STORAGE_SETTINGS_KEY = 'sc_tldraw_settings'; + +export const getUserSettings = (): TDSnapshot['settings'] | undefined => { + const settingsString = localStorage.getItem(STORAGE_SETTINGS_KEY); + return settingsString ? JSON.parse(settingsString) : undefined; +}; diff --git a/src/utilities/logger.ts b/src/utilities/logger.ts new file mode 100644 index 00000000..ddec17f6 --- /dev/null +++ b/src/utilities/logger.ts @@ -0,0 +1,5 @@ +import * as log from 'loglevel'; + +export const errorLogger = (message: string, error: Error): void => { + log.error(`${message}: ${error}`); +};