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

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