TLApi.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import Vec from '@tldraw/vec'
  2. import type { TLAsset, TLBinding, TLEventMap } from '../../types'
  3. import { BoundsUtils, isNonNullable, uniqueId } from '../../utils'
  4. import type { TLShape, TLShapeModel } from '../shapes'
  5. import type { TLApp } from '../TLApp'
  6. export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMap> {
  7. private app: TLApp<S, K>
  8. constructor(app: TLApp<S, K>) {
  9. this.app = app
  10. }
  11. editShape = (shape: S | undefined): this => {
  12. if (!shape?.props.isLocked)
  13. this.app.transition('select').selectedTool.transition('editingShape', { shape })
  14. return this
  15. }
  16. /**
  17. * Set the hovered shape.
  18. *
  19. * @param shape The new hovered shape or shape id.
  20. */
  21. hoverShape = (shape: string | S | undefined): this => {
  22. this.app.setHoveredShape(shape)
  23. return this
  24. }
  25. /**
  26. * Create one or more shapes on the current page.
  27. *
  28. * @param shapes The new shape instances or serialized shapes.
  29. */
  30. createShapes = (...shapes: S[] | TLShapeModel[]): this => {
  31. this.app.createShapes(shapes)
  32. return this
  33. }
  34. /**
  35. * Update one or more shapes on the current page.
  36. *
  37. * @param shapes The serialized shape changes to apply.
  38. */
  39. updateShapes = <T extends S>(...shapes: ({ id: string } & Partial<T['props']>)[]): this => {
  40. this.app.updateShapes(shapes)
  41. return this
  42. }
  43. /**
  44. * Delete one or more shapes from the current page.
  45. *
  46. * @param shapes The shapes or shape ids to delete.
  47. */
  48. deleteShapes = (...shapes: S[] | string[]): this => {
  49. this.app.deleteShapes(shapes.length ? shapes : this.app.selectedShapesArray)
  50. return this
  51. }
  52. /**
  53. * Select one or more shapes on the current page.
  54. *
  55. * @param shapes The shapes or shape ids to select.
  56. */
  57. selectShapes = (...shapes: S[] | string[]): this => {
  58. this.app.setSelectedShapes(shapes)
  59. return this
  60. }
  61. /**
  62. * Deselect one or more selected shapes on the current page.
  63. *
  64. * @param ids The shapes or shape ids to deselect.
  65. */
  66. deselectShapes = (...shapes: S[] | string[]): this => {
  67. const ids =
  68. typeof shapes[0] === 'string' ? (shapes as string[]) : (shapes as S[]).map(shape => shape.id)
  69. this.app.setSelectedShapes(
  70. this.app.selectedShapesArray.filter(shape => !ids.includes(shape.id))
  71. )
  72. return this
  73. }
  74. flipHorizontal = (...shapes: S[] | string[]): this => {
  75. this.app.flipHorizontal(shapes)
  76. return this
  77. }
  78. flipVertical = (...shapes: S[] | string[]): this => {
  79. this.app.flipVertical(shapes)
  80. return this
  81. }
  82. /** Select all shapes on the current page. */
  83. selectAll = (): this => {
  84. this.app.setSelectedShapes(
  85. this.app.currentPage.shapes.filter(s => !this.app.shapesInGroups().includes(s))
  86. )
  87. return this
  88. }
  89. /** Deselect all shapes on the current page. */
  90. deselectAll = (): this => {
  91. this.app.setSelectedShapes([])
  92. return this
  93. }
  94. /** Zoom the camera in. */
  95. zoomIn = (): this => {
  96. this.app.viewport.zoomIn()
  97. return this
  98. }
  99. /** Zoom the camera out. */
  100. zoomOut = (): this => {
  101. this.app.viewport.zoomOut()
  102. return this
  103. }
  104. /** Reset the camera to 100%. */
  105. resetZoom = (): this => {
  106. this.app.viewport.resetZoom()
  107. return this
  108. }
  109. /** Zoom to fit all of the current page's shapes in the viewport. */
  110. zoomToFit = (): this => {
  111. const { shapes } = this.app.currentPage
  112. if (shapes.length === 0) return this
  113. const commonBounds = BoundsUtils.getCommonBounds(shapes.map(shape => shape.bounds))
  114. this.app.viewport.zoomToBounds(commonBounds)
  115. return this
  116. }
  117. cameraToCenter = (): this => {
  118. const { shapes } = this.app.currentPage
  119. if (shapes.length === 0) return this
  120. // Viewport should be focused to existing shapes
  121. const commonBounds = BoundsUtils.getCommonBounds(shapes.map(shape => shape.bounds))
  122. this.app.viewport.update({
  123. point: Vec.add(Vec.neg(BoundsUtils.getBoundsCenter(commonBounds)), [
  124. this.app.viewport.currentView.width / 2,
  125. this.app.viewport.currentView.height / 2,
  126. ]),
  127. })
  128. return this
  129. }
  130. /** Zoom to fit the current selection in the viewport. */
  131. zoomToSelection = (): this => {
  132. const { selectionBounds } = this.app
  133. if (!selectionBounds) return this
  134. this.app.viewport.zoomToBounds(selectionBounds)
  135. return this
  136. }
  137. resetZoomToCursor = (): this => {
  138. const viewport = this.app.viewport
  139. viewport.animateCamera({
  140. zoom: 1,
  141. point: Vec.sub(this.app.inputs.originScreenPoint, this.app.inputs.originPoint),
  142. })
  143. return this
  144. }
  145. toggleGrid = (): this => {
  146. const { settings } = this.app
  147. settings.update({ showGrid: !settings.showGrid })
  148. return this
  149. }
  150. setColor = (color: string): this => {
  151. const { settings } = this.app
  152. settings.update({ color: color })
  153. this.app.selectedShapesArray.forEach(s => {
  154. s.update({ fill: color, stroke: color })
  155. })
  156. this.app.persist()
  157. return this
  158. }
  159. setScaleLevel = (scaleLevel: string): this => {
  160. const { settings } = this.app
  161. settings.update({ scaleLevel })
  162. this.app.selectedShapes.forEach(shape => {
  163. shape.setScaleLevel(scaleLevel)
  164. })
  165. this.app.persist()
  166. return this
  167. }
  168. save = () => {
  169. this.app.save()
  170. return this
  171. }
  172. saveAs = () => {
  173. this.app.save()
  174. return this
  175. }
  176. undo = () => {
  177. this.app.undo()
  178. return this
  179. }
  180. redo = () => {
  181. this.app.redo()
  182. return this
  183. }
  184. persist = () => {
  185. this.app.persist()
  186. return this
  187. }
  188. createNewLineBinding = (source: S | string, target: S | string) => {
  189. return this.app.createNewLineBinding(source, target)
  190. }
  191. /** Clone shapes with given context */
  192. cloneShapes = ({
  193. shapes,
  194. assets,
  195. bindings,
  196. point = [0, 0],
  197. }: {
  198. shapes: TLShapeModel[]
  199. point: number[]
  200. // assets & bindings are the context for creating shapes
  201. assets: TLAsset[]
  202. bindings: Record<string, TLBinding>
  203. }) => {
  204. const commonBounds = BoundsUtils.getCommonBounds(
  205. shapes
  206. .filter(s => s.type !== 'group')
  207. .map(shape => ({
  208. minX: shape.point?.[0] ?? point[0],
  209. minY: shape.point?.[1] ?? point[1],
  210. width: shape.size?.[0] ?? 4,
  211. height: shape.size?.[1] ?? 4,
  212. maxX: (shape.point?.[0] ?? point[0]) + (shape.size?.[0] ?? 4),
  213. maxY: (shape.point?.[1] ?? point[1]) + (shape.size?.[1] ?? 4),
  214. }))
  215. )
  216. const clonedShapes = shapes.map(shape => {
  217. return {
  218. ...shape,
  219. id: uniqueId(),
  220. point: [
  221. point[0] + shape.point![0] - commonBounds.minX,
  222. point[1] + shape.point![1] - commonBounds.minY,
  223. ],
  224. }
  225. })
  226. clonedShapes.forEach(s => {
  227. if (s.children && s.children?.length > 0) {
  228. s.children = s.children
  229. .map(oldId => clonedShapes[shapes.findIndex(s => s.id === oldId)]?.id)
  230. .filter(isNonNullable)
  231. }
  232. })
  233. const clonedBindings: TLBinding[] = []
  234. // Try to rebinding the shapes with the given bindings
  235. clonedShapes
  236. .flatMap(s => Object.values(s.handles ?? {}))
  237. .forEach(handle => {
  238. if (!handle.bindingId) {
  239. return
  240. }
  241. // try to bind the new shape
  242. const binding = bindings[handle.bindingId]
  243. if (binding) {
  244. // if the copied binding from/to is in the source
  245. const oldFromIdx = shapes.findIndex(s => s.id === binding.fromId)
  246. const oldToIdx = shapes.findIndex(s => s.id === binding.toId)
  247. if (binding && oldFromIdx !== -1 && oldToIdx !== -1) {
  248. const newBinding: TLBinding = {
  249. ...binding,
  250. id: uniqueId(),
  251. fromId: clonedShapes[oldFromIdx].id,
  252. toId: clonedShapes[oldToIdx].id,
  253. }
  254. clonedBindings.push(newBinding)
  255. handle.bindingId = newBinding.id
  256. } else {
  257. handle.bindingId = undefined
  258. }
  259. } else {
  260. console.warn('binding not found', handle.bindingId)
  261. }
  262. })
  263. const clonedAssets = assets.filter(asset => {
  264. // do we need to create new asset id?
  265. return clonedShapes.some(shape => shape.assetId === asset.id)
  266. })
  267. return {
  268. shapes: clonedShapes,
  269. assets: clonedAssets,
  270. bindings: clonedBindings,
  271. }
  272. }
  273. getClonedShapesFromTldrString = (text: string, point: number[]) => {
  274. const safeParseJson = (json: string) => {
  275. try {
  276. return JSON.parse(json)
  277. } catch {
  278. return null
  279. }
  280. }
  281. const getWhiteboardsTldrFromText = (text: string) => {
  282. const innerText = text.match(/<whiteboard-tldr>(.*)<\/whiteboard-tldr>/)?.[1]
  283. if (innerText) {
  284. return safeParseJson(innerText)
  285. }
  286. }
  287. try {
  288. const data = getWhiteboardsTldrFromText(text)
  289. if (!data) return null
  290. const { shapes, bindings, assets } = data
  291. return this.cloneShapes({
  292. shapes,
  293. bindings,
  294. assets,
  295. point,
  296. })
  297. } catch (err) {
  298. console.log(err)
  299. }
  300. return null
  301. }
  302. cloneShapesIntoCurrentPage = (opts: {
  303. shapes: TLShapeModel[]
  304. point: number[]
  305. // assets & bindings are the context for creating shapes
  306. assets: TLAsset[]
  307. bindings: Record<string, TLBinding>
  308. }) => {
  309. const data = this.cloneShapes(opts)
  310. if (data) {
  311. this.addClonedShapes(data)
  312. }
  313. return this
  314. }
  315. addClonedShapes = (opts: ReturnType<TLApi['cloneShapes']>) => {
  316. const { shapes, assets, bindings } = opts
  317. if (assets.length > 0) {
  318. this.app.createAssets(assets)
  319. }
  320. if (shapes.length > 0) {
  321. this.app.createShapes(shapes)
  322. }
  323. this.app.currentPage.updateBindings(Object.fromEntries(bindings.map(b => [b.id, b])))
  324. this.app.selectedTool.transition('idle') // clears possible editing states
  325. return this
  326. }
  327. addClonedShapesFromTldrString = (text: string, point: number[]) => {
  328. const data = this.getClonedShapesFromTldrString(text, point)
  329. if (data) {
  330. this.addClonedShapes(data)
  331. }
  332. return this
  333. }
  334. doGroup = (shapes: S[] = this.app.allSelectedShapesArray) => {
  335. if (this.app.readOnly) return
  336. const selectedGroups: S[] = [
  337. ...shapes.filter(s => s.type === 'group'),
  338. ...shapes.map(s => this.app.getParentGroup(s)),
  339. ].filter(isNonNullable)
  340. // not using this.app.removeShapes because it also remove shapes in the group
  341. this.app.currentPage.removeShapes(...selectedGroups)
  342. // group all shapes
  343. const selectedShapes = shapes.filter(s => s.type !== 'group')
  344. if (selectedShapes.length > 1) {
  345. const ShapeGroup = this.app.getShapeClass('group')
  346. const group = new ShapeGroup({
  347. id: uniqueId(),
  348. type: ShapeGroup.id,
  349. parentId: this.app.currentPage.id,
  350. children: selectedShapes.map(s => s.id),
  351. })
  352. this.app.currentPage.addShapes(group)
  353. this.app.setSelectedShapes([group])
  354. // the shapes in the group should also be moved to the bottom of the array (to be on top on the canvas)
  355. this.app.bringForward(selectedShapes)
  356. }
  357. this.app.persist()
  358. }
  359. unGroup = (shapes: S[] = this.app.allSelectedShapesArray) => {
  360. if (this.app.readOnly) return
  361. const selectedGroups: S[] = [
  362. ...shapes.filter(s => s.type === 'group'),
  363. ...shapes.map(s => this.app.getParentGroup(s)),
  364. ].filter(isNonNullable)
  365. const shapesInGroups = this.app.shapesInGroups(selectedGroups)
  366. if (selectedGroups.length > 0) {
  367. // not using this.app.removeShapes because it also remove shapes in the group
  368. this.app.currentPage.removeShapes(...selectedGroups)
  369. this.app.persist()
  370. this.app.setSelectedShapes(shapesInGroups)
  371. }
  372. }
  373. }