TLApp.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998
  1. /* eslint-disable @typescript-eslint/no-extra-semi */
  2. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  3. /* eslint-disable @typescript-eslint/no-explicit-any */
  4. import type { TLBounds } from '@tldraw/intersect'
  5. import { Vec } from '@tldraw/vec'
  6. import { action, computed, makeObservable, observable, toJS, transaction } from 'mobx'
  7. import { GRID_SIZE } from '../../constants'
  8. import type {
  9. TLAsset,
  10. TLCallback,
  11. TLEventMap,
  12. TLEvents,
  13. TLShortcut,
  14. TLStateEvents,
  15. TLSubscription,
  16. TLSubscriptionEventInfo,
  17. TLSubscriptionEventName,
  18. } from '../../types'
  19. import { AlignType, DistributeType } from '../../types'
  20. import { BoundsUtils, createNewLineBinding, dedupe, isNonNullable, uniqueId } from '../../utils'
  21. import type { TLShape, TLShapeConstructor, TLShapeModel } from '../shapes'
  22. import { TLApi } from '../TLApi'
  23. import { TLCursors } from '../TLCursors'
  24. import { TLHistory } from '../TLHistory'
  25. import { TLInputs } from '../TLInputs'
  26. import { TLPage, type TLPageModel } from '../TLPage'
  27. import { TLSettings } from '../TLSettings'
  28. import { TLRootState } from '../TLState'
  29. import type { TLToolConstructor } from '../TLTool'
  30. import { TLViewport } from '../TLViewport'
  31. import { TLMoveTool, TLSelectTool } from '../tools'
  32. export interface TLDocumentModel<S extends TLShape = TLShape, A extends TLAsset = TLAsset> {
  33. // currentPageId: string
  34. selectedIds: string[]
  35. pages: TLPageModel<S>[]
  36. assets?: A[]
  37. }
  38. export class TLApp<
  39. S extends TLShape = TLShape,
  40. K extends TLEventMap = TLEventMap
  41. > extends TLRootState<S, K> {
  42. constructor(
  43. serializedApp?: TLDocumentModel<S>,
  44. Shapes?: TLShapeConstructor<S>[],
  45. Tools?: TLToolConstructor<S, K>[],
  46. readOnly?: boolean
  47. ) {
  48. super()
  49. this._states = [TLSelectTool, TLMoveTool]
  50. this.readOnly = readOnly
  51. this.history.pause()
  52. if (this.states && this.states.length > 0) {
  53. this.registerStates(this.states)
  54. const initialId = this.initial ?? this.states[0].id
  55. const state = this.children.get(initialId)
  56. if (state) {
  57. this.currentState = state
  58. this.currentState?._events.onEnter({ fromId: 'initial' })
  59. }
  60. }
  61. if (Shapes) this.registerShapes(Shapes)
  62. if (Tools) this.registerTools(Tools)
  63. this.history.resume()
  64. if (serializedApp) this.history.deserialize(serializedApp)
  65. this.api = new TLApi(this)
  66. makeObservable(this)
  67. this.notify('mount', null)
  68. }
  69. uuid = uniqueId()
  70. readOnly: boolean | undefined
  71. static id = 'app'
  72. static initial = 'select'
  73. readonly api: TLApi<S, K>
  74. readonly inputs = new TLInputs<K>()
  75. readonly cursors = new TLCursors()
  76. readonly viewport = new TLViewport()
  77. readonly settings = new TLSettings()
  78. Tools: TLToolConstructor<S, K>[] = []
  79. /* --------------------- History -------------------- */
  80. history = new TLHistory<S, K>(this)
  81. persist = this.history.persist
  82. undo = this.history.undo
  83. redo = this.history.redo
  84. saving = false // used to capture direct mutations as part of the history stack
  85. saveState = () => {
  86. if (this.history.isPaused) return
  87. this.saving = true
  88. requestAnimationFrame(() => {
  89. if (this.saving) {
  90. this.persist()
  91. this.saving = false
  92. }
  93. })
  94. }
  95. /* -------------------------------------------------- */
  96. /* Document */
  97. /* -------------------------------------------------- */
  98. loadDocumentModel(model: TLDocumentModel<S>): this {
  99. this.history.deserialize(model)
  100. if (model.assets && model.assets.length > 0) this.addAssets(model.assets)
  101. return this
  102. }
  103. load = (): this => {
  104. // todo
  105. this.notify('load', null)
  106. return this
  107. }
  108. save = (): this => {
  109. // todo
  110. this.notify('save', null)
  111. return this
  112. }
  113. @computed get serialized(): TLDocumentModel<S> {
  114. return {
  115. // currentPageId: this.currentPageId,
  116. // selectedIds: Array.from(this.selectedIds.values()),
  117. // pages: Array.from(this.pages.values()).map(page => page.serialized),
  118. // assets: this.getCleanUpAssets(),
  119. }
  120. }
  121. /* ---------------------- Pages --------------------- */
  122. @observable pages: Map<string, TLPage<S, K>> = new Map([
  123. ['page', new TLPage(this, { id: 'page', name: 'page', shapes: [], bindings: {} })],
  124. ])
  125. @computed get currentPageId() {
  126. return this.pages.keys().next().value
  127. }
  128. @computed get currentPage(): TLPage<S, K> {
  129. return this.getPageById(this.currentPageId)
  130. }
  131. getPageById = (pageId: string): TLPage<S, K> => {
  132. const page = this.pages.get(pageId)
  133. if (!page) throw Error(`Could not find a page named ${pageId}.`)
  134. return page
  135. }
  136. @action addPages(pages: TLPage<S, K>[]): this {
  137. pages.forEach(page => this.pages.set(page.id, page))
  138. this.persist()
  139. return this
  140. }
  141. @action removePages(pages: TLPage<S, K>[]): this {
  142. pages.forEach(page => this.pages.delete(page.id))
  143. this.persist()
  144. return this
  145. }
  146. /* --------------------- Shapes --------------------- */
  147. getShapeById = <T extends S>(id: string, pageId = this.currentPage.id): T | undefined => {
  148. const shape = this.getPageById(pageId)?.shapesById[id] as T
  149. return shape
  150. }
  151. @action readonly createShapes = (shapes: S[] | TLShapeModel[]): this => {
  152. if (this.readOnly) return this
  153. const newShapes = this.currentPage.addShapes(...shapes)
  154. if (newShapes) this.notify('create-shapes', newShapes)
  155. this.persist()
  156. return this
  157. }
  158. @action updateShapes = <T extends S>(
  159. shapes: ({ id: string; type: string } & Partial<T['props']>)[]
  160. ): this => {
  161. if (this.readOnly) return this
  162. shapes.forEach(shape => {
  163. const oldShape = this.getShapeById(shape.id)
  164. oldShape?.update(shape)
  165. if (shape.type !== oldShape?.type) {
  166. this.api.convertShapes(shape.type, [oldShape])
  167. }
  168. })
  169. this.persist()
  170. return this
  171. }
  172. @action readonly deleteShapes = (shapes: S[] | string[]): this => {
  173. if (shapes.length === 0 || this.readOnly) return this
  174. const normalizedShapes: S[] = shapes
  175. .map(shape => (typeof shape === 'string' ? this.getShapeById(shape) : shape))
  176. .filter(isNonNullable)
  177. .filter(s => !s.props.isLocked)
  178. // delete a group shape should also delete its children
  179. const shapesInGroups = this.shapesInGroups(normalizedShapes)
  180. normalizedShapes.forEach(shape => {
  181. if (this.getParentGroup(shape)) {
  182. shapesInGroups.push(shape)
  183. }
  184. })
  185. let ids: Set<string> = new Set([...normalizedShapes, ...shapesInGroups].map(s => s.id))
  186. shapesInGroups.forEach(shape => {
  187. // delete a shape in a group should also update the group shape
  188. const parentGroup = this.getParentGroup(shape)
  189. if (parentGroup) {
  190. const newChildren: string[] | undefined = parentGroup.props.children?.filter(
  191. id => id !== shape.id
  192. )
  193. if (!newChildren || newChildren?.length <= 1) {
  194. // remove empty group or group with only one child
  195. ids.add(parentGroup.id)
  196. } else {
  197. parentGroup.update({ children: newChildren })
  198. }
  199. }
  200. })
  201. const deleteBinding = (shapeA: string, shapeB: string) => {
  202. if ([...ids].includes(shapeA) && this.getShapeById(shapeB)?.type === 'line') ids.add(shapeB)
  203. }
  204. this.currentPage.shapes
  205. .filter(s => !s.props.isLocked)
  206. .flatMap(s => Object.values(s.props.handles ?? {}))
  207. .flatMap(h => h.bindingId)
  208. .filter(isNonNullable)
  209. .map(binding => {
  210. const toId = this.currentPage.bindings[binding]?.toId
  211. const fromId = this.currentPage.bindings[binding]?.fromId
  212. if (toId && fromId) {
  213. deleteBinding(toId, fromId)
  214. deleteBinding(fromId, toId)
  215. }
  216. })
  217. const allShapesToDelete = [...ids].map(id => this.getShapeById(id)!)
  218. this.setSelectedShapes(this.selectedShapesArray.filter(shape => !ids.has(shape.id)))
  219. const removedShapes = this.currentPage.removeShapes(...allShapesToDelete)
  220. if (removedShapes) this.notify('delete-shapes', removedShapes)
  221. this.persist()
  222. return this
  223. }
  224. /** Get all shapes in groups */
  225. shapesInGroups(groups = this.shapes): S[] {
  226. return groups
  227. .flatMap(shape => shape.props.children)
  228. .filter(isNonNullable)
  229. .map(id => this.getShapeById(id))
  230. .filter(isNonNullable)
  231. }
  232. getParentGroup(shape: S) {
  233. return this.shapes.find(group => group.props.children?.includes(shape.id))
  234. }
  235. bringForward = (shapes: S[] | string[] = this.selectedShapesArray): this => {
  236. if (shapes.length > 0 && !this.readOnly) this.currentPage.bringForward(shapes)
  237. return this
  238. }
  239. sendBackward = (shapes: S[] | string[] = this.selectedShapesArray): this => {
  240. if (shapes.length > 0 && !this.readOnly) this.currentPage.sendBackward(shapes)
  241. return this
  242. }
  243. sendToBack = (shapes: S[] | string[] = this.selectedShapesArray): this => {
  244. if (shapes.length > 0 && !this.readOnly) this.currentPage.sendToBack(shapes)
  245. return this
  246. }
  247. bringToFront = (shapes: S[] | string[] = this.selectedShapesArray): this => {
  248. if (shapes.length > 0 && !this.readOnly) this.currentPage.bringToFront(shapes)
  249. return this
  250. }
  251. flipHorizontal = (shapes: S[] | string[] = this.selectedShapesArray): this => {
  252. this.currentPage.flip(shapes, 'horizontal')
  253. return this
  254. }
  255. flipVertical = (shapes: S[] | string[] = this.selectedShapesArray): this => {
  256. this.currentPage.flip(shapes, 'vertical')
  257. return this
  258. }
  259. align = (type: AlignType, shapes: S[] = this.selectedShapesArray): this => {
  260. if (shapes.length < 2 || this.readOnly) return this
  261. const boundsForShapes = shapes.map(shape => {
  262. const bounds = shape.getBounds()
  263. return {
  264. id: shape.id,
  265. point: [bounds.minX, bounds.minY],
  266. bounds: bounds,
  267. }
  268. })
  269. const commonBounds = BoundsUtils.getCommonBounds(boundsForShapes.map(({ bounds }) => bounds))
  270. const midX = commonBounds.minX + commonBounds.width / 2
  271. const midY = commonBounds.minY + commonBounds.height / 2
  272. const deltaMap = Object.fromEntries(
  273. boundsForShapes.map(({ id, point, bounds }) => {
  274. return [
  275. id,
  276. {
  277. prev: point,
  278. next: {
  279. [AlignType.Top]: [point[0], commonBounds.minY],
  280. [AlignType.CenterVertical]: [point[0], midY - bounds.height / 2],
  281. [AlignType.Bottom]: [point[0], commonBounds.maxY - bounds.height],
  282. [AlignType.Left]: [commonBounds.minX, point[1]],
  283. [AlignType.CenterHorizontal]: [midX - bounds.width / 2, point[1]],
  284. [AlignType.Right]: [commonBounds.maxX - bounds.width, point[1]],
  285. }[type],
  286. },
  287. ]
  288. })
  289. )
  290. shapes.forEach(shape => {
  291. if (deltaMap[shape.id]) shape.update({ point: deltaMap[shape.id].next })
  292. })
  293. this.persist()
  294. return this
  295. }
  296. distribute = (type: DistributeType, shapes: S[] = this.selectedShapesArray): this => {
  297. if (shapes.length < 2 || this.readOnly) return this
  298. const deltaMap = Object.fromEntries(
  299. BoundsUtils.getDistributions(shapes, type).map(d => [d.id, d])
  300. )
  301. shapes.forEach(shape => {
  302. if (deltaMap[shape.id]) shape.update({ point: deltaMap[shape.id].next })
  303. })
  304. this.persist()
  305. return this
  306. }
  307. packIntoRectangle = (shapes: S[] = this.selectedShapesArray): this => {
  308. if (shapes.length < 2 || this.readOnly) return this
  309. const deltaMap = Object.fromEntries(
  310. BoundsUtils.getPackedDistributions(shapes).map(d => [d.id, d])
  311. )
  312. shapes.forEach(shape => {
  313. if (deltaMap[shape.id]) shape.update({ point: deltaMap[shape.id].next })
  314. })
  315. this.persist()
  316. return this
  317. }
  318. setLocked = (locked: boolean): this => {
  319. if (this.selectedShapesArray.length === 0 || this.readOnly) return this
  320. this.selectedShapesArray.forEach(shape => {
  321. shape.update({ isLocked: locked })
  322. })
  323. this.persist()
  324. return this
  325. }
  326. /* --------------------- Assets --------------------- */
  327. @observable assets: Record<string, TLAsset> = {}
  328. @action addAssets<T extends TLAsset>(assets: T[]): this {
  329. assets.forEach(asset => (this.assets[asset.id] = asset))
  330. return this
  331. }
  332. @action removeAssets<T extends TLAsset>(assets: T[] | string[]): this {
  333. if (typeof assets[0] === 'string')
  334. (assets as string[]).forEach(asset => delete this.assets[asset])
  335. else (assets as T[]).forEach(asset => delete this.assets[(asset as T).id])
  336. this.persist()
  337. return this
  338. }
  339. @action removeUnusedAssets = (): this => {
  340. const usedAssets = this.getCleanUpAssets()
  341. Object.keys(this.assets).forEach(assetId => {
  342. if (!usedAssets.some(asset => asset.id === assetId)) {
  343. delete this.assets[assetId]
  344. }
  345. })
  346. this.persist()
  347. return this
  348. }
  349. getCleanUpAssets<T extends TLAsset>(): T[] {
  350. const usedAssets = new Set<T>()
  351. this.pages.forEach(p =>
  352. p.shapes.forEach(s => {
  353. if (s.props.assetId && this.assets[s.props.assetId]) {
  354. // @ts-expect-error ???
  355. usedAssets.add(this.assets[s.props.assetId])
  356. }
  357. })
  358. )
  359. return Array.from(usedAssets)
  360. }
  361. createAssets<T extends TLAsset>(assets: T[]): this {
  362. this.addAssets(assets)
  363. this.notify('create-assets', { assets })
  364. this.persist()
  365. return this
  366. }
  367. copy = () => {
  368. if (this.selectedShapesArray.length > 0 && !this.editingShape) {
  369. const selectedShapes = this.allSelectedShapesArray
  370. const jsonString = JSON.stringify({
  371. shapes: selectedShapes.map(shape => shape.serialized),
  372. // pasting into other whiteboard may require this if any shape uses the assets
  373. assets: this.getCleanUpAssets().filter(asset => {
  374. return selectedShapes.some(shape => shape.props.assetId === asset.id)
  375. }),
  376. // convey the bindings to maintain the new links after pasting
  377. bindings: toJS(this.currentPage.bindings),
  378. })
  379. const tldrawString = encodeURIComponent(`<whiteboard-tldr>${jsonString}</whiteboard-tldr>`)
  380. const shapeBlockRefs = this.selectedShapesArray.map(s => `((${s.props.id}))`).join(' ')
  381. this.notify('copy', {
  382. text: shapeBlockRefs,
  383. html: tldrawString,
  384. })
  385. }
  386. }
  387. paste = (e?: ClipboardEvent, shiftKey?: boolean) => {
  388. if (!this.editingShape && !this.readOnly) {
  389. this.notify('paste', {
  390. point: this.inputs.currentPoint,
  391. shiftKey: !!shiftKey,
  392. dataTransfer: e?.clipboardData ?? undefined,
  393. })
  394. }
  395. }
  396. cut = () => {
  397. this.copy()
  398. this.api.deleteShapes()
  399. }
  400. drop = (dataTransfer: DataTransfer, point?: number[]) => {
  401. this.notify('drop', {
  402. dataTransfer,
  403. point: point
  404. ? this.viewport.getPagePoint(point)
  405. : BoundsUtils.getBoundsCenter(this.viewport.currentView),
  406. })
  407. // This callback may be over-written manually, see useSetup.ts in React.
  408. return void null
  409. }
  410. /* ---------------------- Tools --------------------- */
  411. @computed get selectedTool() {
  412. return this.currentState
  413. }
  414. selectTool = (id: string, data: AnyObject = {}) => {
  415. if (!this.readOnly || ['select', 'move'].includes(id)) this.transition(id, data)
  416. }
  417. registerTools(tools: TLToolConstructor<S, K>[]) {
  418. this.Tools = tools
  419. return this.registerStates(tools)
  420. }
  421. /* ------------------ Editing Shape ----------------- */
  422. @observable editingId?: string
  423. @computed get editingShape(): S | undefined {
  424. const { editingId, currentPage } = this
  425. return editingId ? currentPage.shapes.find(shape => shape.id === editingId) : undefined
  426. }
  427. @action readonly setEditingShape = (shape?: string | S): this => {
  428. this.editingId = typeof shape === 'string' ? shape : shape?.id
  429. return this
  430. }
  431. readonly clearEditingState = (): this => {
  432. this.selectedTool.transition('idle')
  433. return this.setEditingShape()
  434. }
  435. /* ------------------ Hovered Shape ----------------- */
  436. @observable hoveredId?: string
  437. @computed get hoveredShape(): S | undefined {
  438. const { hoveredId, currentPage } = this
  439. return hoveredId ? currentPage.shapes.find(shape => shape.id === hoveredId) : undefined
  440. }
  441. @computed get hoveredGroup(): S | undefined {
  442. const { hoveredShape } = this
  443. const hoveredGroup = hoveredShape
  444. ? this.shapes.find(s => s.type === 'group' && s.props.children?.includes(hoveredShape.id))
  445. : undefined
  446. return hoveredGroup as S | undefined
  447. }
  448. @action readonly setHoveredShape = (shape?: string | S): this => {
  449. this.hoveredId = typeof shape === 'string' ? shape : shape?.id
  450. return this
  451. }
  452. /* ----------------- Selected Shapes ---------------- */
  453. @observable selectedIds: Set<string> = new Set()
  454. @observable selectedShapes: Set<S> = new Set()
  455. @observable selectionRotation = 0
  456. @computed get selectedShapesArray() {
  457. const { selectedShapes, selectedTool } = this
  458. const stateId = selectedTool.id
  459. if (stateId !== 'select') return []
  460. return Array.from(selectedShapes.values())
  461. }
  462. // include selected shapes in groups
  463. @computed get allSelectedShapes() {
  464. return new Set(this.allSelectedShapesArray)
  465. }
  466. // include selected shapes in groups
  467. @computed get allSelectedShapesArray() {
  468. const { selectedShapesArray } = this
  469. return dedupe([...selectedShapesArray, ...this.shapesInGroups(selectedShapesArray)])
  470. }
  471. @action setSelectedShapes = (shapes: S[] | string[]): this => {
  472. const { selectedIds, selectedShapes } = this
  473. selectedIds.clear()
  474. selectedShapes.clear()
  475. if (shapes[0] && typeof shapes[0] === 'string') {
  476. ;(shapes as string[]).forEach(s => selectedIds.add(s))
  477. } else {
  478. ;(shapes as S[]).forEach(s => selectedIds.add(s.id))
  479. }
  480. const newSelectedShapes = this.currentPage.shapes.filter(shape => selectedIds.has(shape.id))
  481. newSelectedShapes.forEach(s => selectedShapes.add(s))
  482. if (newSelectedShapes.length === 1) {
  483. this.selectionRotation = newSelectedShapes[0].props.rotation ?? 0
  484. } else {
  485. this.selectionRotation = 0
  486. }
  487. if (shapes.length === 0) {
  488. this.setEditingShape()
  489. }
  490. return this
  491. }
  492. @action setSelectionRotation(radians: number) {
  493. this.selectionRotation = radians
  494. }
  495. /* ------------------ Erasing Shape ----------------- */
  496. @observable erasingIds: Set<string> = new Set()
  497. @observable erasingShapes: Set<S> = new Set()
  498. @computed get erasingShapesArray() {
  499. return Array.from(this.erasingShapes.values())
  500. }
  501. @action readonly setErasingShapes = (shapes: S[] | string[]): this => {
  502. const { erasingIds, erasingShapes } = this
  503. erasingIds.clear()
  504. erasingShapes.clear()
  505. if (shapes[0] && typeof shapes[0] === 'string') {
  506. ;(shapes as string[]).forEach(s => erasingIds.add(s))
  507. } else {
  508. ;(shapes as S[]).forEach(s => erasingIds.add(s.id))
  509. }
  510. const newErasingShapes = this.currentPage.shapes.filter(shape => erasingIds.has(shape.id))
  511. newErasingShapes.forEach(s => erasingShapes.add(s))
  512. return this
  513. }
  514. /* ------------------ Binding Shape ----------------- */
  515. @observable bindingIds?: string[]
  516. @computed get bindingShapes(): S[] | undefined {
  517. const activeBindings =
  518. this.selectedShapesArray.length === 1
  519. ? this.selectedShapesArray
  520. .flatMap(s => Object.values(s.props.handles ?? {}))
  521. .flatMap(h => h.bindingId)
  522. .filter(isNonNullable)
  523. .flatMap(binding => [
  524. this.currentPage.bindings[binding]?.fromId,
  525. this.currentPage.bindings[binding]?.toId,
  526. ])
  527. .filter(isNonNullable)
  528. : []
  529. const bindingIds = [...(this.bindingIds ?? []), ...activeBindings]
  530. return bindingIds
  531. ? this.currentPage.shapes.filter(shape => bindingIds?.includes(shape.id))
  532. : undefined
  533. }
  534. @action readonly setBindingShapes = (ids?: string[]): this => {
  535. this.bindingIds = ids
  536. return this
  537. }
  538. readonly clearBindingShape = (): this => {
  539. return this.setBindingShapes()
  540. }
  541. @action createNewLineBinding = (source: S | string, target: S | string) => {
  542. const src = typeof source === 'string' ? this.getShapeById(source) : source
  543. const tgt = typeof target === 'string' ? this.getShapeById(target) : target
  544. if (src?.canBind && tgt?.canBind) {
  545. const result = createNewLineBinding(src, tgt)
  546. if (result) {
  547. const [newLine, newBindings] = result
  548. this.createShapes([newLine])
  549. this.currentPage.updateBindings(Object.fromEntries(newBindings.map(b => [b.id, b])))
  550. this.persist()
  551. return true
  552. }
  553. }
  554. return false
  555. }
  556. /* ---------------------- Brush --------------------- */
  557. @observable brush?: TLBounds
  558. @action readonly setBrush = (brush?: TLBounds): this => {
  559. this.brush = brush
  560. return this
  561. }
  562. /* --------------------- Camera --------------------- */
  563. @action setCamera = (point?: number[], zoom?: number): this => {
  564. this.viewport.update({ point, zoom })
  565. return this
  566. }
  567. readonly getPagePoint = (point: number[]): number[] => {
  568. const { camera } = this.viewport
  569. return Vec.sub(Vec.div(point, camera.zoom), camera.point)
  570. }
  571. readonly getScreenPoint = (point: number[]): number[] => {
  572. const { camera } = this.viewport
  573. return Vec.mul(Vec.add(point, camera.point), camera.zoom)
  574. }
  575. @computed
  576. get currentGrid() {
  577. const { zoom } = this.viewport.camera
  578. if (zoom < 0.15) {
  579. return GRID_SIZE * 16
  580. } else if (zoom < 1) {
  581. return GRID_SIZE * 4
  582. } else {
  583. return GRID_SIZE * 1
  584. }
  585. }
  586. /* --------------------- Display -------------------- */
  587. @computed get shapes(): S[] {
  588. const {
  589. currentPage: { shapes },
  590. } = this
  591. return Array.from(shapes.values())
  592. }
  593. @computed get shapesInViewport(): S[] {
  594. const {
  595. selectedShapes,
  596. currentPage,
  597. viewport: { currentView },
  598. } = this
  599. return currentPage.shapes.filter(shape => {
  600. return (
  601. !shape.canUnmount ||
  602. selectedShapes.has(shape) ||
  603. BoundsUtils.boundsContain(currentView, shape.rotatedBounds) ||
  604. BoundsUtils.boundsCollide(currentView, shape.rotatedBounds)
  605. )
  606. })
  607. }
  608. @computed get selectionDirectionHint(): number[] | undefined {
  609. const {
  610. selectionBounds,
  611. viewport: { currentView },
  612. } = this
  613. if (
  614. !selectionBounds ||
  615. BoundsUtils.boundsContain(currentView, selectionBounds) ||
  616. BoundsUtils.boundsCollide(currentView, selectionBounds)
  617. ) {
  618. return
  619. }
  620. const center = BoundsUtils.getBoundsCenter(selectionBounds)
  621. return Vec.clampV(
  622. [
  623. (center[0] - currentView.minX - currentView.width / 2) / currentView.width,
  624. (center[1] - currentView.minY - currentView.height / 2) / currentView.height,
  625. ],
  626. -1,
  627. 1
  628. )
  629. }
  630. @computed get selectionBounds(): TLBounds | undefined {
  631. const { selectedShapesArray } = this
  632. if (selectedShapesArray.length === 0) return undefined
  633. if (selectedShapesArray.length === 1) {
  634. return { ...selectedShapesArray[0].bounds, rotation: selectedShapesArray[0].props.rotation }
  635. }
  636. return BoundsUtils.getCommonBounds(this.selectedShapesArray.map(shape => shape.rotatedBounds))
  637. }
  638. @computed get showSelection() {
  639. const { selectedShapesArray } = this
  640. return (
  641. this.isIn('select') &&
  642. !this.isInAny('select.translating', 'select.pinching', 'select.rotating') &&
  643. ((selectedShapesArray.length === 1 && !selectedShapesArray[0]?.hideSelection) ||
  644. selectedShapesArray.length > 1)
  645. )
  646. }
  647. @computed get showSelectionDetail() {
  648. return (
  649. this.isIn('select') &&
  650. !this.isInAny('select.translating', 'select.pinching') &&
  651. this.selectedShapes.size > 0 &&
  652. !this.selectedShapesArray.every(shape => shape.hideSelectionDetail) &&
  653. false // FIXME: should we show the selection detail?
  654. )
  655. }
  656. @computed get showSelectionRotation() {
  657. return (
  658. this.showSelectionDetail && this.isInAny('select.rotating', 'select.pointingRotateHandle')
  659. )
  660. }
  661. @computed get showContextBar() {
  662. const {
  663. selectedShapesArray,
  664. inputs: { ctrlKey },
  665. } = this
  666. return (
  667. this.isInAny('select.idle', 'select.hoveringSelectionHandle') &&
  668. !this.isIn('select.contextMenu') &&
  669. selectedShapesArray.length > 0 &&
  670. !this.readOnly &&
  671. !selectedShapesArray.every(shape => shape.hideContextBar)
  672. )
  673. }
  674. @computed get showRotateHandles() {
  675. const { selectedShapesArray } = this
  676. return (
  677. this.isInAny(
  678. 'select.idle',
  679. 'select.hoveringSelectionHandle',
  680. 'select.pointingRotateHandle',
  681. 'select.pointingResizeHandle'
  682. ) &&
  683. selectedShapesArray.length > 0 &&
  684. !this.readOnly &&
  685. !selectedShapesArray.some(shape => shape.hideRotateHandle)
  686. )
  687. }
  688. @computed get showResizeHandles() {
  689. const { selectedShapesArray } = this
  690. return (
  691. this.isInAny(
  692. 'select.idle',
  693. 'select.hoveringSelectionHandle',
  694. 'select.pointingShape',
  695. 'select.pointingSelectedShape',
  696. 'select.pointingRotateHandle',
  697. 'select.pointingResizeHandle'
  698. ) &&
  699. selectedShapesArray.length === 1 &&
  700. !this.readOnly &&
  701. !selectedShapesArray.every(shape => shape.hideResizeHandles)
  702. )
  703. }
  704. /* ------------------ Shape Classes ----------------- */
  705. Shapes = new Map<string, TLShapeConstructor<S>>()
  706. registerShapes = (Shapes: TLShapeConstructor<S>[]) => {
  707. Shapes.forEach(Shape => {
  708. // monkey patch Shape
  709. if (Shape.id === 'group') {
  710. // Group Shape requires this hack to get the real children shapes
  711. const app = this
  712. Shape.prototype.getShapes = function () {
  713. // @ts-expect-error FIXME: this is a hack to get around the fact that we can't use computed properties in the constructor
  714. return this.props.children?.map(id => app.getShapeById(id)).filter(Boolean) ?? []
  715. }
  716. }
  717. return this.Shapes.set(Shape.id, Shape)
  718. })
  719. }
  720. deregisterShapes = (Shapes: TLShapeConstructor<S>[]) => {
  721. Shapes.forEach(Shape => this.Shapes.delete(Shape.id))
  722. }
  723. getShapeClass = (type: string): TLShapeConstructor<S> => {
  724. if (!type) throw Error('No shape type provided.')
  725. const Shape = this.Shapes.get(type)
  726. if (!Shape) throw Error(`Could not find shape class for ${type}`)
  727. return Shape
  728. }
  729. wrapUpdate = (fn: () => void) => {
  730. transaction(() => {
  731. const shouldSave = !this.history.isPaused
  732. if (shouldSave) {
  733. this.history.pause()
  734. }
  735. fn()
  736. if (shouldSave) {
  737. this.history.resume()
  738. this.persist()
  739. }
  740. })
  741. }
  742. /* ------------------ Subscriptions ----------------- */
  743. private subscriptions = new Set<TLSubscription<S, K, this, any>>([])
  744. subscribe = <E extends TLSubscriptionEventName>(
  745. event: E,
  746. callback: TLCallback<S, K, this, E>
  747. ) => {
  748. if (callback === undefined) throw Error('Callback is required.')
  749. const subscription: TLSubscription<S, K, this, E> = { event, callback }
  750. this.subscriptions.add(subscription)
  751. return () => this.unsubscribe(subscription)
  752. }
  753. unsubscribe = (subscription: TLSubscription<S, K, this, any>) => {
  754. this.subscriptions.delete(subscription)
  755. return this
  756. }
  757. notify = <E extends TLSubscriptionEventName>(event: E, info: TLSubscriptionEventInfo<E>) => {
  758. this.subscriptions.forEach(subscription => {
  759. if (subscription.event === event) {
  760. subscription.callback(this, info)
  761. }
  762. })
  763. return this
  764. }
  765. /* ----------------- Event Handlers ----------------- */
  766. temporaryTransitionToMove(event: any) {
  767. event.stopPropagation()
  768. event.preventDefault()
  769. const prevTool = this.selectedTool
  770. this.transition('move', { prevTool })
  771. this.selectedTool.transition('idleHold')
  772. }
  773. readonly onTransition: TLStateEvents<S, K>['onTransition'] = () => {}
  774. readonly onPointerDown: TLEvents<S, K>['pointer'] = (info, e) => {
  775. // Pan canvas when holding middle click
  776. if (!this.editingShape && e.button === 1 && !this.isIn('move')) {
  777. this.temporaryTransitionToMove(e)
  778. return
  779. }
  780. // Switch to select on right click to enable contextMenu state
  781. if (e.button === 2 && !this.editingShape) {
  782. e.preventDefault()
  783. this.transition('select')
  784. return
  785. }
  786. if ('clientX' in e) {
  787. this.inputs.onPointerDown(
  788. [...this.viewport.getPagePoint([e.clientX, e.clientY]), 0.5],
  789. e as K['pointer']
  790. )
  791. }
  792. }
  793. readonly onPointerUp: TLEvents<S, K>['pointer'] = (info, e) => {
  794. if (!this.editingShape && e.button === 1 && this.isIn('move')) {
  795. this.selectedTool.transition('idle', { exit: true })
  796. e.stopPropagation()
  797. e.preventDefault()
  798. return
  799. }
  800. if ('clientX' in e) {
  801. this.inputs.onPointerUp(
  802. [...this.viewport.getPagePoint([e.clientX, e.clientY]), 0.5],
  803. e as K['pointer']
  804. )
  805. }
  806. }
  807. readonly onPointerMove: TLEvents<S, K>['pointer'] = (info, e) => {
  808. if ('clientX' in e) {
  809. this.inputs.onPointerMove([...this.viewport.getPagePoint([e.clientX, e.clientY]), 0.5], e)
  810. }
  811. }
  812. readonly onKeyDown: TLEvents<S, K>['keyboard'] = (info, e) => {
  813. if (!this.editingShape && e['key'] === ' ' && !this.isIn('move')) {
  814. this.temporaryTransitionToMove(e)
  815. return
  816. }
  817. this.inputs.onKeyDown(e)
  818. }
  819. readonly onKeyUp: TLEvents<S, K>['keyboard'] = (info, e) => {
  820. if (!this.editingShape && e['key'] === ' ' && this.isIn('move')) {
  821. this.selectedTool.transition('idle', { exit: true })
  822. e.stopPropagation()
  823. e.preventDefault()
  824. return
  825. }
  826. this.inputs.onKeyUp(e)
  827. }
  828. readonly onPinchStart: TLEvents<S, K>['pinch'] = (info, e) => {
  829. this.inputs.onPinchStart([...this.viewport.getPagePoint(info.point), 0.5], e)
  830. }
  831. readonly onPinch: TLEvents<S, K>['pinch'] = (info, e) => {
  832. this.inputs.onPinch([...this.viewport.getPagePoint(info.point), 0.5], e)
  833. }
  834. readonly onPinchEnd: TLEvents<S, K>['pinch'] = (info, e) => {
  835. this.inputs.onPinchEnd([...this.viewport.getPagePoint(info.point), 0.5], e)
  836. }
  837. }