@tldraw/editor 3.12.0-internal.624e32507d98 → 3.12.0

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 (102) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist-cjs/index.d.ts +162 -17
  3. package/dist-cjs/index.js +3 -1
  4. package/dist-cjs/index.js.map +2 -2
  5. package/dist-cjs/lib/TldrawEditor.js +5 -0
  6. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  7. package/dist-cjs/lib/components/GeometryDebuggingView.js +2 -2
  8. package/dist-cjs/lib/components/GeometryDebuggingView.js.map +2 -2
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +10 -1
  10. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  11. package/dist-cjs/lib/editor/Editor.js +208 -18
  12. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  13. package/dist-cjs/lib/editor/managers/FocusManager.js +1 -1
  14. package/dist-cjs/lib/editor/managers/FocusManager.js.map +2 -2
  15. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +12 -0
  16. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  17. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +4 -13
  18. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
  19. package/dist-cjs/lib/editor/types/selection-types.js.map +1 -1
  20. package/dist-cjs/lib/exports/StyleEmbedder.js +31 -60
  21. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  22. package/dist-cjs/lib/exports/cssRules.js +3 -9
  23. package/dist-cjs/lib/exports/cssRules.js.map +2 -2
  24. package/dist-cjs/lib/exports/exportToSvg.js +1 -4
  25. package/dist-cjs/lib/exports/exportToSvg.js.map +2 -2
  26. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -2
  27. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  28. package/dist-cjs/lib/hooks/useDocumentEvents.js +16 -0
  29. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  30. package/dist-cjs/lib/license/Watermark.js +10 -20
  31. package/dist-cjs/lib/license/Watermark.js.map +2 -2
  32. package/dist-cjs/lib/primitives/geometry/Geometry2d.js +128 -16
  33. package/dist-cjs/lib/primitives/geometry/Geometry2d.js.map +3 -3
  34. package/dist-cjs/lib/primitives/geometry/Group2d.js +54 -11
  35. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  36. package/dist-cjs/lib/primitives/intersect.js +20 -0
  37. package/dist-cjs/lib/primitives/intersect.js.map +2 -2
  38. package/dist-cjs/lib/utils/reorderShapes.js +2 -8
  39. package/dist-cjs/lib/utils/reorderShapes.js.map +2 -2
  40. package/dist-cjs/version.js +3 -3
  41. package/dist-cjs/version.js.map +1 -1
  42. package/dist-esm/index.d.mts +162 -17
  43. package/dist-esm/index.mjs +8 -2
  44. package/dist-esm/index.mjs.map +2 -2
  45. package/dist-esm/lib/TldrawEditor.mjs +5 -0
  46. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  47. package/dist-esm/lib/components/GeometryDebuggingView.mjs +3 -3
  48. package/dist-esm/lib/components/GeometryDebuggingView.mjs.map +2 -2
  49. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +10 -1
  50. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  51. package/dist-esm/lib/editor/Editor.mjs +209 -18
  52. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/FocusManager.mjs +1 -1
  54. package/dist-esm/lib/editor/managers/FocusManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +12 -0
  56. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  57. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +4 -13
  58. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
  59. package/dist-esm/lib/exports/StyleEmbedder.mjs +32 -61
  60. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  61. package/dist-esm/lib/exports/cssRules.mjs +3 -9
  62. package/dist-esm/lib/exports/cssRules.mjs.map +2 -2
  63. package/dist-esm/lib/exports/exportToSvg.mjs +1 -4
  64. package/dist-esm/lib/exports/exportToSvg.mjs.map +2 -2
  65. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -2
  66. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  67. package/dist-esm/lib/hooks/useDocumentEvents.mjs +16 -0
  68. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  69. package/dist-esm/lib/license/Watermark.mjs +10 -20
  70. package/dist-esm/lib/license/Watermark.mjs.map +2 -2
  71. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs +132 -14
  72. package/dist-esm/lib/primitives/geometry/Geometry2d.mjs.map +2 -2
  73. package/dist-esm/lib/primitives/geometry/Group2d.mjs +55 -12
  74. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  75. package/dist-esm/lib/primitives/intersect.mjs +20 -0
  76. package/dist-esm/lib/primitives/intersect.mjs.map +2 -2
  77. package/dist-esm/lib/utils/reorderShapes.mjs +2 -8
  78. package/dist-esm/lib/utils/reorderShapes.mjs.map +2 -2
  79. package/dist-esm/version.mjs +3 -3
  80. package/dist-esm/version.mjs.map +1 -1
  81. package/editor.css +34 -19
  82. package/package.json +7 -7
  83. package/src/index.ts +11 -2
  84. package/src/lib/TldrawEditor.tsx +30 -3
  85. package/src/lib/components/GeometryDebuggingView.tsx +3 -3
  86. package/src/lib/components/default-components/DefaultCanvas.tsx +6 -1
  87. package/src/lib/editor/Editor.ts +315 -24
  88. package/src/lib/editor/managers/FocusManager.ts +1 -1
  89. package/src/lib/editor/shapes/ShapeUtil.ts +14 -0
  90. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +7 -15
  91. package/src/lib/editor/types/selection-types.ts +3 -0
  92. package/src/lib/exports/StyleEmbedder.ts +34 -81
  93. package/src/lib/exports/cssRules.ts +5 -11
  94. package/src/lib/exports/exportToSvg.tsx +1 -5
  95. package/src/lib/hooks/useCanvasEvents.ts +6 -2
  96. package/src/lib/hooks/useDocumentEvents.ts +18 -0
  97. package/src/lib/license/Watermark.tsx +18 -29
  98. package/src/lib/primitives/geometry/Geometry2d.ts +196 -16
  99. package/src/lib/primitives/geometry/Group2d.ts +76 -13
  100. package/src/lib/primitives/intersect.ts +41 -0
  101. package/src/lib/utils/reorderShapes.ts +2 -9
  102. package/src/version.ts +3 -3
@@ -1,4 +1,4 @@
1
- import { assert, getOwnProperty, objectMapValues, uniqueId } from '@tldraw/utils'
1
+ import { assertExists, getOwnProperty, objectMapValues, uniqueId } from '@tldraw/utils'
2
2
  import { FontEmbedder } from './FontEmbedder'
3
3
  import { ReadonlyStyles, Styles, cssRules } from './cssRules'
4
4
  import {
@@ -23,18 +23,6 @@ export class StyleEmbedder {
23
23
  private readonly styles = new Map<Element, ElementStyleInfo>()
24
24
  readonly fonts = new FontEmbedder()
25
25
 
26
- async collectDefaultStyles(elements: Element[]) {
27
- const collected = new Set<string>()
28
- const promises = []
29
- for (const element of elements) {
30
- const tagName = element.tagName.toLowerCase()
31
- if (collected.has(tagName)) continue
32
- collected.add(tagName)
33
- promises.push(populateDefaultStylesForTagName(tagName))
34
- }
35
- await Promise.all(promises)
36
- }
37
-
38
26
  readRootElementStyles(rootElement: Element) {
39
27
  // when reading a root, we always apply _all_ the styles, even if they match the defaults
40
28
  this.readElementStyles(rootElement, {
@@ -246,7 +234,12 @@ function styleFromComputedStyleMap(
246
234
  ) {
247
235
  const styles: Record<string, string> = {}
248
236
  const currentColor = style.get('color')?.toString() || ''
249
- const ruleOptions = { currentColor, parentStyles, defaultStyles, styles: style }
237
+ const ruleOptions = {
238
+ currentColor,
239
+ parentStyles,
240
+ defaultStyles,
241
+ getStyle: (property: string) => style.get(property)?.toString() ?? '',
242
+ }
250
243
  for (const property of style.keys()) {
251
244
  if (!shouldIncludeCssProperty(property)) continue
252
245
 
@@ -269,7 +262,12 @@ function styleFromComputedStyle(
269
262
  ) {
270
263
  const styles: Record<string, string> = {}
271
264
  const currentColor = style.color
272
- const ruleOptions = { currentColor, parentStyles, defaultStyles, styles: style }
265
+ const ruleOptions = {
266
+ currentColor,
267
+ parentStyles,
268
+ defaultStyles,
269
+ getStyle: (property: string) => style.getPropertyValue(property),
270
+ }
273
271
 
274
272
  for (const property in style) {
275
273
  if (!shouldIncludeCssProperty(property)) continue
@@ -297,88 +295,43 @@ function formatCss(style: ReadonlyStyles) {
297
295
  // when we're figuring out the default values for a tag, we need read them from a separate document
298
296
  // so they're not affected by the current document's styles
299
297
  let defaultStyleFrame:
300
- | Promise<{
301
- iframe: HTMLIFrameElement
302
- foreignObject: SVGForeignObjectElement
303
- document: Document
304
- }>
298
+ | { iframe: HTMLIFrameElement; foreignObject: SVGForeignObjectElement; document: Document }
305
299
  | undefined
306
-
307
- const defaultStylesByTagName: Record<
308
- string,
309
- | { type: 'resolved'; styles: ReadonlyStyles; promise: Promise<ReadonlyStyles> }
310
- | { type: 'pending'; promise: Promise<ReadonlyStyles> }
311
- > = {}
312
-
313
- const emptyFrameBlob = new Blob(
314
- ['<svg xmlns="http://www.w3.org/2000/svg"><foreignObject/></svg>'],
315
- { type: 'image/svg+xml' }
316
- )
317
- const emptyFrameUrl = URL.createObjectURL(emptyFrameBlob)
318
-
300
+ const defaultStylesByTagName: Record<string, ReadonlyStyles> = {}
319
301
  function getDefaultStyleFrame() {
320
302
  if (!defaultStyleFrame) {
321
- defaultStyleFrame = new Promise((resolve) => {
322
- const frame = document.createElement('iframe')
323
- Object.assign(frame.style, {
324
- position: 'absolute',
325
- top: '-10000px',
326
- left: '-10000px',
327
- width: '1px',
328
- height: '1px',
329
- opacity: '0',
330
- pointerEvents: 'none',
331
- })
332
-
333
- frame.onload = () => {
334
- const contentDocument = frame.contentDocument!
335
- const foreignObject = contentDocument.querySelector('foreignObject')!
336
- resolve({ iframe: frame, foreignObject, document: contentDocument })
337
- }
338
-
339
- frame.src = emptyFrameUrl
340
- document.body.appendChild(frame)
341
- })
303
+ const frame = document.createElement('iframe')
304
+ frame.style.display = 'none'
305
+ document.body.appendChild(frame)
306
+ const frameDocument = assertExists(frame.contentDocument, 'frame must have a document')
307
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
308
+ const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject')
309
+ svg.appendChild(foreignObject)
310
+ frameDocument.body.appendChild(svg)
311
+ defaultStyleFrame = { iframe: frame, foreignObject, document: frameDocument }
342
312
  }
343
313
  return defaultStyleFrame
344
314
  }
345
315
 
346
316
  function destroyDefaultStyleFrame() {
347
317
  if (defaultStyleFrame) {
348
- defaultStyleFrame.then(({ iframe }) => document.body.removeChild(iframe))
318
+ document.body.removeChild(defaultStyleFrame.iframe)
349
319
  defaultStyleFrame = undefined
350
320
  }
351
321
  }
352
322
 
353
323
  const defaultStyleReadOptions: ReadStyleOpts = { defaultStyles: NO_STYLES, parentStyles: NO_STYLES }
354
- function populateDefaultStylesForTagName(tagName: string) {
355
- const existing = defaultStylesByTagName[tagName]
356
- if (existing && existing.type === 'resolved') {
357
- return existing.promise
358
- }
359
-
360
- if (existing && existing.type === 'pending') {
361
- return existing.promise
362
- }
363
-
364
- const promise = getDefaultStyleFrame().then(({ foreignObject, document }) => {
365
- const element = document.createElementNS('http://www.w3.org/1999/xhtml', tagName)
366
- element.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
324
+ function getDefaultStylesForTagName(tagName: string) {
325
+ let existing = defaultStylesByTagName[tagName]
326
+ if (!existing) {
327
+ const { foreignObject, document } = getDefaultStyleFrame()
328
+ const element = document.createElement(tagName)
367
329
  foreignObject.appendChild(element)
368
- const styles = element.computedStyleMap
330
+ existing = element.computedStyleMap
369
331
  ? styleFromComputedStyleMap(element.computedStyleMap(), defaultStyleReadOptions)
370
332
  : styleFromComputedStyle(getComputedStyle(element), defaultStyleReadOptions)
371
333
  foreignObject.removeChild(element)
372
- defaultStylesByTagName[tagName] = { type: 'resolved', styles, promise }
373
- return styles
374
- })
375
-
376
- defaultStylesByTagName[tagName] = { type: 'pending', promise }
377
- return promise
378
- }
379
-
380
- function getDefaultStylesForTagName(tagName: string) {
381
- const existing = defaultStylesByTagName[tagName]
382
- assert(existing && existing.type === 'resolved', 'default styles must be populated & resolved')
383
- return existing.styles
334
+ defaultStylesByTagName[tagName] = existing
335
+ }
336
+ return existing
384
337
  }
@@ -5,20 +5,13 @@ type CanSkipRule = (
5
5
  value: string,
6
6
  property: string,
7
7
  options: {
8
- styles: StylePropertyMapReadOnly | CSSStyleDeclaration
8
+ getStyle(property: string): string
9
9
  parentStyles: ReadonlyStyles
10
10
  defaultStyles: ReadonlyStyles
11
11
  currentColor: string
12
12
  }
13
13
  ) => boolean
14
14
 
15
- const getStyle = (styles: StylePropertyMapReadOnly | CSSStyleDeclaration, property: string) => {
16
- if (styles instanceof CSSStyleDeclaration) {
17
- return styles.getPropertyValue(property)
18
- }
19
- return styles.get(property)?.toString()
20
- }
21
-
22
15
  const isCoveredByCurrentColor: CanSkipRule = (value, property, { currentColor }) => {
23
16
  return value === 'currentColor' || value === currentColor
24
17
  }
@@ -27,11 +20,12 @@ const isInherited: CanSkipRule = (value, property, { parentStyles }) => {
27
20
  return parentStyles[property] === value
28
21
  }
29
22
 
23
+ // see comment below about why we exclude border styles
30
24
  const isExcludedBorder =
31
25
  (borderDirection: string): CanSkipRule =>
32
- (value, property, { styles }) => {
33
- const borderWidth = getStyle(styles, `border-${borderDirection}-width`)
34
- const borderStyle = getStyle(styles, `border-${borderDirection}-style`)
26
+ (value, property, { getStyle }) => {
27
+ const borderWidth = getStyle(`border-${borderDirection}-width`)
28
+ const borderStyle = getStyle(`border-${borderDirection}-style`)
35
29
 
36
30
  if (borderWidth === '0px') return true
37
31
  if (borderStyle === 'none') return true
@@ -101,15 +101,11 @@ async function applyChangesToForeignObjects(svg: SVGSVGElement) {
101
101
  // urls, and things like videos will be converted to images.
102
102
  await Promise.all(foreignObjectChildren.map((el) => embedMedia(el as HTMLElement)))
103
103
 
104
- await styleEmbedder.collectDefaultStyles([
105
- ...svg.querySelectorAll('foreignObject.tl-export-embed-styles *'),
106
- ])
107
-
108
104
  // read the computed styles of every element (+ it's children & pseudo-elements) in the
109
105
  // document. we do this in a single pass before we start embedding any CSS stuff to avoid
110
106
  // constantly forcing the browser to recompute styles & layout.
111
107
  for (const el of foreignObjectChildren) {
112
- await styleEmbedder.readRootElementStyles(el as HTMLElement)
108
+ styleEmbedder.readRootElementStyles(el as HTMLElement)
113
109
  }
114
110
 
115
111
  // fetch any resources that we need to embed in the CSS, like background images.
@@ -53,7 +53,11 @@ export function useCanvasEvents() {
53
53
 
54
54
  // For tools that benefit from a higher fidelity of events,
55
55
  // we dispatch the coalesced events.
56
- const events = currentTool.useCoalescedEvents ? e.nativeEvent.getCoalescedEvents() : [e]
56
+ // N.B. Sometimes getCoalescedEvents isn't present on iOS, ugh.
57
+ const events =
58
+ currentTool.useCoalescedEvents && e.nativeEvent.getCoalescedEvents
59
+ ? e.nativeEvent.getCoalescedEvents()
60
+ : [e]
57
61
  for (const singleEvent of events) {
58
62
  editor.dispatch({
59
63
  type: 'pointer',
@@ -107,7 +111,7 @@ export function useCanvasEvents() {
107
111
  if (
108
112
  e.target.tagName !== 'A' &&
109
113
  e.target.tagName !== 'TEXTAREA' &&
110
- e.target.isContentEditable &&
114
+ !e.target.isContentEditable &&
111
115
  // When in EditingShape state, we are actually clicking on a 'DIV'
112
116
  // not A/TEXTAREA/contenteditable element yet. So, to preserve cursor position
113
117
  // for edit mode on mobile we need to not preventDefault.
@@ -104,6 +104,7 @@ export function useDocumentEvents() {
104
104
 
105
105
  if ((e as any).isKilled) return
106
106
  ;(e as any).isKilled = true
107
+ const hasSelectedShapes = !!editor.getSelectedShapeIds().length
107
108
 
108
109
  switch (e.key) {
109
110
  case '=':
@@ -124,6 +125,23 @@ export function useDocumentEvents() {
124
125
  if (areShortcutsDisabled(editor)) {
125
126
  return
126
127
  }
128
+ if (hasSelectedShapes) {
129
+ // This is used in tandem with shape navigation.
130
+ preventDefault(e)
131
+ }
132
+ break
133
+ }
134
+ case 'ArrowLeft':
135
+ case 'ArrowRight':
136
+ case 'ArrowUp':
137
+ case 'ArrowDown': {
138
+ if (areShortcutsDisabled(editor)) {
139
+ return
140
+ }
141
+ if (hasSelectedShapes && (e.metaKey || e.ctrlKey)) {
142
+ // This is used in tandem with shape navigation.
143
+ preventDefault(e)
144
+ }
127
145
  break
128
146
  }
129
147
  case ',': {
@@ -1,6 +1,5 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
2
  import { memo, useRef } from 'react'
3
- import { tlenv } from '../globals/environment'
4
3
  import { useCanvasEvents } from '../hooks/useCanvasEvents'
5
4
  import { useEditor } from '../hooks/useEditor'
6
5
  import { usePassThroughWheelEvents } from '../hooks/usePassThroughWheelEvents'
@@ -57,29 +56,17 @@ const WatermarkInner = memo(function WatermarkInner({ src }: { src: string }) {
57
56
  draggable={false}
58
57
  {...events}
59
58
  >
60
- {tlenv.isWebview ? (
61
- <a
62
- draggable={false}
63
- role="button"
64
- onPointerDown={(e) => {
65
- stopEventPropagation(e)
66
- preventDefault(e)
67
- }}
68
- onClick={() => runtime.openWindow(url, '_blank')}
69
- style={{ mask: maskCss, WebkitMask: maskCss }}
70
- />
71
- ) : (
72
- <a
73
- href={url}
74
- target="_blank"
75
- rel="noreferrer"
76
- draggable={false}
77
- onPointerDown={(e) => {
78
- stopEventPropagation(e)
79
- }}
80
- style={{ mask: maskCss, WebkitMask: maskCss }}
81
- />
82
- )}
59
+ <button
60
+ draggable={false}
61
+ role="button"
62
+ onPointerDown={(e) => {
63
+ stopEventPropagation(e)
64
+ preventDefault(e)
65
+ }}
66
+ title="made with tldraw"
67
+ onClick={() => runtime.openWindow(url, '_blank')}
68
+ style={{ mask: maskCss, WebkitMask: maskCss }}
69
+ />
83
70
  </div>
84
71
  )
85
72
  })
@@ -115,7 +102,7 @@ To remove the watermark, please purchase a license at tldraw.dev.
115
102
  box-sizing: content-box;
116
103
  }
117
104
 
118
- .${className} > a {
105
+ .${className} > button {
119
106
  position: absolute;
120
107
  width: 96px;
121
108
  height: 32px;
@@ -123,6 +110,8 @@ To remove the watermark, please purchase a license at tldraw.dev.
123
110
  cursor: inherit;
124
111
  color: var(--color-text);
125
112
  opacity: .38;
113
+ border: 0;
114
+ padding: 0;
126
115
  background-color: currentColor;
127
116
  }
128
117
 
@@ -137,13 +126,13 @@ To remove the watermark, please purchase a license at tldraw.dev.
137
126
  height: 48px;
138
127
  }
139
128
 
140
- .${className}[data-mobile='true'] > a {
129
+ .${className}[data-mobile='true'] > button {
141
130
  width: 8px;
142
131
  height: 32px;
143
132
  }
144
133
 
145
134
  @media (hover: hover) {
146
- .${className} > a {
135
+ .${className} > button {
147
136
  pointer-events: none;
148
137
  }
149
138
 
@@ -153,12 +142,12 @@ To remove the watermark, please purchase a license at tldraw.dev.
153
142
  transition-delay: 0.32s;
154
143
  }
155
144
 
156
- .${className}:hover > a {
145
+ .${className}:hover > button {
157
146
  animation: delayed_link 0.2s forwards ease-in-out;
158
147
  animation-delay: 0.32s;
159
148
  }
160
149
 
161
- .${className} > a:focus-visible {
150
+ .${className} > button:focus-visible {
162
151
  opacity: 1;
163
152
  }
164
153
  }
@@ -1,12 +1,51 @@
1
+ import { assert } from '@tldraw/utils'
1
2
  import { Box } from '../Box'
2
- import { Vec } from '../Vec'
3
- import { pointInPolygon } from '../utils'
3
+ import { Mat, MatModel } from '../Mat'
4
+ import { Vec, VecLike } from '../Vec'
5
+ import {
6
+ intersectCirclePolygon,
7
+ intersectCirclePolyline,
8
+ intersectLineSegmentPolygon,
9
+ intersectLineSegmentPolyline,
10
+ intersectPolys,
11
+ } from '../intersect'
12
+ import { approximately, pointInPolygon } from '../utils'
13
+
14
+ /**
15
+ * Filter geometry within a group.
16
+ *
17
+ * Filters are ignored when called directly on primitive geometries, but can be used to narrow down
18
+ * the results of an operation on `Group2d` geometries.
19
+ *
20
+ * @public
21
+ */
22
+ export interface Geometry2dFilters {
23
+ readonly includeLabels?: boolean
24
+ readonly includeInternal?: boolean
25
+ }
26
+
27
+ /** @public */
28
+ export const Geometry2dFilters: {
29
+ EXCLUDE_NON_STANDARD: Geometry2dFilters
30
+ INCLUDE_ALL: Geometry2dFilters
31
+ EXCLUDE_LABELS: Geometry2dFilters
32
+ EXCLUDE_INTERNAL: Geometry2dFilters
33
+ } = {
34
+ EXCLUDE_NON_STANDARD: {
35
+ includeLabels: false,
36
+ includeInternal: false,
37
+ },
38
+ INCLUDE_ALL: { includeLabels: true, includeInternal: true },
39
+ EXCLUDE_LABELS: { includeLabels: false, includeInternal: true },
40
+ EXCLUDE_INTERNAL: { includeLabels: true, includeInternal: false },
41
+ }
4
42
 
5
43
  /** @public */
6
44
  export interface Geometry2dOptions {
7
45
  isFilled: boolean
8
46
  isClosed: boolean
9
47
  isLabel?: boolean
48
+ isInternal?: boolean
10
49
  debugColor?: string
11
50
  ignore?: boolean
12
51
  }
@@ -16,6 +55,7 @@ export abstract class Geometry2d {
16
55
  isFilled = false
17
56
  isClosed = true
18
57
  isLabel = false
58
+ isInternal = false
19
59
  debugColor?: string
20
60
  ignore?: boolean
21
61
 
@@ -23,20 +63,23 @@ export abstract class Geometry2d {
23
63
  this.isFilled = opts.isFilled
24
64
  this.isClosed = opts.isClosed
25
65
  this.isLabel = opts.isLabel ?? false
66
+ this.isInternal = opts.isInternal ?? false
26
67
  this.debugColor = opts.debugColor
27
68
  this.ignore = opts.ignore
28
69
  }
29
70
 
30
- abstract getVertices(): Vec[]
71
+ isExcludedByFilter(filters?: Geometry2dFilters) {
72
+ if (!filters) return false
73
+ if (this.isLabel && !filters.includeLabels) return true
74
+ if (this.isInternal && !filters.includeInternal) return true
75
+ return false
76
+ }
31
77
 
32
- abstract nearestPoint(point: Vec): Vec
78
+ abstract getVertices(filters: Geometry2dFilters): Vec[]
33
79
 
34
- // hitTestPoint(point: Vec, margin = 0, hitInside = false) {
35
- // // We've removed the broad phase here; that should be done outside of the call
36
- // return this.distanceToPoint(point, hitInside) <= margin
37
- // }
80
+ abstract nearestPoint(point: Vec, _filters?: Geometry2dFilters): Vec
38
81
 
39
- hitTestPoint(point: Vec, margin = 0, hitInside = false) {
82
+ hitTestPoint(point: Vec, margin = 0, hitInside = false, _filters?: Geometry2dFilters) {
40
83
  // First check whether the point is inside
41
84
  if (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices)) {
42
85
  return true
@@ -45,17 +88,17 @@ export abstract class Geometry2d {
45
88
  return Vec.Dist2(point, this.nearestPoint(point)) <= margin * margin
46
89
  }
47
90
 
48
- distanceToPoint(point: Vec, hitInside = false) {
91
+ distanceToPoint(point: Vec, hitInside = false, filters?: Geometry2dFilters) {
49
92
  return (
50
- point.dist(this.nearestPoint(point)) *
93
+ point.dist(this.nearestPoint(point, filters)) *
51
94
  (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices)
52
95
  ? -1
53
96
  : 1)
54
97
  )
55
98
  }
56
99
 
57
- distanceToLineSegment(A: Vec, B: Vec) {
58
- if (A.equals(B)) return this.distanceToPoint(A)
100
+ distanceToLineSegment(A: Vec, B: Vec, filters?: Geometry2dFilters) {
101
+ if (A.equals(B)) return this.distanceToPoint(A, false, filters)
59
102
  const { vertices } = this
60
103
  let nearest: Vec | undefined
61
104
  let dist = Infinity
@@ -73,10 +116,35 @@ export abstract class Geometry2d {
73
116
  return this.isClosed && this.isFilled && pointInPolygon(nearest, this.vertices) ? -dist : dist
74
117
  }
75
118
 
76
- hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
77
- return this.distanceToLineSegment(A, B) <= distance
119
+ hitTestLineSegment(A: Vec, B: Vec, distance = 0, filters?: Geometry2dFilters): boolean {
120
+ return this.distanceToLineSegment(A, B, filters) <= distance
121
+ }
122
+
123
+ intersectLineSegment(A: VecLike, B: VecLike, _filters?: Geometry2dFilters): VecLike[] {
124
+ const intersections = this.isClosed
125
+ ? intersectLineSegmentPolygon(A, B, this.vertices)
126
+ : intersectLineSegmentPolyline(A, B, this.vertices)
127
+
128
+ return intersections ?? []
129
+ }
130
+
131
+ intersectCircle(center: VecLike, radius: number, _filters?: Geometry2dFilters): VecLike[] {
132
+ const intersections = this.isClosed
133
+ ? intersectCirclePolygon(center, radius, this.vertices)
134
+ : intersectCirclePolyline(center, radius, this.vertices)
135
+
136
+ return intersections ?? []
137
+ }
138
+
139
+ intersectPolygon(polygon: VecLike[], _filters?: Geometry2dFilters): VecLike[] {
140
+ return intersectPolys(polygon, this.vertices, true, this.isClosed)
141
+ }
142
+
143
+ intersectPolyline(polyline: VecLike[], _filters?: Geometry2dFilters): VecLike[] {
144
+ return intersectPolys(polyline, this.vertices, false, this.isClosed)
78
145
  }
79
146
 
147
+ /** @deprecated Iterate the vertices instead. */
80
148
  nearestPointOnLineSegment(A: Vec, B: Vec): Vec {
81
149
  const { vertices } = this
82
150
  let nearest: Vec | undefined
@@ -105,12 +173,16 @@ export abstract class Geometry2d {
105
173
  )
106
174
  }
107
175
 
176
+ transform(transform: MatModel): Geometry2d {
177
+ return new TransformedGeometry2d(this, transform)
178
+ }
179
+
108
180
  private _vertices: Vec[] | undefined
109
181
 
110
182
  // eslint-disable-next-line no-restricted-syntax
111
183
  get vertices(): Vec[] {
112
184
  if (!this._vertices) {
113
- this._vertices = this.getVertices()
185
+ this._vertices = this.getVertices(Geometry2dFilters.EXCLUDE_LABELS)
114
186
  }
115
187
 
116
188
  return this._vertices
@@ -204,3 +276,111 @@ export abstract class Geometry2d {
204
276
 
205
277
  abstract getSvgPathData(first: boolean): string
206
278
  }
279
+
280
+ // =================================================================================================
281
+ // Because Geometry2d.transform depends on TransformedGeometry2d, we need to define it here instead
282
+ // of in its own files. This prevents a circular import error.
283
+ // =================================================================================================
284
+
285
+ /** @public */
286
+ export class TransformedGeometry2d extends Geometry2d {
287
+ private readonly inverse: MatModel
288
+ private readonly decomposed
289
+
290
+ constructor(
291
+ private readonly geometry: Geometry2d,
292
+ private readonly matrix: MatModel
293
+ ) {
294
+ super(geometry)
295
+ this.inverse = Mat.Inverse(matrix)
296
+ this.decomposed = Mat.Decompose(matrix)
297
+
298
+ assert(
299
+ approximately(this.decomposed.scaleX, this.decomposed.scaleY),
300
+ 'non-uniform scaling is not yet supported'
301
+ )
302
+ }
303
+
304
+ getVertices(filters: Geometry2dFilters): Vec[] {
305
+ return this.geometry.getVertices(filters).map((v) => Mat.applyToPoint(this.matrix, v))
306
+ }
307
+
308
+ nearestPoint(point: Vec, filters?: Geometry2dFilters): Vec {
309
+ return Mat.applyToPoint(
310
+ this.matrix,
311
+ this.geometry.nearestPoint(Mat.applyToPoint(this.inverse, point), filters)
312
+ )
313
+ }
314
+
315
+ override hitTestPoint(
316
+ point: Vec,
317
+ margin = 0,
318
+ hitInside?: boolean,
319
+ filters?: Geometry2dFilters
320
+ ): boolean {
321
+ return this.geometry.hitTestPoint(
322
+ Mat.applyToPoint(this.inverse, point),
323
+ margin / this.decomposed.scaleX,
324
+ hitInside,
325
+ filters
326
+ )
327
+ }
328
+
329
+ override distanceToPoint(point: Vec, hitInside = false, filters?: Geometry2dFilters) {
330
+ return (
331
+ this.geometry.distanceToPoint(Mat.applyToPoint(this.inverse, point), hitInside, filters) *
332
+ this.decomposed.scaleX
333
+ )
334
+ }
335
+
336
+ override distanceToLineSegment(A: Vec, B: Vec, filters?: Geometry2dFilters) {
337
+ return (
338
+ this.geometry.distanceToLineSegment(
339
+ Mat.applyToPoint(this.inverse, A),
340
+ Mat.applyToPoint(this.inverse, B),
341
+ filters
342
+ ) * this.decomposed.scaleX
343
+ )
344
+ }
345
+
346
+ override hitTestLineSegment(A: Vec, B: Vec, distance = 0, filters?: Geometry2dFilters): boolean {
347
+ return this.geometry.hitTestLineSegment(
348
+ Mat.applyToPoint(this.inverse, A),
349
+ Mat.applyToPoint(this.inverse, B),
350
+ distance / this.decomposed.scaleX,
351
+ filters
352
+ )
353
+ }
354
+
355
+ override intersectLineSegment(A: VecLike, B: VecLike, filters?: Geometry2dFilters) {
356
+ return this.geometry.intersectLineSegment(
357
+ Mat.applyToPoint(this.inverse, A),
358
+ Mat.applyToPoint(this.inverse, B),
359
+ filters
360
+ )
361
+ }
362
+
363
+ override intersectCircle(center: VecLike, radius: number, filters?: Geometry2dFilters) {
364
+ return this.geometry.intersectCircle(
365
+ Mat.applyToPoint(this.inverse, center),
366
+ radius / this.decomposed.scaleX,
367
+ filters
368
+ )
369
+ }
370
+
371
+ override intersectPolygon(polygon: VecLike[], filters?: Geometry2dFilters): VecLike[] {
372
+ return this.geometry.intersectPolygon(Mat.applyToPoints(this.inverse, polygon), filters)
373
+ }
374
+
375
+ override intersectPolyline(polyline: VecLike[], filters?: Geometry2dFilters): VecLike[] {
376
+ return this.geometry.intersectPolyline(Mat.applyToPoints(this.inverse, polyline), filters)
377
+ }
378
+
379
+ override transform(transform: MatModel): Geometry2d {
380
+ return new TransformedGeometry2d(this.geometry, Mat.Multiply(transform, this.matrix))
381
+ }
382
+
383
+ getSvgPathData(): string {
384
+ throw new Error('Cannot get SVG path data for transformed geometry.')
385
+ }
386
+ }