@tldraw/editor 3.14.0-canary.f907ed7d9ee5 → 3.14.0-canary.fb0390b30559

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 (75) hide show
  1. package/dist-cjs/index.d.ts +149 -50
  2. package/dist-cjs/index.js +4 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/editor/Editor.js +82 -25
  5. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  6. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js +3 -1
  7. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js.map +2 -2
  8. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +73 -42
  9. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  10. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +0 -10
  11. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  12. package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +13 -6
  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 +1 -2
  19. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  20. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +6 -2
  21. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +2 -2
  22. package/dist-cjs/lib/primitives/geometry/Group2d.js +11 -6
  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/lib/utils/reparenting.js +232 -0
  27. package/dist-cjs/lib/utils/reparenting.js.map +7 -0
  28. package/dist-cjs/version.js +3 -3
  29. package/dist-cjs/version.js.map +1 -1
  30. package/dist-esm/index.d.mts +149 -50
  31. package/dist-esm/index.mjs +4 -1
  32. package/dist-esm/index.mjs.map +2 -2
  33. package/dist-esm/lib/editor/Editor.mjs +82 -25
  34. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  35. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs +3 -1
  36. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs.map +2 -2
  37. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +73 -42
  38. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  39. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +0 -10
  40. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  41. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +13 -6
  42. package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +3 -3
  43. package/dist-esm/lib/editor/tools/StateNode.mjs +3 -3
  44. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  45. package/dist-esm/lib/hooks/useCanvasEvents.mjs +1 -2
  46. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  47. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +6 -2
  48. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  49. package/dist-esm/lib/primitives/geometry/Group2d.mjs +11 -6
  50. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  51. package/dist-esm/lib/utils/dom.mjs +1 -1
  52. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  53. package/dist-esm/lib/utils/reparenting.mjs +216 -0
  54. package/dist-esm/lib/utils/reparenting.mjs.map +7 -0
  55. package/dist-esm/version.mjs +3 -3
  56. package/dist-esm/version.mjs.map +1 -1
  57. package/editor.css +446 -489
  58. package/package.json +7 -7
  59. package/src/index.ts +7 -0
  60. package/src/lib/editor/Editor.ts +103 -36
  61. package/src/lib/editor/managers/HistoryManager/HistoryManager.ts +3 -1
  62. package/src/lib/editor/managers/TextManager/TextManager.test.ts +1 -5
  63. package/src/lib/editor/managers/TextManager/TextManager.ts +118 -86
  64. package/src/lib/editor/shapes/ShapeUtil.ts +47 -15
  65. package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +25 -17
  66. package/src/lib/editor/tools/StateNode.ts +3 -3
  67. package/src/lib/editor/types/emit-types.ts +4 -0
  68. package/src/lib/editor/types/external-content.ts +11 -2
  69. package/src/lib/hooks/useCanvasEvents.ts +0 -1
  70. package/src/lib/primitives/geometry/Geometry2d.ts +7 -2
  71. package/src/lib/primitives/geometry/Group2d.ts +11 -5
  72. package/src/lib/utils/dom.ts +1 -1
  73. package/src/lib/utils/reparenting.ts +383 -0
  74. package/src/version.ts +3 -3
  75. package/CHANGELOG.md +0 -4327
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.f907ed7d9ee5",
4
+ "version": "3.14.0-canary.fb0390b30559",
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.f907ed7d9ee5",
52
- "@tldraw/state-react": "3.14.0-canary.f907ed7d9ee5",
53
- "@tldraw/store": "3.14.0-canary.f907ed7d9ee5",
54
- "@tldraw/tlschema": "3.14.0-canary.f907ed7d9ee5",
55
- "@tldraw/utils": "3.14.0-canary.f907ed7d9ee5",
56
- "@tldraw/validate": "3.14.0-canary.f907ed7d9ee5",
51
+ "@tldraw/state": "3.14.0-canary.fb0390b30559",
52
+ "@tldraw/state-react": "3.14.0-canary.fb0390b30559",
53
+ "@tldraw/store": "3.14.0-canary.fb0390b30559",
54
+ "@tldraw/tlschema": "3.14.0-canary.fb0390b30559",
55
+ "@tldraw/utils": "3.14.0-canary.fb0390b30559",
56
+ "@tldraw/validate": "3.14.0-canary.fb0390b30559",
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,6 +174,7 @@ export {
174
174
  } from './lib/editor/managers/SnapManager/SnapManager'
175
175
  export {
176
176
  TextManager,
177
+ type TLMeasureTextOpts,
177
178
  type TLMeasureTextSpanOpts,
178
179
  } from './lib/editor/managers/TextManager/TextManager'
179
180
  export { UserPreferencesManager } from './lib/editor/managers/UserPreferencesManager/UserPreferencesManager'
@@ -181,6 +182,10 @@ export { BaseBoxShapeUtil, type TLBaseBoxShape } from './lib/editor/shapes/BaseB
181
182
  export {
182
183
  ShapeUtil,
183
184
  type TLCropInfo,
185
+ type TLDragShapesInInfo,
186
+ type TLDragShapesOutInfo,
187
+ type TLDragShapesOverInfo,
188
+ type TLDropShapesOverInfo,
184
189
  type TLGeometryOpts,
185
190
  type TLHandleDragInfo,
186
191
  type TLResizeInfo,
@@ -252,6 +257,7 @@ export {
252
257
  type TLExternalContent,
253
258
  type TLExternalContentSource,
254
259
  type TLFileExternalAsset,
260
+ type TLFileReplaceExternalContent,
255
261
  type TLFilesExternalContent,
256
262
  type TLSvgTextExternalContent,
257
263
  type TLTextExternalContent,
@@ -444,6 +450,7 @@ export { hardResetEditor } from './lib/utils/hardResetEditor'
444
450
  export { isAccelKey } from './lib/utils/keyboard'
445
451
  export { normalizeWheel } from './lib/utils/normalizeWheel'
446
452
  export { refreshPage } from './lib/utils/refreshPage'
453
+ export { getDroppedShapesToNewParents, kickoutOccludedShapes } from './lib/utils/reparenting'
447
454
  export {
448
455
  getFontsFromRichText,
449
456
  type RichTextFontVisitor,
@@ -348,6 +348,8 @@ 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
+
351
353
  this.fonts = new FontManager(this, fontAssetUrls)
352
354
 
353
355
  this._tickManager = new TickManager(this)
@@ -2120,6 +2122,20 @@ export class Editor extends EventEmitter<TLEventMap> {
2120
2122
  return this.getShapesPageBounds(this.getSelectedShapeIds())
2121
2123
  }
2122
2124
 
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
+
2123
2139
  /**
2124
2140
  * @internal
2125
2141
  */
@@ -3646,7 +3662,7 @@ export class Editor extends EventEmitter<TLEventMap> {
3646
3662
  * @public
3647
3663
  */
3648
3664
  updateViewportScreenBounds(screenBounds: Box | HTMLElement, center = false): this {
3649
- if (screenBounds instanceof HTMLElement) {
3665
+ if (!(screenBounds instanceof Box)) {
3650
3666
  const rect = screenBounds.getBoundingClientRect()
3651
3667
  screenBounds = new Box(
3652
3668
  rect.left || rect.x,
@@ -5512,7 +5528,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5512
5528
  if (!id) return undefined
5513
5529
  const freshShape = this.getShape(id)
5514
5530
  if (freshShape === undefined || !isShapeId(freshShape.parentId)) return undefined
5515
- return this.store.get(freshShape.parentId)
5531
+ return this.getShape(freshShape.parentId)
5516
5532
  }
5517
5533
 
5518
5534
  /**
@@ -5695,6 +5711,10 @@ export class Editor extends EventEmitter<TLEventMap> {
5695
5711
  const newPoint = invertedParentTransform.applyToPoint(pagePoint)
5696
5712
  const newRotation = pageTransform.rotation() - parentPageRotation
5697
5713
 
5714
+ if (shape.id === parentId) {
5715
+ throw Error('Attempted to reparent a shape to itself!')
5716
+ }
5717
+
5698
5718
  changes.push({
5699
5719
  id: shape.id,
5700
5720
  type: shape.type,
@@ -5798,6 +5818,11 @@ export class Editor extends EventEmitter<TLEventMap> {
5798
5818
  return shapeIds
5799
5819
  }
5800
5820
 
5821
+ /** @deprecated Use {@link Editor.getDraggingOverShape} instead */
5822
+ getDroppingOverShape(point: Vec, droppingShapes: TLShape[]): TLShape | undefined {
5823
+ return this.getDraggingOverShape(point, droppingShapes)
5824
+ }
5825
+
5801
5826
  /**
5802
5827
  * Get the shape that some shapes should be dropped on at a given point.
5803
5828
  *
@@ -5808,35 +5833,33 @@ export class Editor extends EventEmitter<TLEventMap> {
5808
5833
  *
5809
5834
  * @public
5810
5835
  */
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]
5816
-
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
- }
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
+ )
5829
5841
 
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)
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
+ )
5833
5852
 
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
5834
5856
  if (
5835
- maskedPageBounds &&
5836
- maskedPageBounds.containsPoint(point) &&
5837
- this.getShapeGeometry(shape).hitTestPoint(this.getPointInShapeSpace(shape, point), 0, true)
5857
+ shapeUtil.onDragShapesOver ||
5858
+ shapeUtil.onDragShapesIn ||
5859
+ shapeUtil.onDragShapesOut ||
5860
+ shapeUtil.onDropShapesOver
5838
5861
  ) {
5839
- return shape
5862
+ return maybeDraggingOverShape
5840
5863
  }
5841
5864
  }
5842
5865
  }
@@ -6195,11 +6218,12 @@ export class Editor extends EventEmitter<TLEventMap> {
6195
6218
  */
6196
6219
  duplicateShapes(shapes: TLShapeId[] | TLShape[], offset?: VecLike): this {
6197
6220
  this.run(() => {
6198
- const ids =
6221
+ const _ids =
6199
6222
  typeof shapes[0] === 'string'
6200
6223
  ? (shapes as TLShapeId[])
6201
6224
  : (shapes as TLShape[]).map((s) => s.id)
6202
6225
 
6226
+ const ids = this._shouldIgnoreShapeLock ? _ids : this._getUnlockedShapeIds(_ids)
6203
6227
  if (ids.length <= 0) return this
6204
6228
 
6205
6229
  const initialIds = new Set(ids)
@@ -6279,10 +6303,7 @@ export class Editor extends EventEmitter<TLEventMap> {
6279
6303
  })
6280
6304
  const shapesToCreate = shapesToCreateWithOriginals.map(({ shape }) => shape)
6281
6305
 
6282
- const maxShapesReached =
6283
- shapesToCreate.length + this.getCurrentPageShapeIds().size > this.options.maxShapesPerPage
6284
-
6285
- if (maxShapesReached) {
6306
+ if (!this.canCreateShapes(shapesToCreate)) {
6286
6307
  alertMaxShapes(this)
6287
6308
  return
6288
6309
  }
@@ -7711,6 +7732,32 @@ export class Editor extends EventEmitter<TLEventMap> {
7711
7732
  return {}
7712
7733
  }
7713
7734
 
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
+
7714
7761
  /**
7715
7762
  * Create a single shape.
7716
7763
  *
@@ -7757,6 +7804,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7757
7804
  if (maxShapesReached) {
7758
7805
  // can't create more shapes than fit on the page
7759
7806
  alertMaxShapes(this)
7807
+ // todo: throw an error here? Otherwise we'll need to check every time whether the shapes were actually created
7760
7808
  return this
7761
7809
  }
7762
7810
 
@@ -7789,9 +7837,10 @@ export class Editor extends EventEmitter<TLEventMap> {
7789
7837
 
7790
7838
  for (let i = currentPageShapesSorted.length - 1; i >= 0; i--) {
7791
7839
  const parent = currentPageShapesSorted[i]
7840
+ const util = this.getShapeUtil(parent)
7792
7841
  if (
7842
+ util.canReceiveNewChildrenOfType(parent, partial.type) &&
7793
7843
  !this.isShapeHidden(parent) &&
7794
- this.getShapeUtil(parent).canReceiveNewChildrenOfType(parent, partial.type) &&
7795
7844
  this.isPointInShape(
7796
7845
  parent,
7797
7846
  // If no parent is provided, then we can treat the
@@ -7810,7 +7859,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7810
7859
 
7811
7860
  const prevParentId = partial.parentId
7812
7861
 
7813
- // a shape cannot be it's own parent. This was a rare issue with frames/groups in the syncFuzz tests.
7862
+ // a shape cannot be its own parent. This was a rare issue with frames/groups in the syncFuzz tests.
7814
7863
  if (parentId === partial.id) {
7815
7864
  parentId = focusedGroupId
7816
7865
  }
@@ -7920,6 +7969,8 @@ export class Editor extends EventEmitter<TLEventMap> {
7920
7969
  }
7921
7970
  })
7922
7971
 
7972
+ this.emit('created-shapes', shapeRecordsToCreate)
7973
+ this.emit('edit')
7923
7974
  this.store.put(shapeRecordsToCreate)
7924
7975
  })
7925
7976
 
@@ -8314,6 +8365,8 @@ export class Editor extends EventEmitter<TLEventMap> {
8314
8365
  updates.push(updated)
8315
8366
  }
8316
8367
 
8368
+ this.emit('edited-shapes', updates)
8369
+ this.emit('edit')
8317
8370
  this.store.put(updates)
8318
8371
  })
8319
8372
  }
@@ -8363,6 +8416,8 @@ export class Editor extends EventEmitter<TLEventMap> {
8363
8416
  })
8364
8417
  }
8365
8418
 
8419
+ this.emit('deleted-shapes', [...allShapeIdsToDelete])
8420
+ this.emit('edit')
8366
8421
  return this.run(() => this.store.remove([...allShapeIdsToDelete]))
8367
8422
  }
8368
8423
 
@@ -8811,6 +8866,7 @@ export class Editor extends EventEmitter<TLEventMap> {
8811
8866
  } = {
8812
8867
  text: null,
8813
8868
  files: null,
8869
+ 'file-replace': null,
8814
8870
  embed: null,
8815
8871
  'svg-text': null,
8816
8872
  url: null,
@@ -8860,6 +8916,15 @@ export class Editor extends EventEmitter<TLEventMap> {
8860
8916
  return this.externalContentHandlers[info.type]?.(info as any)
8861
8917
  }
8862
8918
 
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
+
8863
8928
  /**
8864
8929
  * Get content that can be exported for the given shape ids.
8865
8930
  *
@@ -9479,6 +9544,8 @@ export class Editor extends EventEmitter<TLEventMap> {
9479
9544
  previousPagePoint,
9480
9545
  currentScreenPoint,
9481
9546
  currentPagePoint,
9547
+ originScreenPoint,
9548
+ originPagePoint,
9482
9549
  } = this.inputs
9483
9550
 
9484
9551
  const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
@@ -9507,8 +9574,8 @@ export class Editor extends EventEmitter<TLEventMap> {
9507
9574
  // Reset velocity on pointer down, or when a pinch starts or ends
9508
9575
  if (info.name === 'pointer_down' || this.inputs.isPinching) {
9509
9576
  pointerVelocity.set(0, 0)
9510
- this.inputs.originScreenPoint.setTo(currentScreenPoint)
9511
- this.inputs.originPagePoint.setTo(currentPagePoint)
9577
+ originScreenPoint.setTo(currentScreenPoint)
9578
+ originPagePoint.setTo(currentPagePoint)
9512
9579
  }
9513
9580
 
9514
9581
  // todo: We only have to do this if there are multiple users in the document
@@ -241,7 +241,9 @@ export class HistoryManager<R extends UnknownRecord> {
241
241
  }
242
242
 
243
243
  bailToMark(id: string) {
244
- this._undo({ pushToRedoStack: false, toMark: id })
244
+ if (id) {
245
+ this._undo({ pushToRedoStack: false, toMark: id })
246
+ }
245
247
 
246
248
  return this
247
249
  }
@@ -99,7 +99,7 @@ describe('TextManager', () => {
99
99
  })
100
100
 
101
101
  it('should handle empty text', () => {
102
- const result = textManager.measureText('', defaultOpts)
102
+ const result = textManager.measureText('', { ...defaultOpts, measureScrollWidth: true })
103
103
  expect(result).toHaveProperty('x', 0)
104
104
  expect(result).toHaveProperty('y', 0)
105
105
  expect(result).toHaveProperty('w')
@@ -128,7 +128,6 @@ describe('TextManager', () => {
128
128
  y: 0,
129
129
  w: expect.any(Number),
130
130
  h: expect.any(Number),
131
- scrollWidth: expect.any(Number),
132
131
  })
133
132
  })
134
133
 
@@ -141,7 +140,6 @@ describe('TextManager', () => {
141
140
  y: 0,
142
141
  w: expect.any(Number),
143
142
  h: expect.any(Number),
144
- scrollWidth: expect.any(Number),
145
143
  })
146
144
  })
147
145
 
@@ -154,7 +152,6 @@ describe('TextManager', () => {
154
152
  y: 0,
155
153
  w: expect.any(Number),
156
154
  h: expect.any(Number),
157
- scrollWidth: expect.any(Number),
158
155
  })
159
156
  })
160
157
 
@@ -173,7 +170,6 @@ describe('TextManager', () => {
173
170
  y: 0,
174
171
  w: expect.any(Number),
175
172
  h: expect.any(Number),
176
- scrollWidth: expect.any(Number),
177
173
  })
178
174
  })
179
175
  })
@@ -20,6 +20,28 @@ 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
+
23
45
  /** @public */
24
46
  export interface TLMeasureTextSpanOpts {
25
47
  overflow: 'wrap' | 'truncate-ellipsis' | 'truncate-clip'
@@ -33,96 +55,99 @@ export interface TLMeasureTextSpanOpts {
33
55
  lineHeight: number
34
56
  textAlign: TLDefaultHorizontalAlignStyle
35
57
  otherStyles?: Record<string, string>
58
+ measureScrollWidth?: boolean
36
59
  }
37
60
 
38
61
  const spaceCharacterRegex = /\s/
39
62
 
40
63
  /** @public */
41
64
  export class TextManager {
42
- private baseElem: HTMLDivElement
65
+ private elm: HTMLDivElement
66
+ private defaultStyles: Record<string, string | null>
43
67
 
44
68
  constructor(public editor: Editor) {
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
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
49
88
  }
50
89
 
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
90
+ dispose() {
91
+ return this.elm.remove()
92
+ }
93
+
94
+ private resetElmStyles() {
95
+ const { elm, defaultStyles } = this
96
+ for (const key in defaultStyles) {
97
+ elm.style.setProperty(key, defaultStyles[key])
68
98
  }
69
- ): BoxModel & { scrollWidth: number } {
99
+ }
100
+
101
+ measureText(textToMeasure: string, opts: TLMeasureTextOpts): BoxModel & { scrollWidth: number } {
70
102
  const div = document.createElement('div')
71
103
  div.textContent = normalizeTextForDom(textToMeasure)
72
104
  return this.measureHtml(div.innerHTML, opts)
73
105
  }
74
106
 
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
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
+ }
93
117
  }
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
- )
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
+
117
143
  if (opts.otherStyles) {
118
144
  for (const [key, value] of Object.entries(opts.otherStyles)) {
119
- wrapperElm.style.setProperty(key, value)
145
+ elm.style.setProperty(key, value)
120
146
  }
121
147
  }
122
148
 
123
- const scrollWidth = wrapperElm.scrollWidth
124
- const rect = wrapperElm.getBoundingClientRect()
125
- wrapperElm.remove()
149
+ const scrollWidth = opts.measureScrollWidth ? elm.scrollWidth : 0
150
+ const rect = elm.getBoundingClientRect()
126
151
 
127
152
  return {
128
153
  x: 0,
@@ -247,27 +272,29 @@ export class TextManager {
247
272
  ): { text: string; box: BoxModel }[] {
248
273
  if (textToMeasure === '') return []
249
274
 
250
- const elm = this.baseElem.cloneNode() as HTMLDivElement
251
- this.editor.getContainer().appendChild(elm)
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())
252
293
 
253
294
  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')
258
295
  elm.style.setProperty('width', `${elementWidth}px`)
259
296
  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`)
264
297
  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
- }
271
298
 
272
299
  const shouldTruncateToFirstLine =
273
300
  opts.overflow === 'truncate-ellipsis' || opts.overflow === 'truncate-clip'
@@ -277,6 +304,12 @@ export class TextManager {
277
304
  elm.style.setProperty('word-break', 'break-all')
278
305
  }
279
306
 
307
+ if (opts.otherStyles) {
308
+ for (const [key, value] of Object.entries(opts.otherStyles)) {
309
+ elm.style.setProperty(key, value)
310
+ }
311
+ }
312
+
280
313
  const normalizedText = normalizeTextForDom(textToMeasure)
281
314
 
282
315
  // Render the text into the measurement element:
@@ -313,11 +346,10 @@ export class TextManager {
313
346
  h: lastSpan.box.h,
314
347
  },
315
348
  })
349
+
316
350
  return truncatedSpans
317
351
  }
318
352
 
319
- elm.remove()
320
-
321
353
  return spans
322
354
  }
323
355
  }