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 7.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import * as fa from 'browser-fs-access'
  2. import { Data, Page, PageState, TLDocument } from 'types'
  3. import { decompress, compress, setToArray } from 'utils/utils'
  4. import state from './state'
  5. import { current } from 'immer'
  6. import { v4 as uuid } from 'uuid'
  7. import * as idb from 'idb-keyval'
  8. const CURRENT_VERSION = 'code_slate_0.0.7'
  9. const DOCUMENT_ID = '0001'
  10. function storageId(fileId: string, label: string, id?: string) {
  11. return [CURRENT_VERSION, fileId, label, id].filter(Boolean).join('_')
  12. }
  13. class Storage {
  14. previousSaveHandle?: fa.FileSystemHandle
  15. constructor() {
  16. // this.loadPreviousHandle() // Still needs debugging
  17. }
  18. firstLoad(data: Data) {
  19. const lastOpened = localStorage.getItem(`${CURRENT_VERSION}_lastOpened`)
  20. this.loadDocumentFromLocalStorage(data, lastOpened || DOCUMENT_ID)
  21. this.loadPage(data, data.currentPageId)
  22. this.saveToLocalStorage(data, data.document.id)
  23. localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
  24. }
  25. load(data: Data, restoredData: any) {
  26. // Before loading the state, save the pages / page states
  27. // Empty current state.
  28. data.document = {} as TLDocument
  29. data.pageStates = {}
  30. // Merge restored data into state.
  31. Object.assign(data, restoredData)
  32. // Add id and name to document, just in case.
  33. data.document = {
  34. id: 'document0',
  35. name: 'My Document',
  36. ...restoredData.document,
  37. }
  38. }
  39. loadDocumentFromLocalStorage(data: Data, fileId = DOCUMENT_ID) {
  40. if (typeof window === 'undefined') return
  41. if (typeof localStorage === 'undefined') return
  42. // Load data from local storage
  43. const savedData = localStorage.getItem(
  44. storageId(fileId, 'document', fileId)
  45. )
  46. if (savedData === null) {
  47. // If we're going to use the default data, assign the
  48. // current document a fresh random id.
  49. data.document.id = uuid()
  50. return false
  51. }
  52. const restoredData: any = JSON.parse(decompress(savedData))
  53. this.load(data, restoredData)
  54. }
  55. getDataToSave = (data: Data) => {
  56. const dataToSave = current(data) as any
  57. for (let pageId in data.document.pages) {
  58. const savedPage = localStorage.getItem(
  59. storageId(data.document.id, 'page', pageId)
  60. )
  61. if (savedPage !== null) {
  62. const restored: Page = JSON.parse(decompress(savedPage))
  63. dataToSave.document.pages[pageId] = restored
  64. }
  65. const pageState = { ...dataToSave.pageStates[pageId] }
  66. pageState.selectedIds = setToArray(pageState.selectedIds)
  67. }
  68. return JSON.stringify(dataToSave, null, 2)
  69. }
  70. saveToLocalStorage = (data: Data, fileId = data.document.id) => {
  71. if (typeof window === 'undefined') return
  72. if (typeof localStorage === 'undefined') return
  73. const dataToSave = this.getDataToSave(data)
  74. // Save current data to local storage
  75. localStorage.setItem(
  76. storageId(fileId, 'document', fileId),
  77. compress(dataToSave)
  78. )
  79. }
  80. loadDocumentFromJson(data: Data, restoredData: any) {
  81. this.load(data, restoredData)
  82. for (let key in restoredData.document.pages) {
  83. this.savePage(restoredData, restoredData.document.id, key)
  84. }
  85. this.loadPage(data, data.currentPageId)
  86. this.saveToLocalStorage(data, data.document.id)
  87. localStorage.setItem(`${CURRENT_VERSION}_lastOpened`, data.document.id)
  88. }
  89. /* ---------------------- Pages --------------------- */
  90. async loadPreviousHandle() {
  91. const handle: fa.FileSystemHandle | undefined = await idb.get(
  92. 'previous_handle'
  93. )
  94. if (handle !== undefined) {
  95. this.previousSaveHandle = handle
  96. }
  97. }
  98. savePage(data: Data, fileId = data.document.id, pageId = data.currentPageId) {
  99. if (typeof window === 'undefined') return
  100. if (typeof localStorage === 'undefined') return
  101. // Save page
  102. const page = data.document.pages[pageId]
  103. const json = JSON.stringify(page)
  104. localStorage.setItem(storageId(fileId, 'page', pageId), compress(json))
  105. // Save page state
  106. let currentPageState = {
  107. camera: {
  108. point: [0, 0],
  109. zoom: 1,
  110. },
  111. selectedIds: new Set([]),
  112. ...data.pageStates[pageId],
  113. }
  114. localStorage.setItem(
  115. storageId(fileId, 'pageState', pageId),
  116. JSON.stringify({
  117. ...currentPageState,
  118. selectedIds: setToArray(currentPageState.selectedIds),
  119. })
  120. )
  121. }
  122. loadPage(data: Data, pageId = data.currentPageId) {
  123. if (typeof window === 'undefined') return
  124. if (typeof localStorage === 'undefined') return
  125. const fileId = data.document.id
  126. // Page
  127. const savedPage = localStorage.getItem(storageId(fileId, 'page', pageId))
  128. if (savedPage !== null) {
  129. data.document.pages[pageId] = JSON.parse(decompress(savedPage))
  130. } else {
  131. data.document.pages[pageId] = {
  132. id: pageId,
  133. type: 'page',
  134. childIndex: 0,
  135. name: 'Page',
  136. shapes: {},
  137. }
  138. }
  139. // Page state
  140. const savedPageState = localStorage.getItem(
  141. storageId(fileId, 'pageState', pageId)
  142. )
  143. if (savedPageState !== null) {
  144. const restored: PageState = JSON.parse(savedPageState)
  145. data.pageStates[pageId] = restored
  146. } else {
  147. data.pageStates[pageId] = {
  148. camera: {
  149. point: [0, 0],
  150. zoom: 1,
  151. },
  152. selectedIds: new Set([]),
  153. }
  154. }
  155. // Empty shapes in state for other pages
  156. for (let key in data.document.pages) {
  157. if (key === pageId) continue
  158. data.document.pages[key].shapes = {}
  159. }
  160. // Force selected Ids into sets
  161. for (let key in data.pageStates) {
  162. data.pageStates[key].selectedIds = new Set([])
  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. /* ------------------- File System ------------------ */
  171. saveToFileSystem = (data: Data) => {
  172. this.saveDataToFileSystem(data, data.document.id, false)
  173. }
  174. saveAsToFileSystem = (data: Data) => {
  175. this.saveDataToFileSystem(data, uuid(), true)
  176. }
  177. saveDataToFileSystem = (data: Data, fileId: string, saveAs: boolean) => {
  178. const json = this.getDataToSave(data)
  179. this.saveToLocalStorage(data, fileId)
  180. const blob = new Blob([json], {
  181. type: 'application/vnd.tldraw+json',
  182. })
  183. fa.fileSave(
  184. blob,
  185. {
  186. fileName: `${
  187. saveAs
  188. ? data.document.name
  189. : this.previousSaveHandle?.name || 'My Document'
  190. }.tldr`,
  191. description: 'tldraw file',
  192. extensions: ['.tldr'],
  193. },
  194. saveAs ? undefined : this.previousSaveHandle,
  195. true
  196. )
  197. .then((handle) => {
  198. this.previousSaveHandle = handle
  199. state.send('SAVED_FILE_TO_FILE_SYSTEM')
  200. idb.set('previous_handle', handle)
  201. })
  202. .catch((e) => {
  203. state.send('CANCELLED_SAVE', { reason: e.message })
  204. })
  205. }
  206. loadDocumentFromFilesystem() {
  207. console.warn('Loading file from file system.')
  208. fa.fileOpen({
  209. description: 'tldraw files',
  210. })
  211. .then((blob) =>
  212. getTextFromBlob(blob).then((text) => {
  213. const restoredData = JSON.parse(text)
  214. if (restoredData === null) {
  215. console.warn('Could not load that data.')
  216. return
  217. }
  218. // Save blob for future saves
  219. this.previousSaveHandle = blob.handle
  220. state.send('LOADED_FROM_FILE', { restoredData: { ...restoredData } })
  221. })
  222. )
  223. .catch((e) => {
  224. state.send('CANCELLED_SAVE', { reason: e.message })
  225. })
  226. }
  227. }
  228. const storage = new Storage()
  229. export default storage
  230. async function getTextFromBlob(blob: Blob): Promise<string> {
  231. // Return blob as text if a text file.
  232. if ('text' in Blob) return blob.text()
  233. // Return blob as text if a text file.
  234. return new Promise((resolve) => {
  235. const reader = new FileReader()
  236. reader.onloadend = () => {
  237. if (reader.readyState === FileReader.DONE) {
  238. resolve(reader.result as string)
  239. }
  240. }
  241. reader.readAsText(blob, 'utf8')
  242. })
  243. }