preview-manager.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { BoundsUtils, TLAsset, TLDocumentModel, TLShapeConstructor, TLViewport } from '@tldraw/core'
  2. import ReactDOMServer from 'react-dom/server'
  3. import { Shape, shapes } from './shapes'
  4. const SVG_EXPORT_PADDING = 16
  5. const ShapesMap = new Map(shapes.map(shape => [shape.id, shape]))
  6. const getShapeClass = (type: string): TLShapeConstructor<Shape> => {
  7. if (!type) throw Error('No shape type provided.')
  8. const Shape = ShapesMap.get(type)
  9. if (!Shape) throw Error(`Could not find shape class for ${type}`)
  10. return Shape
  11. }
  12. export class PreviewManager {
  13. shapes: Shape[] | undefined
  14. pageId: string | undefined
  15. assets: TLAsset[] | undefined
  16. constructor(serializedApp?: TLDocumentModel<Shape>) {
  17. if (serializedApp) {
  18. this.load(serializedApp)
  19. }
  20. }
  21. load(snapshot: TLDocumentModel) {
  22. const page = snapshot.pages[0]
  23. this.pageId = page?.id
  24. this.assets = snapshot.assets
  25. this.shapes = page?.shapes.map(s => {
  26. const ShapeClass = getShapeClass(s.type)
  27. return new ShapeClass(s)
  28. })
  29. }
  30. generatePreviewJsx(viewport?: TLViewport) {
  31. const allBounds = [...(this.shapes ?? []).map(s => s.getRotatedBounds())]
  32. const vBounds = viewport?.currentView
  33. if (vBounds) {
  34. allBounds.push(vBounds)
  35. }
  36. let commonBounds = BoundsUtils.getCommonBounds(allBounds)
  37. if (!commonBounds) {
  38. return null
  39. }
  40. commonBounds = BoundsUtils.expandBounds(commonBounds, SVG_EXPORT_PADDING)
  41. // make sure commonBounds is of ratio 4/3 (should we have another ratio setting?)
  42. commonBounds = viewport ? BoundsUtils.ensureRatio(commonBounds, 4 / 3) : commonBounds
  43. const translatePoint = (p: [number, number]): [string, string] => {
  44. return [(p[0] - commonBounds.minX).toFixed(2), (p[1] - commonBounds.minY).toFixed(2)]
  45. }
  46. const [vx, vy] = vBounds ? translatePoint([vBounds.minX, vBounds.minY]) : [0, 0]
  47. const svgElement = commonBounds && (
  48. <svg
  49. xmlns="http://www.w3.org/2000/svg"
  50. data-common-bound-x={commonBounds.minX.toFixed(2)}
  51. data-common-bound-y={commonBounds.minY.toFixed(2)}
  52. data-common-bound-width={commonBounds.width.toFixed(2)}
  53. data-common-bound-height={commonBounds.height.toFixed(2)}
  54. viewBox={[0, 0, commonBounds.width, commonBounds.height].join(' ')}
  55. >
  56. <defs>
  57. {vBounds && (
  58. <>
  59. <rect
  60. id={this.pageId + '-camera-rect'}
  61. transform={`translate(${vx}, ${vy})`}
  62. width={vBounds.width}
  63. height={vBounds.height}
  64. />
  65. <mask id={this.pageId + '-camera-mask'}>
  66. <rect width={commonBounds.width} height={commonBounds.height} fill="white" />
  67. <use href={`#${this.pageId}-camera-rect`} fill="black" />
  68. </mask>
  69. </>
  70. )}
  71. </defs>
  72. <g id={this.pageId + '-preview-shapes'}>
  73. {this.shapes?.map(s => {
  74. const {
  75. bounds,
  76. props: { rotation },
  77. } = s
  78. const [tx, ty] = translatePoint([bounds.minX, bounds.minY])
  79. const r = +((((rotation ?? 0) + (bounds.rotation ?? 0)) * 180) / Math.PI).toFixed(2)
  80. const [rdx, rdy] = [(bounds.width / 2).toFixed(2), (bounds.height / 2).toFixed(2)]
  81. const transformArr = [`translate(${tx}, ${ty})`, `rotate(${r}, ${rdx}, ${rdy})`]
  82. return (
  83. <g transform={transformArr.join(' ')} key={s.id}>
  84. {s.getShapeSVGJsx({
  85. assets: this.assets ?? [],
  86. })}
  87. </g>
  88. )
  89. })}
  90. </g>
  91. <rect
  92. mask={vBounds ? `url(#${this.pageId}-camera-mask)` : ''}
  93. width={commonBounds.width}
  94. height={commonBounds.height}
  95. fill="transparent"
  96. />
  97. {vBounds && (
  98. <use
  99. id="minimap-camera-rect"
  100. data-x={vx}
  101. data-y={vy}
  102. data-width={vBounds.width}
  103. data-height={vBounds.height}
  104. href={`#${this.pageId}-camera-rect`}
  105. fill="transparent"
  106. stroke="red"
  107. strokeWidth={4 / viewport.camera.zoom}
  108. />
  109. )}
  110. </svg>
  111. )
  112. return svgElement
  113. }
  114. exportAsSVG() {
  115. const svgElement = this.generatePreviewJsx()
  116. return svgElement ? ReactDOMServer.renderToString(svgElement) : ''
  117. }
  118. }
  119. /**
  120. * One off helper to generate tldraw preview
  121. *
  122. * @param serializedApp
  123. */
  124. export function generateSVGFromApp(serializedApp: TLDocumentModel<Shape>) {
  125. const preview = new PreviewManager(serializedApp)
  126. return preview.exportAsSVG()
  127. }
  128. export function generateJSXFromApp(serializedApp: TLDocumentModel<Shape>) {
  129. const preview = new PreviewManager(serializedApp)
  130. return preview.generatePreviewJsx()
  131. }