TLPage.ts 17 KB

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