@tldraw/editor 3.15.0-canary.22a03ce9c171 → 3.15.0-canary.2fa0050cd4a6

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 (32) hide show
  1. package/dist-cjs/index.d.ts +5 -4
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/editor/Editor.js +20 -2
  4. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  5. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +96 -101
  6. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  7. package/dist-cjs/lib/primitives/intersect.js +4 -4
  8. package/dist-cjs/lib/primitives/intersect.js.map +2 -2
  9. package/dist-cjs/lib/primitives/utils.js +4 -0
  10. package/dist-cjs/lib/primitives/utils.js.map +2 -2
  11. package/dist-cjs/version.js +3 -3
  12. package/dist-cjs/version.js.map +1 -1
  13. package/dist-esm/index.d.mts +5 -4
  14. package/dist-esm/index.mjs +1 -1
  15. package/dist-esm/lib/editor/Editor.mjs +20 -2
  16. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  17. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +96 -101
  18. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  19. package/dist-esm/lib/primitives/intersect.mjs +5 -5
  20. package/dist-esm/lib/primitives/intersect.mjs.map +2 -2
  21. package/dist-esm/lib/primitives/utils.mjs +4 -0
  22. package/dist-esm/lib/primitives/utils.mjs.map +2 -2
  23. package/dist-esm/version.mjs +3 -3
  24. package/dist-esm/version.mjs.map +1 -1
  25. package/package.json +7 -7
  26. package/src/lib/editor/Editor.test.ts +407 -0
  27. package/src/lib/editor/Editor.ts +29 -4
  28. package/src/lib/editor/managers/TextManager/TextManager.ts +108 -128
  29. package/src/lib/primitives/intersect.test.ts +57 -11
  30. package/src/lib/primitives/intersect.ts +12 -5
  31. package/src/lib/primitives/utils.ts +11 -0
  32. package/src/version.ts +3 -3
@@ -1,4 +1,5 @@
1
1
  import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
2
+ import { objectMapKeys } from '@tldraw/utils'
2
3
  import { Editor } from '../../Editor'
3
4
 
4
5
  const fixNewLines = /\r?\n|\r/g
@@ -60,10 +61,18 @@ export interface TLMeasureTextSpanOpts {
60
61
 
61
62
  const spaceCharacterRegex = /\s/
62
63
 
64
+ const initialDefaultStyles = Object.freeze({
65
+ 'overflow-wrap': 'break-word',
66
+ 'word-break': 'auto',
67
+ width: null,
68
+ height: null,
69
+ 'max-width': null,
70
+ 'min-width': null,
71
+ })
72
+
63
73
  /** @public */
64
74
  export class TextManager {
65
75
  private elm: HTMLDivElement
66
- private defaultStyles: Record<string, string | null>
67
76
 
68
77
  constructor(public editor: Editor) {
69
78
  const elm = document.createElement('div')
@@ -73,31 +82,34 @@ export class TextManager {
73
82
  elm.tabIndex = -1
74
83
  this.editor.getContainer().appendChild(elm)
75
84
 
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
+ this.elm = elm
86
+
87
+ for (const key of objectMapKeys(initialDefaultStyles)) {
88
+ elm.style.setProperty(key, initialDefaultStyles[key])
85
89
  }
90
+ }
86
91
 
87
- this.elm = elm
92
+ private setElementStyles(styles: Record<string, string | undefined>) {
93
+ const stylesToReinstate = {} as any
94
+ for (const key of objectMapKeys(styles)) {
95
+ if (typeof styles[key] === 'string') {
96
+ const oldValue = this.elm.style.getPropertyValue(key)
97
+ if (oldValue === styles[key]) continue
98
+ stylesToReinstate[key] = oldValue
99
+ this.elm.style.setProperty(key, styles[key])
100
+ }
101
+ }
102
+ return () => {
103
+ for (const key of objectMapKeys(stylesToReinstate)) {
104
+ this.elm.style.setProperty(key, stylesToReinstate[key])
105
+ }
106
+ }
88
107
  }
89
108
 
90
109
  dispose() {
91
110
  return this.elm.remove()
92
111
  }
93
112
 
94
- private resetElmStyles() {
95
- const { elm, defaultStyles } = this
96
- for (const key in defaultStyles) {
97
- elm.style.setProperty(key, defaultStyles[key])
98
- }
99
- }
100
-
101
113
  measureText(textToMeasure: string, opts: TLMeasureTextOpts): BoxModel & { scrollWidth: number } {
102
114
  const div = document.createElement('div')
103
115
  div.textContent = normalizeTextForDom(textToMeasure)
@@ -107,54 +119,36 @@ export class TextManager {
107
119
  measureHtml(html: string, opts: TLMeasureTextOpts): BoxModel & { scrollWidth: number } {
108
120
  const { elm } = this
109
121
 
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
- }
122
+ const newStyles = {
123
+ 'font-family': opts.fontFamily,
124
+ 'font-style': opts.fontStyle,
125
+ 'font-weight': opts.fontWeight,
126
+ 'font-size': opts.fontSize + 'px',
127
+ 'line-height': opts.lineHeight.toString(),
128
+ padding: opts.padding,
129
+ 'max-width': opts.maxWidth ? opts.maxWidth + 'px' : undefined,
130
+ 'min-width': opts.minWidth ? opts.minWidth + 'px' : undefined,
131
+ 'overflow-wrap': opts.disableOverflowWrapBreaking ? 'normal' : undefined,
132
+ ...opts.otherStyles,
117
133
  }
118
134
 
119
- elm.innerHTML = html
135
+ const restoreStyles = this.setElementStyles(newStyles)
120
136
 
121
- // Apply the default styles to the element (for all styles here or that were ever seen in opts.otherStyles)
122
- this.resetElmStyles()
137
+ try {
138
+ elm.innerHTML = html
123
139
 
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)
140
+ const scrollWidth = opts.measureScrollWidth ? elm.scrollWidth : 0
141
+ const rect = elm.getBoundingClientRect()
130
142
 
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
-
143
- if (opts.otherStyles) {
144
- for (const [key, value] of Object.entries(opts.otherStyles)) {
145
- elm.style.setProperty(key, value)
143
+ return {
144
+ x: 0,
145
+ y: 0,
146
+ w: rect.width,
147
+ h: rect.height,
148
+ scrollWidth,
146
149
  }
147
- }
148
-
149
- const scrollWidth = opts.measureScrollWidth ? elm.scrollWidth : 0
150
- const rect = elm.getBoundingClientRect()
151
-
152
- return {
153
- x: 0,
154
- y: 0,
155
- w: rect.width,
156
- h: rect.height,
157
- scrollWidth,
150
+ } finally {
151
+ restoreStyles()
158
152
  }
159
153
  }
160
154
 
@@ -274,82 +268,68 @@ export class TextManager {
274
268
 
275
269
  const { elm } = this
276
270
 
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())
293
-
294
- const elementWidth = Math.ceil(opts.width - opts.padding * 2)
295
- elm.style.setProperty('width', `${elementWidth}px`)
296
- elm.style.setProperty('height', 'min-content')
297
- elm.style.setProperty('text-align', textAlignmentsForLtr[opts.textAlign])
298
-
299
271
  const shouldTruncateToFirstLine =
300
272
  opts.overflow === 'truncate-ellipsis' || opts.overflow === 'truncate-clip'
301
-
302
- if (shouldTruncateToFirstLine) {
303
- elm.style.setProperty('overflow-wrap', 'anywhere')
304
- elm.style.setProperty('word-break', 'break-all')
305
- }
306
-
307
- if (opts.otherStyles) {
308
- for (const [key, value] of Object.entries(opts.otherStyles)) {
309
- elm.style.setProperty(key, value)
310
- }
273
+ const elementWidth = Math.ceil(opts.width - opts.padding * 2)
274
+ const newStyles = {
275
+ 'font-family': opts.fontFamily,
276
+ 'font-style': opts.fontStyle,
277
+ 'font-weight': opts.fontWeight,
278
+ 'font-size': opts.fontSize + 'px',
279
+ 'line-height': opts.lineHeight.toString(),
280
+ width: `${elementWidth}px`,
281
+ height: 'min-content',
282
+ 'text-align': textAlignmentsForLtr[opts.textAlign],
283
+ 'overflow-wrap': shouldTruncateToFirstLine ? 'anywhere' : undefined,
284
+ 'word-break': shouldTruncateToFirstLine ? 'break-all' : undefined,
285
+ ...opts.otherStyles,
311
286
  }
287
+ const restoreStyles = this.setElementStyles(newStyles)
312
288
 
313
- const normalizedText = normalizeTextForDom(textToMeasure)
314
-
315
- // Render the text into the measurement element:
316
- elm.textContent = normalizedText
317
-
318
- // actually measure the text:
319
- const { spans, didTruncate } = this.measureElementTextNodeSpans(elm, {
320
- shouldTruncateToFirstLine,
321
- })
289
+ try {
290
+ const normalizedText = normalizeTextForDom(textToMeasure)
322
291
 
323
- if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
324
- // we need to measure the ellipsis to know how much space it takes up
325
- elm.textContent = '…'
326
- const ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(elm).spans[0].box.w)
327
-
328
- // then, we need to subtract that space from the width we have and measure again:
329
- elm.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
292
+ // Render the text into the measurement element:
330
293
  elm.textContent = normalizedText
331
- const truncatedSpans = this.measureElementTextNodeSpans(elm, {
332
- shouldTruncateToFirstLine: true,
333
- }).spans
334
-
335
- // Finally, we add in our ellipsis at the end of the last span. We
336
- // have to do this after measuring, not before, because adding the
337
- // ellipsis changes how whitespace might be getting collapsed by the
338
- // browser.
339
- const lastSpan = truncatedSpans[truncatedSpans.length - 1]!
340
- truncatedSpans.push({
341
- text: '…',
342
- box: {
343
- x: Math.min(lastSpan.box.x + lastSpan.box.w, opts.width - opts.padding - ellipsisWidth),
344
- y: lastSpan.box.y,
345
- w: ellipsisWidth,
346
- h: lastSpan.box.h,
347
- },
294
+
295
+ // actually measure the text:
296
+ const { spans, didTruncate } = this.measureElementTextNodeSpans(elm, {
297
+ shouldTruncateToFirstLine,
348
298
  })
349
299
 
350
- return truncatedSpans
351
- }
300
+ if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
301
+ // we need to measure the ellipsis to know how much space it takes up
302
+ elm.textContent = '…'
303
+ const ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(elm).spans[0].box.w)
304
+
305
+ // then, we need to subtract that space from the width we have and measure again:
306
+ elm.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
307
+ elm.textContent = normalizedText
308
+ const truncatedSpans = this.measureElementTextNodeSpans(elm, {
309
+ shouldTruncateToFirstLine: true,
310
+ }).spans
311
+
312
+ // Finally, we add in our ellipsis at the end of the last span. We
313
+ // have to do this after measuring, not before, because adding the
314
+ // ellipsis changes how whitespace might be getting collapsed by the
315
+ // browser.
316
+ const lastSpan = truncatedSpans[truncatedSpans.length - 1]!
317
+ truncatedSpans.push({
318
+ text: '…',
319
+ box: {
320
+ x: Math.min(lastSpan.box.x + lastSpan.box.w, opts.width - opts.padding - ellipsisWidth),
321
+ y: lastSpan.box.y,
322
+ w: ellipsisWidth,
323
+ h: lastSpan.box.h,
324
+ },
325
+ })
326
+
327
+ return truncatedSpans
328
+ }
352
329
 
353
- return spans
330
+ return spans
331
+ } finally {
332
+ restoreStyles()
333
+ }
354
334
  }
355
335
  }
@@ -181,6 +181,17 @@ describe('intersectLineSegmentLineSegment', () => {
181
181
  expect(result!.x).toBeCloseTo(5, 1)
182
182
  expect(result!.y).toBeCloseTo(5, 1)
183
183
  })
184
+
185
+ it('should find intersection when segments cross at endpoints (floating point error case)', () => {
186
+ const result = intersectLineSegmentLineSegment(
187
+ { x: 100, y: 100 },
188
+ { x: 20, y: 20 },
189
+ { x: 36.141160159025375, y: 31.811740238538057 },
190
+ { x: 34.14213562373095, y: 34.14213562373095 }
191
+ )
192
+
193
+ expect(result).not.toBeNull()
194
+ })
184
195
  })
185
196
 
186
197
  describe('edge cases', () => {
@@ -195,17 +206,6 @@ describe('intersectLineSegmentLineSegment', () => {
195
206
  expect(result).not.toBeNull()
196
207
  })
197
208
 
198
- it('should handle segments with very small coordinates', () => {
199
- const a1 = new Vec(1e-10, 1e-10)
200
- const a2 = new Vec(1e-9, 1e-9)
201
- const b1 = new Vec(1e-10, 1e-9)
202
- const b2 = new Vec(1e-9, 1e-10)
203
-
204
- const result = intersectLineSegmentLineSegment(a1, a2, b1, b2)
205
-
206
- expect(result).not.toBeNull()
207
- })
208
-
209
209
  it('should handle segments with very large coordinates', () => {
210
210
  const a1 = new Vec(1e10, 1e10)
211
211
  const a2 = new Vec(1e11, 1e11)
@@ -531,6 +531,52 @@ describe('intersectLineSegmentPolyline', () => {
531
531
  expect(sorted[3].x).toBeCloseTo(18, 5)
532
532
  sorted.forEach((pt) => expect(pt.y).toBeCloseTo(5, 5))
533
533
  })
534
+
535
+ // Test cases for vertex intersection issues
536
+ describe('vertex intersection edge cases', () => {
537
+ it('should detect intersection when line segment passes through polyline vertex', () => {
538
+ const a1 = new Vec(0, 5)
539
+ const a2 = new Vec(10, 5)
540
+ const points = [new Vec(5, 0), new Vec(5, 10)] // vertical line at x=5
541
+ const result = intersectLineSegmentPolyline(a1, a2, points)
542
+ expect(result).not.toBeNull()
543
+ expect(result!.length).toBe(1)
544
+ expect(result![0].x).toBeCloseTo(5, 5)
545
+ expect(result![0].y).toBeCloseTo(5, 5)
546
+ })
547
+
548
+ it('should detect intersection when line segment passes through polyline vertex at angle', () => {
549
+ const a1 = new Vec(0, 0)
550
+ const a2 = new Vec(10, 10)
551
+ const points = [new Vec(5, 0), new Vec(5, 10)] // vertical line at x=5
552
+ const result = intersectLineSegmentPolyline(a1, a2, points)
553
+ expect(result).not.toBeNull()
554
+ expect(result!.length).toBe(1)
555
+ expect(result![0].x).toBeCloseTo(5, 5)
556
+ expect(result![0].y).toBeCloseTo(5, 5)
557
+ })
558
+
559
+ it('should detect intersection when line segment passes through polyline vertex at middle', () => {
560
+ const a1 = new Vec(0, 5)
561
+ const a2 = new Vec(10, 5)
562
+ const points = [new Vec(0, 0), new Vec(5, 5), new Vec(10, 0)] // vertex at (5,5)
563
+ const result = intersectLineSegmentPolyline(a1, a2, points)
564
+ expect(result).not.toBeNull()
565
+ expect(result!.length).toBe(1)
566
+ expect(result![0].x).toBeCloseTo(5, 5)
567
+ expect(result![0].y).toBeCloseTo(5, 5)
568
+ })
569
+
570
+ it('should detect intersection when line segment passes through a polyline vertext (floating point error case)', () => {
571
+ const result = intersectLineSegmentPolyline({ x: 100, y: 100 }, { x: 20, y: 20 }, [
572
+ { x: 36.141160159025375, y: 31.811740238538057 },
573
+ { x: 34.14213562373095, y: 34.14213562373095 },
574
+ { x: 31.811740238538057, y: 36.141160159025375 },
575
+ ])
576
+
577
+ expect(result).not.toBeNull()
578
+ })
579
+ })
534
580
  })
535
581
 
536
582
  describe('intersectLineSegmentPolygon', () => {
@@ -1,5 +1,5 @@
1
1
  import { Box } from './Box'
2
- import { pointInPolygon } from './utils'
2
+ import { approximately, approximatelyLte, pointInPolygon } from './utils'
3
3
  import { Vec, VecLike } from './Vec'
4
4
 
5
5
  // need even more intersections? See https://gist.github.com/steveruizok/35c02d526c707003a5c79761bfb89a52
@@ -17,7 +17,8 @@ export function intersectLineSegmentLineSegment(
17
17
  a1: VecLike,
18
18
  a2: VecLike,
19
19
  b1: VecLike,
20
- b2: VecLike
20
+ b2: VecLike,
21
+ precision = 1e-10
21
22
  ) {
22
23
  const ABx = a1.x - b1.x
23
24
  const ABy = a1.y - b1.y
@@ -29,14 +30,19 @@ export function intersectLineSegmentLineSegment(
29
30
  const ub_t = AVx * ABy - AVy * ABx
30
31
  const u_b = BVy * AVx - BVx * AVy
31
32
 
32
- if (ua_t === 0 || ub_t === 0) return null // coincident
33
+ if (approximately(ua_t, 0, precision) || approximately(ub_t, 0, precision)) return null // coincident
33
34
 
34
- if (u_b === 0) return null // parallel
35
+ if (approximately(u_b, 0, precision)) return null // parallel
35
36
 
36
37
  if (u_b !== 0) {
37
38
  const ua = ua_t / u_b
38
39
  const ub = ub_t / u_b
39
- if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
40
+ if (
41
+ approximatelyLte(0, ua, precision) &&
42
+ approximatelyLte(ua, 1, precision) &&
43
+ approximatelyLte(0, ub, precision) &&
44
+ approximatelyLte(ub, 1, precision)
45
+ ) {
40
46
  return Vec.AddXY(a1, ua * AVx, ua * AVy)
41
47
  }
42
48
  }
@@ -125,6 +131,7 @@ export function intersectLineSegmentPolygon(a1: VecLike, a2: VecLike, points: Ve
125
131
  points[i - 1],
126
132
  points[i % points.length]
127
133
  )
134
+
128
135
  if (segmentIntersection) result.push(segmentIntersection)
129
136
  }
130
137
 
@@ -77,6 +77,17 @@ export function approximately(a: number, b: number, precision = 0.000001) {
77
77
  return Math.abs(a - b) <= precision
78
78
  }
79
79
 
80
+ /**
81
+ * Whether a number is approximately less than or equal to another number.
82
+ *
83
+ * @param a - The first number.
84
+ * @param b - The second number.
85
+ * @public
86
+ */
87
+ export function approximatelyLte(a: number, b: number, precision = 0.000001) {
88
+ return a < b || approximately(a, b, precision)
89
+ }
90
+
80
91
  /**
81
92
  * Find the approximate perimeter of an ellipse.
82
93
  *
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '3.15.0-canary.22a03ce9c171'
4
+ export const version = '3.15.0-canary.2fa0050cd4a6'
5
5
  export const publishDates = {
6
6
  major: '2024-09-13T14:36:29.063Z',
7
- minor: '2025-07-09T11:27:52.513Z',
8
- patch: '2025-07-09T11:27:52.513Z',
7
+ minor: '2025-07-10T08:17:03.263Z',
8
+ patch: '2025-07-10T08:17:03.263Z',
9
9
  }