@tldraw/editor 5.4.0-next.ef99ab47e5ce → 5.4.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.
- package/dist-cjs/index.d.ts +114 -47
- package/dist-cjs/index.js +4 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +7 -2
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/components/MenuClickCapture.js +6 -8
- package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
- package/dist-cjs/lib/components/default-components/CanvasOverlays.js +1 -3
- package/dist-cjs/lib/components/default-components/CanvasOverlays.js.map +2 -2
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +1 -1
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +1 -1
- package/dist-cjs/lib/config/TLUserPreferences.js +6 -2
- package/dist-cjs/lib/config/TLUserPreferences.js.map +2 -2
- package/dist-cjs/lib/config/createTLStore.js +1 -1
- package/dist-cjs/lib/config/createTLStore.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +181 -294
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +7 -3
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -3
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +8 -8
- package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +2 -1
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +79 -6
- package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +7 -0
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +0 -2
- package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +1 -2
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +6 -3
- package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js +12 -1
- package/dist-cjs/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.js.map +2 -2
- package/dist-cjs/lib/editor/overlays/OverlayManager.js +3 -7
- package/dist-cjs/lib/editor/overlays/OverlayManager.js.map +2 -2
- package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js +0 -1
- package/dist-cjs/lib/editor/overlays/strokeShapeIndicators.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -1
- package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js +1 -0
- package/dist-cjs/lib/editor/shapes/shared/getPerfectDashProps.js.map +2 -2
- package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -1
- package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
- package/dist-cjs/lib/editor/tools/StateNode.js +3 -2
- package/dist-cjs/lib/editor/tools/StateNode.js.map +2 -2
- package/dist-cjs/lib/globals/editors.js +57 -0
- package/dist-cjs/lib/globals/editors.js.map +7 -0
- package/dist-cjs/lib/globals/menus.js +9 -4
- package/dist-cjs/lib/globals/menus.js.map +2 -2
- package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -0
- package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
- package/dist-cjs/lib/hooks/useDocumentEvents.js +39 -9
- package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
- package/dist-cjs/lib/hooks/useLocalStore.js +10 -6
- package/dist-cjs/lib/hooks/useLocalStore.js.map +2 -2
- package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +1 -0
- package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
- package/dist-cjs/lib/hooks/usePeerIds.js +2 -1
- package/dist-cjs/lib/hooks/usePeerIds.js.map +2 -2
- package/dist-cjs/lib/license/LicenseManager.js +9 -2
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/license/LicenseProvider.js +16 -3
- package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
- package/dist-cjs/lib/license/useLicenseManagerState.js +5 -4
- package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
- package/dist-cjs/lib/primitives/Box.js +2 -1
- package/dist-cjs/lib/primitives/Box.js.map +2 -2
- package/dist-cjs/lib/primitives/Vec.js +4 -3
- package/dist-cjs/lib/primitives/Vec.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Group2d.js +63 -21
- package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
- package/dist-cjs/lib/primitives/utils.js +2 -1
- package/dist-cjs/lib/primitives/utils.js.map +2 -2
- package/dist-cjs/lib/utils/deepLinks.js +5 -1
- package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
- package/dist-cjs/lib/utils/normalizeWheel.js +2 -0
- package/dist-cjs/lib/utils/normalizeWheel.js.map +2 -2
- package/dist-cjs/lib/utils/rotation.js +1 -9
- package/dist-cjs/lib/utils/rotation.js.map +2 -2
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +17 -0
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
- package/dist-cjs/lib/utils/sync/alerts.js +1 -1
- package/dist-cjs/lib/utils/sync/alerts.js.map +1 -1
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +114 -47
- package/dist-esm/index.mjs +9 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +9 -4
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/MenuClickCapture.mjs +6 -8
- package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/CanvasOverlays.mjs +1 -3
- package/dist-esm/lib/components/default-components/CanvasOverlays.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +1 -1
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +1 -1
- package/dist-esm/lib/config/TLUserPreferences.mjs +6 -2
- package/dist-esm/lib/config/TLUserPreferences.mjs.map +2 -2
- package/dist-esm/lib/config/createTLStore.mjs +1 -1
- package/dist-esm/lib/config/createTLStore.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +189 -295
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +7 -3
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -3
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +9 -9
- package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +2 -1
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +81 -8
- package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +7 -0
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +0 -2
- package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +2 -3
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +6 -3
- package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs +12 -1
- package/dist-esm/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.mjs.map +2 -2
- package/dist-esm/lib/editor/overlays/OverlayManager.mjs +3 -7
- package/dist-esm/lib/editor/overlays/OverlayManager.mjs.map +2 -2
- package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs +0 -1
- package/dist-esm/lib/editor/overlays/strokeShapeIndicators.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -1
- package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs +1 -0
- package/dist-esm/lib/editor/shapes/shared/getPerfectDashProps.mjs.map +2 -2
- package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -1
- package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
- package/dist-esm/lib/editor/tools/StateNode.mjs +3 -2
- package/dist-esm/lib/editor/tools/StateNode.mjs.map +2 -2
- package/dist-esm/lib/globals/editors.mjs +37 -0
- package/dist-esm/lib/globals/editors.mjs.map +7 -0
- package/dist-esm/lib/globals/menus.mjs +9 -4
- package/dist-esm/lib/globals/menus.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -0
- package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
- package/dist-esm/lib/hooks/useDocumentEvents.mjs +40 -10
- package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
- package/dist-esm/lib/hooks/useLocalStore.mjs +11 -7
- package/dist-esm/lib/hooks/useLocalStore.mjs.map +2 -2
- package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +1 -0
- package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
- package/dist-esm/lib/hooks/usePeerIds.mjs +2 -1
- package/dist-esm/lib/hooks/usePeerIds.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseManager.mjs +9 -2
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseProvider.mjs +16 -3
- package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
- package/dist-esm/lib/license/useLicenseManagerState.mjs +5 -4
- package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
- package/dist-esm/lib/primitives/Box.mjs +2 -1
- package/dist-esm/lib/primitives/Box.mjs.map +2 -2
- package/dist-esm/lib/primitives/Vec.mjs +4 -3
- package/dist-esm/lib/primitives/Vec.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Group2d.mjs +63 -21
- package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/utils.mjs +2 -1
- package/dist-esm/lib/primitives/utils.mjs.map +2 -2
- package/dist-esm/lib/utils/deepLinks.mjs +5 -1
- package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
- package/dist-esm/lib/utils/normalizeWheel.mjs +2 -0
- package/dist-esm/lib/utils/normalizeWheel.mjs.map +2 -2
- package/dist-esm/lib/utils/rotation.mjs +1 -9
- package/dist-esm/lib/utils/rotation.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +17 -0
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/alerts.mjs +1 -1
- package/dist-esm/lib/utils/sync/alerts.mjs.map +1 -1
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/package.json +13 -7
- package/src/index.ts +6 -1
- package/src/lib/TldrawEditor.tsx +16 -4
- package/src/lib/components/MenuClickCapture.tsx +19 -18
- package/src/lib/components/default-components/CanvasOverlays.tsx +1 -3
- package/src/lib/components/default-components/DefaultErrorFallback.tsx +1 -1
- package/src/lib/config/TLUserPreferences.test.ts +18 -1
- package/src/lib/config/TLUserPreferences.ts +8 -4
- package/src/lib/config/createTLStore.ts +1 -1
- package/src/lib/editor/Editor.ts +266 -383
- package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +7 -3
- package/src/lib/editor/managers/ClickManager/ClickManager.ts +5 -3
- package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +9 -9
- package/src/lib/editor/managers/FontManager/FontManager.ts +2 -1
- package/src/lib/editor/managers/InputsManager/InputsManager.test.ts +199 -3
- package/src/lib/editor/managers/InputsManager/InputsManager.ts +128 -9
- package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +12 -1
- package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +0 -2
- package/src/lib/editor/managers/TextManager/TextManager.ts +2 -6
- package/src/lib/editor/managers/TickManager/TickManager.test.ts +13 -0
- package/src/lib/editor/managers/TickManager/TickManager.ts +9 -3
- package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.test.ts +64 -3
- package/src/lib/editor/managers/UserPreferencesManager/UserPreferencesManager.ts +18 -1
- package/src/lib/editor/overlays/OverlayManager.ts +3 -7
- package/src/lib/editor/overlays/strokeShapeIndicators.ts +0 -1
- package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +5 -1
- package/src/lib/editor/shapes/shared/getPerfectDashProps.ts +3 -0
- package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +4 -1
- package/src/lib/editor/tools/StateNode.test.ts +64 -0
- package/src/lib/editor/tools/StateNode.ts +12 -2
- package/src/lib/globals/editors.test.ts +76 -0
- package/src/lib/globals/editors.ts +53 -0
- package/src/lib/globals/menus.test.ts +33 -0
- package/src/lib/globals/menus.ts +11 -4
- package/src/lib/hooks/useCanvasEvents.ts +2 -0
- package/src/lib/hooks/useDocumentEvents.ts +60 -16
- package/src/lib/hooks/useLocalStore.ts +16 -8
- package/src/lib/hooks/usePassThroughWheelEvents.ts +3 -0
- package/src/lib/hooks/usePeerIds.ts +2 -1
- package/src/lib/license/LicenseManager.test.ts +160 -0
- package/src/lib/license/LicenseManager.ts +13 -4
- package/src/lib/license/LicenseProvider.tsx +24 -3
- package/src/lib/license/useLicenseManagerState.ts +8 -6
- package/src/lib/primitives/Box.test.ts +9 -1
- package/src/lib/primitives/Box.ts +3 -1
- package/src/lib/primitives/Vec.test.ts +10 -0
- package/src/lib/primitives/Vec.ts +4 -4
- package/src/lib/primitives/geometry/Arc2d.test.ts +226 -3
- package/src/lib/primitives/geometry/CubicBezier2d.test.ts +173 -3
- package/src/lib/primitives/geometry/CubicSpline2d.test.ts +160 -3
- package/src/lib/primitives/geometry/Ellipse2d.test.ts +243 -3
- package/src/lib/primitives/geometry/Geometry2d.test.ts +553 -1
- package/src/lib/primitives/geometry/Group2d.test.ts +398 -3
- package/src/lib/primitives/geometry/Group2d.ts +63 -27
- package/src/lib/primitives/geometry/Polyline2d.test.ts +207 -0
- package/src/lib/primitives/geometry/Stadium2d.test.ts +191 -3
- package/src/lib/primitives/utils.test.ts +10 -1
- package/src/lib/primitives/utils.ts +4 -1
- package/src/lib/test/useCanvasEvents.test.tsx +74 -0
- package/src/lib/test/useDocumentEvents.test.tsx +107 -0
- package/src/lib/test/usePassThroughWheelEvents.test.tsx +25 -2
- package/src/lib/utils/deepLinks.test.ts +15 -0
- package/src/lib/utils/deepLinks.ts +7 -1
- package/src/lib/utils/normalizeWheel.test.ts +39 -0
- package/src/lib/utils/normalizeWheel.ts +6 -0
- package/src/lib/utils/rotation.ts +4 -12
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +56 -0
- package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -0
- package/src/lib/utils/sync/alerts.ts +1 -1
- package/src/version.ts +3 -3
- package/src/lib/primitives/geometry/Polyline.test.ts +0 -5
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Vec } from '../Vec'
|
|
2
|
+
import { Polygon2d } from './Polygon2d'
|
|
3
|
+
import { Polyline2d } from './Polyline2d'
|
|
4
|
+
|
|
5
|
+
// An L shape: right along the top for 100, then down for 100
|
|
6
|
+
const points = () => [new Vec(0, 0), new Vec(100, 0), new Vec(100, 100)]
|
|
7
|
+
const polyline = () => new Polyline2d({ points: points() })
|
|
8
|
+
|
|
9
|
+
describe('Polyline2d construction', () => {
|
|
10
|
+
it('is open and unfilled', () => {
|
|
11
|
+
expect(polyline()).toMatchObject({ isClosed: false, isFilled: false })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('passes through the other options', () => {
|
|
15
|
+
const p = new Polyline2d({ points: points(), isLabel: true, debugColor: 'red', ignore: true })
|
|
16
|
+
expect(p).toMatchObject({ isLabel: true, debugColor: 'red', ignore: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('throws with fewer than two points', () => {
|
|
20
|
+
expect(() => new Polyline2d({ points: [] })).toThrow(
|
|
21
|
+
'Polyline2d: points must be an array of at least 2 points'
|
|
22
|
+
)
|
|
23
|
+
expect(() => new Polyline2d({ points: [new Vec(0, 0)] })).toThrow(
|
|
24
|
+
'Polyline2d: points must be an array of at least 2 points'
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('Polyline2d.getVertices', () => {
|
|
30
|
+
it('returns the points as given', () => {
|
|
31
|
+
const pts = points()
|
|
32
|
+
expect(new Polyline2d({ points: pts }).vertices).toBe(pts)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('Polyline2d.bounds', () => {
|
|
37
|
+
it('spans the points', () => {
|
|
38
|
+
expect(polyline().bounds).toMatchObject({ x: 0, y: 0, w: 100, h: 100 })
|
|
39
|
+
expect(polyline().center).toMatchObject({ x: 50, y: 50 })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('handles offset points', () => {
|
|
43
|
+
const p = new Polyline2d({ points: [new Vec(10, 20), new Vec(30, -5)] })
|
|
44
|
+
expect(p.bounds).toMatchObject({ x: 10, y: -5, w: 20, h: 25 })
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('Polyline2d.getLength', () => {
|
|
49
|
+
it('sums the segment lengths without closing', () => {
|
|
50
|
+
expect(polyline().length).toBe(200)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('is zero for coincident points', () => {
|
|
54
|
+
expect(new Polyline2d({ points: [new Vec(5, 5), new Vec(5, 5)] }).length).toBe(0)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('Polyline2d.area', () => {
|
|
59
|
+
it('is zero because the shape is open', () => {
|
|
60
|
+
expect(polyline().area).toBe(0)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('Polyline2d.nearestPoint', () => {
|
|
65
|
+
it('projects onto the nearest segment', () => {
|
|
66
|
+
expect(polyline().nearestPoint(new Vec(50, 10))).toMatchObject({ x: 50, y: 0 })
|
|
67
|
+
expect(polyline().nearestPoint(new Vec(110, 50))).toMatchObject({ x: 100, y: 50 })
|
|
68
|
+
expect(polyline().nearestPoint(new Vec(90, 50))).toMatchObject({ x: 100, y: 50 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('clamps to the end points', () => {
|
|
72
|
+
expect(polyline().nearestPoint(new Vec(-10, -10))).toMatchObject({ x: 0, y: 0 })
|
|
73
|
+
expect(polyline().nearestPoint(new Vec(150, 150))).toMatchObject({ x: 100, y: 100 })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('prefers the earlier segment on a tie', () => {
|
|
77
|
+
expect(polyline().nearestPoint(new Vec(50, 50))).toMatchObject({ x: 50, y: 0 })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('does not use the closing segment', () => {
|
|
81
|
+
// the closing segment (100, 100) -> (0, 0) would pass through (25, 25)
|
|
82
|
+
expect(polyline().nearestPoint(new Vec(20, 30))).toMatchObject({ x: 20, y: 0 })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('handles zero length segments', () => {
|
|
86
|
+
const p = new Polyline2d({ points: [new Vec(5, 5), new Vec(5, 5)] })
|
|
87
|
+
expect(p.nearestPoint(new Vec(10, 10))).toMatchObject({ x: 5, y: 5 })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns a new vector rather than one of the points', () => {
|
|
91
|
+
const pts = points()
|
|
92
|
+
const p = new Polyline2d({ points: pts })
|
|
93
|
+
const nearest = p.nearestPoint(new Vec(-10, -10))
|
|
94
|
+
expect(nearest).toEqual(pts[0])
|
|
95
|
+
expect(nearest).not.toBe(pts[0])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('Polyline2d.distanceToPoint', () => {
|
|
100
|
+
it('is the distance to the nearest segment', () => {
|
|
101
|
+
expect(polyline().distanceToPoint(new Vec(50, 10))).toBe(10)
|
|
102
|
+
expect(polyline().distanceToPoint(new Vec(50, 50))).toBe(50)
|
|
103
|
+
expect(polyline().distanceToPoint(new Vec(-3, -4))).toBe(5)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('is never negative for an open polyline, even with hitInside', () => {
|
|
107
|
+
expect(polyline().distanceToPoint(new Vec(90, 10), true)).toBe(10)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('Polyline2d.hitTestPoint', () => {
|
|
112
|
+
it('hits points on the line', () => {
|
|
113
|
+
expect(polyline().hitTestPoint(new Vec(50, 0))).toBe(true)
|
|
114
|
+
expect(polyline().hitTestPoint(new Vec(100, 100))).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('respects the margin', () => {
|
|
118
|
+
expect(polyline().hitTestPoint(new Vec(50, 10), 10)).toBe(true)
|
|
119
|
+
expect(polyline().hitTestPoint(new Vec(50, 10), 9)).toBe(false)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('ignores hitInside because the shape is open', () => {
|
|
123
|
+
expect(polyline().hitTestPoint(new Vec(90, 10), 0, true)).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('Polyline2d.hitTestLineSegment', () => {
|
|
128
|
+
it('hits a segment crossing the line', () => {
|
|
129
|
+
expect(polyline().hitTestLineSegment(new Vec(50, -10), new Vec(50, 10))).toBe(true)
|
|
130
|
+
expect(polyline().hitTestLineSegment(new Vec(90, 50), new Vec(110, 50))).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('misses a segment inside the corner that does not cross', () => {
|
|
134
|
+
expect(polyline().hitTestLineSegment(new Vec(50, 10), new Vec(90, 90))).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('misses a segment crossing only the closing segment', () => {
|
|
138
|
+
expect(polyline().hitTestLineSegment(new Vec(20, 30), new Vec(30, 20))).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('respects the distance from a vertex', () => {
|
|
142
|
+
expect(polyline().hitTestLineSegment(new Vec(100, -10), new Vec(120, -10), 10)).toBe(true)
|
|
143
|
+
expect(polyline().hitTestLineSegment(new Vec(100, -10), new Vec(120, -10), 9)).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Locks in current behaviour, see #10556.
|
|
147
|
+
it('measures the distance from the vertices, not the edges (known quirk)', () => {
|
|
148
|
+
// the segment is 10 units from the middle of the top edge but 41 from the nearest vertex
|
|
149
|
+
expect(polyline().hitTestLineSegment(new Vec(40, 10), new Vec(60, 10), 10)).toBe(false)
|
|
150
|
+
expect(polyline().hitTestLineSegment(new Vec(40, 10), new Vec(60, 10), 42)).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('Polyline2d.intersectLineSegment', () => {
|
|
155
|
+
it('does not intersect the closing segment', () => {
|
|
156
|
+
expect(polyline().intersectLineSegment(new Vec(50, -10), new Vec(50, 10))).toEqual([
|
|
157
|
+
{ x: 50, y: 0, z: 1 },
|
|
158
|
+
])
|
|
159
|
+
expect(polyline().intersectLineSegment(new Vec(20, 30), new Vec(30, 20))).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('Polyline2d.interpolateAlongEdge', () => {
|
|
164
|
+
it('walks along the segments', () => {
|
|
165
|
+
expect(polyline().interpolateAlongEdge(0)).toMatchObject({ x: 0, y: 0 })
|
|
166
|
+
expect(polyline().interpolateAlongEdge(0.25)).toMatchObject({ x: 50, y: 0 })
|
|
167
|
+
expect(polyline().interpolateAlongEdge(0.75)).toMatchObject({ x: 100, y: 50 })
|
|
168
|
+
expect(polyline().interpolateAlongEdge(1)).toMatchObject({ x: 100, y: 100 })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('is inverted by uninterpolateAlongEdge', () => {
|
|
172
|
+
expect(polyline().uninterpolateAlongEdge(new Vec(50, 0))).toBe(0.25)
|
|
173
|
+
expect(polyline().uninterpolateAlongEdge(new Vec(100, 50))).toBe(0.75)
|
|
174
|
+
expect(polyline().uninterpolateAlongEdge(new Vec(120, 50))).toBe(0.75)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('Polyline2d.getSvgPathData', () => {
|
|
179
|
+
it('emits a move followed by line commands', () => {
|
|
180
|
+
expect(polyline().getSvgPathData()).toBe('M 0 0 L 100 0 L 100 100')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('Polyline2d when closed (via Polygon2d)', () => {
|
|
185
|
+
const closed = (isFilled = false) => new Polygon2d({ points: points(), isFilled })
|
|
186
|
+
|
|
187
|
+
it('uses the closing segment for the nearest point', () => {
|
|
188
|
+
expect(closed().nearestPoint(new Vec(20, 30))).toMatchObject({ x: 25, y: 25 })
|
|
189
|
+
expect(closed().length).toBeCloseTo(200 + Math.hypot(100, 100))
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('returns a negative distance inside when filled or hitInside', () => {
|
|
193
|
+
expect(closed().distanceToPoint(new Vec(90, 10))).toBe(10)
|
|
194
|
+
expect(closed().distanceToPoint(new Vec(90, 10), true)).toBe(-10)
|
|
195
|
+
expect(closed(true).distanceToPoint(new Vec(90, 10))).toBe(-10)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('hits the interior when filled', () => {
|
|
199
|
+
expect(closed(true).hitTestPoint(new Vec(90, 10))).toBe(true)
|
|
200
|
+
expect(closed().hitTestPoint(new Vec(90, 10))).toBe(false)
|
|
201
|
+
expect(closed().hitTestPoint(new Vec(90, 10), 0, true)).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('hits segments crossing the closing segment', () => {
|
|
205
|
+
expect(closed().hitTestLineSegment(new Vec(20, 30), new Vec(30, 20))).toBe(true)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -1,5 +1,193 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { Vec } from '../Vec'
|
|
2
|
+
import { Stadium2d } from './Stadium2d'
|
|
3
|
+
|
|
4
|
+
// A wide 100x50 stadium: semicircles of radius 25 centred at (25, 25) and (75, 25)
|
|
5
|
+
const wide = () => new Stadium2d({ width: 100, height: 50, isFilled: false })
|
|
6
|
+
const wideFilled = () => new Stadium2d({ width: 100, height: 50, isFilled: true })
|
|
7
|
+
// A tall 50x100 stadium: semicircles of radius 25 centred at (25, 25) and (25, 75)
|
|
8
|
+
const tall = () => new Stadium2d({ width: 50, height: 100, isFilled: false })
|
|
9
|
+
|
|
10
|
+
function expectVec(actual: Vec, expected: { x: number; y: number }) {
|
|
11
|
+
expect(actual.x).toBeCloseTo(expected.x)
|
|
12
|
+
expect(actual.y).toBeCloseTo(expected.y)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('Stadium2d construction', () => {
|
|
16
|
+
it('is always closed and keeps the fill flag from config', () => {
|
|
17
|
+
expect(wide()).toMatchObject({ isClosed: true, isFilled: false })
|
|
18
|
+
expect(wideFilled()).toMatchObject({ isClosed: true, isFilled: true })
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('Stadium2d.bounds', () => {
|
|
23
|
+
it('is the width by height box at the origin', () => {
|
|
24
|
+
expect(wide().bounds).toMatchObject({ x: 0, y: 0, w: 100, h: 50 })
|
|
25
|
+
expect(wide().center).toMatchObject({ x: 50, y: 25 })
|
|
26
|
+
expect(tall().bounds).toMatchObject({ x: 0, y: 0, w: 50, h: 100 })
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('Stadium2d.getLength', () => {
|
|
31
|
+
it('is two straight sides plus a full circle', () => {
|
|
32
|
+
expect(wide().length).toBeCloseTo(2 * 50 + Math.PI * 50)
|
|
33
|
+
expect(tall().length).toBeCloseTo(2 * 50 + Math.PI * 50)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('is a circle circumference when width equals height', () => {
|
|
37
|
+
expect(new Stadium2d({ width: 50, height: 50, isFilled: false }).length).toBeCloseTo(
|
|
38
|
+
Math.PI * 50
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('Stadium2d.getVertices', () => {
|
|
44
|
+
it('concatenates the vertices of the two arcs and two edges', () => {
|
|
45
|
+
const { vertices } = wide()
|
|
46
|
+
// each semicircle samples the minimum 8 segments (9 vertices), each edge has 2
|
|
47
|
+
expect(vertices.length).toBe(9 + 2 + 9 + 2)
|
|
48
|
+
// left arc runs from the bottom to the top through the leftmost point
|
|
49
|
+
expectVec(vertices[0], { x: 25, y: 50 })
|
|
50
|
+
expectVec(vertices[4], { x: 0, y: 25 })
|
|
51
|
+
expectVec(vertices[8], { x: 25, y: 0 })
|
|
52
|
+
// top edge
|
|
53
|
+
expect(vertices[9]).toMatchObject({ x: 25, y: 0 })
|
|
54
|
+
expect(vertices[10]).toMatchObject({ x: 75, y: 0 })
|
|
55
|
+
// right arc runs from the top to the bottom through the rightmost point
|
|
56
|
+
expectVec(vertices[11], { x: 75, y: 0 })
|
|
57
|
+
expectVec(vertices[15], { x: 100, y: 25 })
|
|
58
|
+
expectVec(vertices[19], { x: 75, y: 50 })
|
|
59
|
+
// bottom edge
|
|
60
|
+
expect(vertices[20]).toMatchObject({ x: 75, y: 50 })
|
|
61
|
+
expect(vertices[21]).toMatchObject({ x: 25, y: 50 })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('keeps every vertex inside the bounds', () => {
|
|
65
|
+
for (const s of [wide(), tall()]) {
|
|
66
|
+
for (const v of s.vertices) {
|
|
67
|
+
expect(v.x).toBeGreaterThanOrEqual(-1e-9)
|
|
68
|
+
expect(v.y).toBeGreaterThanOrEqual(-1e-9)
|
|
69
|
+
expect(v.x).toBeLessThanOrEqual(s.bounds.w + 1e-9)
|
|
70
|
+
expect(v.y).toBeLessThanOrEqual(s.bounds.h + 1e-9)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('starts a tall stadium with the top arc from left to right', () => {
|
|
76
|
+
const { vertices } = tall()
|
|
77
|
+
expectVec(vertices[0], { x: 0, y: 25 })
|
|
78
|
+
expectVec(vertices[4], { x: 25, y: 0 })
|
|
79
|
+
expectVec(vertices[8], { x: 50, y: 25 })
|
|
80
|
+
expect(vertices[10]).toMatchObject({ x: 50, y: 75 })
|
|
81
|
+
expectVec(vertices[15], { x: 25, y: 100 })
|
|
82
|
+
expect(vertices[21]).toMatchObject({ x: 0, y: 25 })
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('Stadium2d.nearestPoint', () => {
|
|
87
|
+
it('projects onto the straight edges', () => {
|
|
88
|
+
expect(wide().nearestPoint(new Vec(50, -10))).toMatchObject({ x: 50, y: 0 })
|
|
89
|
+
expect(wide().nearestPoint(new Vec(50, 60))).toMatchObject({ x: 50, y: 50 })
|
|
90
|
+
expect(tall().nearestPoint(new Vec(-10, 50))).toMatchObject({ x: 0, y: 50 })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('projects radially onto the arcs', () => {
|
|
94
|
+
expectVec(wide().nearestPoint(new Vec(-10, 25)), { x: 0, y: 25 })
|
|
95
|
+
expectVec(wide().nearestPoint(new Vec(110, 25)), { x: 100, y: 25 })
|
|
96
|
+
expectVec(tall().nearestPoint(new Vec(25, -10)), { x: 25, y: 0 })
|
|
97
|
+
// towards the top-left corner of the bounds the nearest point is on the arc, not the corner
|
|
98
|
+
const p = wide().nearestPoint(new Vec(0, 0))
|
|
99
|
+
expectVec(p, { x: 25 - 25 / Math.SQRT2, y: 25 - 25 / Math.SQRT2 })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('projects from inside', () => {
|
|
103
|
+
expect(wide().nearestPoint(new Vec(50, 20))).toMatchObject({ x: 50, y: 0 })
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('Stadium2d.distanceToPoint', () => {
|
|
108
|
+
it('is positive outside', () => {
|
|
109
|
+
expect(wide().distanceToPoint(new Vec(50, -10))).toBe(10)
|
|
110
|
+
expect(wide().distanceToPoint(new Vec(-10, 25))).toBeCloseTo(10)
|
|
111
|
+
expect(wideFilled().distanceToPoint(new Vec(50, -10))).toBe(10)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('is negative inside only when filled or hitInside', () => {
|
|
115
|
+
expect(wide().distanceToPoint(new Vec(50, 25))).toBe(25)
|
|
116
|
+
expect(wideFilled().distanceToPoint(new Vec(50, 25))).toBe(-25)
|
|
117
|
+
expect(wide().distanceToPoint(new Vec(50, 25), true)).toBe(-25)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('measures to the arc inside the rounded corners', () => {
|
|
121
|
+
// (5, 5) is inside the bounds but outside the left semicircle
|
|
122
|
+
expect(wide().distanceToPoint(new Vec(5, 5))).toBeCloseTo(Math.hypot(20, 20) - 25)
|
|
123
|
+
expect(wideFilled().distanceToPoint(new Vec(5, 5))).toBeCloseTo(Math.hypot(20, 20) - 25)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('Stadium2d.hitTestPoint', () => {
|
|
128
|
+
it('hits the interior only when filled or hitInside', () => {
|
|
129
|
+
expect(wideFilled().hitTestPoint(new Vec(50, 25))).toBe(true)
|
|
130
|
+
expect(wide().hitTestPoint(new Vec(50, 25))).toBe(false)
|
|
131
|
+
expect(wide().hitTestPoint(new Vec(50, 25), 0, true)).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('misses the rounded corners even when filled', () => {
|
|
135
|
+
expect(wideFilled().hitTestPoint(new Vec(5, 5))).toBe(false)
|
|
136
|
+
expect(wideFilled().hitTestPoint(new Vec(95, 45))).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('hits the rounded corners within the margin', () => {
|
|
140
|
+
const d = Math.hypot(20, 20) - 25 // ≈ 3.28
|
|
141
|
+
expect(wide().hitTestPoint(new Vec(5, 5), d + 0.01)).toBe(true)
|
|
142
|
+
expect(wide().hitTestPoint(new Vec(5, 5), d - 0.01)).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('hits the straight edge and respects the margin', () => {
|
|
146
|
+
expect(wide().hitTestPoint(new Vec(50, 0))).toBe(true)
|
|
147
|
+
expect(wide().hitTestPoint(new Vec(50, -5), 5)).toBe(true)
|
|
148
|
+
expect(wide().hitTestPoint(new Vec(50, -5), 4)).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('Stadium2d.hitTestLineSegment', () => {
|
|
153
|
+
it('hits a segment crossing a straight edge', () => {
|
|
154
|
+
expect(wide().hitTestLineSegment(new Vec(50, -10), new Vec(50, 60))).toBe(true)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('hits a segment crossing an arc', () => {
|
|
158
|
+
expect(wide().hitTestLineSegment(new Vec(-10, 25), new Vec(10, 25))).toBe(true)
|
|
159
|
+
// the diagonal from the bounds corner reaches the arc at t ≈ 7.3
|
|
160
|
+
expect(wide().hitTestLineSegment(new Vec(0, 0), new Vec(10, 10))).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('misses a segment that stays within a rounded corner gap', () => {
|
|
164
|
+
expect(wide().hitTestLineSegment(new Vec(0, 0), new Vec(5, 5))).toBe(false)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('misses segments entirely outside or entirely inside', () => {
|
|
168
|
+
expect(wide().hitTestLineSegment(new Vec(110, 0), new Vec(110, 50))).toBe(false)
|
|
169
|
+
expect(wideFilled().hitTestLineSegment(new Vec(40, 25), new Vec(45, 25))).toBe(false)
|
|
170
|
+
expect(wideFilled().hitTestLineSegment(new Vec(45, 2), new Vec(55, 2))).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Locks in current behaviour, see #10555.
|
|
174
|
+
it('hits an interior segment that crosses the far side of an arc circle (known quirk)', () => {
|
|
175
|
+
// (50, 25) is on the left arc's circle but opposite the arc itself; Arc2d clamps points on
|
|
176
|
+
// the complementary arc to the nearest end point, so the crossing counts as a hit
|
|
177
|
+
expect(wideFilled().hitTestLineSegment(new Vec(30, 25), new Vec(70, 25))).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('Stadium2d.getSvgPathData', () => {
|
|
182
|
+
it('draws arc, line, arc, line and closes', () => {
|
|
183
|
+
expect(wide().getSvgPathData()).toBe(
|
|
184
|
+
'M25, 50 A25 25 0 1 1 25, 0 L75, 0 A25 25 0 1 1 75, 50 L25, 50 Z'
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('draws a tall stadium starting from the left of the top arc', () => {
|
|
189
|
+
expect(tall().getSvgPathData()).toBe(
|
|
190
|
+
'M0, 25 A25 25 0 1 1 50, 25 L50, 75 A25 25 0 1 1 0, 75 L0, 25 Z'
|
|
191
|
+
)
|
|
4
192
|
})
|
|
5
193
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getPointInArcT } from './utils'
|
|
1
|
+
import { areAnglesCompatible, getPointInArcT } from './utils'
|
|
2
2
|
|
|
3
3
|
describe('getPointInArcT', () => {
|
|
4
4
|
it('should return 0 for the start of the arc', () => {
|
|
@@ -57,3 +57,12 @@ describe('getPointInArcT', () => {
|
|
|
57
57
|
expect(getPointInArcT(mAB, A, B, P)).toBe(1)
|
|
58
58
|
})
|
|
59
59
|
})
|
|
60
|
+
|
|
61
|
+
describe('areAnglesCompatible', () => {
|
|
62
|
+
it('treats angles a multiple of PI/2 apart as compatible regardless of sign', () => {
|
|
63
|
+
expect(areAnglesCompatible(-Math.PI / 4, Math.PI / 4)).toBe(true)
|
|
64
|
+
expect(areAnglesCompatible(Math.PI / 2 - 1e-9, 0)).toBe(true)
|
|
65
|
+
expect(areAnglesCompatible(Math.PI, -Math.PI / 2)).toBe(true)
|
|
66
|
+
expect(areAnglesCompatible(0, Math.PI / 3)).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -189,7 +189,10 @@ export function snapAngle(r: number, segments: number): number {
|
|
|
189
189
|
* @public
|
|
190
190
|
*/
|
|
191
191
|
export function areAnglesCompatible(a: number, b: number) {
|
|
192
|
-
|
|
192
|
+
// Reduce the difference, not each angle: a sign-preserving % on each made
|
|
193
|
+
// -PI/4 and PI/4 incompatible. A remainder just under PI/2 is a multiple too.
|
|
194
|
+
const d = Math.abs(a - b) % (Math.PI / 2)
|
|
195
|
+
return approximately(d, 0) || approximately(d, Math.PI / 2)
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
/**
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { act, render } from '@testing-library/react'
|
|
2
|
+
import { Editor } from '../editor/Editor'
|
|
3
|
+
import { TldrawEditor } from '../TldrawEditor'
|
|
4
|
+
|
|
5
|
+
function dropEvent(dataTransfer: { files: File[]; getData(type: string): string }) {
|
|
6
|
+
const event = new Event('drop', { bubbles: true, cancelable: true })
|
|
7
|
+
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
|
|
8
|
+
return event
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('useCanvasEvents drop handling', () => {
|
|
12
|
+
let editor: Editor
|
|
13
|
+
let canvas: HTMLElement
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
await act(async () => {
|
|
17
|
+
render(
|
|
18
|
+
<TldrawEditor
|
|
19
|
+
shapeUtils={[]}
|
|
20
|
+
bindingUtils={[]}
|
|
21
|
+
onMount={(e) => {
|
|
22
|
+
editor = e
|
|
23
|
+
}}
|
|
24
|
+
/>
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
canvas = document.querySelector('[data-testid="canvas"]') as HTMLElement
|
|
28
|
+
|
|
29
|
+
// Stand-ins for the default handlers: any undoable change will do.
|
|
30
|
+
editor.registerExternalContentHandler('files', async () => {
|
|
31
|
+
editor.createPage({ name: 'dropped' })
|
|
32
|
+
})
|
|
33
|
+
editor.registerExternalContentHandler('url', async () => {
|
|
34
|
+
editor.createPage({ name: 'dropped' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// An unmarked change before the drop, like a stroke the user just drew.
|
|
38
|
+
editor.createPage({ name: 'before drop' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
editor?.dispose()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function pageNames() {
|
|
46
|
+
return editor.getPages().map((p) => p.name)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('makes a dropped file its own undo step', async () => {
|
|
50
|
+
await act(async () => {
|
|
51
|
+
canvas.dispatchEvent(
|
|
52
|
+
dropEvent({ files: [new File(['x'], 'x.png', { type: 'image/png' })], getData: () => '' })
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
expect(pageNames()).toEqual(['Page 1', 'before drop', 'dropped'])
|
|
56
|
+
|
|
57
|
+
editor.undo()
|
|
58
|
+
|
|
59
|
+
expect(pageNames()).toEqual(['Page 1', 'before drop'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('makes a dropped url its own undo step', async () => {
|
|
63
|
+
await act(async () => {
|
|
64
|
+
canvas.dispatchEvent(
|
|
65
|
+
dropEvent({ files: [], getData: (type) => (type === 'url' ? 'https://example.com' : '') })
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
expect(pageNames()).toEqual(['Page 1', 'before drop', 'dropped'])
|
|
69
|
+
|
|
70
|
+
editor.undo()
|
|
71
|
+
|
|
72
|
+
expect(pageNames()).toEqual(['Page 1', 'before drop'])
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { act, cleanup, render } from '@testing-library/react'
|
|
2
|
+
import { vi } from 'vitest'
|
|
3
|
+
import { createTLStore } from '../config/createTLStore'
|
|
4
|
+
import { Editor } from '../editor/Editor'
|
|
5
|
+
import { TLKeyboardEventInfo } from '../editor/types/event-types'
|
|
6
|
+
import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
|
|
7
|
+
|
|
8
|
+
describe('useDocumentEvents drop handling', () => {
|
|
9
|
+
// The container's native drop listener used to stop propagation before the event reached
|
|
10
|
+
// React's root, so React onDrop handlers inside the canvas never fired.
|
|
11
|
+
it('lets a drop inside the canvas reach React drop handlers there', async () => {
|
|
12
|
+
const onDrop = vi.fn((e: React.DragEvent) => e.stopPropagation())
|
|
13
|
+
function DropTarget() {
|
|
14
|
+
return <div data-testid="drop-target" onDrop={onDrop} />
|
|
15
|
+
}
|
|
16
|
+
const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
|
|
17
|
+
await act(async () => {
|
|
18
|
+
render(<TldrawEditor store={store} components={{ OnTheCanvas: DropTarget }} />)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const target = document.querySelector('[data-testid="drop-target"]')!
|
|
22
|
+
expect(target.closest('.tl-canvas')).not.toBeNull()
|
|
23
|
+
const event = new Event('drop', { bubbles: true, cancelable: true })
|
|
24
|
+
Object.defineProperty(event, 'dataTransfer', { value: { files: [], getData: () => '' } })
|
|
25
|
+
act(() => {
|
|
26
|
+
target.dispatchEvent(event)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(onDrop).toHaveBeenCalledTimes(1)
|
|
30
|
+
// the browser default (navigating to the dropped file) is still prevented
|
|
31
|
+
expect(event.defaultPrevented).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('useDocumentEvents window blur', () => {
|
|
36
|
+
async function renderEditor() {
|
|
37
|
+
let editor!: Editor
|
|
38
|
+
const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
|
|
39
|
+
await act(async () => {
|
|
40
|
+
render(<TldrawEditor store={store} autoFocus onMount={(e) => void (editor = e)} />)
|
|
41
|
+
})
|
|
42
|
+
const container = document.querySelector<HTMLElement>(`.${TL_CONTAINER_CLASS}`)!
|
|
43
|
+
return { editor, container }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function keyDown(container: HTMLElement, init: KeyboardEventInit) {
|
|
47
|
+
act(() => {
|
|
48
|
+
container.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }))
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function blurWindow() {
|
|
53
|
+
act(() => {
|
|
54
|
+
window.dispatchEvent(new Event('blur'))
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
cleanup()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Alt+Tab / Cmd+Tab while holding Space: the keyup goes to the other app, so the editor
|
|
63
|
+
// used to stay in spacebar panning mode with the grab cursor until Space was pressed again.
|
|
64
|
+
it('ends spacebar panning and restores the cursor', async () => {
|
|
65
|
+
const { editor, container } = await renderEditor()
|
|
66
|
+
editor.setCursor({ type: 'cross', rotation: 0 })
|
|
67
|
+
|
|
68
|
+
keyDown(container, { key: ' ', code: 'Space' })
|
|
69
|
+
expect(editor.inputs.keys.has('Space')).toBe(true)
|
|
70
|
+
expect(editor.inputs.getIsSpacebarPanning()).toBe(true)
|
|
71
|
+
expect(editor.inputs.getIsPanning()).toBe(true)
|
|
72
|
+
expect(editor.getInstanceState().cursor.type).toBe('grab')
|
|
73
|
+
|
|
74
|
+
blurWindow()
|
|
75
|
+
expect(editor.inputs.keys.has('Space')).toBe(false)
|
|
76
|
+
expect(editor.inputs.getIsSpacebarPanning()).toBe(false)
|
|
77
|
+
expect(editor.inputs.getIsPanning()).toBe(false)
|
|
78
|
+
expect(editor.getInstanceState().cursor.type).toBe('cross')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('releases every held key through a key_up that tools can match on key', async () => {
|
|
82
|
+
const { editor, container } = await renderEditor()
|
|
83
|
+
const keyUps: TLKeyboardEventInfo[] = []
|
|
84
|
+
editor.on('event', (info) => {
|
|
85
|
+
if (info.type === 'keyboard' && info.name === 'key_up') keyUps.push(info)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
keyDown(container, { key: 'ArrowLeft', code: 'ArrowLeft' })
|
|
89
|
+
keyDown(container, { key: 'a', code: 'KeyA' })
|
|
90
|
+
expect([...editor.inputs.keys]).toEqual(['ArrowLeft', 'KeyA'])
|
|
91
|
+
|
|
92
|
+
blurWindow()
|
|
93
|
+
expect(editor.inputs.keys.size).toBe(0)
|
|
94
|
+
expect(keyUps.map((info) => [info.key, info.code])).toEqual([
|
|
95
|
+
['ArrowLeft', 'ArrowLeft'],
|
|
96
|
+
['a', 'KeyA'],
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does nothing when no keys are held', async () => {
|
|
101
|
+
const { editor } = await renderEditor()
|
|
102
|
+
const onEvent = vi.fn()
|
|
103
|
+
editor.on('event', onEvent)
|
|
104
|
+
blurWindow()
|
|
105
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -22,11 +22,27 @@ function Culled() {
|
|
|
22
22
|
return <button ref={ref} data-testid="marker" />
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The shape the navigation panel has: a pass-through root wrapping another pass-through root (the
|
|
27
|
+
* minimap).
|
|
28
|
+
*/
|
|
29
|
+
function Nested() {
|
|
30
|
+
const outer = useRef<HTMLDivElement>(null)
|
|
31
|
+
const inner = useRef<HTMLButtonElement>(null)
|
|
32
|
+
usePassThroughWheelEvents(outer)
|
|
33
|
+
usePassThroughWheelEvents(inner)
|
|
34
|
+
return (
|
|
35
|
+
<div ref={outer}>
|
|
36
|
+
<button ref={inner} data-testid="marker" />
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
describe('usePassThroughWheelEvents', () => {
|
|
26
|
-
async function renderEditor() {
|
|
42
|
+
async function renderEditor(InFrontOfTheCanvas = Culled) {
|
|
27
43
|
const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
|
|
28
44
|
await act(async () => {
|
|
29
|
-
render(<TldrawEditor store={store} components={{ InFrontOfTheCanvas
|
|
45
|
+
render(<TldrawEditor store={store} components={{ InFrontOfTheCanvas }} />)
|
|
30
46
|
})
|
|
31
47
|
return document.querySelector(`.${TL_CONTAINER_CLASS}`)!
|
|
32
48
|
}
|
|
@@ -64,6 +80,13 @@ describe('usePassThroughWheelEvents', () => {
|
|
|
64
80
|
expect(wheelOverMarker(container)).toHaveLength(1)
|
|
65
81
|
})
|
|
66
82
|
|
|
83
|
+
// Issue #10441: the original event keeps bubbling after the inner root redispatches it, so
|
|
84
|
+
// without a flag on it the outer root would redispatch it again.
|
|
85
|
+
it('redispatches once when the element is inside another pass-through element', async () => {
|
|
86
|
+
const container = await renderEditor(Nested)
|
|
87
|
+
expect(wheelOverMarker(container)).toHaveLength(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
67
90
|
it('stops redispatching once the owner unmounts', async () => {
|
|
68
91
|
const container = await renderEditor()
|
|
69
92
|
const marker = container.querySelector('[data-testid="marker"]')!
|
|
@@ -70,3 +70,18 @@ test.each(testCases)('works with $name', ({ deepLink, expected }) => {
|
|
|
70
70
|
expect(createDeepLinkString(deepLink)).toBe(expected)
|
|
71
71
|
expect(parseDeepLinkString(expected)).toEqual(deepLink)
|
|
72
72
|
})
|
|
73
|
+
|
|
74
|
+
describe('parseDeepLinkString', () => {
|
|
75
|
+
it('throws on a viewport link with non-numeric bounds', () => {
|
|
76
|
+
expect(() => parseDeepLinkString('vabc.def.ghi.jkl')).toThrow('Invalid deep link string')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('throws on a viewport link with missing bounds', () => {
|
|
80
|
+
expect(() => parseDeepLinkString('v1.2')).toThrow('Invalid deep link string')
|
|
81
|
+
expect(() => parseDeepLinkString('v')).toThrow('Invalid deep link string')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('throws on an unknown link type', () => {
|
|
85
|
+
expect(() => parseDeepLinkString('xabc')).toThrow('Invalid deep link string')
|
|
86
|
+
})
|
|
87
|
+
})
|