@tldraw/editor 3.14.0-canary.e0ab6f4c80f9 → 3.14.0-canary.e2a8e4a03aff

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 (235) hide show
  1. package/dist-cjs/index.d.ts +133 -107
  2. package/dist-cjs/index.js +8 -8
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/config/TLSessionStateSnapshot.js +1 -12
  5. package/dist-cjs/lib/config/TLSessionStateSnapshot.js.map +3 -3
  6. package/dist-cjs/lib/editor/Editor.js +82 -78
  7. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  8. package/dist-cjs/lib/editor/bindings/BindingUtil.js.map +2 -2
  9. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +22 -22
  10. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  11. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js +16 -20
  12. package/dist-cjs/lib/editor/derivations/notVisibleShapes.js.map +3 -3
  13. package/dist-cjs/lib/editor/derivations/parentsToChildren.js +16 -16
  14. package/dist-cjs/lib/editor/derivations/parentsToChildren.js.map +2 -2
  15. package/dist-cjs/lib/editor/managers/{ClickManager.js → ClickManager/ClickManager.js} +1 -1
  16. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +7 -0
  17. package/dist-cjs/lib/editor/managers/{EdgeScrollManager.js → EdgeScrollManager/EdgeScrollManager.js} +2 -2
  18. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +7 -0
  19. package/dist-cjs/lib/editor/managers/{FocusManager.js → FocusManager/FocusManager.js} +2 -0
  20. package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +7 -0
  21. package/dist-cjs/lib/editor/managers/{FontManager.js → FontManager/FontManager.js} +4 -1
  22. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +7 -0
  23. package/dist-cjs/lib/editor/managers/{HistoryManager.js → HistoryManager/HistoryManager.js} +64 -6
  24. package/dist-cjs/lib/editor/managers/HistoryManager/HistoryManager.js.map +7 -0
  25. package/dist-cjs/lib/editor/managers/{ScribbleManager.js → ScribbleManager/ScribbleManager.js} +1 -1
  26. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +7 -0
  27. package/dist-cjs/lib/editor/managers/{TextManager.js → TextManager/TextManager.js} +73 -42
  28. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +7 -0
  29. package/dist-cjs/lib/editor/managers/{TickManager.js → TickManager/TickManager.js} +1 -1
  30. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +7 -0
  31. package/dist-cjs/lib/editor/managers/{UserPreferencesManager.js → UserPreferencesManager/UserPreferencesManager.js} +1 -1
  32. package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +7 -0
  33. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +8 -0
  34. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  35. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +6 -0
  36. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
  37. package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
  38. package/dist-cjs/lib/editor/tools/StateNode.js +3 -3
  39. package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
  40. package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
  41. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  42. package/dist-cjs/lib/exports/getSvgJsx.js.map +1 -1
  43. package/dist-cjs/lib/hooks/useCanvasEvents.js +1 -2
  44. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  45. package/dist-cjs/lib/primitives/Box.js +33 -33
  46. package/dist-cjs/lib/primitives/Box.js.map +2 -2
  47. package/dist-cjs/lib/primitives/Vec.js +18 -13
  48. package/dist-cjs/lib/primitives/Vec.js.map +3 -3
  49. package/dist-cjs/lib/primitives/geometry/Arc2d.js +41 -21
  50. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  51. package/dist-cjs/lib/primitives/geometry/Circle2d.js +11 -11
  52. package/dist-cjs/lib/primitives/geometry/Circle2d.js.map +2 -2
  53. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +13 -16
  54. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  55. package/dist-cjs/lib/primitives/geometry/CubicSpline2d.js +4 -4
  56. package/dist-cjs/lib/primitives/geometry/CubicSpline2d.js.map +2 -2
  57. package/dist-cjs/lib/primitives/geometry/Edge2d.js +14 -21
  58. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  59. package/dist-cjs/lib/primitives/geometry/Ellipse2d.js +10 -10
  60. package/dist-cjs/lib/primitives/geometry/Ellipse2d.js.map +2 -2
  61. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +5 -0
  62. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +2 -2
  63. package/dist-cjs/lib/primitives/geometry/Point2d.js +6 -6
  64. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  65. package/dist-cjs/lib/primitives/geometry/Polygon2d.js +3 -0
  66. package/dist-cjs/lib/primitives/geometry/Polygon2d.js.map +2 -2
  67. package/dist-cjs/lib/primitives/geometry/Polyline2d.js +8 -5
  68. package/dist-cjs/lib/primitives/geometry/Polyline2d.js.map +2 -2
  69. package/dist-cjs/lib/primitives/geometry/Rectangle2d.js +22 -11
  70. package/dist-cjs/lib/primitives/geometry/Rectangle2d.js.map +2 -2
  71. package/dist-cjs/lib/primitives/geometry/Stadium2d.js +22 -22
  72. package/dist-cjs/lib/primitives/geometry/Stadium2d.js.map +2 -2
  73. package/dist-cjs/lib/utils/reorderShapes.js +11 -10
  74. package/dist-cjs/lib/utils/reorderShapes.js.map +2 -2
  75. package/dist-cjs/lib/utils/richText.js +7 -2
  76. package/dist-cjs/lib/utils/richText.js.map +2 -2
  77. package/dist-cjs/version.js +3 -3
  78. package/dist-cjs/version.js.map +1 -1
  79. package/dist-esm/index.d.mts +133 -107
  80. package/dist-esm/index.mjs +15 -9
  81. package/dist-esm/index.mjs.map +2 -2
  82. package/dist-esm/lib/config/TLSessionStateSnapshot.mjs +1 -1
  83. package/dist-esm/lib/config/TLSessionStateSnapshot.mjs.map +2 -2
  84. package/dist-esm/lib/editor/Editor.mjs +82 -78
  85. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  86. package/dist-esm/lib/editor/bindings/BindingUtil.mjs.map +2 -2
  87. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +22 -22
  88. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  89. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs +16 -20
  90. package/dist-esm/lib/editor/derivations/notVisibleShapes.mjs.map +3 -3
  91. package/dist-esm/lib/editor/derivations/parentsToChildren.mjs +16 -16
  92. package/dist-esm/lib/editor/derivations/parentsToChildren.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/{ClickManager.mjs → ClickManager/ClickManager.mjs} +1 -1
  94. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +7 -0
  95. package/dist-esm/lib/editor/managers/{EdgeScrollManager.mjs → EdgeScrollManager/EdgeScrollManager.mjs} +2 -2
  96. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +7 -0
  97. package/dist-esm/lib/editor/managers/{FocusManager.mjs → FocusManager/FocusManager.mjs} +2 -0
  98. package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +7 -0
  99. package/dist-esm/lib/editor/managers/{FontManager.mjs → FontManager/FontManager.mjs} +4 -1
  100. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +7 -0
  101. package/dist-esm/lib/editor/managers/{HistoryManager.mjs → HistoryManager/HistoryManager.mjs} +60 -2
  102. package/dist-esm/lib/editor/managers/HistoryManager/HistoryManager.mjs.map +7 -0
  103. package/dist-esm/lib/editor/managers/{ScribbleManager.mjs → ScribbleManager/ScribbleManager.mjs} +1 -1
  104. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +7 -0
  105. package/dist-esm/lib/editor/managers/{TextManager.mjs → TextManager/TextManager.mjs} +73 -42
  106. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +7 -0
  107. package/dist-esm/lib/editor/managers/{TickManager.mjs → TickManager/TickManager.mjs} +1 -1
  108. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +7 -0
  109. package/dist-esm/lib/editor/managers/{UserPreferencesManager.mjs → UserPreferencesManager/UserPreferencesManager.mjs} +1 -1
  110. package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +7 -0
  111. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +8 -0
  112. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  113. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +6 -0
  114. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
  115. package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
  116. package/dist-esm/lib/editor/tools/StateNode.mjs +3 -3
  117. package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
  118. package/dist-esm/lib/exports/getSvgJsx.mjs.map +1 -1
  119. package/dist-esm/lib/hooks/useCanvasEvents.mjs +1 -2
  120. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  121. package/dist-esm/lib/primitives/Box.mjs +33 -33
  122. package/dist-esm/lib/primitives/Box.mjs.map +2 -2
  123. package/dist-esm/lib/primitives/Vec.mjs +19 -14
  124. package/dist-esm/lib/primitives/Vec.mjs.map +3 -3
  125. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +41 -21
  126. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  127. package/dist-esm/lib/primitives/geometry/Circle2d.mjs +11 -11
  128. package/dist-esm/lib/primitives/geometry/Circle2d.mjs.map +2 -2
  129. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +13 -16
  130. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  131. package/dist-esm/lib/primitives/geometry/CubicSpline2d.mjs +4 -4
  132. package/dist-esm/lib/primitives/geometry/CubicSpline2d.mjs.map +2 -2
  133. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +14 -21
  134. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  135. package/dist-esm/lib/primitives/geometry/Ellipse2d.mjs +11 -11
  136. package/dist-esm/lib/primitives/geometry/Ellipse2d.mjs.map +2 -2
  137. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +7 -1
  138. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  139. package/dist-esm/lib/primitives/geometry/Point2d.mjs +6 -6
  140. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  141. package/dist-esm/lib/primitives/geometry/Polygon2d.mjs +3 -0
  142. package/dist-esm/lib/primitives/geometry/Polygon2d.mjs.map +2 -2
  143. package/dist-esm/lib/primitives/geometry/Polyline2d.mjs +8 -5
  144. package/dist-esm/lib/primitives/geometry/Polyline2d.mjs.map +2 -2
  145. package/dist-esm/lib/primitives/geometry/Rectangle2d.mjs +22 -11
  146. package/dist-esm/lib/primitives/geometry/Rectangle2d.mjs.map +2 -2
  147. package/dist-esm/lib/primitives/geometry/Stadium2d.mjs +22 -22
  148. package/dist-esm/lib/primitives/geometry/Stadium2d.mjs.map +2 -2
  149. package/dist-esm/lib/utils/reorderShapes.mjs +11 -10
  150. package/dist-esm/lib/utils/reorderShapes.mjs.map +2 -2
  151. package/dist-esm/lib/utils/richText.mjs +8 -3
  152. package/dist-esm/lib/utils/richText.mjs.map +2 -2
  153. package/dist-esm/version.mjs +3 -3
  154. package/dist-esm/version.mjs.map +1 -1
  155. package/editor.css +433 -482
  156. package/package.json +8 -9
  157. package/src/index.ts +19 -8
  158. package/src/lib/config/TLSessionStateSnapshot.ts +1 -1
  159. package/src/lib/editor/Editor.test.ts +252 -3
  160. package/src/lib/editor/Editor.ts +83 -76
  161. package/src/lib/editor/bindings/BindingUtil.ts +6 -0
  162. package/src/lib/editor/derivations/bindingsIndex.ts +27 -26
  163. package/src/lib/editor/derivations/notVisibleShapes.ts +24 -25
  164. package/src/lib/editor/derivations/parentsToChildren.ts +28 -25
  165. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +442 -0
  166. package/src/lib/editor/managers/{ClickManager.ts → ClickManager/ClickManager.ts} +3 -3
  167. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +374 -0
  168. package/src/lib/editor/managers/{EdgeScrollManager.ts → EdgeScrollManager/EdgeScrollManager.ts} +3 -3
  169. package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +455 -0
  170. package/src/lib/editor/managers/{FocusManager.ts → FocusManager/FocusManager.ts} +3 -1
  171. package/src/lib/editor/managers/FontManager/FontManager.test.ts +263 -0
  172. package/src/lib/editor/managers/{FontManager.ts → FontManager/FontManager.ts} +5 -2
  173. package/src/lib/editor/managers/{HistoryManager.test.ts → HistoryManager/HistoryManager.test.ts} +388 -1
  174. package/src/lib/editor/managers/{HistoryManager.ts → HistoryManager/HistoryManager.ts} +73 -2
  175. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.test.ts +624 -0
  176. package/src/lib/editor/managers/{ScribbleManager.ts → ScribbleManager/ScribbleManager.ts} +2 -2
  177. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +485 -0
  178. package/src/lib/editor/managers/TextManager/TextManager.test.ts +407 -0
  179. package/src/lib/editor/managers/{TextManager.ts → TextManager/TextManager.ts} +119 -87
  180. package/src/lib/editor/managers/TickManager/TickManager.test.ts +314 -0
  181. package/src/lib/editor/managers/{TickManager.ts → TickManager/TickManager.ts} +2 -2
  182. package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +591 -0
  183. package/src/lib/editor/managers/{UserPreferencesManager.ts → UserPreferencesManager/UserPreferencesManager.ts} +2 -2
  184. package/src/lib/editor/shapes/ShapeUtil.ts +11 -1
  185. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +8 -0
  186. package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +5 -2
  187. package/src/lib/editor/tools/StateNode.ts +3 -3
  188. package/src/lib/editor/types/emit-types.ts +4 -0
  189. package/src/lib/editor/types/external-content.ts +11 -2
  190. package/src/lib/exports/getSvgJsx.tsx +1 -1
  191. package/src/lib/hooks/useCanvasEvents.ts +0 -1
  192. package/src/lib/primitives/Box.test.ts +588 -7
  193. package/src/lib/primitives/Box.ts +33 -33
  194. package/src/lib/primitives/Vec.test.ts +2 -2
  195. package/src/lib/primitives/Vec.ts +15 -10
  196. package/src/lib/primitives/geometry/Arc2d.ts +42 -23
  197. package/src/lib/primitives/geometry/Circle2d.ts +12 -12
  198. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +5 -0
  199. package/src/lib/primitives/geometry/CubicBezier2d.ts +13 -17
  200. package/src/lib/primitives/geometry/CubicSpline2d.ts +5 -5
  201. package/src/lib/primitives/geometry/Edge2d.ts +14 -25
  202. package/src/lib/primitives/geometry/Ellipse2d.ts +12 -13
  203. package/src/lib/primitives/geometry/Geometry2d.ts +6 -0
  204. package/src/lib/primitives/geometry/Point2d.ts +6 -6
  205. package/src/lib/primitives/geometry/Polygon2d.ts +4 -0
  206. package/src/lib/primitives/geometry/Polyline2d.ts +10 -7
  207. package/src/lib/primitives/geometry/Rectangle2d.ts +24 -11
  208. package/src/lib/primitives/geometry/Stadium2d.ts +22 -23
  209. package/src/lib/utils/reorderShapes.ts +10 -13
  210. package/src/lib/utils/richText.ts +10 -4
  211. package/src/version.ts +3 -3
  212. package/dist-cjs/lib/editor/managers/ClickManager.js.map +0 -7
  213. package/dist-cjs/lib/editor/managers/EdgeScrollManager.js.map +0 -7
  214. package/dist-cjs/lib/editor/managers/FocusManager.js.map +0 -7
  215. package/dist-cjs/lib/editor/managers/FontManager.js.map +0 -7
  216. package/dist-cjs/lib/editor/managers/HistoryManager.js.map +0 -7
  217. package/dist-cjs/lib/editor/managers/ScribbleManager.js.map +0 -7
  218. package/dist-cjs/lib/editor/managers/Stack.js +0 -82
  219. package/dist-cjs/lib/editor/managers/Stack.js.map +0 -7
  220. package/dist-cjs/lib/editor/managers/TextManager.js.map +0 -7
  221. package/dist-cjs/lib/editor/managers/TickManager.js.map +0 -7
  222. package/dist-cjs/lib/editor/managers/UserPreferencesManager.js.map +0 -7
  223. package/dist-esm/lib/editor/managers/ClickManager.mjs.map +0 -7
  224. package/dist-esm/lib/editor/managers/EdgeScrollManager.mjs.map +0 -7
  225. package/dist-esm/lib/editor/managers/FocusManager.mjs.map +0 -7
  226. package/dist-esm/lib/editor/managers/FontManager.mjs.map +0 -7
  227. package/dist-esm/lib/editor/managers/HistoryManager.mjs.map +0 -7
  228. package/dist-esm/lib/editor/managers/ScribbleManager.mjs.map +0 -7
  229. package/dist-esm/lib/editor/managers/Stack.mjs +0 -62
  230. package/dist-esm/lib/editor/managers/Stack.mjs.map +0 -7
  231. package/dist-esm/lib/editor/managers/TextManager.mjs.map +0 -7
  232. package/dist-esm/lib/editor/managers/TickManager.mjs.map +0 -7
  233. package/dist-esm/lib/editor/managers/UserPreferencesManager.mjs.map +0 -7
  234. package/src/lib/editor/managers/ScribbleManager.test.ts +0 -32
  235. package/src/lib/editor/managers/Stack.ts +0 -71
@@ -0,0 +1,407 @@
1
+ import { Editor } from '../../Editor'
2
+ import { TextManager, TLMeasureTextSpanOpts } from './TextManager'
3
+
4
+ // Create a simple mock DOM environment
5
+ const mockElement = {
6
+ classList: { add: jest.fn() },
7
+ tabIndex: -1,
8
+ cloneNode: jest.fn(),
9
+ innerHTML: '',
10
+ textContent: '',
11
+ setAttribute: jest.fn(),
12
+ style: { setProperty: jest.fn() },
13
+ scrollWidth: 100,
14
+ getBoundingClientRect: jest.fn(() => ({
15
+ width: 100,
16
+ height: 20,
17
+ left: 0,
18
+ top: 0,
19
+ right: 100,
20
+ bottom: 20,
21
+ })),
22
+ remove: jest.fn(),
23
+ insertAdjacentElement: jest.fn(),
24
+ childNodes: [],
25
+ }
26
+
27
+ // Mock document.createElement to return our mock element
28
+ const mockCreateElement = jest.fn(() => {
29
+ const element = { ...mockElement }
30
+ element.cloneNode = jest.fn(() => ({ ...element }))
31
+ return element
32
+ })
33
+
34
+ // Mock editor
35
+ const mockEditor = {
36
+ getContainer: jest.fn(() => ({
37
+ appendChild: jest.fn(),
38
+ })),
39
+ } as unknown as Editor
40
+
41
+ // Setup global mocks
42
+ global.document = {
43
+ createElement: mockCreateElement,
44
+ } as any
45
+
46
+ global.Range = jest.fn(() => ({
47
+ setStart: jest.fn(),
48
+ setEnd: jest.fn(),
49
+ getClientRects: jest.fn(() => [
50
+ {
51
+ width: 10,
52
+ height: 16,
53
+ left: 0,
54
+ top: 0,
55
+ right: 10,
56
+ bottom: 16,
57
+ },
58
+ ]),
59
+ })) as any
60
+
61
+ describe('TextManager', () => {
62
+ let textManager: TextManager
63
+
64
+ beforeEach(() => {
65
+ jest.clearAllMocks()
66
+ textManager = new TextManager(mockEditor)
67
+ })
68
+
69
+ describe('constructor', () => {
70
+ it('should create a TextManager instance', () => {
71
+ expect(textManager).toBeInstanceOf(TextManager)
72
+ expect(textManager.editor).toBe(mockEditor)
73
+ })
74
+ })
75
+
76
+ describe('measureText', () => {
77
+ const defaultOpts = {
78
+ fontStyle: 'normal',
79
+ fontWeight: '400',
80
+ fontFamily: 'Arial',
81
+ fontSize: 16,
82
+ lineHeight: 1.2,
83
+ maxWidth: 200,
84
+ minWidth: null,
85
+ padding: '0px',
86
+ }
87
+
88
+ it('should call measureHtml with normalized text', () => {
89
+ const spy = jest.spyOn(textManager, 'measureHtml')
90
+ textManager.measureText('Hello World', defaultOpts)
91
+ expect(spy).toHaveBeenCalledWith('Hello World', defaultOpts)
92
+ })
93
+
94
+ it('should normalize line breaks', () => {
95
+ const spy = jest.spyOn(textManager, 'measureHtml')
96
+ textManager.measureText('Hello\nWorld\r\nTest', defaultOpts)
97
+ // The text should be normalized to use consistent line breaks
98
+ expect(spy).toHaveBeenCalled()
99
+ })
100
+
101
+ it('should handle empty text', () => {
102
+ const result = textManager.measureText('', { ...defaultOpts, measureScrollWidth: true })
103
+ expect(result).toHaveProperty('x', 0)
104
+ expect(result).toHaveProperty('y', 0)
105
+ expect(result).toHaveProperty('w')
106
+ expect(result).toHaveProperty('h')
107
+ expect(result).toHaveProperty('scrollWidth')
108
+ })
109
+ })
110
+
111
+ describe('measureHtml', () => {
112
+ const defaultOpts = {
113
+ fontStyle: 'normal',
114
+ fontWeight: '400',
115
+ fontFamily: 'Arial',
116
+ fontSize: 16,
117
+ lineHeight: 1.2,
118
+ maxWidth: 200,
119
+ minWidth: null,
120
+ padding: '0px',
121
+ }
122
+
123
+ it('should return measurement object with correct structure', () => {
124
+ const result = textManager.measureHtml('<span>Test</span>', defaultOpts)
125
+
126
+ expect(result).toMatchObject({
127
+ x: 0,
128
+ y: 0,
129
+ w: expect.any(Number),
130
+ h: expect.any(Number),
131
+ })
132
+ })
133
+
134
+ it('should handle null maxWidth', () => {
135
+ const opts = { ...defaultOpts, maxWidth: null }
136
+ const result = textManager.measureHtml('Test', opts)
137
+
138
+ expect(result).toMatchObject({
139
+ x: 0,
140
+ y: 0,
141
+ w: expect.any(Number),
142
+ h: expect.any(Number),
143
+ })
144
+ })
145
+
146
+ it('should handle overflow wrap breaking', () => {
147
+ const opts = { ...defaultOpts, disableOverflowWrapBreaking: true }
148
+ const result = textManager.measureHtml('Test', opts)
149
+
150
+ expect(result).toMatchObject({
151
+ x: 0,
152
+ y: 0,
153
+ w: expect.any(Number),
154
+ h: expect.any(Number),
155
+ })
156
+ })
157
+
158
+ it('should handle other styles', () => {
159
+ const opts = {
160
+ ...defaultOpts,
161
+ otherStyles: {
162
+ 'text-decoration': 'underline',
163
+ color: 'red',
164
+ },
165
+ }
166
+
167
+ const result = textManager.measureHtml('Test', opts)
168
+ expect(result).toMatchObject({
169
+ x: 0,
170
+ y: 0,
171
+ w: expect.any(Number),
172
+ h: expect.any(Number),
173
+ })
174
+ })
175
+ })
176
+
177
+ describe('measureElementTextNodeSpans', () => {
178
+ it('should handle elements with text nodes', () => {
179
+ const mockTextNode = {
180
+ nodeType: 3, // TEXT_NODE
181
+ textContent: 'Hello',
182
+ }
183
+
184
+ const mockElementWithText = {
185
+ childNodes: [mockTextNode],
186
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
187
+ }
188
+
189
+ const result = textManager.measureElementTextNodeSpans(mockElementWithText as any)
190
+
191
+ expect(result).toHaveProperty('spans')
192
+ expect(result).toHaveProperty('didTruncate')
193
+ expect(Array.isArray(result.spans)).toBe(true)
194
+ expect(typeof result.didTruncate).toBe('boolean')
195
+ })
196
+
197
+ it('should handle empty elements', () => {
198
+ const mockEmptyElement = {
199
+ childNodes: [],
200
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
201
+ }
202
+
203
+ const result = textManager.measureElementTextNodeSpans(mockEmptyElement as any)
204
+
205
+ expect(result.didTruncate).toBe(false)
206
+ expect(result.spans).toHaveLength(0)
207
+ })
208
+
209
+ it('should handle truncation option', () => {
210
+ const mockTextNode = {
211
+ nodeType: 3, // TEXT_NODE
212
+ textContent: 'Hello World',
213
+ }
214
+
215
+ const mockElementWithText = {
216
+ childNodes: [mockTextNode],
217
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
218
+ }
219
+
220
+ const result = textManager.measureElementTextNodeSpans(mockElementWithText as any, {
221
+ shouldTruncateToFirstLine: true,
222
+ })
223
+
224
+ expect(result).toHaveProperty('spans')
225
+ expect(result).toHaveProperty('didTruncate')
226
+ })
227
+ })
228
+
229
+ describe('measureTextSpans', () => {
230
+ const defaultOpts: TLMeasureTextSpanOpts = {
231
+ overflow: 'wrap',
232
+ width: 200,
233
+ height: 100,
234
+ padding: 10,
235
+ fontSize: 16,
236
+ fontWeight: '400',
237
+ fontFamily: 'Arial',
238
+ fontStyle: 'normal',
239
+ lineHeight: 1.2,
240
+ textAlign: 'start',
241
+ }
242
+
243
+ it('should return empty array for empty text', () => {
244
+ const result = textManager.measureTextSpans('', defaultOpts)
245
+ expect(result).toEqual([])
246
+ })
247
+
248
+ it('should return array of text spans for non-empty text', () => {
249
+ // Mock measureElementTextNodeSpans to return some spans
250
+ jest.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
251
+ spans: [
252
+ {
253
+ text: 'Hello World',
254
+ box: { x: 0, y: 0, w: 100, h: 16 },
255
+ },
256
+ ],
257
+ didTruncate: false,
258
+ })
259
+
260
+ const result = textManager.measureTextSpans('Hello World', defaultOpts)
261
+
262
+ expect(Array.isArray(result)).toBe(true)
263
+ expect(result.length).toBeGreaterThan(0)
264
+ expect(result[0]).toHaveProperty('text')
265
+ expect(result[0]).toHaveProperty('box')
266
+ })
267
+
268
+ it('should handle wrap overflow', () => {
269
+ jest.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
270
+ spans: [
271
+ {
272
+ text: 'Hello World',
273
+ box: { x: 0, y: 0, w: 100, h: 16 },
274
+ },
275
+ ],
276
+ didTruncate: false,
277
+ })
278
+
279
+ const opts = { ...defaultOpts, overflow: 'wrap' as const }
280
+ const result = textManager.measureTextSpans('Hello World', opts)
281
+
282
+ expect(Array.isArray(result)).toBe(true)
283
+ })
284
+
285
+ it('should handle truncate-ellipsis overflow', () => {
286
+ // Mock the calls for ellipsis handling
287
+ jest
288
+ .spyOn(textManager, 'measureElementTextNodeSpans')
289
+ .mockReturnValueOnce({
290
+ spans: [
291
+ {
292
+ text: 'Hello Wo',
293
+ box: { x: 0, y: 0, w: 80, h: 16 },
294
+ },
295
+ ],
296
+ didTruncate: true,
297
+ })
298
+ .mockReturnValueOnce({
299
+ spans: [
300
+ {
301
+ text: '…',
302
+ box: { x: 0, y: 0, w: 10, h: 16 },
303
+ },
304
+ ],
305
+ didTruncate: false,
306
+ })
307
+ .mockReturnValueOnce({
308
+ spans: [
309
+ {
310
+ text: 'Hello W',
311
+ box: { x: 0, y: 0, w: 70, h: 16 },
312
+ },
313
+ ],
314
+ didTruncate: false,
315
+ })
316
+
317
+ const opts = { ...defaultOpts, overflow: 'truncate-ellipsis' as const }
318
+ const result = textManager.measureTextSpans('Hello World', opts)
319
+
320
+ expect(Array.isArray(result)).toBe(true)
321
+ })
322
+
323
+ it('should handle truncate-clip overflow', () => {
324
+ jest.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
325
+ spans: [
326
+ {
327
+ text: 'Hello Wo',
328
+ box: { x: 0, y: 0, w: 80, h: 16 },
329
+ },
330
+ ],
331
+ didTruncate: true,
332
+ })
333
+
334
+ const opts = { ...defaultOpts, overflow: 'truncate-clip' as const }
335
+ const result = textManager.measureTextSpans('Hello World', opts)
336
+
337
+ expect(Array.isArray(result)).toBe(true)
338
+ })
339
+
340
+ it('should handle different text alignments', () => {
341
+ jest.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
342
+ spans: [
343
+ {
344
+ text: 'Test',
345
+ box: { x: 0, y: 0, w: 40, h: 16 },
346
+ },
347
+ ],
348
+ didTruncate: false,
349
+ })
350
+
351
+ const alignments: Array<TLMeasureTextSpanOpts['textAlign']> = ['start', 'middle', 'end']
352
+
353
+ alignments.forEach((textAlign) => {
354
+ const opts = { ...defaultOpts, textAlign }
355
+ const result = textManager.measureTextSpans('Test', opts)
356
+ expect(Array.isArray(result)).toBe(true)
357
+ })
358
+ })
359
+
360
+ it('should handle custom font properties', () => {
361
+ jest.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
362
+ spans: [
363
+ {
364
+ text: 'Test',
365
+ box: { x: 0, y: 0, w: 40, h: 16 },
366
+ },
367
+ ],
368
+ didTruncate: false,
369
+ })
370
+
371
+ const opts = {
372
+ ...defaultOpts,
373
+ fontSize: 18,
374
+ fontFamily: 'Times',
375
+ fontWeight: 'bold',
376
+ fontStyle: 'italic',
377
+ lineHeight: 1.5,
378
+ }
379
+
380
+ const result = textManager.measureTextSpans('Test', opts)
381
+ expect(Array.isArray(result)).toBe(true)
382
+ })
383
+
384
+ it('should handle other styles', () => {
385
+ jest.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
386
+ spans: [
387
+ {
388
+ text: 'Test',
389
+ box: { x: 0, y: 0, w: 40, h: 16 },
390
+ },
391
+ ],
392
+ didTruncate: false,
393
+ })
394
+
395
+ const opts = {
396
+ ...defaultOpts,
397
+ otherStyles: {
398
+ 'text-shadow': '1px 1px 1px black',
399
+ 'letter-spacing': '1px',
400
+ },
401
+ }
402
+
403
+ const result = textManager.measureTextSpans('Test', opts)
404
+ expect(Array.isArray(result)).toBe(true)
405
+ })
406
+ })
407
+ })
@@ -1,5 +1,5 @@
1
1
  import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
2
- import { Editor } from '../Editor'
2
+ import { Editor } from '../../Editor'
3
3
 
4
4
  const fixNewLines = /\r?\n|\r/g
5
5
 
@@ -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
  }