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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import { Data, PageState, TLDocument } from 'types'
  2. import { decompress, compress, setToArray } from 'utils'
  3. import state from './state'
  4. import { uniqueId } from '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. saveDocumentToLocalStorage(data: Data) {
  43. const document = this.getCompleteDocument(data)
  44. localStorage.setItem(
  45. storageId(data.document.id, 'document', data.document.id),
  46. compress(JSON.stringify(document))
  47. )
  48. localStorage.setItem(
  49. storageId(data.document.id, 'document-state', data.document.id),
  50. compress(JSON.stringify(data))
  51. )
  52. }
  53. getCompleteDocument = (data: Data) => {
  54. // Create a safely mutable copy of the data
  55. const document: TLDocument = { ...data.document }
  56. // Try to find the document's pages and page states in local storage.
  57. Object.keys(document.pages).forEach((pageId) => {
  58. const savedPage = localStorage.getItem(
  59. storageId(document.id, 'page', pageId)
  60. )
  61. if (savedPage !== null) {
  62. document.pages[pageId] = JSON.parse(decompress(savedPage))
  63. }
  64. })
  65. return document
  66. }
  67. savePageState = (data: Data) => {
  68. localStorage.setItem(
  69. storageId(data.document.id, 'lastPageState', data.document.id),
  70. JSON.stringify(data.pageStates[data.currentPageId])
  71. )
  72. }
  73. loadDocumentFromJson(data: Data, json: string) {
  74. const restoredDocument: { document: TLDocument; pageState: PageState } =
  75. JSON.parse(json)
  76. data.document = restoredDocument.document
  77. // Save pages to local storage, possibly overwriting unsaved local copies
  78. Object.values(data.document.pages).forEach((page) => {
  79. localStorage.setItem(
  80. storageId(data.document.id, 'page', page.id),
  81. compress(JSON.stringify(page))
  82. )
  83. })
  84. localStorage.setItem(
  85. storageId(data.document.id, 'lastPageState', data.document.id),
  86. JSON.stringify(restoredDocument.pageState)
  87. )
  88. // Save the new file as the last opened document id
  89. localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
  90. this.load(data)
  91. }
  92. load(data: Data) {
  93. // Once we've loaded data either from local storage or json, run through these steps.
  94. data.pageStates = {}
  95. // 2. Load Pages from Local Storage
  96. // Try to find the document's pages and page states in local storage.
  97. Object.keys(data.document.pages).forEach((pageId) => {
  98. const savedPage = localStorage.getItem(
  99. storageId(data.document.id, 'page', pageId)
  100. )
  101. if (savedPage !== null) {
  102. // If we've found a page in local storage, set it into state.
  103. data.document.pages[pageId] = JSON.parse(decompress(savedPage))
  104. }
  105. const savedPageState = localStorage.getItem(
  106. storageId(data.document.id, 'pageState', pageId)
  107. )
  108. if (savedPageState !== null) {
  109. // If we've found a page state in local storage, set it into state.
  110. data.pageStates[pageId] = JSON.parse(decompress(savedPageState))
  111. data.pageStates[pageId].selectedIds = new Set([])
  112. } else {
  113. // Or else create a new one.
  114. data.pageStates[pageId] = {
  115. id: pageId,
  116. selectedIds: new Set([]),
  117. camera: {
  118. point: [0, 0],
  119. zoom: 1,
  120. },
  121. }
  122. }
  123. })
  124. // 3. Restore the last page state
  125. // Using the "last page state" in local storage.
  126. const savedPageState = localStorage.getItem(
  127. storageId(data.document.id, 'lastPageState', data.document.id)
  128. )
  129. if (savedPageState !== null) {
  130. const pageState = JSON.parse(decompress(savedPageState))
  131. pageState.selectedIds = new Set([])
  132. data.pageStates[pageState.id] = pageState
  133. data.currentPageId = pageState.id
  134. }
  135. // 4. Save the current document
  136. // The document is now "full" and ready. Whether we've restored a
  137. // document or created a new one, save the entire current document.
  138. localStorage.setItem(
  139. storageId(data.document.id, 'document', data.document.id),
  140. compress(JSON.stringify(data.document))
  141. )
  142. localStorage.setItem(
  143. storageId(data.document.id, 'document-state', data.document.id),
  144. compress(JSON.stringify(data))
  145. )
  146. // 4.1
  147. // Also save out copies of each page separately.
  148. Object.values(data.document.pages).forEach((page) => {
  149. // Save page
  150. localStorage.setItem(
  151. storageId(data.document.id, 'page', page.id),
  152. compress(JSON.stringify(page))
  153. )
  154. })
  155. // Save the last page state
  156. const currentPageState = data.pageStates[data.currentPageId]
  157. localStorage.setItem(
  158. storageId(data.document.id, 'lastPageState', data.document.id),
  159. JSON.stringify(currentPageState)
  160. )
  161. // Finally, save the current document id as the "last opened" document id.
  162. localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
  163. // 5. Prepare the new state.
  164. // Clear out the other pages from state.
  165. Object.values(data.document.pages).forEach((page) => {
  166. if (page.id !== data.currentPageId) {
  167. page.shapes = {}
  168. }
  169. })
  170. // Update camera for the new page state
  171. document.documentElement.style.setProperty(
  172. '--camera-zoom',
  173. data.pageStates[data.currentPageId].camera.zoom.toString()
  174. )
  175. }
  176. /* ---------------------- Pages --------------------- */
  177. async loadPreviousHandle() {
  178. const handle = await idb.get('previous_handle')
  179. if (handle !== undefined) {
  180. this.previousSaveHandle = handle
  181. }
  182. }
  183. savePage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
  184. const page = data.document.pages[pageId]
  185. // Save page
  186. localStorage.setItem(
  187. storageId(fileId, 'page', pageId),
  188. compress(JSON.stringify(page))
  189. )
  190. // Save page state
  191. const currentPageState = data.pageStates[pageId]
  192. localStorage.setItem(
  193. storageId(fileId, 'pageState', pageId),
  194. JSON.stringify({
  195. ...currentPageState,
  196. selectedIds: setToArray(currentPageState.selectedIds),
  197. })
  198. )
  199. }
  200. loadPage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
  201. if (typeof window === 'undefined') return
  202. if (typeof localStorage === 'undefined') return
  203. data.currentPageId = pageId
  204. // Get saved page from local storage
  205. const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
  206. if (savedPage !== null) {
  207. // If we have a page, move it into state
  208. data.document.pages[pageId] = JSON.parse(decompress(savedPage))
  209. } else {
  210. // If we don't have a page, create a new page
  211. data.document.pages[pageId] = {
  212. id: pageId,
  213. type: 'page',
  214. childIndex: Object.keys(data.document.pages).length,
  215. name: 'New Page',
  216. shapes: {},
  217. }
  218. }
  219. // Get saved page state from local storage
  220. const savedPageState = localStorage.getItem(
  221. storageId(fileId, 'pageState', pageId)
  222. )
  223. if (savedPageState !== null) {
  224. // If we have a page, move it into state
  225. const restored: PageState = JSON.parse(savedPageState)
  226. data.pageStates[pageId] = restored
  227. data.pageStates[pageId].selectedIds = new Set(restored.selectedIds)
  228. } else {
  229. data.pageStates[pageId] = {
  230. id: pageId,
  231. camera: {
  232. point: [0, 0],
  233. zoom: 1,
  234. },
  235. selectedIds: new Set([]),
  236. }
  237. }
  238. // Save the last page state
  239. localStorage.setItem(
  240. storageId(fileId, 'lastPageState'),
  241. JSON.stringify(data.pageStates[pageId])
  242. )
  243. // Prepare new state
  244. // Now clear out the other pages from state.
  245. Object.values(data.document.pages).forEach((page) => {
  246. if (page.id !== data.currentPageId) {
  247. page.shapes = {}
  248. }
  249. })
  250. // Update camera for the new page state
  251. document.documentElement.style.setProperty(
  252. '--camera-zoom',
  253. data.pageStates[data.currentPageId].camera.zoom.toString()
  254. )
  255. }
  256. /* ------------------- File System ------------------ */
  257. saveToFileSystem = (data: Data) => {
  258. this.saveDocumentToLocalStorage(data)
  259. this.saveDataToFileSystem(data, data.document.id, false)
  260. }
  261. saveAsToFileSystem = (data: 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. }