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

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