tldraw/state/storage.ts

307 lines
8 KiB
TypeScript
Raw Normal View History

import * as fa from 'browser-fs-access'
import { Data, Page, PageState, TLDocument } from 'types'
import { lzw_decode, lzw_encode, setToArray } from 'utils/utils'
import state from './state'
2021-06-12 19:40:26 +00:00
import { current } from 'immer'
import { v4 as uuid } from 'uuid'
2021-06-12 19:40:26 +00:00
import * as idb from 'idb-keyval'
const CURRENT_VERSION = 'code_slate_0.0.7'
const DOCUMENT_ID = '0001'
2021-06-12 19:40:26 +00:00
function storageId(fileId: string, label: string, id?: string) {
return [CURRENT_VERSION, fileId, label, id].filter(Boolean).join('_')
}
class Storage {
2021-06-11 10:31:31 +00:00
previousSaveHandle?: fa.FileSystemHandle
2021-06-12 19:40:26 +00:00
constructor() {
// this.loadPreviousHandle() // Still needs debugging
}
2021-06-11 10:31:31 +00:00
firstLoad(data: Data) {
const lastOpened = localStorage.getItem(`${CURRENT_VERSION}_lastOpened`)
2021-06-12 19:40:26 +00:00
2021-06-11 10:31:31 +00:00
this.loadDocumentFromLocalStorage(data, lastOpened || DOCUMENT_ID)
2021-06-12 19:40:26 +00:00
2021-06-11 22:06:09 +00:00
this.loadPage(data, data.currentPageId)
2021-06-12 19:40:26 +00:00
this.saveToLocalStorage(data, data.document.id)
2021-06-12 19:40:26 +00:00
2021-06-11 22:06:09 +00:00
localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
2021-06-11 10:31:31 +00:00
}
load(data: Data, restoredData: any) {
2021-06-11 22:06:09 +00:00
// Before loading the state, save the pages / page states
2021-06-12 19:40:26 +00:00
// Empty current state.
data.document = {} as TLDocument
data.pageStates = {}
2021-06-12 19:40:26 +00:00
// Merge restored data into state.
Object.assign(data, restoredData)
2021-06-12 19:40:26 +00:00
// Add id and name to document, just in case.
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
2021-06-11 22:06:09 +00:00
const savedData = localStorage.getItem(
storageId(fileId, 'document', fileId)
)
2021-06-11 10:31:31 +00:00
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: any = JSON.parse(lzw_decode(savedData))
2021-06-12 19:40:26 +00:00
this.load(data, restoredData)
}
getDataToSave = (data: Data) => {
2021-06-12 19:40:26 +00:00
const dataToSave = current(data) as any
2021-06-11 22:06:09 +00:00
for (let pageId in data.document.pages) {
const savedPage = localStorage.getItem(
storageId(data.document.id, 'page', pageId)
)
if (savedPage !== null) {
const restored: Page = JSON.parse(lzw_decode(savedPage))
dataToSave.document.pages[pageId] = restored
}
2021-06-13 13:24:03 +00:00
const pageState = { ...dataToSave.pageStates[pageId] }
2021-06-12 19:40:26 +00:00
pageState.selectedIds = setToArray(pageState.selectedIds)
2021-06-11 22:06:09 +00:00
}
return JSON.stringify(dataToSave, null, 2)
}
2021-06-12 19:40:26 +00:00
saveToLocalStorage = (data: Data, fileId = data.document.id) => {
if (typeof window === 'undefined') return
if (typeof localStorage === 'undefined') return
2021-06-11 22:06:09 +00:00
const dataToSave = this.getDataToSave(data)
2021-06-11 22:06:09 +00:00
// Save current data to local storage
localStorage.setItem(
storageId(fileId, 'document', fileId),
lzw_encode(dataToSave)
)
}
2021-06-11 22:06:09 +00:00
loadDocumentFromJson(data: Data, restoredData: any) {
this.load(data, restoredData)
2021-06-13 13:24:03 +00:00
for (let key in restoredData.document.pages) {
this.savePage(restoredData, restoredData.document.id, key)
}
2021-06-11 22:06:09 +00:00
this.loadPage(data, data.currentPageId)
this.saveToLocalStorage(data, data.document.id)
localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
}
2021-06-11 22:06:09 +00:00
/* ---------------------- Pages --------------------- */
2021-06-12 19:40:26 +00:00
async loadPreviousHandle() {
const handle: fa.FileSystemHandle | undefined = await idb.get(
'previous_handle'
)
if (handle !== undefined) {
this.previousSaveHandle = handle
}
}
2021-06-11 22:06:09 +00:00
savePage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
if (typeof window === 'undefined') return
if (typeof localStorage === 'undefined') return
2021-06-11 22:06:09 +00:00
// Save page
const page = data.document.pages[pageId]
const json = JSON.stringify(page)
localStorage.setItem(storageId(fileId, 'page', pageId), lzw_encode(json))
2021-06-11 22:06:09 +00:00
// Save page state
let currentPageState = {
camera: {
point: [0, 0],
zoom: 1,
2021-06-11 10:31:31 +00:00
},
2021-06-11 22:06:09 +00:00
selectedIds: new Set([]),
...data.pageStates[pageId],
}
localStorage.setItem(
storageId(fileId, 'pageState', pageId),
2021-06-12 19:40:26 +00:00
JSON.stringify({
...currentPageState,
selectedIds: setToArray(currentPageState.selectedIds),
})
2021-06-11 10:31:31 +00:00
)
}
2021-06-11 22:06:09 +00:00
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(lzw_decode(savedPage))
} else {
2021-06-11 22:06:09 +00:00
data.document.pages[pageId] = {
id: pageId,
type: 'page',
childIndex: 0,
name: 'Page',
shapes: {},
}
}
2021-06-11 22:06:09 +00:00
// Page state
const savedPageState = localStorage.getItem(
storageId(fileId, 'pageState', pageId)
)
if (savedPageState !== null) {
2021-06-11 22:06:09 +00:00
const restored: PageState = JSON.parse(savedPageState)
data.pageStates[pageId] = restored
} else {
2021-06-11 22:06:09 +00:00
data.pageStates[pageId] = {
camera: {
point: [0, 0],
zoom: 1,
},
selectedIds: new Set([]),
}
}
2021-06-11 22:06:09 +00:00
// Empty shapes in state for other pages
for (let key in data.document.pages) {
if (key === pageId) continue
data.document.pages[key].shapes = {}
}
2021-06-13 13:24:03 +00:00
// Force selected Ids into sets
for (let key in data.pageStates) {
data.pageStates[key].selectedIds = new Set([])
}
2021-06-11 22:06:09 +00:00
// Update camera for the new page state
document.documentElement.style.setProperty(
'--camera-zoom',
data.pageStates[data.currentPageId].camera.zoom.toString()
)
}
2021-06-11 22:06:09 +00:00
/* ------------------- File System ------------------ */
2021-06-11 22:06:09 +00:00
saveToFileSystem = (data: Data) => {
this.saveDataToFileSystem(data, data.document.id, false)
}
2021-06-11 22:06:09 +00:00
saveAsToFileSystem = (data: Data) => {
this.saveDataToFileSystem(data, uuid(), true)
}
2021-06-12 19:40:26 +00:00
saveDataToFileSystem = (data: Data, fileId: string, saveAs: boolean) => {
2021-06-11 22:06:09 +00:00
const json = this.getDataToSave(data)
2021-06-12 19:40:26 +00:00
this.saveToLocalStorage(data, fileId)
2021-06-11 22:06:09 +00:00
const blob = new Blob([json], {
type: 'application/vnd.tldraw+json',
})
2021-06-11 22:06:09 +00:00
fa.fileSave(
blob,
{
fileName: `${
saveAs
? data.document.name
: this.previousSaveHandle?.name || 'My Document'
}.tldr`,
description: 'tldraw file',
extensions: ['.tldr'],
},
saveAs ? undefined : this.previousSaveHandle,
true
)
2021-06-11 22:06:09 +00:00
.then((handle) => {
this.previousSaveHandle = handle
state.send('SAVED_FILE_TO_FILE_SYSTEM')
2021-06-12 19:40:26 +00:00
idb.set('previous_handle', handle)
2021-06-11 22:06:09 +00:00
})
.catch((e) => {
state.send('CANCELLED_SAVE', { reason: e.message })
})
}
2021-06-11 22:06:09 +00:00
loadDocumentFromFilesystem() {
2021-06-12 19:40:26 +00:00
console.warn('Loading file from file system.')
2021-06-11 22:06:09 +00:00
fa.fileOpen({
description: 'tldraw files',
})
.then((blob) =>
getTextFromBlob(blob).then((text) => {
const restoredData = JSON.parse(text)
2021-06-11 22:06:09 +00:00
if (restoredData === null) {
console.warn('Could not load that data.')
return
}
2021-06-11 22:06:09 +00:00
// Save blob for future saves
this.previousSaveHandle = blob.handle
2021-06-11 22:06:09 +00:00
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')
})
}