tldraw/state/storage.ts
2021-06-12 09:10:12 +01:00

284 lines
7.4 KiB
TypeScript

import * as fa from 'browser-fs-access'
import { Data, Page, PageState, TLDocument } from 'types'
import { setToArray } from 'utils/utils'
import state from './state'
import { v4 as uuid } from 'uuid'
const CURRENT_VERSION = 'code_slate_0.0.5'
const DOCUMENT_ID = '0001'
function storageId(fileId: string, label: string, id: string) {
return `${CURRENT_VERSION}_doc_${fileId}_${label}_${id}`
}
class Storage {
previousSaveHandle?: fa.FileSystemHandle
firstLoad(data: Data) {
const lastOpened = localStorage.getItem(`${CURRENT_VERSION}_lastOpened`)
this.loadDocumentFromLocalStorage(data, lastOpened || DOCUMENT_ID)
this.loadPage(data, data.currentPageId)
this.saveToLocalStorage(data, data.document.id)
localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
}
load(data: Data, restoredData: any) {
// Before loading the state, save the pages / page states
for (let key in restoredData.document.pages) {
this.savePage(restoredData, restoredData.document.id, key)
}
// Empty shapes in state for each page
for (let key in restoredData.document.pages) {
// restoredData.document.pages[key].shapes = {}
}
data.document = {} as TLDocument
data.pageStates = {}
// Merge restored data into state
Object.assign(data, restoredData)
// Minor migrtation: add id and name to document
data.document = {
id: 'document0',
name: 'My Document',
...restoredData.document,
}
}
loadDocumentFromLocalStorage(data: Data, fileId = DOCUMENT_ID) {
if (typeof window === 'undefined') return
if (typeof localStorage === 'undefined') return
// Load data from local storage
const savedData = localStorage.getItem(
storageId(fileId, 'document', fileId)
)
if (savedData === null) {
// If we're going to use the default data, assign the
// current document a fresh random id.
data.document.id = uuid()
return false
}
const restoredData = JSON.parse(savedData)
this.load(data, restoredData)
}
getDataToSave = (data: Data) => {
const dataToSave: any = { ...data }
for (let pageId in data.document.pages) {
// Page
const savedPage = localStorage.getItem(
storageId(data.document.id, 'page', pageId)
)
if (savedPage !== null) {
const restored: Page = JSON.parse(savedPage)
dataToSave.document.pages[pageId] = restored
}
dataToSave.pageStates = {}
}
return JSON.stringify(dataToSave, null, 2)
}
saveToLocalStorage = (data: Data, id = data.document.id) => {
if (typeof window === 'undefined') return
if (typeof localStorage === 'undefined') return
const dataToSave = this.getDataToSave(data)
// Save current data to local storage
localStorage.setItem(storageId(id, 'document', id), dataToSave)
}
loadDocumentFromJson(data: Data, restoredData: any) {
this.load(data, restoredData)
this.loadPage(data, data.currentPageId)
this.saveToLocalStorage(data, data.document.id)
localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
}
/* ---------------------- Pages --------------------- */
savePage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
if (typeof window === 'undefined') return
if (typeof localStorage === 'undefined') return
// Save page
const page = data.document.pages[pageId]
const json = JSON.stringify(page)
localStorage.setItem(storageId(fileId, 'page', pageId), json)
// Save page state
let currentPageState = {
camera: {
point: [0, 0],
zoom: 1,
},
selectedIds: new Set([]),
...data.pageStates[pageId],
}
const pageState = {
...currentPageState,
selectedIds: setToArray(currentPageState.selectedIds),
}
localStorage.setItem(
storageId(fileId, 'pageState', pageId),
JSON.stringify(pageState)
)
}
loadPage(data: Data, pageId = data.currentPageId) {
if (typeof window === 'undefined') return
if (typeof localStorage === 'undefined') return
const fileId = data.document.id
// Page
const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
if (savedPage !== null) {
data.document.pages[pageId] = JSON.parse(savedPage)
} else {
data.document.pages[pageId] = {
id: pageId,
type: 'page',
childIndex: 0,
name: 'Page',
shapes: {},
}
}
// Page state
const savedPageState = localStorage.getItem(
storageId(fileId, 'pageState', pageId)
)
if (savedPageState !== null) {
const restored: PageState = JSON.parse(savedPageState)
restored.selectedIds = new Set(restored.selectedIds)
data.pageStates[pageId] = restored
} else {
data.pageStates[pageId] = {
camera: {
point: [0, 0],
zoom: 1,
},
selectedIds: new Set([]),
}
}
// Empty shapes in state for other pages
for (let key in data.document.pages) {
if (key === pageId) continue
data.document.pages[key].shapes = {}
}
// Update camera for the new page state
document.documentElement.style.setProperty(
'--camera-zoom',
data.pageStates[data.currentPageId].camera.zoom.toString()
)
}
/* ------------------- File System ------------------ */
saveToFileSystem = (data: Data) => {
this.saveDataToFileSystem(data, data.document.id, false)
}
saveAsToFileSystem = (data: Data) => {
this.saveDataToFileSystem(data, uuid(), true)
}
saveDataToFileSystem = (data: Data, id: string, saveAs: boolean) => {
const json = this.getDataToSave(data)
this.saveToLocalStorage(data, id)
const blob = new Blob([json], {
type: 'application/vnd.tldraw+json',
})
fa.fileSave(
blob,
{
fileName: `${
saveAs
? data.document.name
: this.previousSaveHandle?.name || 'My Document'
}.tldr`,
description: 'tldraw file',
extensions: ['.tldr'],
},
saveAs ? undefined : this.previousSaveHandle,
true
)
.then((handle) => {
this.previousSaveHandle = handle
state.send('SAVED_FILE_TO_FILE_SYSTEM')
})
.catch((e) => {
state.send('CANCELLED_SAVE', { reason: e.message })
})
}
loadDocumentFromFilesystem() {
fa.fileOpen({
description: 'tldraw files',
})
.then((blob) =>
getTextFromBlob(blob).then((text) => {
const restoredData = JSON.parse(text)
if (restoredData === null) {
console.warn('Could not load that data.')
return
}
// Save blob for future saves
this.previousSaveHandle = blob.handle
state.send('LOADED_FROM_FILE', { restoredData: { ...restoredData } })
})
)
.catch((e) => {
state.send('CANCELLED_SAVE', { reason: e.message })
})
}
}
const storage = new Storage()
export default storage
async function getTextFromBlob(blob: Blob): Promise<string> {
// Return blob as text if a text file.
if ('text' in Blob) return blob.text()
// Return blob as text if a text file.
return new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
if (reader.readyState === FileReader.DONE) {
resolve(reader.result as string)
}
}
reader.readAsText(blob, 'utf8')
})
}