TLPage.ts 17 KB

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