@tldraw/editor 5.3.0-next.fc37bd96825b → 5.3.1
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 +138 -3
- package/dist-cjs/index.js +13 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +24 -19
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/components/EditorPortal.js +44 -0
- package/dist-cjs/lib/components/EditorPortal.js.map +7 -0
- package/dist-cjs/lib/components/LiveCollaborators.js +102 -0
- package/dist-cjs/lib/components/LiveCollaborators.js.map +7 -0
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js +4 -10
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +3 -3
- package/dist-cjs/lib/components/default-components/DefaultCursor.js +59 -0
- package/dist-cjs/lib/components/default-components/DefaultCursor.js.map +7 -0
- package/dist-cjs/lib/editor/Editor.js +7 -2
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js +4 -4
- package/dist-cjs/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
- package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
- package/dist-cjs/lib/hooks/useCursor.js +1 -0
- package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
- package/dist-cjs/lib/hooks/useDocumentEvents.js +3 -2
- package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
- package/dist-cjs/lib/hooks/useEditorComponents.js +2 -0
- package/dist-cjs/lib/hooks/useEditorComponents.js.map +2 -2
- package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js +49 -18
- package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +3 -3
- package/dist-cjs/lib/hooks/useTransform.js +45 -0
- package/dist-cjs/lib/hooks/useTransform.js.map +7 -0
- package/dist-cjs/lib/license/LicenseManager.js +51 -2
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/license/useLicenseManagerState.js +7 -0
- package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
- package/dist-cjs/lib/utils/collaborators.js +27 -0
- package/dist-cjs/lib/utils/collaborators.js.map +7 -0
- package/dist-cjs/lib/utils/getHtmlLayerTransform.js +33 -0
- package/dist-cjs/lib/utils/getHtmlLayerTransform.js.map +7 -0
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +138 -3
- package/dist-esm/index.mjs +19 -4
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +23 -18
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/EditorPortal.mjs +24 -0
- package/dist-esm/lib/components/EditorPortal.mjs.map +7 -0
- package/dist-esm/lib/components/LiveCollaborators.mjs +82 -0
- package/dist-esm/lib/components/LiveCollaborators.mjs.map +7 -0
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -11
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultCursor.mjs +29 -0
- package/dist-esm/lib/components/default-components/DefaultCursor.mjs.map +7 -0
- package/dist-esm/lib/editor/Editor.mjs +7 -2
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs +5 -5
- package/dist-esm/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCursor.mjs +1 -0
- package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
- package/dist-esm/lib/hooks/useDocumentEvents.mjs +3 -2
- package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
- package/dist-esm/lib/hooks/useEditorComponents.mjs +2 -0
- package/dist-esm/lib/hooks/useEditorComponents.mjs.map +2 -2
- package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs +50 -19
- package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +3 -3
- package/dist-esm/lib/hooks/useTransform.mjs +25 -0
- package/dist-esm/lib/hooks/useTransform.mjs.map +7 -0
- package/dist-esm/lib/license/LicenseManager.mjs +52 -3
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/license/useLicenseManagerState.mjs +7 -0
- package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
- package/dist-esm/lib/utils/collaborators.mjs +7 -0
- package/dist-esm/lib/utils/collaborators.mjs.map +7 -0
- package/dist-esm/lib/utils/getHtmlLayerTransform.mjs +13 -0
- package/dist-esm/lib/utils/getHtmlLayerTransform.mjs.map +7 -0
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +43 -4
- package/package.json +7 -7
- package/src/index.ts +14 -2
- package/src/lib/TldrawEditor.tsx +21 -13
- package/src/lib/components/EditorPortal.tsx +77 -0
- package/src/lib/components/LiveCollaborators.tsx +133 -0
- package/src/lib/components/default-components/DefaultCanvas.tsx +5 -17
- package/src/lib/components/default-components/DefaultCursor.tsx +65 -0
- package/src/lib/editor/Editor.ts +14 -2
- package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts +46 -1
- package/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts +10 -5
- package/src/lib/editor/shapes/ShapeUtil.ts +24 -0
- package/src/lib/editor/types/external-content.ts +5 -0
- package/src/lib/hooks/EditorComponentsContext.tsx +2 -0
- package/src/lib/hooks/useCursor.ts +1 -0
- package/src/lib/hooks/useDocumentEvents.ts +9 -8
- package/src/lib/hooks/useEditorComponents.tsx +2 -0
- package/src/lib/hooks/usePassThroughWheelEvents.ts +83 -24
- package/src/lib/hooks/useTransform.ts +38 -0
- package/src/lib/license/LicenseManager.test.ts +146 -0
- package/src/lib/license/LicenseManager.ts +98 -2
- package/src/lib/license/useLicenseManagerState.ts +17 -1
- package/src/lib/test/EditorPortal.test.tsx +48 -0
- package/src/lib/test/usePassThroughWheelEvents.test.tsx +77 -0
- package/src/lib/utils/collaborators.test.ts +34 -0
- package/src/lib/utils/collaborators.ts +25 -0
- package/src/lib/utils/getHtmlLayerTransform.ts +22 -0
- package/src/version.ts +3 -3
- package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +0 -51
- package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +0 -7
- package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +0 -31
- package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +0 -7
- package/src/lib/hooks/usePassThroughMouseOverEvents.ts +0 -32
|
@@ -1,7 +1,50 @@
|
|
|
1
|
-
import { RefObject, useEffect } from 'react'
|
|
1
|
+
import { RefObject, useEffect, useRef } from 'react'
|
|
2
2
|
import { preventDefault } from '../utils/dom'
|
|
3
3
|
import { useContainer } from './useContainer'
|
|
4
4
|
import { useMaybeEditor } from './useEditor'
|
|
5
|
+
import { useEvent } from './useEvent'
|
|
6
|
+
|
|
7
|
+
function isScrollableOverflow(overflow: string) {
|
|
8
|
+
return overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// An element only consumes a wheel event when it's a real scroll container: its content overflows
|
|
12
|
+
// AND its computed overflow is scrollable. Checking scrollHeight/scrollWidth alone is not enough —
|
|
13
|
+
// an `overflow: visible` element whose content merely overflows (e.g. a comment pin whose hover
|
|
14
|
+
// `transform: scale()` inflates scrollHeight past clientHeight) never scrolls, so the wheel must
|
|
15
|
+
// still pass through to the canvas.
|
|
16
|
+
function isScrollable(elm: HTMLElement) {
|
|
17
|
+
// Cheap first: if nothing overflows, it can't scroll — skip the getComputedStyle read. This runs
|
|
18
|
+
// per ancestor on every wheel event, so avoid resolving styles for the common (non-overflowing) case.
|
|
19
|
+
const overflowsY = elm.scrollHeight > elm.clientHeight
|
|
20
|
+
const overflowsX = elm.scrollWidth > elm.clientWidth
|
|
21
|
+
if (!overflowsY && !overflowsX) return false
|
|
22
|
+
|
|
23
|
+
const style = getComputedStyle(elm)
|
|
24
|
+
return (
|
|
25
|
+
(overflowsY && isScrollableOverflow(style.overflowY)) ||
|
|
26
|
+
(overflowsX && isScrollableOverflow(style.overflowX))
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Walk from the wheeled element up to (and including) the pass-through root, looking for a scroll
|
|
31
|
+
// container. If one is found, the wheel belongs to it — don't redispatch it to the canvas.
|
|
32
|
+
function hasScrollableElement(target: EventTarget | null, root: HTMLElement) {
|
|
33
|
+
if (!(target instanceof Node)) {
|
|
34
|
+
return isScrollable(root)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let elm: Element | null = target instanceof Element ? target : target.parentElement
|
|
38
|
+
while (elm) {
|
|
39
|
+
if (elm instanceof HTMLElement && isScrollable(elm)) {
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
if (elm === root) return false
|
|
43
|
+
elm = elm.parentElement
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
5
48
|
|
|
6
49
|
/** @public */
|
|
7
50
|
export function usePassThroughWheelEvents(ref: RefObject<HTMLElement | null>) {
|
|
@@ -9,33 +52,49 @@ export function usePassThroughWheelEvents(ref: RefObject<HTMLElement | null>) {
|
|
|
9
52
|
const container = useContainer()
|
|
10
53
|
const editor = useMaybeEditor()
|
|
11
54
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
preventDefault(e)
|
|
26
|
-
const cvs = container.querySelector('.tl-canvas')
|
|
27
|
-
if (!cvs) return
|
|
28
|
-
const newEvent = new WheelEvent('wheel', e as any)
|
|
29
|
-
;(newEvent as any).isSpecialRedispatchedEvent = true
|
|
30
|
-
cvs.dispatchEvent(newEvent)
|
|
55
|
+
const onWheel = useEvent((e: WheelEvent) => {
|
|
56
|
+
// Only pass through wheel events if the editor is focused
|
|
57
|
+
if (!editor?.getInstanceState().isFocused) return
|
|
58
|
+
|
|
59
|
+
if ((e as any).isSpecialRedispatchedEvent) return
|
|
60
|
+
|
|
61
|
+
// If the wheel is over a scrollable element, let it scroll instead of redispatching.
|
|
62
|
+
const elm = ref.current
|
|
63
|
+
if (elm && hasScrollableElement(e.target, elm)) {
|
|
64
|
+
return
|
|
31
65
|
}
|
|
32
66
|
|
|
67
|
+
preventDefault(e)
|
|
68
|
+
const cvs = container.querySelector('.tl-canvas')
|
|
69
|
+
if (!cvs) return
|
|
70
|
+
const newEvent = new WheelEvent('wheel', e as any)
|
|
71
|
+
;(newEvent as any).isSpecialRedispatchedEvent = true
|
|
72
|
+
cvs.dispatchEvent(newEvent)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// The element the listener is currently on, which isn't necessarily `ref.current`: a `RefObject`
|
|
76
|
+
// gives no notification when its element changes.
|
|
77
|
+
const attached = useRef<HTMLElement | null>(null)
|
|
78
|
+
|
|
79
|
+
// Deliberately no dependency array — this re-checks the ref after every render of the component
|
|
80
|
+
// that owns it, and re-attaches when the element has been swapped. A component that keeps the
|
|
81
|
+
// hook mounted while unmounting and remounting the element it points at (a comment pin culled
|
|
82
|
+
// while its anchor is off screen, say) would otherwise leave the listener on the discarded node
|
|
83
|
+
// and silently lose pass-through once the element comes back.
|
|
84
|
+
useEffect(() => {
|
|
33
85
|
const elm = ref.current
|
|
34
|
-
if (
|
|
86
|
+
if (elm === attached.current) return
|
|
87
|
+
attached.current?.removeEventListener('wheel', onWheel)
|
|
88
|
+
attached.current = elm
|
|
89
|
+
elm?.addEventListener('wheel', onWheel, { passive: false })
|
|
90
|
+
})
|
|
35
91
|
|
|
36
|
-
|
|
92
|
+
// Teardown is its own effect so the re-attach check above can skip returning a cleanup that
|
|
93
|
+
// would tear the listener down and rebuild it on every render.
|
|
94
|
+
useEffect(() => {
|
|
37
95
|
return () => {
|
|
38
|
-
|
|
96
|
+
attached.current?.removeEventListener('wheel', onWheel)
|
|
97
|
+
attached.current = null
|
|
39
98
|
}
|
|
40
|
-
}, [
|
|
99
|
+
}, [onWheel])
|
|
41
100
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'react'
|
|
2
|
+
import { VecLike } from '../primitives/Vec'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Position an element by writing its `transform` directly, outside React's render output — the
|
|
6
|
+
* cheap path for elements that move at pointer/presence frequency.
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export function useTransform(
|
|
10
|
+
ref: React.RefObject<HTMLElement | SVGElement | null>,
|
|
11
|
+
x?: number,
|
|
12
|
+
y?: number,
|
|
13
|
+
scale?: number,
|
|
14
|
+
rotate?: number,
|
|
15
|
+
additionalOffset?: VecLike
|
|
16
|
+
) {
|
|
17
|
+
// Read the offset into primitives so the effect can depend on them, not the object identity.
|
|
18
|
+
const additionalOffsetX = additionalOffset?.x
|
|
19
|
+
const additionalOffsetY = additionalOffset?.y
|
|
20
|
+
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
const elm = ref.current
|
|
23
|
+
if (!elm) return
|
|
24
|
+
if (x === undefined) return
|
|
25
|
+
|
|
26
|
+
let trans = `translate(${x}px, ${y}px)`
|
|
27
|
+
if (scale !== undefined) {
|
|
28
|
+
trans += ` scale(${scale})`
|
|
29
|
+
}
|
|
30
|
+
if (rotate !== undefined) {
|
|
31
|
+
trans += ` rotate(${rotate}rad)`
|
|
32
|
+
}
|
|
33
|
+
if (additionalOffsetX !== undefined && additionalOffsetY !== undefined) {
|
|
34
|
+
trans += ` translate(${additionalOffsetX}px, ${additionalOffsetY}px)`
|
|
35
|
+
}
|
|
36
|
+
elm.style.transform = trans
|
|
37
|
+
}, [additionalOffsetX, additionalOffsetY, ref, rotate, scale, x, y])
|
|
38
|
+
}
|
|
@@ -4,6 +4,7 @@ import { publishDates } from '../../version'
|
|
|
4
4
|
import { str2ab } from '../utils/licensing'
|
|
5
5
|
import {
|
|
6
6
|
FLAGS,
|
|
7
|
+
getEnabledFeatures,
|
|
7
8
|
getLicenseState,
|
|
8
9
|
LicenseManager,
|
|
9
10
|
PROPERTIES,
|
|
@@ -508,6 +509,62 @@ describe('LicenseManager', () => {
|
|
|
508
509
|
expect(result.isEvaluationLicense).toBe(true)
|
|
509
510
|
expect(result.isEvaluationLicenseExpired).toBe(true)
|
|
510
511
|
})
|
|
512
|
+
|
|
513
|
+
it('Checks for the commenting feature flag', async () => {
|
|
514
|
+
const commentingLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
|
|
515
|
+
commentingLicenseInfo[PROPERTIES.FLAGS] |= FLAGS.FEAT_COMMENTING
|
|
516
|
+
const commentingLicenseKey = await generateLicenseKey(
|
|
517
|
+
JSON.stringify(commentingLicenseInfo),
|
|
518
|
+
keyPair
|
|
519
|
+
)
|
|
520
|
+
const result = (await licenseManager.getLicenseFromKey(
|
|
521
|
+
commentingLicenseKey
|
|
522
|
+
)) as ValidLicenseKeyResult
|
|
523
|
+
expect(result.isCommentingEnabled).toBe(true)
|
|
524
|
+
expect(result.isCollaborationEnabled).toBe(false)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('Checks for the collaboration feature flag', async () => {
|
|
528
|
+
const collaborationLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
|
|
529
|
+
collaborationLicenseInfo[PROPERTIES.FLAGS] |= FLAGS.FEAT_COLLABORATION
|
|
530
|
+
const collaborationLicenseKey = await generateLicenseKey(
|
|
531
|
+
JSON.stringify(collaborationLicenseInfo),
|
|
532
|
+
keyPair
|
|
533
|
+
)
|
|
534
|
+
const result = (await licenseManager.getLicenseFromKey(
|
|
535
|
+
collaborationLicenseKey
|
|
536
|
+
)) as ValidLicenseKeyResult
|
|
537
|
+
expect(result.isCollaborationEnabled).toBe(true)
|
|
538
|
+
// The collaboration umbrella grants the commenting sub-feature.
|
|
539
|
+
expect(result.isCommentingEnabled).toBe(true)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('Leaves feature flags off when no feature bits are set', async () => {
|
|
543
|
+
const standardLicenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
|
|
544
|
+
const result = (await licenseManager.getLicenseFromKey(
|
|
545
|
+
standardLicenseKey
|
|
546
|
+
)) as ValidLicenseKeyResult
|
|
547
|
+
expect(result.isCollaborationEnabled).toBe(false)
|
|
548
|
+
expect(result.isCommentingEnabled).toBe(false)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('Enables features synchronously in development, before validation resolves', () => {
|
|
552
|
+
process.env.NODE_ENV = 'development'
|
|
553
|
+
// @ts-ignore
|
|
554
|
+
delete window.location
|
|
555
|
+
// @ts-ignore
|
|
556
|
+
window.location = new URL('https://www.example.com')
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
// No await: the feature flags should already reflect development (all enabled) rather
|
|
560
|
+
// than the fail-closed default, which only lifts once async validation resolves.
|
|
561
|
+
const devLicenseManager = new LicenseManager('', keyPair.publicKey)
|
|
562
|
+
expect(devLicenseManager.isFeatureEnabled('commenting')).toBe(true)
|
|
563
|
+
expect(devLicenseManager.isFeatureEnabled('collaboration')).toBe(true)
|
|
564
|
+
} finally {
|
|
565
|
+
process.env.NODE_ENV = 'test'
|
|
566
|
+
}
|
|
567
|
+
})
|
|
511
568
|
})
|
|
512
569
|
|
|
513
570
|
describe('Expiry date timezone handling', () => {
|
|
@@ -793,6 +850,8 @@ function getDefaultLicenseResult(overrides: Partial<ValidLicenseKeyResult>): Val
|
|
|
793
850
|
isLicensedWithWatermark: false,
|
|
794
851
|
isEvaluationLicense: false,
|
|
795
852
|
isEvaluationLicenseExpired: false,
|
|
853
|
+
isCollaborationEnabled: false,
|
|
854
|
+
isCommentingEnabled: false,
|
|
796
855
|
daysSinceExpiry: 0,
|
|
797
856
|
// WatermarkManager does not check these fields, it relies on the calculated values like isAnnualLicenseExpired
|
|
798
857
|
license: {
|
|
@@ -1094,3 +1153,90 @@ describe('getLicenseState', () => {
|
|
|
1094
1153
|
})
|
|
1095
1154
|
})
|
|
1096
1155
|
})
|
|
1156
|
+
|
|
1157
|
+
describe('getEnabledFeatures', () => {
|
|
1158
|
+
it('enables every feature in development regardless of flags', () => {
|
|
1159
|
+
const result = getDefaultLicenseResult({
|
|
1160
|
+
isCollaborationEnabled: false,
|
|
1161
|
+
isCommentingEnabled: false,
|
|
1162
|
+
})
|
|
1163
|
+
expect(getEnabledFeatures(result, 'unlicensed', true)).toEqual({
|
|
1164
|
+
collaboration: true,
|
|
1165
|
+
commenting: true,
|
|
1166
|
+
})
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
it('reflects the license flags for a valid licensed state', () => {
|
|
1170
|
+
const result = getDefaultLicenseResult({
|
|
1171
|
+
isCollaborationEnabled: false,
|
|
1172
|
+
isCommentingEnabled: true,
|
|
1173
|
+
})
|
|
1174
|
+
expect(getEnabledFeatures(result, 'licensed', false)).toEqual({
|
|
1175
|
+
collaboration: false,
|
|
1176
|
+
commenting: true,
|
|
1177
|
+
})
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
it('still grants features while showing a watermark', () => {
|
|
1181
|
+
const result = getDefaultLicenseResult({
|
|
1182
|
+
isCollaborationEnabled: true,
|
|
1183
|
+
isCommentingEnabled: true,
|
|
1184
|
+
})
|
|
1185
|
+
expect(getEnabledFeatures(result, 'licensed-with-watermark', false)).toEqual({
|
|
1186
|
+
collaboration: true,
|
|
1187
|
+
commenting: true,
|
|
1188
|
+
})
|
|
1189
|
+
})
|
|
1190
|
+
|
|
1191
|
+
it.each(['unlicensed', 'unlicensed-production', 'expired', 'pending'] as const)(
|
|
1192
|
+
'disables all features for the %s state in production',
|
|
1193
|
+
(state) => {
|
|
1194
|
+
const result = getDefaultLicenseResult({
|
|
1195
|
+
isCollaborationEnabled: true,
|
|
1196
|
+
isCommentingEnabled: true,
|
|
1197
|
+
})
|
|
1198
|
+
expect(getEnabledFeatures(result, state, false)).toEqual({
|
|
1199
|
+
collaboration: false,
|
|
1200
|
+
commenting: false,
|
|
1201
|
+
})
|
|
1202
|
+
}
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
it('enables every feature for an evaluation license regardless of flags', () => {
|
|
1206
|
+
const result = getDefaultLicenseResult({
|
|
1207
|
+
isEvaluationLicense: true,
|
|
1208
|
+
isCollaborationEnabled: false,
|
|
1209
|
+
isCommentingEnabled: false,
|
|
1210
|
+
})
|
|
1211
|
+
expect(getEnabledFeatures(result, 'licensed', false)).toEqual({
|
|
1212
|
+
collaboration: true,
|
|
1213
|
+
commenting: true,
|
|
1214
|
+
})
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
it('disables all features for an expired evaluation license', () => {
|
|
1218
|
+
const result = getDefaultLicenseResult({
|
|
1219
|
+
isEvaluationLicense: true,
|
|
1220
|
+
isEvaluationLicenseExpired: true,
|
|
1221
|
+
isCollaborationEnabled: false,
|
|
1222
|
+
isCommentingEnabled: false,
|
|
1223
|
+
})
|
|
1224
|
+
expect(getEnabledFeatures(result, 'expired', false)).toEqual({
|
|
1225
|
+
collaboration: false,
|
|
1226
|
+
commenting: false,
|
|
1227
|
+
})
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
it('disables all features when the license is not parseable', () => {
|
|
1231
|
+
expect(
|
|
1232
|
+
getEnabledFeatures(
|
|
1233
|
+
{ isLicenseParseable: false, reason: 'no-key-provided' },
|
|
1234
|
+
'licensed',
|
|
1235
|
+
false
|
|
1236
|
+
)
|
|
1237
|
+
).toEqual({
|
|
1238
|
+
collaboration: false,
|
|
1239
|
+
commenting: false,
|
|
1240
|
+
})
|
|
1241
|
+
})
|
|
1242
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { atom } from '@tldraw/state'
|
|
1
|
+
import { atom, transact } from '@tldraw/state'
|
|
2
2
|
import { publishDates, version } from '../../version'
|
|
3
3
|
import { getDefaultCdnBaseUrl } from '../utils/assets'
|
|
4
4
|
import { importPublicKey, str2ab } from '../utils/licensing'
|
|
@@ -22,6 +22,12 @@ export const FLAGS = {
|
|
|
22
22
|
// Native means the license is for native apps which switches
|
|
23
23
|
// on special-case logic.
|
|
24
24
|
NATIVE_LICENSE: 1 << 5,
|
|
25
|
+
|
|
26
|
+
// -- FEATURE FLAGS --
|
|
27
|
+
// Collaboration is the umbrella flag for collaboration features; it grants all sub-features.
|
|
28
|
+
FEAT_COLLABORATION: 1 << 6,
|
|
29
|
+
// Commenting is the first sub-feature of collaboration.
|
|
30
|
+
FEAT_COMMENTING: 1 << 7,
|
|
25
31
|
}
|
|
26
32
|
const HIGHEST_FLAG = Math.max(...Object.values(FLAGS))
|
|
27
33
|
|
|
@@ -45,6 +51,23 @@ export interface LicenseInfo {
|
|
|
45
51
|
expiryDate: string
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Names of the licensable product features gated by the license. `collaboration` is an umbrella
|
|
56
|
+
* that also grants all of its sub-features (currently just `commenting`).
|
|
57
|
+
*
|
|
58
|
+
* @internal
|
|
59
|
+
*/
|
|
60
|
+
export type LicenseFeatureName = 'collaboration' | 'commenting'
|
|
61
|
+
|
|
62
|
+
const NO_FEATURES: Readonly<Record<LicenseFeatureName, boolean>> = {
|
|
63
|
+
collaboration: false,
|
|
64
|
+
commenting: false,
|
|
65
|
+
}
|
|
66
|
+
const ALL_FEATURES: Readonly<Record<LicenseFeatureName, boolean>> = {
|
|
67
|
+
collaboration: true,
|
|
68
|
+
commenting: true,
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
/** @internal */
|
|
49
72
|
export type LicenseState =
|
|
50
73
|
| 'pending' // License validation is in progress
|
|
@@ -84,6 +107,8 @@ export interface ValidLicenseKeyResult {
|
|
|
84
107
|
isLicensedWithWatermark: boolean
|
|
85
108
|
isEvaluationLicense: boolean
|
|
86
109
|
isEvaluationLicenseExpired: boolean
|
|
110
|
+
isCollaborationEnabled: boolean
|
|
111
|
+
isCommentingEnabled: boolean
|
|
87
112
|
daysSinceExpiry: number
|
|
88
113
|
}
|
|
89
114
|
|
|
@@ -98,6 +123,9 @@ export class LicenseManager {
|
|
|
98
123
|
public isTest: boolean
|
|
99
124
|
public isCryptoAvailable: boolean
|
|
100
125
|
state = atom<LicenseState>('license state', 'pending')
|
|
126
|
+
featureFlags = atom<Record<LicenseFeatureName, boolean>>('license feature flags', {
|
|
127
|
+
...NO_FEATURES,
|
|
128
|
+
})
|
|
101
129
|
public verbose = true
|
|
102
130
|
|
|
103
131
|
constructor(licenseKey: string | undefined, testPublicKey?: string) {
|
|
@@ -106,6 +134,14 @@ export class LicenseManager {
|
|
|
106
134
|
this.publicKey = testPublicKey || this.publicKey
|
|
107
135
|
this.isCryptoAvailable = !!crypto.subtle
|
|
108
136
|
|
|
137
|
+
// In development every feature is enabled (see `getEnabledFeatures`), and that doesn't depend
|
|
138
|
+
// on the async validation result. Reflect it eagerly so features aren't reported as disabled
|
|
139
|
+
// during the validation window — or left disabled if validation rejects (the `.catch` below
|
|
140
|
+
// never sets `featureFlags`). In production the fail-closed default stands until validation.
|
|
141
|
+
if (this.isDevelopment) {
|
|
142
|
+
this.featureFlags.set({ ...ALL_FEATURES })
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
this.getLicenseFromKey(licenseKey)
|
|
110
146
|
.then((result) => {
|
|
111
147
|
const licenseState = getLicenseState(
|
|
@@ -116,7 +152,12 @@ export class LicenseManager {
|
|
|
116
152
|
|
|
117
153
|
this.maybeTrack(result, licenseState)
|
|
118
154
|
|
|
119
|
-
|
|
155
|
+
// Update both atoms atomically so dependents never observe the license state and the
|
|
156
|
+
// feature flags out of sync mid-update.
|
|
157
|
+
transact(() => {
|
|
158
|
+
this.state.set(licenseState)
|
|
159
|
+
this.featureFlags.set(getEnabledFeatures(result, licenseState, this.isDevelopment))
|
|
160
|
+
})
|
|
120
161
|
})
|
|
121
162
|
.catch((error) => {
|
|
122
163
|
console.error('License validation failed:', error)
|
|
@@ -124,6 +165,14 @@ export class LicenseManager {
|
|
|
124
165
|
})
|
|
125
166
|
}
|
|
126
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Returns whether a given licensable feature is enabled. Reactive: reading this inside a signal
|
|
170
|
+
* recomputes when license validation resolves.
|
|
171
|
+
*/
|
|
172
|
+
isFeatureEnabled(feature: LicenseFeatureName): boolean {
|
|
173
|
+
return this.featureFlags.get()[feature]
|
|
174
|
+
}
|
|
175
|
+
|
|
127
176
|
private getIsDevelopment() {
|
|
128
177
|
// If we are using https on a non-loopback domain we assume it's a production env and a development one otherwise
|
|
129
178
|
return (
|
|
@@ -286,6 +335,12 @@ export class LicenseManager {
|
|
|
286
335
|
const isPerpetualLicenseExpired =
|
|
287
336
|
isPerpetualLicense && this.isPerpetualLicenseExpired(expiryDate)
|
|
288
337
|
|
|
338
|
+
// The collaboration umbrella grants all of its sub-features, so commenting is enabled
|
|
339
|
+
// by either the commenting flag or the collaboration flag.
|
|
340
|
+
const isCollaborationEnabled = this.isFlagEnabled(licenseInfo.flags, FLAGS.FEAT_COLLABORATION)
|
|
341
|
+
const isCommentingEnabled =
|
|
342
|
+
isCollaborationEnabled || this.isFlagEnabled(licenseInfo.flags, FLAGS.FEAT_COMMENTING)
|
|
343
|
+
|
|
289
344
|
// For perpetual licenses, the calendar expiry date only gates access to future
|
|
290
345
|
// major/minor releases; it does not "expire" the license itself. While the user
|
|
291
346
|
// is still on a covered version we report `daysSinceExpiry` as 0 so consumers
|
|
@@ -309,6 +364,8 @@ export class LicenseManager {
|
|
|
309
364
|
isEvaluationLicense,
|
|
310
365
|
isEvaluationLicenseExpired:
|
|
311
366
|
isEvaluationLicense && this.isEvaluationLicenseExpired(expiryDate),
|
|
367
|
+
isCollaborationEnabled,
|
|
368
|
+
isCommentingEnabled,
|
|
312
369
|
daysSinceExpiry,
|
|
313
370
|
}
|
|
314
371
|
this.outputLicenseInfoIfNeeded(result)
|
|
@@ -483,6 +540,45 @@ export class LicenseManager {
|
|
|
483
540
|
static className = 'tl-watermark_SEE-LICENSE'
|
|
484
541
|
}
|
|
485
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Derives which licensable features are enabled from the parse result and the derived license
|
|
545
|
+
* state. In development every feature is enabled so SDK developers can build against them; in
|
|
546
|
+
* production a feature requires both an active, valid license and the corresponding flag.
|
|
547
|
+
*
|
|
548
|
+
* @internal
|
|
549
|
+
*/
|
|
550
|
+
export function getEnabledFeatures(
|
|
551
|
+
result: LicenseFromKeyResult,
|
|
552
|
+
licenseState: LicenseState,
|
|
553
|
+
isDevelopment: boolean
|
|
554
|
+
): Record<LicenseFeatureName, boolean> {
|
|
555
|
+
// Development gets all features so SDK developers can build against them.
|
|
556
|
+
if (isDevelopment) {
|
|
557
|
+
return { ...ALL_FEATURES }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Features require an active, valid license. Both the 30-day grace-period 'licensed' state and
|
|
561
|
+
// the watermark state count as valid; unlicensed/expired/pending do not.
|
|
562
|
+
if (
|
|
563
|
+
!result.isLicenseParseable ||
|
|
564
|
+
(licenseState !== 'licensed' && licenseState !== 'licensed-with-watermark')
|
|
565
|
+
) {
|
|
566
|
+
return { ...NO_FEATURES }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Evaluation licenses get every feature so prospects can trial the whole product without us
|
|
570
|
+
// having to mint per-feature evaluation keys. Expired evaluation licenses are already excluded
|
|
571
|
+
// above: they resolve to the 'expired' state.
|
|
572
|
+
if (result.isEvaluationLicense) {
|
|
573
|
+
return { ...ALL_FEATURES }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
collaboration: result.isCollaborationEnabled,
|
|
578
|
+
commenting: result.isCommentingEnabled,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
486
582
|
export function getLicenseState(
|
|
487
583
|
result: LicenseFromKeyResult,
|
|
488
584
|
outputMessages: (messages: string[]) => void,
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { useValue } from '@tldraw/state-react'
|
|
2
|
-
import { LicenseManager, LicenseState } from './LicenseManager'
|
|
2
|
+
import { LicenseFeatureName, LicenseManager, LicenseState } from './LicenseManager'
|
|
3
3
|
|
|
4
4
|
/** @internal */
|
|
5
5
|
export function useLicenseManagerState(licenseManager: LicenseManager): LicenseState {
|
|
6
6
|
return useValue('watermarkState', () => licenseManager.state.get(), [licenseManager])
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reactively reads whether a licensable feature is enabled for the current license. Re-renders when
|
|
11
|
+
* license validation resolves.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export function useLicenseFeatureFlag(
|
|
16
|
+
licenseManager: LicenseManager,
|
|
17
|
+
feature: LicenseFeatureName
|
|
18
|
+
): boolean {
|
|
19
|
+
return useValue('licenseFeature', () => licenseManager.isFeatureEnabled(feature), [
|
|
20
|
+
licenseManager,
|
|
21
|
+
feature,
|
|
22
|
+
])
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react'
|
|
2
|
+
import { EditorPortal } from '../components/EditorPortal'
|
|
3
|
+
import { createTLStore } from '../config/createTLStore'
|
|
4
|
+
import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
|
|
5
|
+
|
|
6
|
+
/** Mounted in a canvas slot — the deep-in-the-tree case the portal exists for. */
|
|
7
|
+
function Portaled() {
|
|
8
|
+
return (
|
|
9
|
+
<EditorPortal>
|
|
10
|
+
<div data-testid="portaled" />
|
|
11
|
+
</EditorPortal>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('EditorPortal', () => {
|
|
16
|
+
async function renderEditor() {
|
|
17
|
+
const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
|
|
18
|
+
await act(async () => {
|
|
19
|
+
render(<TldrawEditor store={store} components={{ InFrontOfTheCanvas: Portaled }} />)
|
|
20
|
+
})
|
|
21
|
+
return document.querySelector(`.${TL_CONTAINER_CLASS}`)!
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it('renders its children into the editor container', async () => {
|
|
25
|
+
const container = await renderEditor()
|
|
26
|
+
expect(container.contains(screen.getByTestId('portaled'))).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders into a host that comes after everything the editor renders', async () => {
|
|
30
|
+
const container = await renderEditor()
|
|
31
|
+
const host = container.querySelector('.tl-portal-host')!
|
|
32
|
+
expect(host.contains(screen.getByTestId('portaled'))).toBe(true)
|
|
33
|
+
// Last of the container's rendered children. Managers append their own nodes after this one
|
|
34
|
+
// (the text measurement element, say) but those are `tabIndex = -1` and out of the tab order.
|
|
35
|
+
const rendered = [...container.children].filter((el) => !el.matches('.tl-text-measure'))
|
|
36
|
+
expect(rendered.at(-1)).toBe(host)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// The tab order guarantee: mounted in a canvas slot, the portaled content still lands after
|
|
40
|
+
// everything the editor renders itself — including (in tldraw) the UI's skip link, which only
|
|
41
|
+
// works while nothing precedes it. Portaling straight into the container lands it ahead of them.
|
|
42
|
+
it('places its children after the rest of the editor in document order', async () => {
|
|
43
|
+
await renderEditor()
|
|
44
|
+
const portaled = screen.getByTestId('portaled')
|
|
45
|
+
const canvas = screen.getByTestId('canvas')
|
|
46
|
+
expect(canvas.compareDocumentPosition(portaled) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { act, cleanup, render } from '@testing-library/react'
|
|
2
|
+
import { useRef, useState } from 'react'
|
|
3
|
+
import { createTLStore } from '../config/createTLStore'
|
|
4
|
+
import { usePassThroughWheelEvents } from '../hooks/usePassThroughWheelEvents'
|
|
5
|
+
import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
|
|
6
|
+
|
|
7
|
+
let setVisible: (visible: boolean) => void
|
|
8
|
+
let rerender: () => void
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The shape a comment pin has: the hook's owner stays mounted while the element the ref points at
|
|
12
|
+
* is culled and later remounted (the pin returns null when its anchor leaves the viewport).
|
|
13
|
+
*/
|
|
14
|
+
function Culled() {
|
|
15
|
+
const ref = useRef<HTMLButtonElement>(null)
|
|
16
|
+
usePassThroughWheelEvents(ref)
|
|
17
|
+
const [visible, _setVisible] = useState(true)
|
|
18
|
+
const [, setTick] = useState(0)
|
|
19
|
+
setVisible = _setVisible
|
|
20
|
+
rerender = () => setTick((t) => t + 1)
|
|
21
|
+
if (!visible) return null
|
|
22
|
+
return <button ref={ref} data-testid="marker" />
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('usePassThroughWheelEvents', () => {
|
|
26
|
+
async function renderEditor() {
|
|
27
|
+
const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
|
|
28
|
+
await act(async () => {
|
|
29
|
+
render(<TldrawEditor store={store} components={{ InFrontOfTheCanvas: Culled }} />)
|
|
30
|
+
})
|
|
31
|
+
return document.querySelector(`.${TL_CONTAINER_CLASS}`)!
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function wheelOverMarker(container: Element) {
|
|
35
|
+
const canvas = container.querySelector('.tl-canvas')!
|
|
36
|
+
const received: Event[] = []
|
|
37
|
+
canvas.addEventListener('wheel', (e) => received.push(e))
|
|
38
|
+
const marker = container.querySelector('[data-testid="marker"]')!
|
|
39
|
+
marker.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 10 }))
|
|
40
|
+
return received
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it('redispatches wheel events over the element to the canvas', async () => {
|
|
44
|
+
const container = await renderEditor()
|
|
45
|
+
expect(wheelOverMarker(container)).toHaveLength(1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// The regression: the owner stays mounted across the cull, so an effect keyed on its deps never
|
|
49
|
+
// re-runs and the listener is left on the discarded element.
|
|
50
|
+
it('still redispatches after the element unmounts and remounts', async () => {
|
|
51
|
+
const container = await renderEditor()
|
|
52
|
+
await act(async () => setVisible(false))
|
|
53
|
+
await act(async () => setVisible(true))
|
|
54
|
+
expect(wheelOverMarker(container)).toHaveLength(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// The re-attach check runs on every render, so it has to be a no-op when the element hasn't
|
|
58
|
+
// changed — otherwise each render stacks another listener and one wheel redispatches many.
|
|
59
|
+
it('does not stack listeners across renders that leave the element in place', async () => {
|
|
60
|
+
const container = await renderEditor()
|
|
61
|
+
for (let i = 0; i < 3; i++) {
|
|
62
|
+
await act(async () => rerender())
|
|
63
|
+
}
|
|
64
|
+
expect(wheelOverMarker(container)).toHaveLength(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('stops redispatching once the owner unmounts', async () => {
|
|
68
|
+
const container = await renderEditor()
|
|
69
|
+
const marker = container.querySelector('[data-testid="marker"]')!
|
|
70
|
+
const canvas = container.querySelector('.tl-canvas')!
|
|
71
|
+
const received: Event[] = []
|
|
72
|
+
canvas.addEventListener('wheel', (e) => received.push(e))
|
|
73
|
+
await act(async () => cleanup())
|
|
74
|
+
marker.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 10 }))
|
|
75
|
+
expect(received).toHaveLength(0)
|
|
76
|
+
})
|
|
77
|
+
})
|