420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
import { Data, PageState, TLDocument } from 'types'
|
|
import { decompress, compress } from 'utils'
|
|
import state from './state'
|
|
import { uniqueId } from 'utils/utils'
|
|
import * as idb from 'idb-keyval'
|
|
|
|
const CURRENT_VERSION = 'code_slate_0.0.9'
|
|
|
|
function storageId(fileId: string, label: string, id?: string) {
|
|
return [CURRENT_VERSION, fileId, label, id].filter(Boolean).join('_')
|
|
}
|
|
|
|
class Storage {
|
|
previousSaveHandle?: any // FileSystemHandle
|
|
|
|
firstLoad(data: Data, roomId = 'TESTING') {
|
|
const lastOpenedFileId =
|
|
roomId || localStorage.getItem(`${CURRENT_VERSION}_lastOpened`)
|
|
|
|
// 1. Load Document from Local Storage
|
|
// Using the "last opened file id" in local storage.
|
|
if (lastOpenedFileId !== null) {
|
|
// Load state from local storage
|
|
const savedState = localStorage.getItem(
|
|
storageId(lastOpenedFileId, 'document-state', lastOpenedFileId)
|
|
)
|
|
|
|
if (!savedState) {
|
|
// If no state with that document was found, create a fresh random id.
|
|
data.document.id = roomId ? roomId : uniqueId()
|
|
} else {
|
|
// If we did find a state and document, load it into state.
|
|
const restoredDocument: Data = JSON.parse(decompress(savedState))
|
|
|
|
// Merge restored data into state.
|
|
Object.assign(data, restoredDocument)
|
|
}
|
|
}
|
|
|
|
this.load(data)
|
|
}
|
|
|
|
saveAppStateToLocalStorage = (data: Data) => {
|
|
localStorage.setItem(
|
|
storageId(data.document.id, 'document-state', data.document.id),
|
|
compress(JSON.stringify(data))
|
|
)
|
|
}
|
|
|
|
saveDocumentToLocalStorage(data: Data) {
|
|
const document = this.getCompleteDocument(data)
|
|
|
|
localStorage.setItem(
|
|
storageId(data.document.id, 'document', data.document.id),
|
|
compress(JSON.stringify(document))
|
|
)
|
|
|
|
localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
|
|
}
|
|
|
|
getCompleteDocument = (data: Data) => {
|
|
// Create a safely mutable copy of the data
|
|
const document: TLDocument = { ...data.document }
|
|
|
|
// Try to find the document's pages and page states in local storage.
|
|
Object.keys(document.pages).forEach((pageId) => {
|
|
const savedPage = localStorage.getItem(
|
|
storageId(document.id, 'page', pageId)
|
|
)
|
|
|
|
if (savedPage !== null) {
|
|
document.pages[pageId] = JSON.parse(decompress(savedPage))
|
|
}
|
|
})
|
|
|
|
return document
|
|
}
|
|
|
|
savePageState = (data: Data) => {
|
|
localStorage.setItem(
|
|
storageId(data.document.id, 'lastPageState', data.document.id),
|
|
JSON.stringify(data.pageStates[data.currentPageId])
|
|
)
|
|
}
|
|
|
|
loadDocumentFromJson(data: Data, json: string) {
|
|
const restoredDocument: { document: TLDocument; pageState: PageState } =
|
|
JSON.parse(json)
|
|
|
|
data.document = restoredDocument.document
|
|
data.pageStates[restoredDocument.pageState.id] = restoredDocument.pageState
|
|
|
|
// Save pages to local storage, possibly overwriting unsaved local copies
|
|
Object.values(data.document.pages).forEach((page) => {
|
|
localStorage.setItem(
|
|
storageId(data.document.id, 'page', page.id),
|
|
compress(JSON.stringify(page))
|
|
)
|
|
})
|
|
|
|
localStorage.setItem(
|
|
storageId(data.document.id, 'lastPageState', data.document.id),
|
|
JSON.stringify(restoredDocument.pageState)
|
|
)
|
|
|
|
// Save the new file as the last opened document id
|
|
localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
|
|
|
|
this.load(data)
|
|
}
|
|
|
|
load(data: Data) {
|
|
// Once we've loaded data either from local storage or json, run through these steps.
|
|
data.pageStates = {}
|
|
|
|
// 2. Load Pages from Local Storage
|
|
// Try to find the document's pages and page states in local storage.
|
|
Object.keys(data.document.pages).forEach((pageId) => {
|
|
const savedPage = localStorage.getItem(
|
|
storageId(data.document.id, 'page', pageId)
|
|
)
|
|
|
|
if (savedPage !== null) {
|
|
// If we've found a page in local storage, set it into state.
|
|
data.document.pages[pageId] = JSON.parse(decompress(savedPage))
|
|
}
|
|
|
|
const savedPageState = localStorage.getItem(
|
|
storageId(data.document.id, 'pageState', pageId)
|
|
)
|
|
|
|
if (savedPageState !== null) {
|
|
// If we've found a page state in local storage, set it into state.
|
|
data.pageStates[pageId] = JSON.parse(decompress(savedPageState))
|
|
} else {
|
|
// Or else create a new one.
|
|
data.pageStates[pageId] = {
|
|
id: pageId,
|
|
selectedIds: [],
|
|
camera: {
|
|
point: [0, 0],
|
|
zoom: 1,
|
|
},
|
|
}
|
|
}
|
|
})
|
|
|
|
// 3. Restore the last page state
|
|
// Using the "last page state" in local storage.
|
|
|
|
try {
|
|
const savedPageState = localStorage.getItem(
|
|
storageId(data.document.id, 'lastPageState', data.document.id)
|
|
)
|
|
const pageState = JSON.parse(decompress(savedPageState))
|
|
|
|
if (!data.document.pages[pageState.id]) {
|
|
throw new Error('Page state id not in document')
|
|
}
|
|
|
|
pageState.selectedIds = []
|
|
data.pageStates[pageState.id] = pageState
|
|
data.currentPageId = pageState.id
|
|
} catch (e) {
|
|
data.pageStates[data.currentPageId] = {
|
|
id: data.currentPageId,
|
|
selectedIds: [],
|
|
camera: {
|
|
point: [0, 0],
|
|
zoom: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 4. Save the current app state / document
|
|
// The document is now "full" and ready. Whether we've restored a
|
|
// document or created a new one, save the entire current document.
|
|
this.saveDocumentToLocalStorage(data)
|
|
|
|
// 4.1
|
|
// Also save the app state.
|
|
this.saveAppStateToLocalStorage(data)
|
|
|
|
// 4.1
|
|
// Also save out copies of each page separately.
|
|
Object.values(data.document.pages).forEach((page) => {
|
|
// Save page
|
|
localStorage.setItem(
|
|
storageId(data.document.id, 'page', page.id),
|
|
compress(JSON.stringify(page))
|
|
)
|
|
})
|
|
|
|
// Save the last page state
|
|
const currentPageState = data.pageStates[data.currentPageId]
|
|
|
|
localStorage.setItem(
|
|
storageId(data.document.id, 'lastPageState', data.document.id),
|
|
JSON.stringify(currentPageState)
|
|
)
|
|
|
|
// Finally, save the current document id as the "last opened" document id.
|
|
localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
|
|
|
|
// 5. Prepare the new state.
|
|
// Clear out the other pages from state.
|
|
Object.values(data.document.pages).forEach((page) => {
|
|
if (page.id !== data.currentPageId) {
|
|
page.shapes = {}
|
|
}
|
|
})
|
|
|
|
// Load the current page
|
|
this.loadPage(data, data.document.id, data.currentPageId)
|
|
|
|
// Update camera for the new page state
|
|
document.documentElement.style.setProperty(
|
|
'--camera-zoom',
|
|
data.pageStates[data.currentPageId].camera.zoom.toString()
|
|
)
|
|
}
|
|
/* ---------------------- Pages --------------------- */
|
|
|
|
async loadPreviousHandle() {
|
|
const handle = await idb.get('previous_handle')
|
|
|
|
if (handle !== undefined) {
|
|
this.previousSaveHandle = handle
|
|
}
|
|
}
|
|
|
|
savePage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
|
|
const page = data.document.pages[pageId]
|
|
|
|
// Save page
|
|
|
|
localStorage.setItem(
|
|
storageId(fileId, 'page', pageId),
|
|
compress(JSON.stringify(page))
|
|
)
|
|
|
|
// Save page state
|
|
const currentPageState = data.pageStates[pageId]
|
|
|
|
localStorage.setItem(
|
|
storageId(fileId, 'pageState', pageId),
|
|
JSON.stringify({
|
|
...currentPageState,
|
|
selectedIds: [...currentPageState.selectedIds],
|
|
})
|
|
)
|
|
}
|
|
|
|
loadPage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
|
|
if (typeof window === 'undefined') return
|
|
if (typeof localStorage === 'undefined') return
|
|
|
|
data.currentPageId = pageId
|
|
|
|
try {
|
|
// If we have a page in local storage, move it into state
|
|
const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
|
|
data.document.pages[pageId] = JSON.parse(decompress(savedPage))
|
|
} catch (e) {
|
|
console.warn('Could not load a page with the id', pageId)
|
|
|
|
// If we don't have a page, create a new page
|
|
data.document.pages[pageId] = {
|
|
id: pageId,
|
|
type: 'page',
|
|
childIndex: Object.keys(data.document.pages).length,
|
|
name: 'New Page',
|
|
shapes: {},
|
|
}
|
|
}
|
|
|
|
// Get saved page state from local storage
|
|
const savedPageState = localStorage.getItem(
|
|
storageId(fileId, 'pageState', pageId)
|
|
)
|
|
|
|
if (savedPageState !== null) {
|
|
// If we have a page, move it into state
|
|
const restored: PageState = JSON.parse(savedPageState)
|
|
data.pageStates[pageId] = restored
|
|
} else {
|
|
data.pageStates[pageId] = {
|
|
id: pageId,
|
|
camera: {
|
|
point: [0, 0],
|
|
zoom: 1,
|
|
},
|
|
selectedIds: [],
|
|
}
|
|
}
|
|
|
|
// Save the last page state
|
|
localStorage.setItem(
|
|
storageId(fileId, 'lastPageState'),
|
|
JSON.stringify(data.pageStates[pageId])
|
|
)
|
|
|
|
// Prepare new state
|
|
|
|
// Now clear out the other pages from state.
|
|
Object.values(data.document.pages).forEach((page) => {
|
|
if (page.id !== data.currentPageId) {
|
|
page.shapes = {}
|
|
}
|
|
})
|
|
|
|
// Update camera for the new page state
|
|
document.documentElement.style.setProperty(
|
|
'--camera-zoom',
|
|
data.pageStates[data.currentPageId].camera.zoom.toString()
|
|
)
|
|
}
|
|
|
|
/* ------------------- File System ------------------ */
|
|
|
|
reset = () => {
|
|
this.previousSaveHandle = undefined
|
|
}
|
|
|
|
saveToFileSystem = (data: Data) => {
|
|
this.saveDataToFileSystem(data, false)
|
|
}
|
|
|
|
saveAsToFileSystem = (data: Data) => {
|
|
this.saveDataToFileSystem(data, true)
|
|
}
|
|
|
|
saveDataToFileSystem = async (data: Data, saveAs: boolean) => {
|
|
const isSavingAs = saveAs || !this.previousSaveHandle
|
|
const document = this.getCompleteDocument(data)
|
|
|
|
// Then save to file system
|
|
const blob = new Blob(
|
|
[
|
|
compress(
|
|
JSON.stringify({
|
|
document,
|
|
pageState: data.pageStates[data.currentPageId],
|
|
})
|
|
),
|
|
],
|
|
{
|
|
type: 'application/vnd.tldraw+json',
|
|
}
|
|
)
|
|
|
|
const documentName = data.document.name
|
|
|
|
const fa = await import('browser-fs-access')
|
|
|
|
fa.fileSave(
|
|
blob,
|
|
{
|
|
fileName: `${
|
|
isSavingAs
|
|
? documentName
|
|
: this.previousSaveHandle?.name || 'My Document'
|
|
}.tldr`,
|
|
description: 'tldraw file',
|
|
extensions: ['.tldr'],
|
|
},
|
|
isSavingAs ? undefined : this.previousSaveHandle,
|
|
true
|
|
)
|
|
.then((handle) => {
|
|
this.previousSaveHandle = handle
|
|
state.send('SAVED_FILE_TO_FILE_SYSTEM')
|
|
idb.set('previous_handle', handle)
|
|
})
|
|
.catch((e) => {
|
|
state.send('CANCELLED_SAVE', { reason: e.message })
|
|
})
|
|
}
|
|
|
|
async loadDocumentFromFilesystem() {
|
|
const fa = await import('browser-fs-access')
|
|
|
|
fa.fileOpen({
|
|
description: 'tldraw files',
|
|
})
|
|
.then((blob) =>
|
|
getTextFromBlob(blob).then((json) => {
|
|
// Save blob for future saves
|
|
this.previousSaveHandle = blob.handle
|
|
|
|
state.send('LOADED_FROM_FILE', { json: decompress(json) })
|
|
})
|
|
)
|
|
.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')
|
|
})
|
|
}
|