@tldraw/editor 3.14.0-canary.744f8a453221 → 3.14.0-canary.766a02a82239

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist-cjs/index.d.ts +50 -149
  2. package/dist-cjs/index.js +1 -4
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/editor/Editor.js +25 -82
  5. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  6. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js +1 -3
  7. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js.map +2 -2
  8. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +42 -73
  9. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  10. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +10 -0
  11. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  12. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +6 -13
  13. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +3 -3
  14. package/dist-cjs/lib/editor/tools/StateNode.js +3 -3
  15. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  16. package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
  17. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  18. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -1
  19. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  20. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +2 -6
  21. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +2 -2
  22. package/dist-cjs/lib/primitives/geometry/Group2d.js +6 -11
  23. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  24. package/dist-cjs/lib/utils/dom.js +1 -1
  25. package/dist-cjs/lib/utils/dom.js.map +2 -2
  26. package/dist-cjs/version.js +3 -3
  27. package/dist-cjs/version.js.map +1 -1
  28. package/dist-esm/index.d.mts +50 -149
  29. package/dist-esm/index.mjs +1 -4
  30. package/dist-esm/index.mjs.map +2 -2
  31. package/dist-esm/lib/editor/Editor.mjs +25 -82
  32. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  33. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs +1 -3
  34. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs.map +2 -2
  35. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +42 -73
  36. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  37. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +10 -0
  38. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  39. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +6 -13
  40. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +3 -3
  41. package/dist-esm/lib/editor/tools/StateNode.mjs +3 -3
  42. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  43. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -1
  44. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  45. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +2 -6
  46. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  47. package/dist-esm/lib/primitives/geometry/Group2d.mjs +6 -11
  48. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  49. package/dist-esm/lib/utils/dom.mjs +1 -1
  50. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  51. package/dist-esm/version.mjs +3 -3
  52. package/dist-esm/version.mjs.map +1 -1
  53. package/editor.css +483 -440
  54. package/package.json +7 -7
  55. package/src/index.ts +0 -7
  56. package/src/lib/editor/Editor.ts +36 -103
  57. package/src/lib/editor/managers/HistoryManager/HistoryManager.ts +1 -3
  58. package/src/lib/editor/managers/TextManager/TextManager.test.ts +5 -1
  59. package/src/lib/editor/managers/TextManager/TextManager.ts +86 -118
  60. package/src/lib/editor/shapes/ShapeUtil.ts +15 -47
  61. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +17 -25
  62. package/src/lib/editor/tools/StateNode.ts +3 -3
  63. package/src/lib/editor/types/emit-types.ts +0 -4
  64. package/src/lib/editor/types/external-content.ts +2 -11
  65. package/src/lib/hooks/useCanvasEvents.ts +1 -0
  66. package/src/lib/primitives/geometry/Geometry2d.ts +2 -7
  67. package/src/lib/primitives/geometry/Group2d.ts +5 -11
  68. package/src/lib/utils/dom.ts +1 -1
  69. package/src/version.ts +3 -3
  70. package/dist-cjs/lib/utils/reparenting.js +0 -232
  71. package/dist-cjs/lib/utils/reparenting.js.map +0 -7
  72. package/dist-esm/lib/utils/reparenting.mjs +0 -216
  73. package/dist-esm/lib/utils/reparenting.mjs.map +0 -7
  74. package/src/lib/utils/reparenting.ts +0 -383
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/editor",
3
3
  "description": "A tiny little drawing app (editor).",
4
- "version": "3.14.0-canary.744f8a453221",
4
+ "version": "3.14.0-canary.766a02a82239",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -48,12 +48,12 @@
48
48
  "@tiptap/core": "^2.9.1",
49
49
  "@tiptap/pm": "^2.9.1",
50
50
  "@tiptap/react": "^2.9.1",
51
- "@tldraw/state": "3.14.0-canary.744f8a453221",
52
- "@tldraw/state-react": "3.14.0-canary.744f8a453221",
53
- "@tldraw/store": "3.14.0-canary.744f8a453221",
54
- "@tldraw/tlschema": "3.14.0-canary.744f8a453221",
55
- "@tldraw/utils": "3.14.0-canary.744f8a453221",
56
- "@tldraw/validate": "3.14.0-canary.744f8a453221",
51
+ "@tldraw/state": "3.14.0-canary.766a02a82239",
52
+ "@tldraw/state-react": "3.14.0-canary.766a02a82239",
53
+ "@tldraw/store": "3.14.0-canary.766a02a82239",
54
+ "@tldraw/tlschema": "3.14.0-canary.766a02a82239",
55
+ "@tldraw/utils": "3.14.0-canary.766a02a82239",
56
+ "@tldraw/validate": "3.14.0-canary.766a02a82239",
57
57
  "@types/core-js": "^2.5.8",
58
58
  "@use-gesture/react": "^10.3.1",
59
59
  "classnames": "^2.5.1",
package/src/index.ts CHANGED
@@ -174,7 +174,6 @@ export {
174
174
  } from './lib/editor/managers/SnapManager/SnapManager'
175
175
  export {
176
176
  TextManager,
177
- type TLMeasureTextOpts,
178
177
  type TLMeasureTextSpanOpts,
179
178
  } from './lib/editor/managers/TextManager/TextManager'
180
179
  export { UserPreferencesManager } from './lib/editor/managers/UserPreferencesManager/UserPreferencesManager'
@@ -182,10 +181,6 @@ export { BaseBoxShapeUtil, type TLBaseBoxShape } from './lib/editor/shapes/BaseB
182
181
  export {
183
182
  ShapeUtil,
184
183
  type TLCropInfo,
185
- type TLDragShapesInInfo,
186
- type TLDragShapesOutInfo,
187
- type TLDragShapesOverInfo,
188
- type TLDropShapesOverInfo,
189
184
  type TLGeometryOpts,
190
185
  type TLHandleDragInfo,
191
186
  type TLResizeInfo,
@@ -257,7 +252,6 @@ export {
257
252
  type TLExternalContent,
258
253
  type TLExternalContentSource,
259
254
  type TLFileExternalAsset,
260
- type TLFileReplaceExternalContent,
261
255
  type TLFilesExternalContent,
262
256
  type TLSvgTextExternalContent,
263
257
  type TLTextExternalContent,
@@ -450,7 +444,6 @@ export { hardResetEditor } from './lib/utils/hardResetEditor'
450
444
  export { isAccelKey } from './lib/utils/keyboard'
451
445
  export { normalizeWheel } from './lib/utils/normalizeWheel'
452
446
  export { refreshPage } from './lib/utils/refreshPage'
453
- export { getDroppedShapesToNewParents, kickoutOccludedShapes } from './lib/utils/reparenting'
454
447
  export {
455
448
  getFontsFromRichText,
456
449
  type RichTextFontVisitor,
@@ -348,8 +348,6 @@ export class Editor extends EventEmitter<TLEventMap> {
348
348
  this.getContainer = getContainer
349
349
 
350
350
  this.textMeasure = new TextManager(this)
351
- this.disposables.add(() => this.textMeasure.dispose())
352
-
353
351
  this.fonts = new FontManager(this, fontAssetUrls)
354
352
 
355
353
  this._tickManager = new TickManager(this)
@@ -2122,20 +2120,6 @@ export class Editor extends EventEmitter<TLEventMap> {
2122
2120
  return this.getShapesPageBounds(this.getSelectedShapeIds())
2123
2121
  }
2124
2122
 
2125
- /**
2126
- * The bounds of the selection bounding box in the current page space.
2127
- *
2128
- * @readonly
2129
- * @public
2130
- */
2131
- getSelectionScreenBounds(): Box | undefined {
2132
- const bounds = this.getSelectionPageBounds()
2133
- if (!bounds) return undefined
2134
- const { x, y } = this.pageToScreen(bounds.point)
2135
- const zoom = this.getZoomLevel()
2136
- return new Box(x, y, bounds.width * zoom, bounds.height * zoom)
2137
- }
2138
-
2139
2123
  /**
2140
2124
  * @internal
2141
2125
  */
@@ -3662,7 +3646,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3662
3646
  * @public
3663
3647
  */
3664
3648
  updateViewportScreenBounds(screenBounds: Box | HTMLElement, center = false): this {
3665
- if (!(screenBounds instanceof Box)) {
3649
+ if (screenBounds instanceof HTMLElement) {
3666
3650
  const rect = screenBounds.getBoundingClientRect()
3667
3651
  screenBounds = new Box(
3668
3652
  rect.left || rect.x,
@@ -5528,7 +5512,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5528
5512
  if (!id) return undefined
5529
5513
  const freshShape = this.getShape(id)
5530
5514
  if (freshShape === undefined || !isShapeId(freshShape.parentId)) return undefined
5531
- return this.getShape(freshShape.parentId)
5515
+ return this.store.get(freshShape.parentId)
5532
5516
  }
5533
5517
 
5534
5518
  /**
@@ -5711,10 +5695,6 @@ export class Editor extends EventEmitter<TLEventMap> {
5711
5695
  const newPoint = invertedParentTransform.applyToPoint(pagePoint)
5712
5696
  const newRotation = pageTransform.rotation() - parentPageRotation
5713
5697
 
5714
- if (shape.id === parentId) {
5715
- throw Error('Attempted to reparent a shape to itself!')
5716
- }
5717
-
5718
5698
  changes.push({
5719
5699
  id: shape.id,
5720
5700
  type: shape.type,
@@ -5818,11 +5798,6 @@ export class Editor extends EventEmitter<TLEventMap> {
5818
5798
  return shapeIds
5819
5799
  }
5820
5800
 
5821
- /** @deprecated Use {@link Editor.getDraggingOverShape} instead */
5822
- getDroppingOverShape(point: Vec, droppingShapes: TLShape[]): TLShape | undefined {
5823
- return this.getDraggingOverShape(point, droppingShapes)
5824
- }
5825
-
5826
5801
  /**
5827
5802
  * Get the shape that some shapes should be dropped on at a given point.
5828
5803
  *
@@ -5833,33 +5808,35 @@ export class Editor extends EventEmitter<TLEventMap> {
5833
5808
  *
5834
5809
  * @public
5835
5810
  */
5836
- getDraggingOverShape(point: Vec, droppingShapes: TLShape[]): TLShape | undefined {
5837
- // get fresh moving shapes
5838
- const draggingShapes = compact(droppingShapes.map((s) => this.getShape(s))).filter(
5839
- (s) => !s.isLocked && !this.isShapeHidden(s)
5840
- )
5811
+ getDroppingOverShape(point: VecLike, droppingShapes: TLShape[] = []) {
5812
+ // starting from the top...
5813
+ const currentPageShapesSorted = this.getCurrentPageShapesSorted()
5814
+ for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) {
5815
+ const shape = currentPageShapesSorted[i]
5841
5816
 
5842
- const maybeDraggingOverShapes = this.getShapesAtPoint(point, {
5843
- hitInside: true,
5844
- margin: 0,
5845
- }).filter(
5846
- (s) =>
5847
- !droppingShapes.includes(s) &&
5848
- !s.isLocked &&
5849
- !this.isShapeHidden(s) &&
5850
- !draggingShapes.includes(s)
5851
- )
5817
+ if (
5818
+ // ignore hidden shapes
5819
+ this.isShapeHidden(shape) ||
5820
+ // don't allow dropping on selected shapes
5821
+ this.getSelectedShapeIds().includes(shape.id) ||
5822
+ // only allow shapes that can receive children
5823
+ !this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) ||
5824
+ // don't allow dropping a shape on itself or one of it's children
5825
+ droppingShapes.find((s) => s.id === shape.id || this.hasAncestor(shape, s.id))
5826
+ ) {
5827
+ continue
5828
+ }
5829
+
5830
+ // Only allow dropping into the masked page bounds of the shape, e.g. when a frame is
5831
+ // partially clipped by its own parent frame
5832
+ const maskedPageBounds = this.getShapeMaskedPageBounds(shape.id)
5852
5833
 
5853
- for (const maybeDraggingOverShape of maybeDraggingOverShapes) {
5854
- const shapeUtil = this.getShapeUtil(maybeDraggingOverShape)
5855
- // Any shape that can handle any dragging interactions is a valid target
5856
5834
  if (
5857
- shapeUtil.onDragShapesOver ||
5858
- shapeUtil.onDragShapesIn ||
5859
- shapeUtil.onDragShapesOut ||
5860
- shapeUtil.onDropShapesOver
5835
+ maskedPageBounds &&
5836
+ maskedPageBounds.containsPoint(point) &&
5837
+ this.getShapeGeometry(shape).hitTestPoint(this.getPointInShapeSpace(shape, point), 0, true)
5861
5838
  ) {
5862
- return maybeDraggingOverShape
5839
+ return shape
5863
5840
  }
5864
5841
  }
5865
5842
  }
@@ -6218,12 +6195,11 @@ export class Editor extends EventEmitter<TLEventMap> {
6218
6195
  */
6219
6196
  duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this {
6220
6197
  this.run(() => {
6221
- const _ids =
6198
+ const ids =
6222
6199
  typeof shapes[0] === 'string'
6223
6200
  ? (shapes as TLShapeId[])
6224
6201
  : (shapes as TLShape[]).map((s) => s.id)
6225
6202
 
6226
- const ids = this._shouldIgnoreShapeLock ? _ids : this._getUnlockedShapeIds(_ids)
6227
6203
  if (ids.length <= 0) return this
6228
6204
 
6229
6205
  const initialIds = new Set(ids)
@@ -6303,7 +6279,10 @@ export class Editor extends EventEmitter<TLEventMap> {
6303
6279
  })
6304
6280
  const shapesToCreate = shapesToCreateWithOriginals.map(({ shape }) => shape)
6305
6281
 
6306
- if (!this.canCreateShapes(shapesToCreate)) {
6282
+ const maxShapesReached =
6283
+ shapesToCreate.length + this.getCurrentPageShapeIds().size > this.options.maxShapesPerPage
6284
+
6285
+ if (maxShapesReached) {
6307
6286
  alertMaxShapes(this)
6308
6287
  return
6309
6288
  }
@@ -7732,32 +7711,6 @@ export class Editor extends EventEmitter<TLEventMap> {
7732
7711
  return {}
7733
7712
  }
7734
7713
 
7735
- /**
7736
- * Get whether the provided shape can be created.
7737
- *
7738
- * @param shape - The shape or shape IDs to check.
7739
- *
7740
- * @public
7741
- */
7742
- canCreateShape<T extends TLUnknownShape>(
7743
- shape: OptionalKeys<TLShapePartial<T>, 'id'> | T['id']
7744
- ): boolean {
7745
- return this.canCreateShapes([shape])
7746
- }
7747
-
7748
- /**
7749
- * Get whether the provided shapes can be created.
7750
- *
7751
- * @param shapes - The shapes or shape IDs to create.
7752
- *
7753
- * @public
7754
- */
7755
- canCreateShapes<T extends TLUnknownShape>(
7756
- shapes: (T['id'] | OptionalKeys<TLShapePartial<T>, 'id'>)[]
7757
- ): boolean {
7758
- return shapes.length + this.getCurrentPageShapeIds().size <= this.options.maxShapesPerPage
7759
- }
7760
-
7761
7714
  /**
7762
7715
  * Create a single shape.
7763
7716
  *
@@ -7804,7 +7757,6 @@ export class Editor extends EventEmitter<TLEventMap> {
7804
7757
  if (maxShapesReached) {
7805
7758
  // can't create more shapes than fit on the page
7806
7759
  alertMaxShapes(this)
7807
- // todo: throw an error here? Otherwise we'll need to check every time whether the shapes were actually created
7808
7760
  return this
7809
7761
  }
7810
7762
 
@@ -7837,10 +7789,9 @@ export class Editor extends EventEmitter<TLEventMap> {
7837
7789
 
7838
7790
  for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) {
7839
7791
  const parent = currentPageShapesSorted[i]
7840
- const util = this.getShapeUtil(parent)
7841
7792
  if (
7842
- util.canReceiveNewChildrenOfType(parent, partial.type) &&
7843
7793
  !this.isShapeHidden(parent) &&
7794
+ this.getShapeUtil(parent).canReceiveNewChildrenOfType(parent, partial.type) &&
7844
7795
  this.isPointInShape(
7845
7796
  parent,
7846
7797
  // If no parent is provided, then we can treat the
@@ -7859,7 +7810,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7859
7810
 
7860
7811
  const prevParentId = partial.parentId
7861
7812
 
7862
- // a shape cannot be its own parent. This was a rare issue with frames/groups in the syncFuzz tests.
7813
+ // a shape cannot be it's own parent. This was a rare issue with frames/groups in the syncFuzz tests.
7863
7814
  if (parentId === partial.id) {
7864
7815
  parentId = focusedGroupId
7865
7816
  }
@@ -7969,8 +7920,6 @@ export class Editor extends EventEmitter<TLEventMap> {
7969
7920
  }
7970
7921
  })
7971
7922
 
7972
- this.emit('created-shapes', shapeRecordsToCreate)
7973
- this.emit('edit')
7974
7923
  this.store.put(shapeRecordsToCreate)
7975
7924
  })
7976
7925
 
@@ -8365,8 +8314,6 @@ export class Editor extends EventEmitter<TLEventMap> {
8365
8314
  updates.push(updated)
8366
8315
  }
8367
8316
 
8368
- this.emit('edited-shapes', updates)
8369
- this.emit('edit')
8370
8317
  this.store.put(updates)
8371
8318
  })
8372
8319
  }
@@ -8416,8 +8363,6 @@ export class Editor extends EventEmitter<TLEventMap> {
8416
8363
  })
8417
8364
  }
8418
8365
 
8419
- this.emit('deleted-shapes', [...allShapeIdsToDelete])
8420
- this.emit('edit')
8421
8366
  return this.run(() => this.store.remove([...allShapeIdsToDelete]))
8422
8367
  }
8423
8368
 
@@ -8866,7 +8811,6 @@ export class Editor extends EventEmitter<TLEventMap> {
8866
8811
  } = {
8867
8812
  text: null,
8868
8813
  files: null,
8869
- 'file-replace': null,
8870
8814
  embed: null,
8871
8815
  'svg-text': null,
8872
8816
  url: null,
@@ -8916,15 +8860,6 @@ export class Editor extends EventEmitter<TLEventMap> {
8916
8860
  return this.externalContentHandlers[info.type]?.(info as any)
8917
8861
  }
8918
8862
 
8919
- /**
8920
- * Handle replacing external content.
8921
- *
8922
- * @param info - Info about the external content.
8923
- */
8924
- async replaceExternalContent<E>(info: TLExternalContent<E>): Promise<void> {
8925
- return this.externalContentHandlers[info.type]?.(info as any)
8926
- }
8927
-
8928
8863
  /**
8929
8864
  * Get content that can be exported for the given shape ids.
8930
8865
  *
@@ -9544,8 +9479,6 @@ export class Editor extends EventEmitter<TLEventMap> {
9544
9479
  previousPagePoint,
9545
9480
  currentScreenPoint,
9546
9481
  currentPagePoint,
9547
- originScreenPoint,
9548
- originPagePoint,
9549
9482
  } = this.inputs
9550
9483
 
9551
9484
  const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
@@ -9574,8 +9507,8 @@ export class Editor extends EventEmitter<TLEventMap> {
9574
9507
  // Reset velocity on pointer down, or when a pinch starts or ends
9575
9508
  if (info.name === 'pointer_down' || this.inputs.isPinching) {
9576
9509
  pointerVelocity.set(0, 0)
9577
- originScreenPoint.setTo(currentScreenPoint)
9578
- originPagePoint.setTo(currentPagePoint)
9510
+ this.inputs.originScreenPoint.setTo(currentScreenPoint)
9511
+ this.inputs.originPagePoint.setTo(currentPagePoint)
9579
9512
  }
9580
9513
 
9581
9514
  // todo: We only have to do this if there are multiple users in the document
@@ -241,9 +241,7 @@ export class HistoryManager<R extends UnknownRecord> {
241
241
  }
242
242
 
243
243
  bailToMark(id: string) {
244
- if (id) {
245
- this._undo({ pushToRedoStack: false, toMark: id })
246
- }
244
+ this._undo({ pushToRedoStack: false, toMark: id })
247
245
 
248
246
  return this
249
247
  }
@@ -99,7 +99,7 @@ describe('TextManager', () => {
99
99
  })
100
100
 
101
101
  it('should handle empty text', () => {
102
- const result = textManager.measureText('', { ...defaultOpts, measureScrollWidth: true })
102
+ const result = textManager.measureText('', defaultOpts)
103
103
  expect(result).toHaveProperty('x', 0)
104
104
  expect(result).toHaveProperty('y', 0)
105
105
  expect(result).toHaveProperty('w')
@@ -128,6 +128,7 @@ describe('TextManager', () => {
128
128
  y: 0,
129
129
  w: expect.any(Number),
130
130
  h: expect.any(Number),
131
+ scrollWidth: expect.any(Number),
131
132
  })
132
133
  })
133
134
 
@@ -140,6 +141,7 @@ describe('TextManager', () => {
140
141
  y: 0,
141
142
  w: expect.any(Number),
142
143
  h: expect.any(Number),
144
+ scrollWidth: expect.any(Number),
143
145
  })
144
146
  })
145
147
 
@@ -152,6 +154,7 @@ describe('TextManager', () => {
152
154
  y: 0,
153
155
  w: expect.any(Number),
154
156
  h: expect.any(Number),
157
+ scrollWidth: expect.any(Number),
155
158
  })
156
159
  })
157
160
 
@@ -170,6 +173,7 @@ describe('TextManager', () => {
170
173
  y: 0,
171
174
  w: expect.any(Number),
172
175
  h: expect.any(Number),
176
+ scrollWidth: expect.any(Number),
173
177
  })
174
178
  })
175
179
  })
@@ -20,28 +20,6 @@ const textAlignmentsForLtr = {
20
20
  'end-legacy': 'right',
21
21
  }
22
22
 
23
- /** @public */
24
- export interface TLMeasureTextOpts {
25
- fontStyle: string
26
- fontWeight: string
27
- fontFamily: string
28
- fontSize: number
29
- /** This must be a number, e.g. 1.35, not a pixel value. */
30
- lineHeight: number
31
- /**
32
- * When maxWidth is a number, the text will be wrapped to that maxWidth. When maxWidth
33
- * is null, the text will be measured without wrapping, but explicit line breaks and
34
- * space are preserved.
35
- */
36
- maxWidth: null | number
37
- minWidth?: null | number
38
- // todo: make this a number so that it is consistent with other TLMeasureTextSpanOpts
39
- padding: string
40
- otherStyles?: Record<string, string>
41
- disableOverflowWrapBreaking?: boolean
42
- measureScrollWidth?: boolean
43
- }
44
-
45
23
  /** @public */
46
24
  export interface TLMeasureTextSpanOpts {
47
25
  overflow: 'wrap' | 'truncate-ellipsis' | 'truncate-clip'
@@ -55,99 +33,96 @@ export interface TLMeasureTextSpanOpts {
55
33
  lineHeight: number
56
34
  textAlign: TLDefaultHorizontalAlignStyle
57
35
  otherStyles?: Record<string, string>
58
- measureScrollWidth?: boolean
59
36
  }
60
37
 
61
38
  const spaceCharacterRegex = /\s/
62
39
 
63
40
  /** @public */
64
41
  export class TextManager {
65
- private elm: HTMLDivElement
66
- private defaultStyles: Record<string, string | null>
42
+ private baseElem: HTMLDivElement
67
43
 
68
44
  constructor(public editor: Editor) {
69
- const elm = document.createElement('div')
70
- elm.classList.add('tl-text')
71
- elm.classList.add('tl-text-measure')
72
- elm.setAttribute('dir', 'auto')
73
- elm.tabIndex = -1
74
- this.editor.getContainer().appendChild(elm)
75
-
76
- // we need to save the default styles so that we can restore them when we're done
77
- // these must be the css names, not the js names for the styles
78
- this.defaultStyles = {
79
- 'overflow-wrap': 'break-word',
80
- 'word-break': 'auto',
81
- width: null,
82
- height: null,
83
- 'max-width': null,
84
- 'min-width': null,
85
- }
86
-
87
- this.elm = elm
88
- }
89
-
90
- dispose() {
91
- return this.elm.remove()
45
+ this.baseElem = document.createElement('div')
46
+ this.baseElem.classList.add('tl-text')
47
+ this.baseElem.classList.add('tl-text-measure')
48
+ this.baseElem.tabIndex = -1
92
49
  }
93
50
 
94
- private resetElmStyles() {
95
- const { elm, defaultStyles } = this
96
- for (const key in defaultStyles) {
97
- elm.style.setProperty(key, defaultStyles[key])
51
+ measureText(
52
+ textToMeasure: string,
53
+ opts: {
54
+ fontStyle: string
55
+ fontWeight: string
56
+ fontFamily: string
57
+ fontSize: number
58
+ lineHeight: number
59
+ /**
60
+ * When maxWidth is a number, the text will be wrapped to that maxWidth. When maxWidth
61
+ * is null, the text will be measured without wrapping, but explicit line breaks and
62
+ * space are preserved.
63
+ */
64
+ maxWidth: null | number
65
+ minWidth?: null | number
66
+ padding: string
67
+ disableOverflowWrapBreaking?: boolean
98
68
  }
99
- }
100
-
101
- measureText(textToMeasure: string, opts: TLMeasureTextOpts): BoxModel & { scrollWidth: number } {
69
+ ): BoxModel & { scrollWidth: number } {
102
70
  const div = document.createElement('div')
103
71
  div.textContent = normalizeTextForDom(textToMeasure)
104
72
  return this.measureHtml(div.innerHTML, opts)
105
73
  }
106
74
 
107
- measureHtml(html: string, opts: TLMeasureTextOpts): BoxModel & { scrollWidth: number } {
108
- const { elm } = this
109
-
110
- if (opts.otherStyles) {
111
- for (const key in opts.otherStyles) {
112
- if (!this.defaultStyles[key]) {
113
- // we need to save the original style so that we can restore it when we're done
114
- this.defaultStyles[key] = elm.style.getPropertyValue(key)
115
- }
116
- }
75
+ measureHtml(
76
+ html: string,
77
+ opts: {
78
+ fontStyle: string
79
+ fontWeight: string
80
+ fontFamily: string
81
+ fontSize: number
82
+ lineHeight: number
83
+ /**
84
+ * When maxWidth is a number, the text will be wrapped to that maxWidth. When maxWidth
85
+ * is null, the text will be measured without wrapping, but explicit line breaks and
86
+ * space are preserved.
87
+ */
88
+ maxWidth: null | number
89
+ minWidth?: null | number
90
+ otherStyles?: Record<string, string>
91
+ padding: string
92
+ disableOverflowWrapBreaking?: boolean
117
93
  }
118
-
119
- elm.innerHTML = html
120
-
121
- // Apply the default styles to the element (for all styles here or that were ever seen in opts.otherStyles)
122
- this.resetElmStyles()
123
-
124
- elm.style.setProperty('font-family', opts.fontFamily)
125
- elm.style.setProperty('font-style', opts.fontStyle)
126
- elm.style.setProperty('font-weight', opts.fontWeight)
127
- elm.style.setProperty('font-size', opts.fontSize + 'px')
128
- elm.style.setProperty('line-height', opts.lineHeight.toString())
129
- elm.style.setProperty('padding', opts.padding)
130
-
131
- if (opts.maxWidth) {
132
- elm.style.setProperty('max-width', opts.maxWidth + 'px')
133
- }
134
-
135
- if (opts.minWidth) {
136
- elm.style.setProperty('min-width', opts.minWidth + 'px')
137
- }
138
-
139
- if (opts.disableOverflowWrapBreaking) {
140
- elm.style.setProperty('overflow-wrap', 'normal')
141
- }
142
-
94
+ ): BoxModel & { scrollWidth: number } {
95
+ // Duplicate our base element; we don't need to clone deep
96
+ const wrapperElm = this.baseElem.cloneNode() as HTMLDivElement
97
+ this.editor.getContainer().appendChild(wrapperElm)
98
+ wrapperElm.innerHTML = html
99
+ this.baseElem.insertAdjacentElement('afterend', wrapperElm)
100
+
101
+ wrapperElm.setAttribute('dir', 'auto')
102
+ // N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers")
103
+ // is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs.
104
+ wrapperElm.style.setProperty('unicode-bidi', 'plaintext')
105
+ wrapperElm.style.setProperty('font-family', opts.fontFamily)
106
+ wrapperElm.style.setProperty('font-style', opts.fontStyle)
107
+ wrapperElm.style.setProperty('font-weight', opts.fontWeight)
108
+ wrapperElm.style.setProperty('font-size', opts.fontSize + 'px')
109
+ wrapperElm.style.setProperty('line-height', opts.lineHeight * opts.fontSize + 'px')
110
+ wrapperElm.style.setProperty('max-width', opts.maxWidth === null ? null : opts.maxWidth + 'px')
111
+ wrapperElm.style.setProperty('min-width', opts.minWidth === null ? null : opts.minWidth + 'px')
112
+ wrapperElm.style.setProperty('padding', opts.padding)
113
+ wrapperElm.style.setProperty(
114
+ 'overflow-wrap',
115
+ opts.disableOverflowWrapBreaking ? 'normal' : 'break-word'
116
+ )
143
117
  if (opts.otherStyles) {
144
118
  for (const [key, value] of Object.entries(opts.otherStyles)) {
145
- elm.style.setProperty(key, value)
119
+ wrapperElm.style.setProperty(key, value)
146
120
  }
147
121
  }
148
122
 
149
- const scrollWidth = opts.measureScrollWidth ? elm.scrollWidth : 0
150
- const rect = elm.getBoundingClientRect()
123
+ const scrollWidth = wrapperElm.scrollWidth
124
+ const rect = wrapperElm.getBoundingClientRect()
125
+ wrapperElm.remove()
151
126
 
152
127
  return {
153
128
  x: 0,
@@ -272,29 +247,27 @@ export class TextManager {
272
247
  ): { text: string; box: BoxModel }[] {
273
248
  if (textToMeasure === '') return []
274
249
 
275
- const { elm } = this
276
-
277
- if (opts.otherStyles) {
278
- for (const key in opts.otherStyles) {
279
- if (!this.defaultStyles[key]) {
280
- // we need to save the original style so that we can restore it when we're done
281
- this.defaultStyles[key] = elm.style.getPropertyValue(key)
282
- }
283
- }
284
- }
285
-
286
- this.resetElmStyles()
287
-
288
- elm.style.setProperty('font-family', opts.fontFamily)
289
- elm.style.setProperty('font-style', opts.fontStyle)
290
- elm.style.setProperty('font-weight', opts.fontWeight)
291
- elm.style.setProperty('font-size', opts.fontSize + 'px')
292
- elm.style.setProperty('line-height', opts.lineHeight.toString())
250
+ const elm = this.baseElem.cloneNode() as HTMLDivElement
251
+ this.editor.getContainer().appendChild(elm)
293
252
 
294
253
  const elementWidth = Math.ceil(opts.width - opts.padding * 2)
254
+ elm.setAttribute('dir', 'auto')
255
+ // N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers")
256
+ // is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs.
257
+ elm.style.setProperty('unicode-bidi', 'plaintext')
295
258
  elm.style.setProperty('width', `${elementWidth}px`)
296
259
  elm.style.setProperty('height', 'min-content')
260
+ elm.style.setProperty('font-size', `${opts.fontSize}px`)
261
+ elm.style.setProperty('font-family', opts.fontFamily)
262
+ elm.style.setProperty('font-weight', opts.fontWeight)
263
+ elm.style.setProperty('line-height', `${opts.lineHeight * opts.fontSize}px`)
297
264
  elm.style.setProperty('text-align', textAlignmentsForLtr[opts.textAlign])
265
+ elm.style.setProperty('font-style', opts.fontStyle)
266
+ if (opts.otherStyles) {
267
+ for (const [key, value] of Object.entries(opts.otherStyles)) {
268
+ elm.style.setProperty(key, value)
269
+ }
270
+ }
298
271
 
299
272
  const shouldTruncateToFirstLine =
300
273
  opts.overflow === 'truncate-ellipsis' || opts.overflow === 'truncate-clip'
@@ -304,12 +277,6 @@ export class TextManager {
304
277
  elm.style.setProperty('word-break', 'break-all')
305
278
  }
306
279
 
307
- if (opts.otherStyles) {
308
- for (const [key, value] of Object.entries(opts.otherStyles)) {
309
- elm.style.setProperty(key, value)
310
- }
311
- }
312
-
313
280
  const normalizedText = normalizeTextForDom(textToMeasure)
314
281
 
315
282
  // Render the text into the measurement element:
@@ -346,10 +313,11 @@ export class TextManager {
346
313
  h: lastSpan.box.h,
347
314
  },
348
315
  })
349
-
350
316
  return truncatedSpans
351
317
  }
352
318
 
319
+ elm.remove()
320
+
353
321
  return spans
354
322
  }
355
323
  }