TLApp.ts 24 KB

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