TLPage.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. import { intersectRayBounds, TLBounds } from '@tldraw/intersect'
  3. import Vec from '@tldraw/vec'
  4. import { action, computed, makeObservable, observable, reaction, toJS } from 'mobx'
  5. import { BINDING_DISTANCE } from '../../constants'
  6. import { type TLBinding, type TLEventMap, TLResizeCorner, type TLHandle } from '../../types'
  7. import { deepCopy, BoundsUtils, deepEqual, PointUtils } from '../../utils'
  8. import type { TLShape, TLShapeModel, TLLineShape } from '../shapes'
  9. import type { TLApp } from '../TLApp'
  10. export interface TLPageModel<S extends TLShape = TLShape> {
  11. id: string
  12. name: string
  13. shapes: TLShapeModel<S['props']>[]
  14. bindings: Record<string, TLBinding>
  15. nonce?: number
  16. }
  17. export interface TLPageProps<S> {
  18. id: string
  19. name: string
  20. shapes: S[]
  21. bindings: Record<string, TLBinding>
  22. nonce?: number
  23. }
  24. export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventMap> {
  25. constructor(app: TLApp<S, E>, props = {} as TLPageProps<S>) {
  26. const { id, name, shapes = [], bindings = {}, nonce } = props
  27. this.id = id
  28. this.name = name
  29. this.bindings = Object.assign({}, bindings) // make sure it is type of object
  30. this.app = app
  31. this.nonce = nonce || 0
  32. this.addShapes(...shapes)
  33. makeObservable(this)
  34. // Performance bottleneck!! Optimize me :/
  35. // Instead of watch for every shape change, we should only watch for the changed ones
  36. reaction(
  37. () => ({
  38. id: this.id,
  39. name: this.name,
  40. shapes: toJS(this.shapes.map(shape => toJS(shape.props))),
  41. bindings: toJS(this.bindings),
  42. nonce: this.nonce,
  43. }),
  44. (curr, prev) => {
  45. if (this.app.isInAny('creating')) return
  46. this.cleanup(curr, prev)
  47. }
  48. )
  49. }
  50. app: TLApp<S, E>
  51. @observable id: string
  52. @observable name: string
  53. @observable shapes: S[] = []
  54. @observable bindings: Record<string, TLBinding> = {}
  55. @computed get serialized(): TLPageModel<S> {
  56. return {
  57. id: this.id,
  58. name: this.name,
  59. // @ts-expect-error maybe later
  60. shapes: this.shapes
  61. .map(shape => shape.serialized)
  62. .filter(s => !!s)
  63. .map(s => toJS(s)),
  64. bindings: deepCopy(this.bindings),
  65. nonce: this.nonce,
  66. }
  67. }
  68. @observable nonce = 0
  69. @action bump = () => {
  70. this.nonce++
  71. }
  72. @action update(props: Partial<TLPageProps<S>>) {
  73. Object.assign(this, props)
  74. return this
  75. }
  76. @action updateBindings(bindings: Record<string, TLBinding>) {
  77. Object.assign(this.bindings, bindings)
  78. return this
  79. }
  80. @action addShapes(...shapes: S[] | TLShapeModel[]) {
  81. if (shapes.length === 0) return
  82. const shapeInstances =
  83. 'getBounds' in shapes[0]
  84. ? (shapes as S[])
  85. : (shapes as TLShapeModel[]).map(shape => {
  86. const ShapeClass = this.app.getShapeClass(shape.type)
  87. return new ShapeClass(shape)
  88. })
  89. this.shapes.push(...shapeInstances)
  90. this.bump()
  91. return shapeInstances
  92. }
  93. private parseShapesArg<S>(shapes: S[] | string[]) {
  94. if (typeof shapes[0] === 'string') {
  95. return this.shapes.filter(shape => (shapes as string[]).includes(shape.id))
  96. } else {
  97. return shapes as S[]
  98. }
  99. }
  100. @action removeShapes(...shapes: S[] | string[]) {
  101. const shapeInstances = this.parseShapesArg(shapes)
  102. this.shapes = this.shapes.filter(shape => !shapeInstances.includes(shape))
  103. return shapeInstances
  104. }
  105. @action bringForward = (shapes: S[] | string[]): this => {
  106. const shapesToMove = this.parseShapesArg(shapes)
  107. shapesToMove
  108. .sort((a, b) => this.shapes.indexOf(b) - this.shapes.indexOf(a))
  109. .map(shape => this.shapes.indexOf(shape))
  110. .forEach(index => {
  111. if (index === this.shapes.length - 1) return
  112. const next = this.shapes[index + 1]
  113. if (shapesToMove.includes(next)) return
  114. const t = this.shapes[index]
  115. this.shapes[index] = this.shapes[index + 1]
  116. this.shapes[index + 1] = t
  117. })
  118. this.app.persist()
  119. return this
  120. }
  121. @action sendBackward = (shapes: S[] | string[]): this => {
  122. const shapesToMove = this.parseShapesArg(shapes)
  123. shapesToMove
  124. .sort((a, b) => this.shapes.indexOf(a) - this.shapes.indexOf(b))
  125. .map(shape => this.shapes.indexOf(shape))
  126. .forEach(index => {
  127. if (index === 0) return
  128. const next = this.shapes[index - 1]
  129. if (shapesToMove.includes(next)) return
  130. const t = this.shapes[index]
  131. this.shapes[index] = this.shapes[index - 1]
  132. this.shapes[index - 1] = t
  133. })
  134. this.app.persist()
  135. return this
  136. }
  137. @action bringToFront = (shapes: S[] | string[]): this => {
  138. const shapesToMove = this.parseShapesArg(shapes)
  139. this.shapes = this.shapes.filter(shape => !shapesToMove.includes(shape)).concat(shapesToMove)
  140. this.app.persist()
  141. return this
  142. }
  143. @action sendToBack = (shapes: S[] | string[]): this => {
  144. const shapesToMove = this.parseShapesArg(shapes)
  145. this.shapes = shapesToMove.concat(this.shapes.filter(shape => !shapesToMove.includes(shape)))
  146. this.app.persist()
  147. return this
  148. }
  149. flip = (shapes: S[] | string[], direction: 'horizontal' | 'vertical'): this => {
  150. const shapesToMove = this.parseShapesArg(shapes)
  151. const commonBounds = BoundsUtils.getCommonBounds(shapesToMove.map(shape => shape.bounds))
  152. shapesToMove.forEach(shape => {
  153. const relativeBounds = BoundsUtils.getRelativeTransformedBoundingBox(
  154. commonBounds,
  155. commonBounds,
  156. shape.bounds,
  157. direction === 'horizontal',
  158. direction === 'vertical'
  159. )
  160. if (shape.serialized) {
  161. shape.onResize(shape.serialized, {
  162. bounds: relativeBounds,
  163. center: BoundsUtils.getBoundsCenter(relativeBounds),
  164. rotation: shape.props.rotation ?? 0 * -1,
  165. type: TLResizeCorner.TopLeft,
  166. scale:
  167. shape.canFlip && shape.props.scale
  168. ? direction === 'horizontal'
  169. ? [-shape.props.scale[0], 1]
  170. : [1, -shape.props.scale[1]]
  171. : [1, 1],
  172. clip: false,
  173. transformOrigin: [0.5, 0.5],
  174. })
  175. }
  176. })
  177. this.app.persist()
  178. return this
  179. }
  180. getBindableShapes() {
  181. return this.shapes
  182. .filter(shape => shape.canBind)
  183. .sort((a, b) => b.nonce - a.nonce)
  184. .map(s => s.id)
  185. }
  186. getShapeById = <T extends S>(id: string): T | undefined => {
  187. const shape = this.shapes.find(shape => shape.id === id) as T
  188. return shape
  189. }
  190. /**
  191. * Recalculate binding positions etc. Will also persist state when needed.
  192. *
  193. * @param curr
  194. * @param prev
  195. */
  196. @action
  197. cleanup = (curr: TLPageModel, prev: TLPageModel) => {
  198. const updated = deepCopy(curr)
  199. const changedShapes: Record<string, TLShapeModel<S['props']> | undefined> = {}
  200. const prevShapes = Object.fromEntries(prev.shapes.map(shape => [shape.id, shape]))
  201. const currShapes = Object.fromEntries(curr.shapes.map(shape => [shape.id, shape]))
  202. // TODO: deleted shapes?
  203. curr.shapes.forEach(shape => {
  204. if (deepEqual(shape, prevShapes[shape.id])) {
  205. changedShapes[shape.id] = shape
  206. }
  207. })
  208. // Get bindings related to the changed shapes
  209. const bindingsToUpdate = getRelatedBindings(curr, Object.keys(changedShapes))
  210. const visitedShapes = new Set<TLShapeModel>()
  211. // Update all of the bindings we've just collected
  212. bindingsToUpdate.forEach(binding => {
  213. if (!updated.bindings[binding.id]) {
  214. return
  215. }
  216. const toShape = currShapes[binding.toId]
  217. const fromShape = currShapes[binding.fromId]
  218. if (!(toShape && fromShape)) {
  219. delete updated.bindings[binding.id]
  220. return
  221. }
  222. if (visitedShapes.has(fromShape)) {
  223. return
  224. }
  225. // We only need to update the binding's "from" shape (an arrow)
  226. // @ts-expect-error ???
  227. const fromDelta = this.updateArrowBindings(this.getShapeById<TLLineShape>(fromShape.id))
  228. visitedShapes.add(fromShape)
  229. if (fromDelta) {
  230. const nextShape = {
  231. ...fromShape,
  232. ...fromDelta,
  233. }
  234. const idx = updated.shapes.findIndex(s => s.id === nextShape.id)
  235. if (idx !== -1) {
  236. updated.shapes[idx] = nextShape
  237. }
  238. }
  239. })
  240. // Cleanup outdated bindings
  241. Object.keys(updated.bindings).forEach(id => {
  242. if (
  243. !updated.shapes.find(
  244. shape => shape.id === updated.bindings[id].toId || updated.bindings[id].fromId
  245. )
  246. ) {
  247. delete updated.bindings[id]
  248. }
  249. })
  250. if (!deepEqual(updated, curr)) {
  251. this.update({
  252. bindings: updated.bindings,
  253. })
  254. updated.shapes.forEach(shape => {
  255. this.getShapeById(shape.id)?.update(shape)
  256. })
  257. this.app.persist(true)
  258. }
  259. }
  260. private updateArrowBindings = (lineShape: TLLineShape) => {
  261. const result = {
  262. start: deepCopy(lineShape.props.handles.start),
  263. end: deepCopy(lineShape.props.handles.end),
  264. }
  265. type HandleInfo = {
  266. handle: TLHandle
  267. point: number[] // in page space
  268. } & (
  269. | {
  270. isBound: false
  271. }
  272. | {
  273. isBound: true
  274. hasDecoration: boolean
  275. binding: TLBinding
  276. target: TLShape
  277. bounds: TLBounds
  278. expandedBounds: TLBounds
  279. intersectBounds: TLBounds
  280. center: number[]
  281. }
  282. )
  283. let start: HandleInfo = {
  284. isBound: false,
  285. handle: lineShape.props.handles.start,
  286. point: Vec.add(lineShape.props.handles.start.point, lineShape.props.point),
  287. }
  288. let end: HandleInfo = {
  289. isBound: false,
  290. handle: lineShape.props.handles.end,
  291. point: Vec.add(lineShape.props.handles.end.point, lineShape.props.point),
  292. }
  293. if (lineShape.props.handles.start.bindingId) {
  294. const hasDecoration = lineShape.props.decorations?.start !== undefined
  295. const handle = lineShape.props.handles.start
  296. const binding = this.bindings[lineShape.props.handles.start.bindingId]
  297. // if (!binding) throw Error("Could not find a binding to match the start handle's bindingId")
  298. const target = this.getShapeById(binding?.toId)
  299. if (target) {
  300. const bounds = target.getBounds()
  301. const expandedBounds = target.getExpandedBounds()
  302. const intersectBounds = BoundsUtils.expandBounds(
  303. bounds,
  304. hasDecoration ? binding.distance : 1
  305. )
  306. const { minX, minY, width, height } = expandedBounds
  307. const anchorPoint = Vec.add(
  308. [minX, minY],
  309. Vec.mulV(
  310. [width, height],
  311. Vec.rotWith(binding.point, [0.5, 0.5], target.props.rotation || 0)
  312. )
  313. )
  314. start = {
  315. isBound: true,
  316. hasDecoration,
  317. binding,
  318. handle,
  319. point: anchorPoint,
  320. target,
  321. bounds,
  322. expandedBounds,
  323. intersectBounds,
  324. center: target.getCenter(),
  325. }
  326. }
  327. }
  328. if (lineShape.props.handles.end.bindingId) {
  329. const hasDecoration = lineShape.props.decorations?.end !== undefined
  330. const handle = lineShape.props.handles.end
  331. const binding = this.bindings[lineShape.props.handles.end.bindingId]
  332. const target = this.getShapeById(binding?.toId)
  333. if (target) {
  334. const bounds = target.getBounds()
  335. const expandedBounds = target.getExpandedBounds()
  336. const intersectBounds = hasDecoration
  337. ? BoundsUtils.expandBounds(bounds, binding.distance)
  338. : bounds
  339. const { minX, minY, width, height } = expandedBounds
  340. const anchorPoint = Vec.add(
  341. [minX, minY],
  342. Vec.mulV(
  343. [width, height],
  344. Vec.rotWith(binding.point, [0.5, 0.5], target.props.rotation || 0)
  345. )
  346. )
  347. end = {
  348. isBound: true,
  349. hasDecoration,
  350. binding,
  351. handle,
  352. point: anchorPoint,
  353. target,
  354. bounds,
  355. expandedBounds,
  356. intersectBounds,
  357. center: target.getCenter(),
  358. }
  359. }
  360. }
  361. for (const ID of ['end', 'start'] as const) {
  362. const A = ID === 'start' ? start : end
  363. const B = ID === 'start' ? end : start
  364. if (A.isBound) {
  365. if (!A.binding.distance) {
  366. // If the binding distance is zero, then the arrow is bound to a specific point
  367. // in the target shape. The resulting handle should be exactly at that point.
  368. result[ID].point = Vec.sub(A.point, lineShape.props.point)
  369. } else {
  370. // We'll need to figure out the handle's true point based on some intersections
  371. // between the opposite handle point and this handle point. This is different
  372. // for each type of shape.
  373. const direction = Vec.uni(Vec.sub(A.point, B.point))
  374. switch (A.target.type) {
  375. // TODO: do we need to support othershapes?
  376. default: {
  377. const hits = intersectRayBounds(
  378. B.point,
  379. direction,
  380. A.intersectBounds,
  381. A.target.props.rotation
  382. )
  383. .filter(int => int.didIntersect)
  384. .map(int => int.points[0])
  385. .sort((a, b) => Vec.dist(a, B.point) - Vec.dist(b, B.point))
  386. if (!hits[0]) continue
  387. let bHit: number[] | undefined = undefined
  388. if (B.isBound) {
  389. const bHits = intersectRayBounds(
  390. B.point,
  391. direction,
  392. B.intersectBounds,
  393. B.target.props.rotation
  394. )
  395. .filter(int => int.didIntersect)
  396. .map(int => int.points[0])
  397. .sort((a, b) => Vec.dist(a, B.point) - Vec.dist(b, B.point))
  398. bHit = bHits[0]
  399. }
  400. if (
  401. B.isBound &&
  402. (hits.length < 2 ||
  403. (bHit &&
  404. hits[0] &&
  405. Math.ceil(Vec.dist(hits[0], bHit)) < BINDING_DISTANCE * 2.5) ||
  406. BoundsUtils.boundsContain(A.expandedBounds, B.expandedBounds) ||
  407. BoundsUtils.boundsCollide(A.expandedBounds, B.expandedBounds))
  408. ) {
  409. // If the other handle is bound, and if we need to fallback to the short arrow method...
  410. const shortArrowDirection = Vec.uni(Vec.sub(B.point, A.point))
  411. const shortArrowHits = intersectRayBounds(
  412. A.point,
  413. shortArrowDirection,
  414. A.bounds,
  415. A.target.props.rotation
  416. )
  417. .filter(int => int.didIntersect)
  418. .map(int => int.points[0])
  419. if (!shortArrowHits[0]) continue
  420. result[ID].point = Vec.toFixed(Vec.sub(shortArrowHits[0], lineShape.props.point))
  421. result[ID === 'start' ? 'end' : 'start'].point = Vec.toFixed(
  422. Vec.add(
  423. Vec.sub(shortArrowHits[0], lineShape.props.point),
  424. Vec.mul(
  425. shortArrowDirection,
  426. Math.min(
  427. Vec.dist(shortArrowHits[0], B.point),
  428. BINDING_DISTANCE *
  429. 2.5 *
  430. (BoundsUtils.boundsContain(B.bounds, A.intersectBounds) ? -1 : 1)
  431. )
  432. )
  433. )
  434. )
  435. } else if (
  436. !B.isBound &&
  437. ((hits[0] && Vec.dist(hits[0], B.point) < BINDING_DISTANCE * 2.5) ||
  438. PointUtils.pointInBounds(B.point, A.intersectBounds))
  439. ) {
  440. // Short arrow time!
  441. const shortArrowDirection = Vec.uni(Vec.sub(A.center, B.point))
  442. return lineShape.getHandlesChange?.(lineShape.props, {
  443. [ID]: {
  444. ...lineShape.props.handles[ID],
  445. point: Vec.toFixed(
  446. Vec.add(
  447. Vec.sub(B.point, lineShape.props.point),
  448. Vec.mul(shortArrowDirection, BINDING_DISTANCE * 2.5)
  449. )
  450. ),
  451. },
  452. })
  453. } else if (hits[0]) {
  454. result[ID].point = Vec.toFixed(Vec.sub(hits[0], lineShape.props.point))
  455. }
  456. }
  457. }
  458. }
  459. }
  460. }
  461. return lineShape.getHandlesChange(lineShape.props, result)
  462. }
  463. }
  464. function getRelatedBindings(page: TLPageModel, ids: string[]): TLBinding[] {
  465. const changedShapeIds = new Set(ids)
  466. const bindingsArr = Object.values(page.bindings)
  467. // Start with bindings that are directly bound to our changed shapes
  468. const bindingsToUpdate = new Set(
  469. bindingsArr.filter(
  470. binding => changedShapeIds.has(binding.toId) || changedShapeIds.has(binding.fromId)
  471. )
  472. )
  473. // Next, look for other bindings that effect the same shapes
  474. let prevSize = bindingsToUpdate.size
  475. let delta = -1
  476. while (delta !== 0) {
  477. bindingsToUpdate.forEach(binding => {
  478. const fromId = binding.fromId
  479. for (const otherBinding of bindingsArr) {
  480. if (otherBinding.fromId === fromId) {
  481. bindingsToUpdate.add(otherBinding)
  482. }
  483. if (otherBinding.toId === fromId) {
  484. bindingsToUpdate.add(otherBinding)
  485. }
  486. }
  487. })
  488. // Continue until we stop finding new bindings to update
  489. delta = bindingsToUpdate.size - prevSize
  490. prevSize = bindingsToUpdate.size
  491. }
  492. return Array.from(bindingsToUpdate.values())
  493. }