You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

storage.ts 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import { Data, PageState, TLDocument } from 'types'
  2. import { decompress, compress, setToArray } from 'utils'
  3. import state from './state'
  4. import { uniqueId } from 'utils/utils'
  5. import * as idb from 'idb-keyval'
  6. const CURRENT_VERSION = 'code_slate_0.0.8'
  7. function storageId(fileId: string, label: string, id?: string) {
  8. return [CURRENT_VERSION, fileId, label, id].filter(Boolean).join('_')
  9. }
  10. class Storage {
  11. previousSaveHandle?: any // FileSystemHandle
  12. constructor() {
  13. // this.loadPreviousHandle() // Still needs debugging
  14. }
  15. firstLoad(data: Data) {
  16. const lastOpenedFileId = localStorage.getItem(
  17. `${CURRENT_VERSION}_lastOpened`
  18. )
  19. // 1. Load Document from Local Storage
  20. // Using the "last opened file id" in local storage.
  21. if (lastOpenedFileId !== null) {
  22. // Load state from local storage
  23. const savedState = localStorage.getItem(
  24. storageId(lastOpenedFileId, 'document-state', lastOpenedFileId)
  25. )
  26. if (savedState === null) {
  27. // If no state with that document was found, create a fresh random id.
  28. data.document.id = uniqueId()
  29. } else {
  30. // If we did find a state and document, load it into state.
  31. const restoredDocument: Data = JSON.parse(decompress(savedState))
  32. // Merge restored data into state.
  33. Object.assign(data, restoredDocument)
  34. }
  35. }
  36. try {
  37. this.load(data)
  38. } catch (error) {
  39. console.error(error)
  40. }
  41. }
  42. saveAppStateToLocalStorage = (data: Data) => {
  43. localStorage.setItem(
  44. storageId(data.document.id, 'document-state', data.document.id),
  45. compress(JSON.stringify(data))
  46. )
  47. }
  48. saveDocumentToLocalStorage(data: Data) {
  49. const document = this.getCompleteDocument(data)
  50. localStorage.setItem(
  51. storageId(data.document.id, 'document', data.document.id),
  52. compress(JSON.stringify(document))
  53. )
  54. }
  55. getCompleteDocument = (data: Data) => {
  56. // Create a safely mutable copy of the data
  57. const document: TLDocument = { ...data.document }
  58. // Try to find the document's pages and page states in local storage.
  59. Object.keys(document.pages).forEach((pageId) => {
  60. const savedPage = localStorage.getItem(
  61. storageId(document.id, 'page', pageId)
  62. )
  63. if (savedPage !== null) {
  64. document.pages[pageId] = JSON.parse(decompress(savedPage))
  65. }
  66. })
  67. return document
  68. }
  69. savePageState = (data: Data) => {
  70. localStorage.setItem(
  71. storageId(data.document.id, 'lastPageState', data.document.id),
  72. JSON.stringify(data.pageStates[data.currentPageId])
  73. )
  74. }
  75. loadDocumentFromJson(data: Data, json: string) {
  76. const restoredDocument: { document: TLDocument; pageState: PageState } =
  77. JSON.parse(json)
  78. data.document = restoredDocument.document
  79. // Save pages to local storage, possibly overwriting unsaved local copies
  80. Object.values(data.document.pages).forEach((page) => {
  81. localStorage.setItem(
  82. storageId(data.document.id, 'page', page.id),
  83. compress(JSON.stringify(page))
  84. )
  85. })
  86. localStorage.setItem(
  87. storageId(data.document.id, 'lastPageState', data.document.id),
  88. JSON.stringify(restoredDocument.pageState)
  89. )
  90. // Save the new file as the last opened document id
  91. localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
  92. this.load(data)
  93. }
  94. load(data: Data) {
  95. // Once we've loaded data either from local storage or json, run through these steps.
  96. data.pageStates = {}
  97. // 2. Load Pages from Local Storage
  98. // Try to find the document's pages and page states in local storage.
  99. Object.keys(data.document.pages).forEach((pageId) => {
  100. const savedPage = localStorage.getItem(
  101. storageId(data.document.id, 'page', pageId)
  102. )
  103. if (savedPage !== null) {
  104. // If we've found a page in local storage, set it into state.
  105. data.document.pages[pageId] = JSON.parse(decompress(savedPage))
  106. }
  107. const savedPageState = localStorage.getItem(
  108. storageId(data.document.id, 'pageState', pageId)
  109. )
  110. if (savedPageState !== null) {
  111. // If we've found a page state in local storage, set it into state.
  112. data.pageStates[pageId] = JSON.parse(decompress(savedPageState))
  113. data.pageStates[pageId].selectedIds = new Set([])
  114. } else {
  115. // Or else create a new one.
  116. data.pageStates[pageId] = {
  117. id: pageId,
  118. selectedIds: new Set([]),
  119. camera: {
  120. point: [0, 0],
  121. zoom: 1,
  122. },
  123. }
  124. }
  125. })
  126. // 3. Restore the last page state
  127. // Using the "last page state" in local storage.
  128. const savedPageState = localStorage.getItem(
  129. storageId(data.document.id, 'lastPageState', data.document.id)
  130. )
  131. if (savedPageState !== null) {
  132. const pageState = JSON.parse(decompress(savedPageState))
  133. pageState.selectedIds = new Set([])
  134. data.pageStates[pageState.id] = pageState
  135. data.currentPageId = pageState.id
  136. }
  137. // 4. Save the current app state / document
  138. // The document is now "full" and ready. Whether we've restored a
  139. // document or created a new one, save the entire current document.
  140. this.saveDocumentToLocalStorage(data)
  141. // 4.1
  142. // Also save the app state.
  143. this.saveAppStateToLocalStorage(data)
  144. // 4.1
  145. // Also save out copies of each page separately.
  146. Object.values(data.document.pages).forEach((page) => {
  147. // Save page
  148. localStorage.setItem(
  149. storageId(data.document.id, 'page', page.id),
  150. compress(JSON.stringify(page))
  151. )
  152. })
  153. // Save the last page state
  154. const currentPageState = data.pageStates[data.currentPageId]
  155. localStorage.setItem(
  156. storageId(data.document.id, 'lastPageState', data.document.id),
  157. JSON.stringify(currentPageState)
  158. )
  159. // Finally, save the current document id as the "last opened" document id.
  160. localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
  161. // 5. Prepare the new state.
  162. // Clear out the other pages from state.
  163. Object.values(data.document.pages).forEach((page) => {
  164. if (page.id !== data.currentPageId) {
  165. page.shapes = {}
  166. }
  167. })
  168. // Update camera for the new page state
  169. document.documentElement.style.setProperty(
  170. '--camera-zoom',
  171. data.pageStates[data.currentPageId].camera.zoom.toString()
  172. )
  173. }
  174. /* ---------------------- Pages --------------------- */
  175. async loadPreviousHandle() {
  176. const handle = await idb.get('previous_handle')
  177. if (handle !== undefined) {
  178. this.previousSaveHandle = handle
  179. }
  180. }
  181. savePage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
  182. const page = data.document.pages[pageId]
  183. // Save page
  184. localStorage.setItem(
  185. storageId(fileId, 'page', pageId),
  186. compress(JSON.stringify(page))
  187. )
  188. // Save page state
  189. const currentPageState = data.pageStates[pageId]
  190. localStorage.setItem(
  191. storageId(fileId, 'pageState', pageId),
  192. JSON.stringify({
  193. ...currentPageState,
  194. selectedIds: setToArray(currentPageState.selectedIds),
  195. })
  196. )
  197. }
  198. loadPage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
  199. if (typeof window === 'undefined') return
  200. if (typeof localStorage === 'undefined') return
  201. data.currentPageId = pageId
  202. // Get saved page from local storage
  203. const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
  204. if (savedPage !== null) {
  205. // If we have a page, move it into state
  206. data.document.pages[pageId] = JSON.parse(decompress(savedPage))
  207. } else {
  208. // If we don't have a page, create a new page
  209. data.document.pages[pageId] = {
  210. id: pageId,
  211. type: 'page',
  212. childIndex: Object.keys(data.document.pages).length,
  213. name: 'New Page',
  214. shapes: {},
  215. }
  216. }
  217. // Get saved page state from local storage
  218. const savedPageState = localStorage.getItem(
  219. storageId(fileId, 'pageState', pageId)
  220. )
  221. if (savedPageState !== null) {
  222. // If we have a page, move it into state
  223. const restored: PageState = JSON.parse(savedPageState)
  224. data.pageStates[pageId] = restored
  225. data.pageStates[pageId].selectedIds = new Set(restored.selectedIds)
  226. } else {
  227. data.pageStates[pageId] = {
  228. id: pageId,
  229. camera: {
  230. point: [0, 0],
  231. zoom: 1,
  232. },
  233. selectedIds: new Set([]),
  234. }
  235. }
  236. // Save the last page state
  237. localStorage.setItem(
  238. storageId(fileId, 'lastPageState'),
  239. JSON.stringify(data.pageStates[pageId])
  240. )
  241. // Prepare new state
  242. // Now clear out the other pages from state.
  243. Object.values(data.document.pages).forEach((page) => {
  244. if (page.id !== data.currentPageId) {
  245. page.shapes = {}
  246. }
  247. })
  248. // Update camera for the new page state
  249. document.documentElement.style.setProperty(
  250. '--camera-zoom',
  251. data.pageStates[data.currentPageId].camera.zoom.toString()
  252. )
  253. }
  254. /* ------------------- File System ------------------ */
  255. saveToFileSystem = (data: Data) => {
  256. this.saveAppStateToLocalStorage(data)
  257. this.saveDocumentToLocalStorage(data)
  258. this.saveDataToFileSystem(data, data.document.id, false)
  259. }
  260. saveAsToFileSystem = (data: Data) => {
  261. this.saveAppStateToLocalStorage(data)
  262. this.saveDocumentToLocalStorage(data)
  263. this.saveDataToFileSystem(data, uniqueId(), true)
  264. }
  265. saveDataToFileSystem = async (
  266. data: Data,
  267. fileId: string,
  268. saveAs: boolean
  269. ) => {
  270. const document = this.getCompleteDocument(data)
  271. // Then save to file system
  272. const blob = new Blob(
  273. [
  274. compress(
  275. JSON.stringify({
  276. document,
  277. pageState: data.pageStates[data.currentPageId],
  278. })
  279. ),
  280. ],
  281. {
  282. type: 'application/vnd.tldraw+json',
  283. }
  284. )
  285. const documentName = data.document.name
  286. const fa = await import('browser-fs-access')
  287. fa.fileSave(
  288. blob,
  289. {
  290. fileName: `${
  291. saveAs ? documentName : this.previousSaveHandle?.name || 'My Document'
  292. }.tldr`,
  293. description: 'tldraw file',
  294. extensions: ['.tldr'],
  295. },
  296. saveAs ? undefined : this.previousSaveHandle,
  297. true
  298. )
  299. .then((handle) => {
  300. this.previousSaveHandle = handle
  301. state.send('SAVED_FILE_TO_FILE_SYSTEM')
  302. idb.set('previous_handle', handle)
  303. })
  304. .catch((e) => {
  305. state.send('CANCELLED_SAVE', { reason: e.message })
  306. })
  307. }
  308. async loadDocumentFromFilesystem() {
  309. const fa = await import('browser-fs-access')
  310. fa.fileOpen({
  311. description: 'tldraw files',
  312. })
  313. .then((blob) =>
  314. getTextFromBlob(blob).then((json) => {
  315. // Save blob for future saves
  316. this.previousSaveHandle = blob.handle
  317. state.send('LOADED_FROM_FILE', { json: decompress(json) })
  318. })
  319. )
  320. .catch((e) => {
  321. state.send('CANCELLED_SAVE', { reason: e.message })
  322. })
  323. }
  324. }
  325. const storage = new Storage()
  326. export default storage
  327. async function getTextFromBlob(blob: Blob): Promise<string> {
  328. // Return blob as text if a text file.
  329. if ('text' in Blob) return blob.text()
  330. // Return blob as text if a text file.
  331. return new Promise((resolve) => {
  332. const reader = new FileReader()
  333. reader.onloadend = () => {
  334. if (reader.readyState === FileReader.DONE) {
  335. resolve(reader.result as string)
  336. }
  337. }
  338. reader.readAsText(blob, 'utf8')
  339. })
  340. }