diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e70e7168d..e172c635b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -19,7 +19,7 @@ body: id: reproduction attributes: label: How can we reproduce the bug? - description: If you can make the bug happen again, please share the steps involved. + description: If you can make the bug happen again, please share the steps involved. You can [fork this CodeSandbox](https://codesandbox.io/p/sandbox/tldraw-example-n539u) to make a reproduction. validations: required: false - type: dropdown diff --git a/apps/dotcom/src/components/IFrameProtector.tsx b/apps/dotcom/src/components/IFrameProtector.tsx index cc3ea7355..b4fd9f06d 100644 --- a/apps/dotcom/src/components/IFrameProtector.tsx +++ b/apps/dotcom/src/components/IFrameProtector.tsx @@ -1,5 +1,6 @@ -import { ReactNode, useEffect, useState, version } from 'react' +import { ReactNode, useEffect, useState } from 'react' import { LoadingScreen } from 'tldraw' +import { version } from '../../version' import { useUrl } from '../hooks/useUrl' import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent' @@ -113,7 +114,7 @@ export function IFrameProtector({
- {'Visit this page on tldraw.com '} + {'Visit this page on tldraw.com'} { text={text} labelColor={theme[color].solid} isSelected={isSelected} - disableTab wrap /> diff --git a/apps/vscode/extension/CHANGELOG.md b/apps/vscode/extension/CHANGELOG.md index 7258ab1d7..f90bd5340 100644 --- a/apps/vscode/extension/CHANGELOG.md +++ b/apps/vscode/extension/CHANGELOG.md @@ -1,3 +1,13 @@ +## 2.0.30 + +- Fixes a bug that prevented opening some files. + +## 2.0.29 + +- Improved note shapes. +- Color improvements for both light and dark mode. +- Bug fixes and performance improvements. + ## 2.0.28 - Fix an issue with panning the canvas. diff --git a/apps/vscode/extension/package.json b/apps/vscode/extension/package.json index 3f78d29f0..f70a6dae6 100644 --- a/apps/vscode/extension/package.json +++ b/apps/vscode/extension/package.json @@ -1,7 +1,7 @@ { "name": "tldraw-vscode", "description": "The tldraw extension for VS Code.", - "version": "2.0.28", + "version": "2.0.30", "private": true, "author": { "name": "tldraw Inc.", diff --git a/config/setupJest.ts b/config/setupJest.ts index bc6cecb22..d89601dd3 100644 --- a/config/setupJest.ts +++ b/config/setupJest.ts @@ -11,6 +11,10 @@ import { TextDecoder, TextEncoder } from 'util' global.TextEncoder = TextEncoder global.TextDecoder = TextDecoder +Image.prototype.decode = async function () { + return true +} + function convertNumbersInObject(obj: any, roundToNearest: number) { if (!obj) return obj if (Array.isArray(obj)) { diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 624eb33ad..eccc06a84 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -698,6 +698,8 @@ export class Editor extends EventEmitter { getCameraState(): "idle" | "moving"; getCanRedo(): boolean; getCanUndo(): boolean; + getCollaborators(): TLInstancePresence[]; + getCollaboratorsOnCurrentPage(): TLInstancePresence[]; getContainer: () => HTMLElement; getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined; // @internal @@ -709,6 +711,8 @@ export class Editor extends EventEmitter { getCurrentPageId(): TLPageId; getCurrentPageRenderingShapesSorted(): TLShape[]; getCurrentPageShapeIds(): Set; + // @internal (undocumented) + getCurrentPageShapeIdsSorted(): TLShapeId[]; getCurrentPageShapes(): TLShape[]; getCurrentPageShapesSorted(): TLShape[]; getCurrentPageState(): TLInstancePageState; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 5abc04426..4afeb56ed 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -10030,6 +10030,86 @@ "isAbstract": false, "name": "getCanUndo" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getCollaborators:member(1)", + "docComment": "/**\n * Returns a list of presence records for all peer collaborators. This will return the latest presence record for each connected user.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getCollaborators(): " + }, + { + "kind": "Content", + "text": "import(\"@tldraw/tlschema\")." + }, + { + "kind": "Reference", + "text": "TLInstancePresence", + "canonicalReference": "@tldraw/tlschema!TLInstancePresence:interface" + }, + { + "kind": "Content", + "text": "[]" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [], + "isOptional": false, + "isAbstract": false, + "name": "getCollaborators" + }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#getCollaboratorsOnCurrentPage:member(1)", + "docComment": "/**\n * Returns a list of presence records for all peer collaborators on the current page. This will return the latest presence record for each connected user.\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getCollaboratorsOnCurrentPage(): " + }, + { + "kind": "Content", + "text": "import(\"@tldraw/tlschema\")." + }, + { + "kind": "Reference", + "text": "TLInstancePresence", + "canonicalReference": "@tldraw/tlschema!TLInstancePresence:interface" + }, + { + "kind": "Content", + "text": "[]" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [], + "isOptional": false, + "isAbstract": false, + "name": "getCollaboratorsOnCurrentPage" + }, { "kind": "Property", "canonicalReference": "@tldraw/editor!Editor#getContainer:member", diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 20f7741b9..f56f5b865 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -2915,15 +2915,7 @@ export class Editor extends EventEmitter { * @public */ zoomToUser(userId: string, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this { - const presences = this.store.query.records('instance_presence', () => ({ - userId: { eq: userId }, - })) - - const presence = [...presences.get()] - .sort((a, b) => { - return a.lastActivityTimestamp - b.lastActivityTimestamp - }) - .pop() + const presence = this.getCollaborators().find((c) => c.userId === userId) if (!presence) return this @@ -3186,6 +3178,45 @@ export class Editor extends EventEmitter { const { x: cx, y: cy, z: cz = 1 } = this.getCamera() return new Vec((point.x + cx) * cz, (point.y + cy) * cz, point.z ?? 0.5) } + // Collaborators + + @computed + private _getCollaboratorsQuery() { + return this.store.query.records('instance_presence', () => ({ + userId: { neq: this.user.getId() }, + })) + } + + /** + * Returns a list of presence records for all peer collaborators. + * This will return the latest presence record for each connected user. + * + * @public + */ + @computed + getCollaborators() { + const allPresenceRecords = this._getCollaboratorsQuery().get() + if (!allPresenceRecords.length) return EMPTY_ARRAY + const userIds = [...new Set(allPresenceRecords.map((c) => c.userId))].sort() + return userIds.map((id) => { + const latestPresence = allPresenceRecords + .filter((c) => c.userId === id) + .sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0] + return latestPresence + }) + } + + /** + * Returns a list of presence records for all peer collaborators on the current page. + * This will return the latest presence record for each connected user. + * + * @public + */ + @computed + getCollaboratorsOnCurrentPage() { + const currentPageId = this.getCurrentPageId() + return this.getCollaborators().filter((c) => c.currentPageId === currentPageId) + } // Following @@ -3202,9 +3233,9 @@ export class Editor extends EventEmitter { * @public */ startFollowingUser(userId: string): this { - const leaderPresences = this.store.query.records('instance_presence', () => ({ - userId: { eq: userId }, - })) + const leaderPresences = this._getCollaboratorsQuery() + .get() + .filter((p) => p.userId === userId) const thisUserId = this.user.getId() @@ -3213,7 +3244,7 @@ export class Editor extends EventEmitter { } // If the leader is following us, then we can't follow them - if (leaderPresences.get().some((p) => p.followingUserId === thisUserId)) { + if (leaderPresences.some((p) => p.followingUserId === thisUserId)) { return this } @@ -3232,11 +3263,9 @@ export class Editor extends EventEmitter { const moveTowardsUser = () => transact(() => { // Stop following if we can't find the user - const leaderPresence = [...leaderPresences.get()] - .sort((a, b) => { - return a.lastActivityTimestamp - b.lastActivityTimestamp - }) - .pop() + const leaderPresence = [...leaderPresences].sort( + (a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp + )[0] if (!leaderPresence) { this.stopFollowingUser() return @@ -3636,6 +3665,14 @@ export class Editor extends EventEmitter { return this._currentPageShapeIds.get() } + /** + * @internal + */ + @computed + getCurrentPageShapeIdsSorted() { + return Array.from(this.getCurrentPageShapeIds()).sort() + } + /** * Get the ids of shapes on a page. * @@ -4248,7 +4285,7 @@ export class Editor extends EventEmitter { * @public */ getShapePageTransform(shape: TLShape | TLShapeId): Mat { - const id = typeof shape === 'string' ? shape : this.getShape(shape)!.id + const id = typeof shape === 'string' ? shape : shape.id return this._getShapePageTransformCache().get(id) ?? Mat.Identity() } @@ -4582,7 +4619,7 @@ export class Editor extends EventEmitter { @computed getCurrentPageBounds(): Box | undefined { let commonBounds: Box | undefined - this.getCurrentPageShapeIds().forEach((shapeId) => { + this.getCurrentPageShapeIdsSorted().forEach((shapeId) => { const bounds = this.getShapeMaskedPageBounds(shapeId) if (!bounds) return if (!commonBounds) { @@ -4911,28 +4948,11 @@ export class Editor extends EventEmitter { * @public */ @computed getCurrentPageShapesSorted(): TLShape[] { - const shapes = this.getCurrentPageShapes().sort(sortByIndex) - const parentChildMap = new Map() const result: TLShape[] = [] - const topLevelShapes: TLShape[] = [] - let shape: TLShape, parent: TLShape | undefined - - for (let i = 0, n = shapes.length; i < n; i++) { - shape = shapes[i] - parent = this.getShape(shape.parentId) - if (parent) { - if (!parentChildMap.has(parent.id)) { - parentChildMap.set(parent.id, []) - } - parentChildMap.get(parent.id)!.push(shape) - } else { - // undefined if parent is a shape - topLevelShapes.push(shape) - } - } + const topLevelShapes = this.getSortedChildIdsForParent(this.getCurrentPageId()) for (let i = 0, n = topLevelShapes.length; i < n; i++) { - pushShapeWithDescendants(topLevelShapes[i], parentChildMap, result) + pushShapeWithDescendants(this, topLevelShapes[i], result) } return result @@ -8532,7 +8552,11 @@ export class Editor extends EventEmitter { // it will be 0,0 when its actual screen position is equal // to screenBounds.point. This is confusing! currentScreenPoint.set(sx, sy) - currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz) + const nx = sx / cz - cx + const ny = sy / cz - cy + if (isFinite(nx) && isFinite(ny)) { + currentPagePoint.set(nx, ny, sz) + } this.inputs.isPen = info.type === 'pointer' && info.isPen @@ -9220,16 +9244,12 @@ function applyPartialToShape(prev: T, partial?: TLShapePartia return next } -function pushShapeWithDescendants( - shape: TLShape, - parentChildMap: Map, - result: TLShape[] -): void { +function pushShapeWithDescendants(editor: Editor, id: TLShapeId, result: TLShape[]): void { + const shape = editor.getShape(id) + if (!shape) return result.push(shape) - const children = parentChildMap.get(shape.id) - if (children) { - for (let i = 0, n = children.length; i < n; i++) { - pushShapeWithDescendants(children[i], parentChildMap, result) - } + const childIds = editor.getSortedChildIdsForParent(id) + for (let i = 0, n = childIds.length; i < n; i++) { + pushShapeWithDescendants(editor, childIds[i], result) } } diff --git a/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts index 461835500..25a676706 100644 --- a/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts +++ b/packages/editor/src/lib/editor/derivations/notVisibleShapes.ts @@ -1,5 +1,5 @@ -import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state' -import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema' +import { computed, isUninitialized } from '@tldraw/state' +import { TLShapeId } from '@tldraw/tlschema' import { Box } from '../../primitives/Box' import { Editor } from '../Editor' @@ -21,15 +21,10 @@ function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Bo */ export const notVisibleShapes = (editor: Editor) => { const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin) - const shapeHistory = editor.store.query.filterHistory('shape') - let lastPageId: TLPageId | null = null - let prevViewportPageBounds: Box function fromScratch(editor: Editor): Set { const shapes = editor.getCurrentPageShapeIds() - lastPageId = editor.getCurrentPageId() const viewportPageBounds = editor.getViewportPageBounds() - prevViewportPageBounds = viewportPageBounds.clone() const notVisibleShapes = new Set() shapes.forEach((id) => { if (isShapeNotVisible(editor, id, viewportPageBounds)) { @@ -38,68 +33,21 @@ export const notVisibleShapes = (editor: Editor) => { }) return notVisibleShapes } - return computed>('getCulledShapes', (prevValue, lastComputedEpoch) => { + return computed>('getCulledShapes', (prevValue) => { if (!isCullingOffScreenShapes) return new Set() if (isUninitialized(prevValue)) { return fromScratch(editor) } - const diff = shapeHistory.getDiffSince(lastComputedEpoch) - if (diff === RESET_VALUE) { - return fromScratch(editor) - } + const nextValue = fromScratch(editor) - const currentPageId = editor.getCurrentPageId() - if (lastPageId !== currentPageId) { - return fromScratch(editor) - } - const viewportPageBounds = editor.getViewportPageBounds() - if (!prevViewportPageBounds || !viewportPageBounds.equals(prevViewportPageBounds)) { - return fromScratch(editor) - } - - let nextValue = null as null | Set - const addId = (id: TLShapeId) => { - // Already added - if (prevValue.has(id)) return - if (!nextValue) nextValue = new Set(prevValue) - nextValue.add(id) - } - const deleteId = (id: TLShapeId) => { - // No need to delete since it's not there - if (!prevValue.has(id)) return - if (!nextValue) nextValue = new Set(prevValue) - nextValue.delete(id) - } - - for (const changes of diff) { - for (const record of Object.values(changes.added)) { - if (isShape(record)) { - const isCulled = isShapeNotVisible(editor, record.id, viewportPageBounds) - if (isCulled) { - addId(record.id) - } - } - } - - for (const [_from, to] of Object.values(changes.updated)) { - if (isShape(to)) { - const isCulled = isShapeNotVisible(editor, to.id, viewportPageBounds) - if (isCulled) { - addId(to.id) - } else { - deleteId(to.id) - } - } - } - for (const id of Object.keys(changes.removed)) { - if (isShapeId(id)) { - deleteId(id) - } + if (prevValue.size !== nextValue.size) return nextValue + for (const prev of prevValue) { + if (!nextValue.has(prev)) { + return nextValue } } - - return nextValue ?? prevValue + return prevValue }) } diff --git a/packages/editor/src/lib/hooks/usePeerIds.ts b/packages/editor/src/lib/hooks/usePeerIds.ts index 22308aa99..add0bb996 100644 --- a/packages/editor/src/lib/hooks/usePeerIds.ts +++ b/packages/editor/src/lib/hooks/usePeerIds.ts @@ -1,5 +1,4 @@ import { useComputed, useValue } from '@tldraw/state' -import { useMemo } from 'react' import { uniq } from '../utils/uniq' import { useEditor } from './useEditor' @@ -10,17 +9,12 @@ import { useEditor } from './useEditor' */ export function usePeerIds() { const editor = useEditor() - const $presences = useMemo(() => { - return editor.store.query.records('instance_presence', () => ({ - userId: { neq: editor.user.getId() }, - })) - }, [editor]) const $userIds = useComputed( 'userIds', - () => uniq($presences.get().map((p) => p.userId)).sort(), + () => uniq(editor.getCollaborators().map((p) => p.userId)).sort(), { isEqual: (a, b) => a.join(',') === b.join?.(',') }, - [$presences] + [editor] ) return useValue($userIds) diff --git a/packages/editor/src/lib/hooks/usePresence.ts b/packages/editor/src/lib/hooks/usePresence.ts index 6f75337d5..55e51950a 100644 --- a/packages/editor/src/lib/hooks/usePresence.ts +++ b/packages/editor/src/lib/hooks/usePresence.ts @@ -1,6 +1,5 @@ import { useValue } from '@tldraw/state' import { TLInstancePresence } from '@tldraw/tlschema' -import { useMemo } from 'react' import { useEditor } from './useEditor' // TODO: maybe move this to a computed property on the App class? @@ -11,21 +10,12 @@ import { useEditor } from './useEditor' export function usePresence(userId: string): TLInstancePresence | null { const editor = useEditor() - const $presences = useMemo(() => { - return editor.store.query.records('instance_presence', () => ({ - userId: { eq: userId }, - })) - }, [editor, userId]) - const latestPresence = useValue( `latestPresence:${userId}`, () => { - return $presences - .get() - .slice() - .sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0] + return editor.getCollaborators().find((c) => c.userId === userId) }, - [] + [editor] ) return latestPresence ?? null diff --git a/packages/editor/src/lib/primitives/Mat.ts b/packages/editor/src/lib/primitives/Mat.ts index b2388fcb1..14d874c21 100644 --- a/packages/editor/src/lib/primitives/Mat.ts +++ b/packages/editor/src/lib/primitives/Mat.ts @@ -39,12 +39,13 @@ export class Mat { equals(m: Mat | MatModel) { return ( - this.a === m.a && - this.b === m.b && - this.c === m.c && - this.d === m.d && - this.e === m.e && - this.f === m.f + this === m || + (this.a === m.a && + this.b === m.b && + this.c === m.c && + this.d === m.d && + this.e === m.e && + this.f === m.f) ) } diff --git a/packages/state/src/lib/core/Computed.ts b/packages/state/src/lib/core/Computed.ts index 290f8c97d..2d82490d6 100644 --- a/packages/state/src/lib/core/Computed.ts +++ b/packages/state/src/lib/core/Computed.ts @@ -4,7 +4,7 @@ import { HistoryBuffer } from './HistoryBuffer' import { maybeCaptureParent, startCapturingParents, stopCapturingParents } from './capture' import { GLOBAL_START_EPOCH } from './constants' import { EMPTY_ARRAY, equals, haveParentsChanged, singleton } from './helpers' -import { getGlobalEpoch } from './transactions' +import { getGlobalEpoch, getIsReacting } from './transactions' import { Child, ComputeDiff, RESET_VALUE, Signal } from './types' import { logComputedGetterWarning } from './warnings' @@ -189,8 +189,15 @@ class __UNSAFE__Computed implements Computed __unsafe__getWithoutCapture(ignoreErrors?: boolean): Value { const isNew = this.lastChangedEpoch === GLOBAL_START_EPOCH - if (!isNew && (this.lastCheckedEpoch === getGlobalEpoch() || !haveParentsChanged(this))) { - this.lastCheckedEpoch = getGlobalEpoch() + const globalEpoch = getGlobalEpoch() + + if ( + !isNew && + (this.lastCheckedEpoch === globalEpoch || + (this.isActivelyListening && getIsReacting() && this.lastTraversedEpoch < globalEpoch) || + !haveParentsChanged(this)) + ) { + this.lastCheckedEpoch = globalEpoch if (this.error) { if (!ignoreErrors) { throw this.error.thrownValue diff --git a/packages/state/src/lib/core/transactions.ts b/packages/state/src/lib/core/transactions.ts index afb92d7d1..0e3672eee 100644 --- a/packages/state/src/lib/core/transactions.ts +++ b/packages/state/src/lib/core/transactions.ts @@ -70,6 +70,10 @@ export function getGlobalEpoch() { return inst.globalEpoch } +export function getIsReacting() { + return inst.globalIsReacting +} + /** * Collect all of the reactors that need to run for an atom and run them. * diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index e6e590898..d5bc01f14 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -2507,9 +2507,7 @@ export function useDefaultHelpers(): { export function useDialogs(): TLUiDialogsContextType; // @public (undocumented) -export function useEditableText(id: TLShapeId, type: string, text: string, opts?: { - disableTab: boolean; -}): { +export function useEditableText(id: TLShapeId, type: string, text: string): { handleBlur: () => void; handleChange: (e: React_2.ChangeEvent) => void; handleDoubleClick: (e: any) => any; diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index 9fb5b3376..a68690d00 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -15735,7 +15735,7 @@ }, { "kind": "Content", - "text": ") => {\n id: import(\"@tldraw/editor\")." + "text": ") => {\n id: " }, { "kind": "Reference", @@ -15819,7 +15819,7 @@ }, { "kind": "Content", - "text": ") => {\n id: import(\"@tldraw/editor\")." + "text": ") => {\n id: " }, { "kind": "Reference", @@ -15894,7 +15894,7 @@ }, { "kind": "Content", - "text": ") => {\n id: import(\"@tldraw/editor\")." + "text": ") => {\n id: " }, { "kind": "Reference", @@ -15903,7 +15903,7 @@ }, { "kind": "Content", - "text": ";\n props: {\n autoSize: boolean;\n scale?: undefined;\n };\n type: \"text\";\n } | {\n id: import(\"@tldraw/editor\")." + "text": ";\n props: {\n autoSize: boolean;\n scale?: undefined;\n };\n type: \"text\";\n } | {\n id: " }, { "kind": "Reference", @@ -27480,14 +27480,6 @@ "kind": "Content", "text": "string" }, - { - "kind": "Content", - "text": ", opts?: " - }, - { - "kind": "Content", - "text": "{\n disableTab: boolean;\n}" - }, { "kind": "Content", "text": "): " @@ -27575,8 +27567,8 @@ ], "fileUrlPath": "packages/tldraw/src/lib/shapes/shared/useEditableText.ts", "returnTypeTokenRange": { - "startIndex": 9, - "endIndex": 26 + "startIndex": 7, + "endIndex": 24 }, "releaseTag": "Public", "overloadIndex": 1, @@ -27604,14 +27596,6 @@ "endIndex": 6 }, "isOptional": false - }, - { - "parameterName": "opts", - "parameterTypeTokenRange": { - "startIndex": 7, - "endIndex": 8 - }, - "isOptional": true } ], "name": "useEditableText" diff --git a/packages/tldraw/src/lib/Tldraw.tsx b/packages/tldraw/src/lib/Tldraw.tsx index 203cf49f9..159cf2023 100644 --- a/packages/tldraw/src/lib/Tldraw.tsx +++ b/packages/tldraw/src/lib/Tldraw.tsx @@ -109,13 +109,10 @@ export function Tldraw(props: TldrawProps) { ) const assets = useDefaultEditorAssetsWithOverrides(rest.assetUrls) - const { done: preloadingComplete, error: preloadingError } = usePreloadAssets(assets) - if (preloadingError) { return Could not load assets. Please refresh the page. } - if (!preloadingComplete) { return Loading assets... } diff --git a/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts b/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts index e2e871bb4..edef80479 100644 --- a/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts +++ b/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts @@ -268,14 +268,16 @@ export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) { const debugGeom: Geometry2d[] = [] const info = editor.getArrowInfo(shape)! + const hasStartBinding = shape.props.start.type === 'binding' + const hasEndBinding = shape.props.end.type === 'binding' const hasStartArrowhead = info.start.arrowhead !== 'none' const hasEndArrowhead = info.end.arrowhead !== 'none' if (info.isStraight) { const range = getStraightArrowLabelRange(editor, shape, info) let clampedPosition = clamp( shape.props.labelPosition, - hasStartArrowhead ? range.start : 0, - hasEndArrowhead ? range.end : 1 + hasStartArrowhead || hasStartBinding ? range.start : 0, + hasEndArrowhead || hasEndBinding ? range.end : 1 ) // This makes the position snap in the middle. clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition @@ -285,8 +287,8 @@ export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) { if (range.dbg) debugGeom.push(...range.dbg) let clampedPosition = clamp( shape.props.labelPosition, - hasStartArrowhead ? range.start : 0, - hasEndArrowhead ? range.end : 1 + hasStartArrowhead || hasStartBinding ? range.start : 0, + hasEndArrowhead || hasEndBinding ? range.end : 1 ) // This makes the position snap in the middle. clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition diff --git a/packages/tldraw/src/lib/shapes/arrow/components/ArrowTextLabel.tsx b/packages/tldraw/src/lib/shapes/arrow/components/ArrowTextLabel.tsx index a2bfd3915..f4f133c41 100644 --- a/packages/tldraw/src/lib/shapes/arrow/components/ArrowTextLabel.tsx +++ b/packages/tldraw/src/lib/shapes/arrow/components/ArrowTextLabel.tsx @@ -35,7 +35,6 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({ labelColor={theme[labelColor].solid} textWidth={width} isSelected={isSelected} - disableTab style={{ transform: `translate(${position.x}px, ${position.y}px)`, }} diff --git a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts index 5d6f7c548..8c23577e1 100644 --- a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts +++ b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts @@ -97,7 +97,7 @@ export class Drawing extends StateNode { this.mergeNextPoint = false } - this.updateShapes() + this.updateDrawingShape() } } @@ -115,7 +115,7 @@ export class Drawing extends StateNode { } } } - this.updateShapes() + this.updateDrawingShape() } override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => { @@ -137,7 +137,7 @@ export class Drawing extends StateNode { } } - this.updateShapes() + this.updateDrawingShape() } override onExit? = () => { @@ -281,7 +281,7 @@ export class Drawing extends StateNode { this.initialShape = this.editor.getShape(id) } - private updateShapes() { + private updateDrawingShape() { const { initialShape } = this const { inputs } = this.editor diff --git a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx index b249c3531..420811938 100644 --- a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx @@ -402,7 +402,6 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { {showHtmlContainer && ( { text={text} isSelected={isSelected} labelColor={theme[props.labelColor].solid} - disableTab wrap /> diff --git a/packages/tldraw/src/lib/shapes/note/NoteShapeUtil.tsx b/packages/tldraw/src/lib/shapes/note/NoteShapeUtil.tsx index 4a0e1144f..89ae4a330 100644 --- a/packages/tldraw/src/lib/shapes/note/NoteShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/note/NoteShapeUtil.tsx @@ -190,7 +190,6 @@ export class NoteShapeUtil extends ShapeUtil { isNote isSelected={isSelected} labelColor={theme[color].note.text} - disableTab wrap onKeyDown={handleKeyDown} /> diff --git a/packages/tldraw/src/lib/shapes/shared/TextLabel.tsx b/packages/tldraw/src/lib/shapes/shared/TextLabel.tsx index d34e009af..81adb2a9a 100644 --- a/packages/tldraw/src/lib/shapes/shared/TextLabel.tsx +++ b/packages/tldraw/src/lib/shapes/shared/TextLabel.tsx @@ -27,7 +27,6 @@ type TextLabelProps = { bounds?: Box isNote?: boolean isSelected: boolean - disableTab?: boolean onKeyDown?: (e: React.KeyboardEvent) => void classNamePrefix?: string style?: React.CSSProperties @@ -51,15 +50,13 @@ export const TextLabel = React.memo(function TextLabel({ onKeyDown: handleKeyDownCustom, classNamePrefix, style, - disableTab = false, textWidth, textHeight, }: TextLabelProps) { const { rInput, isEmpty, isEditing, isEditingAnything, ...editableTextRest } = useEditableText( id, type, - text, - { disableTab } + text ) const [initialText, setInitialText] = useState(text) diff --git a/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts b/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts index bb60d358b..eb1241877 100644 --- a/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts +++ b/packages/tldraw/src/lib/shapes/shared/freehand/svgInk.ts @@ -1,12 +1,4 @@ -import { - Vec, - VecLike, - assert, - average, - precise, - shortAngleDist, - toDomPrecision, -} from '@tldraw/editor' +import { Vec, VecLike, assert, average, precise, toDomPrecision } from '@tldraw/editor' import { getStrokeOutlineTracks } from './getStrokeOutlinePoints' import { getStrokePoints } from './getStrokePoints' import { setStrokePointRadii } from './setStrokePointRadii' @@ -36,17 +28,20 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] { const result: StrokePoint[][] = [] let currentPartition: StrokePoint[] = [points[0]] - for (let i = 1; i < points.length - 1; i++) { - const prevPoint = points[i - 1] - const thisPoint = points[i] - const nextPoint = points[i + 1] - const prevAngle = Vec.Angle(prevPoint.point, thisPoint.point) - const nextAngle = Vec.Angle(thisPoint.point, nextPoint.point) - // acuteness is a normalized representation of how acute the angle is. - // 1 is an infinitely thin wedge - // 0 is a straight line - const acuteness = Math.abs(shortAngleDist(prevAngle, nextAngle)) / Math.PI - if (acuteness > 0.8) { + let prevV = Vec.Sub(points[1].point, points[0].point).uni() + let nextV: Vec + let dpr: number + let prevPoint: StrokePoint, thisPoint: StrokePoint, nextPoint: StrokePoint + for (let i = 1, n = points.length; i < n - 1; i++) { + prevPoint = points[i - 1] + thisPoint = points[i] + nextPoint = points[i + 1] + + nextV = Vec.Sub(nextPoint.point, thisPoint.point).uni() + dpr = Vec.Dpr(prevV, nextV) + prevV = nextV + + if (dpr < -0.8) { // always treat such acute angles as elbows // and use the extended .input point as the elbow point for swooshiness in fast zaggy lines const elbowPoint = { @@ -59,19 +54,20 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] { continue } currentPartition.push(thisPoint) - if (acuteness < 0.25) { - // this is not an elbow, bail out + + if (dpr > 0.7) { + // Not an elbow continue } + // so now we have a reasonably acute angle but it might not be an elbow if it's far - // away from it's neighbors - const avgRadius = (prevPoint.radius + thisPoint.radius + nextPoint.radius) / 3 - const incomingNormalizedDist = Vec.Dist(prevPoint.point, thisPoint.point) / avgRadius - const outgoingNormalizedDist = Vec.Dist(thisPoint.point, nextPoint.point) / avgRadius - // angular dist is a normalized representation of how far away the point is from it's neighbors + // away from it's neighbors, angular dist is a normalized representation of how far away the point is from it's neighbors // (normalized by the radius) - const angularDist = incomingNormalizedDist + outgoingNormalizedDist - if (angularDist < 1.5) { + if ( + (Vec.Dist2(prevPoint.point, thisPoint.point) + Vec.Dist2(thisPoint.point, nextPoint.point)) / + ((prevPoint.radius + thisPoint.radius + nextPoint.radius) / 3) ** 2 < + 1.5 + ) { // if this point is kinda close to its neighbors and it has a reasonably // acute angle, it's probably a hard elbow currentPartition.push(thisPoint) @@ -89,11 +85,13 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] { function cleanUpPartition(partition: StrokePoint[]) { // clean up start of partition (remove points that are too close to the start) const startPoint = partition[0] + let nextPoint: StrokePoint while (partition.length > 2) { - const nextPoint = partition[1] - const dist = Vec.Dist(startPoint.point, nextPoint.point) - const avgRadius = (startPoint.radius + nextPoint.radius) / 2 - if (dist < avgRadius * 0.5) { + nextPoint = partition[1] + if ( + Vec.Dist2(startPoint.point, nextPoint.point) < + (((startPoint.radius + nextPoint.radius) / 2) * 0.5) ** 2 + ) { partition.splice(1, 1) } else { break @@ -101,11 +99,13 @@ function cleanUpPartition(partition: StrokePoint[]) { } // clean up end of partition in the same fashion const endPoint = partition[partition.length - 1] + let prevPoint: StrokePoint while (partition.length > 2) { - const prevPoint = partition[partition.length - 2] - const dist = Vec.Dist(endPoint.point, prevPoint.point) - const avgRadius = (endPoint.radius + prevPoint.radius) / 2 - if (dist < avgRadius * 0.5) { + prevPoint = partition[partition.length - 2] + if ( + Vec.Dist2(endPoint.point, prevPoint.point) < + (((endPoint.radius + prevPoint.radius) / 2) * 0.5) ** 2 + ) { partition.splice(partition.length - 2, 1) } else { break @@ -115,13 +115,14 @@ function cleanUpPartition(partition: StrokePoint[]) { if (partition.length > 1) { partition[0] = { ...partition[0], - vector: Vec.FromAngle(Vec.Angle(partition[1].point, partition[0].point)), + vector: Vec.Sub(partition[0].point, partition[1].point).uni(), } partition[partition.length - 1] = { ...partition[partition.length - 1], - vector: Vec.FromAngle( - Vec.Angle(partition[partition.length - 1].point, partition[partition.length - 2].point) - ), + vector: Vec.Sub( + partition[partition.length - 2].point, + partition[partition.length - 1].point + ).uni(), } } return partition diff --git a/packages/tldraw/src/lib/shapes/shared/useEditableText.ts b/packages/tldraw/src/lib/shapes/shared/useEditableText.ts index 696a2279f..696f456a9 100644 --- a/packages/tldraw/src/lib/shapes/shared/useEditableText.ts +++ b/packages/tldraw/src/lib/shapes/shared/useEditableText.ts @@ -2,7 +2,6 @@ import { TLShapeId, TLUnknownShape, getPointerInfo, - preventDefault, stopEventPropagation, useEditor, useValue, @@ -11,31 +10,14 @@ import React, { useCallback, useEffect, useRef } from 'react' import { INDENT, TextHelpers } from './TextHelpers' /** @public */ -export function useEditableText( - id: TLShapeId, - type: string, - text: string, - opts = { disableTab: false } as { disableTab: boolean } -) { +export function useEditableText(id: TLShapeId, type: string, text: string) { const editor = useEditor() - const rInput = useRef(null) - - const isEditing = useValue( - 'isEditing', - () => { - return editor.getEditingShapeId() === id - }, - [editor] - ) - - const isEditingAnything = useValue( - 'isEditingAnything', - () => { - return editor.getEditingShapeId() !== null - }, - [editor] - ) + const rSelectionRanges = useRef() + const isEditing = useValue('isEditing', () => editor.getEditingShapeId() === id, [editor]) + const isEditingAnything = useValue('isEditingAnything', () => !!editor.getEditingShapeId(), [ + editor, + ]) useEffect(() => { function selectAllIfEditing({ shapeId }: { shapeId: TLShapeId }) { @@ -52,14 +34,13 @@ export function useEditableText( } }) } + editor.on('select-all-text', selectAllIfEditing) return () => { editor.off('select-all-text', selectAllIfEditing) } }, [editor, id]) - const rSelectionRanges = useRef() - useEffect(() => { if (!isEditing) return @@ -69,10 +50,18 @@ export function useEditableText( // Focus if we're not already focused if (document.activeElement !== elm) { elm.focus() + // On mobile etc, just select all the text when we start focusing if (editor.getInstanceState().isCoarsePointer) { elm.select() } + } else { + // This fixes iOS not showing the cursor sometimes. This "shakes" the cursor + // awake. + if (editor.environment.isSafari) { + elm.blur() + elm.focus() + } } // When the selection changes, save the selection ranges @@ -103,12 +92,14 @@ export function useEditableText( requestAnimationFrame(() => { const elm = rInput.current const editingShapeId = editor.getEditingShapeId() + // Did we move to a different shape? if (editingShapeId) { // important! these ^v are two different things // is that shape OUR shape? if (elm && editingShapeId === id) { elm.focus() + if (ranges && ranges.length) { const selection = window.getSelection() if (selection) { @@ -134,20 +125,9 @@ export function useEditableText( } break } - case 'Tab': { - if (!opts.disableTab) { - preventDefault(e) - if (e.shiftKey) { - TextHelpers.unindent(e.currentTarget) - } else { - TextHelpers.indent(e.currentTarget) - } - } - break - } } }, - [editor, id, opts.disableTab] + [editor, id] ) // When the text changes, update the text value. @@ -198,8 +178,6 @@ export function useEditableText( [editor, id, isEditing] ) - const handleDoubleClick = stopEventPropagation - return { rInput, handleFocus: noop, @@ -207,7 +185,7 @@ export function useEditableText( handleKeyDown, handleChange, handleInputPointerDown, - handleDoubleClick, + handleDoubleClick: stopEventPropagation, isEmpty: text.trim().length === 0, isEditing, isEditingAnything, diff --git a/packages/tldraw/src/lib/shapes/text/TextShapeUtil.tsx b/packages/tldraw/src/lib/shapes/text/TextShapeUtil.tsx index 7ff86dd9f..7378d16b6 100644 --- a/packages/tldraw/src/lib/shapes/text/TextShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/text/TextShapeUtil.tsx @@ -7,18 +7,22 @@ import { SvgExportContext, TLOnEditEndHandler, TLOnResizeHandler, + TLShapeId, TLShapeUtilFlag, TLTextShape, Vec, WeakMapCache, getDefaultColorTheme, + preventDefault, textShapeMigrations, textShapeProps, toDomPrecision, useEditor, } from '@tldraw/editor' +import { useCallback } from 'react' import { useDefaultColorTheme } from '../shared/ShapeFill' import { SvgTextLabel } from '../shared/SvgTextLabel' +import { TextHelpers } from '../shared/TextHelpers' import { TextLabel } from '../shared/TextLabel' import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants' import { getFontDefForExport } from '../shared/defaultStyleDefs' @@ -73,6 +77,7 @@ export class TextShapeUtil extends ShapeUtil { const { width, height } = this.getMinDimensions(shape) const isSelected = shape.id === this.editor.getOnlySelectedShapeId() const theme = useDefaultColorTheme() + const handleKeyDown = useTextShapeKeydownHandler(id) return ( { transformOrigin: 'top left', }} wrap + onKeyDown={handleKeyDown} /> ) } @@ -332,3 +338,32 @@ function getTextSize(editor: Editor, props: TLTextShape['props']) { height: Math.max(fontSize, result.h), } } + +function useTextShapeKeydownHandler(id: TLShapeId) { + const editor = useEditor() + + return useCallback( + (e: React.KeyboardEvent) => { + if (editor.getEditingShapeId() !== id) return + + switch (e.key) { + case 'Enter': { + if (e.ctrlKey || e.metaKey) { + editor.complete() + } + break + } + case 'Tab': { + preventDefault(e) + if (e.shiftKey) { + TextHelpers.unindent(e.currentTarget) + } else { + TextHelpers.indent(e.currentTarget) + } + break + } + } + }, + [editor, id] + ) +} diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingArrowLabel.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingArrowLabel.ts index 1509d1935..fd84f09c0 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingArrowLabel.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingArrowLabel.ts @@ -92,8 +92,8 @@ export class PointingArrowLabel extends StateNode { let nextLabelPosition if (info.isStraight) { // straight arrows - const lineLength = Vec.Dist2(info.start.point, info.end.point) - const segmentLength = Vec.Dist2(info.end.point, nearestPoint) + const lineLength = Vec.Dist(info.start.point, info.end.point) + const segmentLength = Vec.Dist(info.end.point, nearestPoint) nextLabelPosition = 1 - segmentLength / lineLength } else { const { _center, measure, angleEnd, angleStart } = groupGeometry.children[0] as Arc2d diff --git a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx index 004dedfdf..a68b0ce02 100644 --- a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx +++ b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx @@ -1,5 +1,5 @@ -import { useEditor } from '@tldraw/editor' -import { useEffect, useState } from 'react' +import { useEditor, useQuickReactor } from '@tldraw/editor' +import { useRef, useState } from 'react' import { useActions } from '../../context/actions' import { TldrawUiMenuItem } from '../primitives/menus/TldrawUiMenuItem' @@ -9,33 +9,25 @@ export function BackToContent() { const actions = useActions() const [showBackToContent, setShowBackToContent] = useState(false) + const rIsShowing = useRef(false) - useEffect(() => { - let showBackToContentPrev = false - - const interval = setInterval(() => { - const renderingShapes = editor.getRenderingShapes() - const renderingBounds = editor.getRenderingBounds() - - // Rendering shapes includes all the shapes in the current page. - // We have to filter them down to just the shapes that are inside the renderingBounds. - const visibleShapes = renderingShapes.filter((s) => { - const maskedPageBounds = editor.getShapeMaskedPageBounds(s.id) - return maskedPageBounds && renderingBounds.includes(maskedPageBounds) - }) - const showBackToContentNow = - visibleShapes.length === 0 && editor.getCurrentPageShapes().length > 0 + useQuickReactor( + 'toggle showback to content', + () => { + const showBackToContentPrev = rIsShowing.current + const shapeIds = editor.getCurrentPageShapeIds() + let showBackToContentNow = false + if (shapeIds.size) { + showBackToContentNow = shapeIds.size === editor.getCulledShapes().size + } if (showBackToContentPrev !== showBackToContentNow) { setShowBackToContent(showBackToContentNow) - showBackToContentPrev = showBackToContentNow + rIsShowing.current = showBackToContentNow } - }, 1000) - - return () => { - clearInterval(interval) - } - }, [editor]) + }, + [editor] + ) if (!showBackToContent) return null diff --git a/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx b/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx index 4d525ba0c..efd082400 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx +++ b/packages/tldraw/src/lib/ui/components/Minimap/DefaultMinimap.tsx @@ -1,18 +1,13 @@ import { ANIMATION_MEDIUM_MS, - Box, TLPointerEventInfo, - TLShapeId, Vec, getPointerInfo, - intersectPolygonPolygon, normalizeWheel, releasePointerCapture, setPointerCapture, - useComputed, useEditor, useIsDarkMode, - useQuickReactor, } from '@tldraw/editor' import * as React from 'react' import { MinimapManager } from './MinimapManager' @@ -24,67 +19,78 @@ export function DefaultMinimap() { const rCanvas = React.useRef(null!) const rPointing = React.useRef(false) - const isDarkMode = useIsDarkMode() - const devicePixelRatio = useComputed('dpr', () => editor.getInstanceState().devicePixelRatio, [ - editor, - ]) - const presences = React.useMemo(() => editor.store.query.records('instance_presence'), [editor]) - - const minimap = React.useMemo(() => new MinimapManager(editor), [editor]) + const minimapRef = React.useRef() React.useEffect(() => { - // Must check after render - const raf = requestAnimationFrame(() => { - minimap.updateColors() - minimap.render() - }) - return () => { - cancelAnimationFrame(raf) - } - }, [editor, minimap, isDarkMode]) + const minimap = new MinimapManager(editor, rCanvas.current) + minimapRef.current = minimap + return minimapRef.current.close + }, [editor]) const onDoubleClick = React.useCallback( (e: React.MouseEvent) => { if (!editor.getCurrentPageShapeIds().size) return + if (!minimapRef.current) return - const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) + const point = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + false, + false + ) - const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) + const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + false, + true + ) - minimap.originPagePoint.setTo(clampedPoint) - minimap.originPageCenter.setTo(editor.getViewportPageBounds().center) + minimapRef.current.originPagePoint.setTo(clampedPoint) + minimapRef.current.originPageCenter.setTo(editor.getViewportPageBounds().center) editor.centerOnPoint(point, { animation: { duration: ANIMATION_MEDIUM_MS } }) }, - [editor, minimap] + [editor] ) const onPointerDown = React.useCallback( (e: React.PointerEvent) => { + if (!minimapRef.current) return const elm = e.currentTarget setPointerCapture(elm, e) if (!editor.getCurrentPageShapeIds().size) return rPointing.current = true - minimap.isInViewport = false + minimapRef.current.isInViewport = false - const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) + const point = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + false, + false + ) - const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) + const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + false, + true + ) const _vpPageBounds = editor.getViewportPageBounds() - minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint) + minimapRef.current.isInViewport = _vpPageBounds.containsPoint(clampedPoint) - if (minimap.isInViewport) { - minimap.originPagePoint.setTo(clampedPoint) - minimap.originPageCenter.setTo(_vpPageBounds.center) + if (minimapRef.current.isInViewport) { + minimapRef.current.originPagePoint.setTo(clampedPoint) + minimapRef.current.originPageCenter.setTo(_vpPageBounds.center) } else { const delta = Vec.Sub(_vpPageBounds.center, _vpPageBounds.point) const pagePoint = Vec.Add(point, delta) - minimap.originPagePoint.setTo(pagePoint) - minimap.originPageCenter.setTo(point) + minimapRef.current.originPagePoint.setTo(pagePoint) + minimapRef.current.originPageCenter.setTo(point) editor.centerOnPoint(point, { animation: { duration: ANIMATION_MEDIUM_MS } }) } @@ -98,16 +104,24 @@ export function DefaultMinimap() { document.body.addEventListener('pointerup', release) }, - [editor, minimap] + [editor] ) const onPointerMove = React.useCallback( (e: React.PointerEvent) => { - const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, e.shiftKey, true) + if (!minimapRef.current) return + const point = minimapRef.current.minimapScreenPointToPagePoint( + e.clientX, + e.clientY, + e.shiftKey, + true + ) if (rPointing.current) { - if (minimap.isInViewport) { - const delta = minimap.originPagePoint.clone().sub(minimap.originPageCenter) + if (minimapRef.current.isInViewport) { + const delta = minimapRef.current.originPagePoint + .clone() + .sub(minimapRef.current.originPageCenter) editor.centerOnPoint(Vec.Sub(point, delta)) return } @@ -115,7 +129,7 @@ export function DefaultMinimap() { editor.centerOnPoint(point) } - const pagePoint = minimap.getPagePoint(e.clientX, e.clientY) + const pagePoint = minimapRef.current.getPagePoint(e.clientX, e.clientY) const screenPoint = editor.pageToScreen(pagePoint) @@ -130,7 +144,7 @@ export function DefaultMinimap() { editor.dispatch(info) }, - [editor, minimap] + [editor] ) const onWheel = React.useCallback( @@ -150,73 +164,16 @@ export function DefaultMinimap() { [editor] ) - // Update the minimap's dpr when the dpr changes - useQuickReactor( - 'update when dpr changes', - () => { - const dpr = devicePixelRatio.get() - minimap.setDpr(dpr) + const isDarkMode = useIsDarkMode() - const canvas = rCanvas.current as HTMLCanvasElement - const rect = canvas.getBoundingClientRect() - const width = rect.width * dpr - const height = rect.height * dpr - - // These must happen in order - canvas.width = width - canvas.height = height - minimap.canvasScreenBounds.set(rect.x, rect.y, width, height) - - minimap.cvs = rCanvas.current - }, - [devicePixelRatio, minimap] - ) - - useQuickReactor( - 'minimap render when pagebounds or collaborators changes', - () => { - const shapeIdsOnCurrentPage = editor.getCurrentPageShapeIds() - const commonBoundsOfAllShapesOnCurrentPage = editor.getCurrentPageBounds() - const viewportPageBounds = editor.getViewportPageBounds() - - const _dpr = devicePixelRatio.get() // dereference - - minimap.contentPageBounds = commonBoundsOfAllShapesOnCurrentPage - ? Box.Expand(commonBoundsOfAllShapesOnCurrentPage, viewportPageBounds) - : viewportPageBounds - - minimap.updateContentScreenBounds() - - // All shape bounds - - const allShapeBounds = [] as (Box & { id: TLShapeId })[] - - shapeIdsOnCurrentPage.forEach((id) => { - let pageBounds = editor.getShapePageBounds(id) as Box & { id: TLShapeId } - if (!pageBounds) return - - const pageMask = editor.getShapeMask(id) - - if (pageMask) { - const intersection = intersectPolygonPolygon(pageMask, pageBounds.corners) - if (!intersection) { - return - } - pageBounds = Box.FromPoints(intersection) as Box & { id: TLShapeId } - } - - if (pageBounds) { - pageBounds.id = id // kinda dirty but we want to include the id here - allShapeBounds.push(pageBounds) - } - }) - - minimap.pageBounds = allShapeBounds - minimap.collaborators = presences.get() - minimap.render() - }, - [editor, minimap] - ) + React.useEffect(() => { + // need to wait a tick for next theme css to be applied + // otherwise the minimap will render with the wrong colors + setTimeout(() => { + minimapRef.current?.updateColors() + minimapRef.current?.render() + }) + }, [isDarkMode]) return (
diff --git a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts index eeef0fd7f..3e7757b15 100644 --- a/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts +++ b/packages/tldraw/src/lib/ui/components/Minimap/MinimapManager.ts @@ -1,114 +1,159 @@ import { Box, + ComputedCache, Editor, - PI2, - TLInstancePresence, - TLShapeId, + TLShape, Vec, + atom, clamp, + computed, + react, uniqueId, } from '@tldraw/editor' +import { getRgba } from './getRgba' +import { BufferStuff, appendVertices, setupWebGl } from './minimap-webgl-setup' +import { pie, rectangle, roundedRectangle } from './minimap-webgl-shapes' export class MinimapManager { - constructor(public editor: Editor) {} - - dpr = 1 - - colors = { - shapeFill: 'rgba(144, 144, 144, .1)', - selectFill: '#2f80ed', - viewportFill: 'rgba(144, 144, 144, .1)', + disposables = [] as (() => void)[] + close = () => this.disposables.forEach((d) => d()) + gl: ReturnType + shapeGeometryCache: ComputedCache + constructor( + public editor: Editor, + public readonly elem: HTMLCanvasElement + ) { + this.gl = setupWebGl(elem) + this.shapeGeometryCache = editor.store.createComputedCache('webgl-geometry', (r: TLShape) => { + const bounds = editor.getShapeMaskedPageBounds(r.id) + if (!bounds) return null + const arr = new Float32Array(12) + rectangle(arr, 0, bounds.x, bounds.y, bounds.w, bounds.h) + return arr + }) + this.colors = this._getColors() + this.disposables.push(this._listenForCanvasResize(), react('minimap render', this.render)) } - id = uniqueId() - cvs: HTMLCanvasElement | null = null - pageBounds: (Box & { id: TLShapeId })[] = [] - collaborators: TLInstancePresence[] = [] + private _getColors() { + const style = getComputedStyle(this.editor.getContainer()) - canvasScreenBounds = new Box() - canvasPageBounds = new Box() + return { + shapeFill: getRgba(style.getPropertyValue('--color-text-3').trim()), + selectFill: getRgba(style.getPropertyValue('--color-selected').trim()), + viewportFill: getRgba(style.getPropertyValue('--color-muted-1').trim()), + } + } - contentPageBounds = new Box() - contentScreenBounds = new Box() + private colors: ReturnType + // this should be called after dark/light mode changes have propagated to the dom + updateColors() { + this.colors = this._getColors() + } + + readonly id = uniqueId() + @computed + getDpr() { + return this.editor.getInstanceState().devicePixelRatio + } + + @computed + getContentPageBounds() { + const viewportPageBounds = this.editor.getViewportPageBounds() + const commonShapeBounds = this.editor.getCurrentPageBounds() + return commonShapeBounds + ? Box.Expand(commonShapeBounds, viewportPageBounds) + : viewportPageBounds + } + + @computed + getContentScreenBounds() { + const contentPageBounds = this.getContentPageBounds() + const topLeft = this.editor.pageToScreen(contentPageBounds.point) + const bottomRight = this.editor.pageToScreen( + new Vec(contentPageBounds.maxX, contentPageBounds.maxY) + ) + return new Box(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y) + } + + private _getCanvasBoundingRect() { + const { x, y, width, height } = this.elem.getBoundingClientRect() + return new Box(x, y, width, height) + } + + private readonly canvasBoundingClientRect = atom('canvasBoundingClientRect', new Box()) + + getCanvasScreenBounds() { + return this.canvasBoundingClientRect.get() + } + + private _listenForCanvasResize() { + const observer = new ResizeObserver(() => { + const rect = this._getCanvasBoundingRect() + this.canvasBoundingClientRect.set(rect) + }) + observer.observe(this.elem) + return () => observer.disconnect() + } + + @computed + getCanvasSize() { + const rect = this.canvasBoundingClientRect.get() + const dpr = this.getDpr() + return new Vec(rect.width * dpr, rect.height * dpr) + } + + @computed + getCanvasClientPosition() { + return this.canvasBoundingClientRect.get().point + } originPagePoint = new Vec() originPageCenter = new Vec() isInViewport = false - debug = false + /** Get the canvas's true bounds converted to page bounds. */ + @computed getCanvasPageBounds() { + const canvasScreenBounds = this.getCanvasScreenBounds() + const contentPageBounds = this.getContentPageBounds() - setDpr(dpr: number) { - this.dpr = +dpr.toFixed(2) - } + const aspectRatio = canvasScreenBounds.width / canvasScreenBounds.height - updateContentScreenBounds = () => { - const { contentScreenBounds, contentPageBounds: content, canvasScreenBounds: canvas } = this - - let { x, y, w, h } = contentScreenBounds - - if (content.w > content.h) { - const sh = canvas.w / (content.w / content.h) - if (sh > canvas.h) { - x = (canvas.w - canvas.w * (canvas.h / sh)) / 2 - y = 0 - w = canvas.w * (canvas.h / sh) - h = canvas.h - } else { - x = 0 - y = (canvas.h - sh) / 2 - w = canvas.w - h = sh - } - } else if (content.w < content.h) { - const sw = canvas.h / (content.h / content.w) - x = (canvas.w - sw) / 2 - y = 0 - w = sw - h = canvas.h - } else { - x = canvas.h / 2 - y = 0 - w = canvas.h - h = canvas.h + let targetWidth = contentPageBounds.width + let targetHeight = targetWidth / aspectRatio + if (targetHeight < contentPageBounds.height) { + targetHeight = contentPageBounds.height + targetWidth = targetHeight * aspectRatio } - contentScreenBounds.set(x, y, w, h) + const box = new Box(0, 0, targetWidth, targetHeight) + box.center = contentPageBounds.center + return box } - /** Get the canvas's true bounds converted to page bounds. */ - updateCanvasPageBounds = () => { - const { canvasPageBounds, canvasScreenBounds, contentPageBounds, contentScreenBounds } = this - - canvasPageBounds.set( - 0, - 0, - contentPageBounds.width / (contentScreenBounds.width / canvasScreenBounds.width), - contentPageBounds.height / (contentScreenBounds.height / canvasScreenBounds.height) - ) - - canvasPageBounds.center = contentPageBounds.center + @computed getCanvasPageBoundsArray() { + const { x, y, w, h } = this.getCanvasPageBounds() + return new Float32Array([x, y, w, h]) } - getScreenPoint = (x: number, y: number) => { - const { canvasScreenBounds } = this + getPagePoint = (clientX: number, clientY: number) => { + const canvasPageBounds = this.getCanvasPageBounds() + const canvasScreenBounds = this.getCanvasScreenBounds() - const screenX = (x - canvasScreenBounds.minX) * this.dpr - const screenY = (y - canvasScreenBounds.minY) * this.dpr + // first offset the canvas position + let x = clientX - canvasScreenBounds.x + let y = clientY - canvasScreenBounds.y - return { x: screenX, y: screenY } - } + // then multiply by the ratio between the page and screen bounds + x *= canvasPageBounds.width / canvasScreenBounds.width + y *= canvasPageBounds.height / canvasScreenBounds.height - getPagePoint = (x: number, y: number) => { - const { contentPageBounds, contentScreenBounds, canvasPageBounds } = this + // then add the canvas page bounds' offset + x += canvasPageBounds.minX + y += canvasPageBounds.minY - const { x: screenX, y: screenY } = this.getScreenPoint(x, y) - - return new Vec( - canvasPageBounds.minX + (screenX * contentPageBounds.width) / contentScreenBounds.width, - canvasPageBounds.minY + (screenY * contentPageBounds.height) / contentScreenBounds.height, - 1 - ) + return new Vec(x, y, 1) } minimapScreenPointToPagePoint = ( @@ -123,13 +168,13 @@ export class MinimapManager { let { x: px, y: py } = this.getPagePoint(x, y) if (clampToBounds) { - const shapesPageBounds = this.editor.getCurrentPageBounds() + const shapesPageBounds = this.editor.getCurrentPageBounds() ?? new Box() const vpPageBounds = viewportPageBounds - const minX = (shapesPageBounds?.minX ?? 0) - vpPageBounds.width / 2 - const maxX = (shapesPageBounds?.maxX ?? 0) + vpPageBounds.width / 2 - const minY = (shapesPageBounds?.minY ?? 0) - vpPageBounds.height / 2 - const maxY = (shapesPageBounds?.maxY ?? 0) + vpPageBounds.height / 2 + const minX = shapesPageBounds.minX - vpPageBounds.width / 2 + const maxX = shapesPageBounds.maxX + vpPageBounds.width / 2 + const minY = shapesPageBounds.minY - vpPageBounds.height / 2 + const maxY = shapesPageBounds.maxY + vpPageBounds.height / 2 const lx = Math.max(0, minX + vpPageBounds.width - px) const rx = Math.max(0, -(maxX - vpPageBounds.width - px)) @@ -171,209 +216,110 @@ export class MinimapManager { return new Vec(px, py) } - updateColors = () => { - const style = getComputedStyle(this.editor.getContainer()) - - this.colors = { - shapeFill: style.getPropertyValue('--color-text-3').trim(), - selectFill: style.getPropertyValue('--color-selected').trim(), - viewportFill: style.getPropertyValue('--color-muted-1').trim(), - } - } - render = () => { - const { cvs, pageBounds } = this - this.updateCanvasPageBounds() + // make sure we update when dark mode switches + const context = this.gl.context + const canvasSize = this.getCanvasSize() - const { editor, canvasScreenBounds, canvasPageBounds, contentPageBounds, contentScreenBounds } = - this - const { width: cw, height: ch } = canvasScreenBounds + this.gl.setCanvasPageBounds(this.getCanvasPageBoundsArray()) - const selectedShapeIds = new Set(editor.getSelectedShapeIds()) - const viewportPageBounds = editor.getViewportPageBounds() + this.elem.width = canvasSize.x + this.elem.height = canvasSize.y + context.viewport(0, 0, canvasSize.x, canvasSize.y) - if (!cvs || !pageBounds) { - return + // this affects which color transparent shapes are blended with + // during rendering. If we were to invert this any shapes narrower + // than 1 px in screen space would have much lower contrast. e.g. + // draw shapes on a large canvas. + if (this.editor.user.getIsDarkMode()) { + context.clearColor(1, 1, 1, 0) + } else { + context.clearColor(0, 0, 0, 0) } - const ctx = cvs.getContext('2d')! + context.clear(context.COLOR_BUFFER_BIT) - if (!ctx) { - throw new Error('Minimap (shapes): Could not get context') - } + const selectedShapes = new Set(this.editor.getSelectedShapeIds()) - ctx.resetTransform() - ctx.globalAlpha = 1 - ctx.clearRect(0, 0, cw, ch) + const colors = this.colors + let selectedShapeOffset = 0 + let unselectedShapeOffset = 0 - // Transform canvas + const ids = this.editor.getCurrentPageShapeIdsSorted() - const sx = contentScreenBounds.width / contentPageBounds.width - const sy = contentScreenBounds.height / contentPageBounds.height + for (let i = 0, len = ids.length; i < len; i++) { + const shapeId = ids[i] + const geometry = this.shapeGeometryCache.get(shapeId) + if (!geometry) continue - ctx.translate((cw - contentScreenBounds.width) / 2, (ch - contentScreenBounds.height) / 2) - ctx.scale(sx, sy) - ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY) + const len = geometry.length - // shapes - const shapesPath = new Path2D() - const selectedPath = new Path2D() - - const { shapeFill, selectFill, viewportFill } = this.colors - - // When there are many shapes, don't draw rounded rectangles; - // consider using the shape's size instead. - - let pb: Box & { id: TLShapeId } - for (let i = 0, n = pageBounds.length; i < n; i++) { - pb = pageBounds[i] - ;(selectedShapeIds.has(pb.id) ? selectedPath : shapesPath).rect( - pb.minX, - pb.minY, - pb.width, - pb.height - ) - } - - // Fill the shapes paths - ctx.fillStyle = shapeFill - ctx.fill(shapesPath) - - // Fill the selected paths - ctx.fillStyle = selectFill - ctx.fill(selectedPath) - - if (this.debug) { - // Page bounds - const commonBounds = Box.Common(pageBounds) - const { minX, minY, width, height } = commonBounds - ctx.strokeStyle = 'green' - ctx.lineWidth = 2 / sx - ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) - } - - // Brush - { - const { brush } = editor.getInstanceState() - if (brush) { - const { x, y, w, h } = brush - ctx.beginPath() - MinimapManager.sharpRect(ctx, x, y, w, h) - ctx.closePath() - ctx.fillStyle = viewportFill - ctx.fill() + if (selectedShapes.has(shapeId)) { + appendVertices(this.gl.selectedShapes, selectedShapeOffset, geometry) + selectedShapeOffset += len + } else { + appendVertices(this.gl.unselectedShapes, unselectedShapeOffset, geometry) + unselectedShapeOffset += len } } - // Viewport - { - const { minX, minY, width, height } = viewportPageBounds - - ctx.beginPath() - - const rx = 12 / sx - const ry = 12 / sx - MinimapManager.roundedRect( - ctx, - minX, - minY, - width, - height, - Math.min(width / 4, rx), - Math.min(height / 4, ry) - ) - ctx.closePath() - ctx.fillStyle = viewportFill - ctx.fill() - - if (this.debug) { - ctx.strokeStyle = 'orange' - ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) - } - } - - // Show collaborator cursors - - // Padding for canvas bounds edges - const px = 2.5 / sx - const py = 2.5 / sy - - const currentPageId = editor.getCurrentPageId() - - let collaborator: TLInstancePresence - for (let i = 0; i < this.collaborators.length; i++) { - collaborator = this.collaborators[i] - if (collaborator.currentPageId !== currentPageId) { - continue - } - - ctx.beginPath() - ctx.ellipse( - clamp(collaborator.cursor.x, canvasPageBounds.minX + px, canvasPageBounds.maxX - px), - clamp(collaborator.cursor.y, canvasPageBounds.minY + py, canvasPageBounds.maxY - py), - 5 / sx, - 5 / sy, - 0, - 0, - PI2 - ) - ctx.fillStyle = collaborator.color - ctx.fill() - } - - if (this.debug) { - ctx.lineWidth = 2 / sx - - { - // Minimap Bounds - const { minX, minY, width, height } = contentPageBounds - ctx.strokeStyle = 'red' - ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) - } - - { - // Canvas Bounds - const { minX, minY, width, height } = canvasPageBounds - ctx.strokeStyle = 'blue' - ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) - } - } + this.drawViewport() + this.drawShapes(this.gl.unselectedShapes, unselectedShapeOffset, colors.shapeFill) + this.drawShapes(this.gl.selectedShapes, selectedShapeOffset, colors.selectFill) + this.drawCollaborators() } - static roundedRect( - ctx: CanvasRenderingContext2D | Path2D, - x: number, - y: number, - width: number, - height: number, - rx: number, - ry: number - ) { - if (rx < 1 && ry < 1) { - ctx.rect(x, y, width, height) - return - } - - ctx.moveTo(x + rx, y) - ctx.lineTo(x + width - rx, y) - ctx.quadraticCurveTo(x + width, y, x + width, y + ry) - ctx.lineTo(x + width, y + height - ry) - ctx.quadraticCurveTo(x + width, y + height, x + width - rx, y + height) - ctx.lineTo(x + rx, y + height) - ctx.quadraticCurveTo(x, y + height, x, y + height - ry) - ctx.lineTo(x, y + ry) - ctx.quadraticCurveTo(x, y, x + rx, y) + private drawShapes(stuff: BufferStuff, len: number, color: Float32Array) { + this.gl.prepareTriangles(stuff, len) + this.gl.setFillColor(color) + this.gl.drawTriangles(len) } - static sharpRect( - ctx: CanvasRenderingContext2D | Path2D, - x: number, - y: number, - width: number, - height: number, - _rx?: number, - _ry?: number - ) { - ctx.rect(x, y, width, height) + private drawViewport() { + const viewport = this.editor.getViewportPageBounds() + const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width + const len = roundedRectangle(this.gl.viewport.vertices, viewport, 4 * zoom) + + this.gl.prepareTriangles(this.gl.viewport, len) + this.gl.setFillColor(this.colors.viewportFill) + this.gl.drawTriangles(len) + } + + drawCollaborators() { + const collaborators = this.editor.getCollaboratorsOnCurrentPage() + if (!collaborators.length) return + + const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width + + // just draw a little circle for each collaborator + const numSegmentsPerCircle = 20 + const dataSizePerCircle = numSegmentsPerCircle * 6 + const totalSize = dataSizePerCircle * collaborators.length + + // expand vertex array if needed + if (this.gl.collaborators.vertices.length < totalSize) { + this.gl.collaborators.vertices = new Float32Array(totalSize) + } + + const vertices = this.gl.collaborators.vertices + let offset = 0 + for (const { cursor } of collaborators) { + pie(vertices, { + center: Vec.From(cursor), + radius: 2 * zoom, + offset, + numArcSegments: numSegmentsPerCircle, + }) + offset += dataSizePerCircle + } + + this.gl.prepareTriangles(this.gl.collaborators, totalSize) + + offset = 0 + for (const { color } of collaborators) { + this.gl.setFillColor(getRgba(color)) + this.gl.context.drawArrays(this.gl.context.TRIANGLES, offset / 2, dataSizePerCircle / 2) + offset += dataSizePerCircle + } } } diff --git a/packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts b/packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts new file mode 100644 index 000000000..43726f6b6 --- /dev/null +++ b/packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts @@ -0,0 +1,16 @@ +const memo = {} as Record + +export function getRgba(colorString: string) { + if (memo[colorString]) { + return memo[colorString] + } + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + context!.fillStyle = colorString + context!.fillRect(0, 0, 1, 1) + const [r, g, b, a] = context!.getImageData(0, 0, 1, 1).data + const result = new Float32Array([r / 255, g / 255, b / 255, a / 255]) + + memo[colorString] = result + return result +} diff --git a/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-setup.ts b/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-setup.ts new file mode 100644 index 000000000..0f5585d26 --- /dev/null +++ b/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-setup.ts @@ -0,0 +1,148 @@ +import { roundedRectangleDataSize } from './minimap-webgl-shapes' + +export function setupWebGl(canvas: HTMLCanvasElement | null) { + if (!canvas) throw new Error('Canvas element not found') + + const context = canvas.getContext('webgl2', { + premultipliedAlpha: false, + }) + if (!context) throw new Error('Failed to get webgl2 context') + + const vertexShaderSourceCode = `#version 300 es + precision mediump float; + + in vec2 shapeVertexPosition; + + uniform vec4 canvasPageBounds; + + // taken (with thanks) from + // https://webglfundamentals.org/webgl/lessons/webgl-2d-matrices.html + void main() { + // convert the position from pixels to 0.0 to 1.0 + vec2 zeroToOne = (shapeVertexPosition - canvasPageBounds.xy) / canvasPageBounds.zw; + + // convert from 0->1 to 0->2 + vec2 zeroToTwo = zeroToOne * 2.0; + + // convert from 0->2 to -1->+1 (clipspace) + vec2 clipSpace = zeroToTwo - 1.0; + + gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); + }` + + const vertexShader = context.createShader(context.VERTEX_SHADER) + if (!vertexShader) { + throw new Error('Failed to create vertex shader') + } + context.shaderSource(vertexShader, vertexShaderSourceCode) + context.compileShader(vertexShader) + if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) { + throw new Error('Failed to compile vertex shader') + } + + const fragmentShaderSourceCode = `#version 300 es + precision mediump float; + + uniform vec4 fillColor; + out vec4 outputColor; + + void main() { + outputColor = fillColor; + }` + + const fragmentShader = context.createShader(context.FRAGMENT_SHADER) + if (!fragmentShader) { + throw new Error('Failed to create fragment shader') + } + context.shaderSource(fragmentShader, fragmentShaderSourceCode) + context.compileShader(fragmentShader) + if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) { + throw new Error('Failed to compile fragment shader') + } + + const program = context.createProgram() + if (!program) { + throw new Error('Failed to create program') + } + context.attachShader(program, vertexShader) + context.attachShader(program, fragmentShader) + context.linkProgram(program) + if (!context.getProgramParameter(program, context.LINK_STATUS)) { + throw new Error('Failed to link program') + } + context.useProgram(program) + + const shapeVertexPositionAttributeLocation = context.getAttribLocation( + program, + 'shapeVertexPosition' + ) + if (shapeVertexPositionAttributeLocation < 0) { + throw new Error('Failed to get shapeVertexPosition attribute location') + } + context.enableVertexAttribArray(shapeVertexPositionAttributeLocation) + + const canvasPageBoundsLocation = context.getUniformLocation(program, 'canvasPageBounds') + const fillColorLocation = context.getUniformLocation(program, 'fillColor') + + const selectedShapesBuffer = context.createBuffer() + if (!selectedShapesBuffer) throw new Error('Failed to create buffer') + + const unselectedShapesBuffer = context.createBuffer() + if (!unselectedShapesBuffer) throw new Error('Failed to create buffer') + + return { + context, + selectedShapes: allocateBuffer(context, 1024), + unselectedShapes: allocateBuffer(context, 4096), + viewport: allocateBuffer(context, roundedRectangleDataSize), + collaborators: allocateBuffer(context, 1024), + + prepareTriangles(stuff: BufferStuff, len: number) { + context.bindBuffer(context.ARRAY_BUFFER, stuff.buffer) + context.bufferData(context.ARRAY_BUFFER, stuff.vertices, context.STATIC_DRAW, 0, len) + context.enableVertexAttribArray(shapeVertexPositionAttributeLocation) + context.vertexAttribPointer( + shapeVertexPositionAttributeLocation, + 2, + context.FLOAT, + false, + 0, + 0 + ) + }, + + drawTriangles(len: number) { + context.drawArrays(context.TRIANGLES, 0, len / 2) + }, + + setFillColor(color: Float32Array) { + context.uniform4fv(fillColorLocation, color) + }, + + setCanvasPageBounds(bounds: Float32Array) { + context.uniform4fv(canvasPageBoundsLocation, bounds) + }, + } +} + +export type BufferStuff = ReturnType + +function allocateBuffer(context: WebGL2RenderingContext, size: number) { + const buffer = context.createBuffer() + if (!buffer) throw new Error('Failed to create buffer') + return { buffer, vertices: new Float32Array(size) } +} + +export function appendVertices(bufferStuff: BufferStuff, offset: number, data: Float32Array) { + let len = bufferStuff.vertices.length + while (len < offset + data.length) { + len *= 2 + } + if (len != bufferStuff.vertices.length) { + const newVertices = new Float32Array(len) + newVertices.set(bufferStuff.vertices) + bufferStuff.vertices = newVertices + } + + bufferStuff.vertices.set(data, offset) +} diff --git a/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-shapes.ts b/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-shapes.ts new file mode 100644 index 000000000..283e89344 --- /dev/null +++ b/packages/tldraw/src/lib/ui/components/Minimap/minimap-webgl-shapes.ts @@ -0,0 +1,144 @@ +import { Box, HALF_PI, PI, PI2, Vec } from '@tldraw/editor' + +export const numArcSegmentsPerCorner = 10 + +export const roundedRectangleDataSize = + // num triangles in corners + 4 * 6 * numArcSegmentsPerCorner + + // num triangles in center rect + 12 + + // num triangles in outer rects + 4 * 12 + +export function pie( + array: Float32Array, + { + center, + radius, + numArcSegments = 20, + startAngle = 0, + endAngle = PI2, + offset = 0, + }: { + center: Vec + radius: number + numArcSegments?: number + startAngle?: number + endAngle?: number + offset?: number + } +) { + const angle = (endAngle - startAngle) / numArcSegments + let i = offset + for (let a = startAngle; a < endAngle; a += angle) { + array[i++] = center.x + array[i++] = center.y + array[i++] = center.x + Math.cos(a) * radius + array[i++] = center.y + Math.sin(a) * radius + array[i++] = center.x + Math.cos(a + angle) * radius + array[i++] = center.y + Math.sin(a + angle) * radius + } + return array +} + +/** @internal **/ +export function rectangle( + array: Float32Array, + offset: number, + x: number, + y: number, + w: number, + h: number +) { + array[offset++] = x + array[offset++] = y + array[offset++] = x + array[offset++] = y + h + array[offset++] = x + w + array[offset++] = y + + array[offset++] = x + w + array[offset++] = y + array[offset++] = x + array[offset++] = y + h + array[offset++] = x + w + array[offset++] = y + h +} + +export function roundedRectangle(data: Float32Array, box: Box, radius: number): number { + const numArcSegments = numArcSegmentsPerCorner + radius = Math.min(radius, Math.min(box.w, box.h) / 2) + // first draw the inner box + const innerBox = Box.ExpandBy(box, -radius) + if (innerBox.w <= 0 || innerBox.h <= 0) { + // just draw a circle + pie(data, { center: box.center, radius: radius, numArcSegments: numArcSegmentsPerCorner * 4 }) + return numArcSegmentsPerCorner * 4 * 6 + } + let offset = 0 + // draw center rect first + rectangle(data, offset, innerBox.minX, innerBox.minY, innerBox.w, innerBox.h) + offset += 12 + // then top rect + rectangle(data, offset, innerBox.minX, box.minY, innerBox.w, radius) + offset += 12 + // then right rect + rectangle(data, offset, innerBox.maxX, innerBox.minY, radius, innerBox.h) + offset += 12 + // then bottom rect + rectangle(data, offset, innerBox.minX, innerBox.maxY, innerBox.w, radius) + offset += 12 + // then left rect + rectangle(data, offset, box.minX, innerBox.minY, radius, innerBox.h) + offset += 12 + + // draw the corners + + // top left + pie(data, { + numArcSegments, + offset, + center: innerBox.point, + radius, + startAngle: PI, + endAngle: PI * 1.5, + }) + + offset += numArcSegments * 6 + + // top right + pie(data, { + numArcSegments, + offset, + center: Vec.Add(innerBox.point, new Vec(innerBox.w, 0)), + radius, + startAngle: PI * 1.5, + endAngle: PI2, + }) + + offset += numArcSegments * 6 + + // bottom right + pie(data, { + numArcSegments, + offset, + center: Vec.Add(innerBox.point, innerBox.size), + radius, + startAngle: 0, + endAngle: HALF_PI, + }) + + offset += numArcSegments * 6 + + // bottom left + pie(data, { + numArcSegments, + offset, + center: Vec.Add(innerBox.point, new Vec(0, innerBox.h)), + radius, + startAngle: HALF_PI, + endAngle: PI, + }) + + return roundedRectangleDataSize +} diff --git a/packages/tldraw/src/lib/ui/context/asset-urls.tsx b/packages/tldraw/src/lib/ui/context/asset-urls.tsx index 4fd3b9fd6..af35d5800 100644 --- a/packages/tldraw/src/lib/ui/context/asset-urls.tsx +++ b/packages/tldraw/src/lib/ui/context/asset-urls.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext } from 'react' +import { createContext, useContext, useEffect } from 'react' import { TLUiAssetUrls } from '../assetUrls' /** @internal */ @@ -14,6 +14,19 @@ export function AssetUrlsProvider({ assetUrls: TLUiAssetUrls children: React.ReactNode }) { + useEffect(() => { + for (const src of Object.values(assetUrls.icons)) { + const image = new Image() + image.src = src + image.decode() + } + for (const src of Object.values(assetUrls.embedIcons)) { + const image = new Image() + image.src = src + image.decode() + } + }, [assetUrls]) + return {children} } diff --git a/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts b/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts index f1227c4cc..24ead9291 100644 --- a/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts +++ b/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts @@ -9,6 +9,8 @@ import { TLTextShape, VecLike, isNonNull, + preventDefault, + stopEventPropagation, uniq, useEditor, useValue, @@ -615,24 +617,29 @@ export function useNativeClipboardEvents() { useEffect(() => { if (!appIsFocused) return - const copy = () => { + const copy = (e: ClipboardEvent) => { if ( editor.getSelectedShapeIds().length === 0 || editor.getEditingShapeId() !== null || disallowClipboardEvents(editor) - ) + ) { return + } + + preventDefault(e) handleNativeOrMenuCopy(editor) trackEvent('copy', { source: 'kbd' }) } - function cut() { + function cut(e: ClipboardEvent) { if ( editor.getSelectedShapeIds().length === 0 || editor.getEditingShapeId() !== null || disallowClipboardEvents(editor) - ) + ) { return + } + preventDefault(e) handleNativeOrMenuCopy(editor) editor.deleteShapes(editor.getSelectedShapeIds()) trackEvent('cut', { source: 'kbd' }) @@ -649,9 +656,9 @@ export function useNativeClipboardEvents() { } } - const paste = (event: ClipboardEvent) => { + const paste = (e: ClipboardEvent) => { if (disablingMiddleClickPaste) { - event.stopPropagation() + stopEventPropagation(e) return } @@ -661,8 +668,8 @@ export function useNativeClipboardEvents() { if (editor.getEditingShapeId() !== null || disallowClipboardEvents(editor)) return // First try to use the clipboard data on the event - if (event.clipboardData && !editor.inputs.shiftKey) { - handlePasteFromEventClipboardData(editor, event.clipboardData) + if (e.clipboardData && !editor.inputs.shiftKey) { + handlePasteFromEventClipboardData(editor, e.clipboardData) } else { // Or else use the clipboard API navigator.clipboard.read().then((clipboardItems) => { @@ -672,6 +679,7 @@ export function useNativeClipboardEvents() { }) } + preventDefault(e) trackEvent('paste', { source: 'kbd' }) } diff --git a/packages/tldraw/src/lib/ui/hooks/usePreloadIcons.ts b/packages/tldraw/src/lib/ui/hooks/usePreloadIcons.ts deleted file mode 100644 index ee7d1aa37..000000000 --- a/packages/tldraw/src/lib/ui/hooks/usePreloadIcons.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect, useState } from 'react' -import { useAssetUrls } from '../context/asset-urls' -import { iconTypes } from '../icon-types' - -/** @internal */ -export function usePreloadIcons(): boolean { - const [isLoaded, setIsLoaded] = useState(false) - const assetUrls = useAssetUrls() - - useEffect(() => { - let cancelled = false - async function loadImages() { - // Run through all of the icons and load them. It doesn't matter - // if any of the images don't load; though we expect that they would. - // Instead, we just want to make sure that the browser has cached - // all of the icons it can so that they're available when we need them. - - await Promise.allSettled( - iconTypes.map((icon) => { - const image = new Image() - image.src = assetUrls.icons[icon] - return image.decode() - }) - ) - - if (cancelled) return - setIsLoaded(true) - } - - loadImages() - - return () => { - cancelled = true - } - }, [isLoaded, assetUrls]) - - return isLoaded -} diff --git a/packages/tldraw/src/lib/utils/tldr/file.ts b/packages/tldraw/src/lib/utils/tldr/file.ts index bee4c2ac2..8302d53df 100644 --- a/packages/tldraw/src/lib/utils/tldr/file.ts +++ b/packages/tldraw/src/lib/utils/tldr/file.ts @@ -62,7 +62,7 @@ const schemaV2 = T.object({ const tldrawFileValidator: T.Validator = T.object({ tldrawFileFormatVersion: T.nonZeroInteger, - schema: T.union('schemaVersion', { + schema: T.numberUnion('schemaVersion', { 1: schemaV1, 2: schemaV2, }), diff --git a/packages/tldraw/src/test/__snapshots__/drawing.test.ts.snap b/packages/tldraw/src/test/__snapshots__/drawing.test.ts.snap new file mode 100644 index 000000000..d0450b5e3 --- /dev/null +++ b/packages/tldraw/src/test/__snapshots__/drawing.test.ts.snap @@ -0,0 +1,1287 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Draws a bunch: draw shape 1`] = ` +{ + "index": "a1", + "isLocked": false, + "meta": {}, + "opacity": 1, + "parentId": "page:page", + "props": { + "color": "black", + "dash": "draw", + "fill": "none", + "isClosed": false, + "isComplete": true, + "isPen": false, + "segments": [ + { + "points": [ + { + "x": 0, + "y": 0, + "z": 0.5, + }, + { + "x": 1, + "y": 0, + "z": 0.5, + }, + { + "x": 4, + "y": 0, + "z": 0.5, + }, + { + "x": 10, + "y": -1, + "z": 0.5, + }, + { + "x": 19, + "y": -4, + "z": 0.5, + }, + { + "x": 30, + "y": -10, + "z": 0.5, + }, + { + "x": 46, + "y": -20, + "z": 0.5, + }, + { + "x": 61, + "y": -30, + "z": 0.5, + }, + { + "x": 74, + "y": -43, + "z": 0.5, + }, + { + "x": 89, + "y": -59, + "z": 0.5, + }, + { + "x": 102, + "y": -77, + "z": 0.5, + }, + { + "x": 108, + "y": -90, + "z": 0.5, + }, + { + "x": 112, + "y": -103, + "z": 0.5, + }, + { + "x": 117, + "y": -119, + "z": 0.5, + }, + { + "x": 118, + "y": -131, + "z": 0.5, + }, + { + "x": 119, + "y": -137, + "z": 0.5, + }, + { + "x": 119, + "y": -145, + "z": 0.5, + }, + { + "x": 120, + "y": -152, + "z": 0.5, + }, + { + "x": 119, + "y": -158, + "z": 0.5, + }, + { + "x": 117, + "y": -163, + "z": 0.5, + }, + { + "x": 114, + "y": -167, + "z": 0.5, + }, + { + "x": 109, + "y": -169, + "z": 0.5, + }, + { + "x": 103, + "y": -170, + "z": 0.5, + }, + { + "x": 97, + "y": -170, + "z": 0.5, + }, + { + "x": 89, + "y": -170, + "z": 0.5, + }, + { + "x": 80, + "y": -166, + "z": 0.5, + }, + { + "x": 71, + "y": -159, + "z": 0.5, + }, + { + "x": 62, + "y": -150, + "z": 0.5, + }, + { + "x": 54, + "y": -138, + "z": 0.5, + }, + { + "x": 50, + "y": -126, + "z": 0.5, + }, + { + "x": 47, + "y": -113, + "z": 0.5, + }, + { + "x": 46, + "y": -99, + "z": 0.5, + }, + { + "x": 46, + "y": -82, + "z": 0.5, + }, + { + "x": 47, + "y": -61, + "z": 0.5, + }, + { + "x": 53, + "y": -41, + "z": 0.5, + }, + { + "x": 60, + "y": -24, + "z": 0.5, + }, + { + "x": 68, + "y": -7, + "z": 0.5, + }, + { + "x": 79, + "y": 12, + "z": 0.5, + }, + { + "x": 88, + "y": 32, + "z": 0.5, + }, + { + "x": 96, + "y": 50, + "z": 0.5, + }, + { + "x": 103, + "y": 69, + "z": 0.5, + }, + { + "x": 106, + "y": 86, + "z": 0.5, + }, + { + "x": 107, + "y": 102, + "z": 0.5, + }, + { + "x": 107, + "y": 120, + "z": 0.5, + }, + { + "x": 102, + "y": 136, + "z": 0.5, + }, + { + "x": 90, + "y": 146, + "z": 0.5, + }, + { + "x": 74, + "y": 154, + "z": 0.5, + }, + { + "x": 43, + "y": 163, + "z": 0.5, + }, + { + "x": 32, + "y": 164, + "z": 0.5, + }, + { + "x": 21, + "y": 164, + "z": 0.5, + }, + { + "x": 11, + "y": 164, + "z": 0.5, + }, + { + "x": 2, + "y": 164, + "z": 0.5, + }, + { + "x": -7, + "y": 162, + "z": 0.5, + }, + { + "x": -13, + "y": 159, + "z": 0.5, + }, + { + "x": -15, + "y": 153, + "z": 0.5, + }, + { + "x": -15, + "y": 147, + "z": 0.5, + }, + { + "x": -11, + "y": 138, + "z": 0.5, + }, + { + "x": 1, + "y": 127, + "z": 0.5, + }, + { + "x": 15, + "y": 112, + "z": 0.5, + }, + { + "x": 34, + "y": 96, + "z": 0.5, + }, + { + "x": 56, + "y": 79, + "z": 0.5, + }, + { + "x": 81, + "y": 58, + "z": 0.5, + }, + { + "x": 107, + "y": 33, + "z": 0.5, + }, + { + "x": 126, + "y": 12, + "z": 0.5, + }, + { + "x": 145, + "y": -10, + "z": 0.5, + }, + { + "x": 160, + "y": -30, + "z": 0.5, + }, + { + "x": 172, + "y": -50, + "z": 0.5, + }, + { + "x": 185, + "y": -73, + "z": 0.5, + }, + { + "x": 194, + "y": -93, + "z": 0.5, + }, + { + "x": 199, + "y": -112, + "z": 0.5, + }, + { + "x": 202, + "y": -127, + "z": 0.5, + }, + { + "x": 203, + "y": -138, + "z": 0.5, + }, + { + "x": 203, + "y": -146, + "z": 0.5, + }, + { + "x": 201, + "y": -152, + "z": 0.5, + }, + { + "x": 196, + "y": -155, + "z": 0.5, + }, + { + "x": 191, + "y": -156, + "z": 0.5, + }, + { + "x": 186, + "y": -157, + "z": 0.5, + }, + { + "x": 178, + "y": -156, + "z": 0.5, + }, + { + "x": 170, + "y": -150, + "z": 0.5, + }, + { + "x": 164, + "y": -140, + "z": 0.5, + }, + { + "x": 158, + "y": -128, + "z": 0.5, + }, + { + "x": 151, + "y": -110, + "z": 0.5, + }, + { + "x": 144, + "y": -89, + "z": 0.5, + }, + { + "x": 139, + "y": -64, + "z": 0.5, + }, + { + "x": 135, + "y": -36, + "z": 0.5, + }, + { + "x": 132, + "y": -7, + "z": 0.5, + }, + { + "x": 132, + "y": 22, + "z": 0.5, + }, + { + "x": 132, + "y": 49, + "z": 0.5, + }, + { + "x": 133, + "y": 74, + "z": 0.5, + }, + { + "x": 140, + "y": 97, + "z": 0.5, + }, + { + "x": 148, + "y": 113, + "z": 0.5, + }, + { + "x": 156, + "y": 124, + "z": 0.5, + }, + { + "x": 166, + "y": 137, + "z": 0.5, + }, + { + "x": 175, + "y": 145, + "z": 0.5, + }, + { + "x": 183, + "y": 150, + "z": 0.5, + }, + { + "x": 191, + "y": 152, + "z": 0.5, + }, + { + "x": 197, + "y": 152, + "z": 0.5, + }, + { + "x": 205, + "y": 151, + "z": 0.5, + }, + { + "x": 214, + "y": 146, + "z": 0.5, + }, + { + "x": 223, + "y": 136, + "z": 0.5, + }, + { + "x": 230, + "y": 125, + "z": 0.5, + }, + { + "x": 236, + "y": 112, + "z": 0.5, + }, + { + "x": 242, + "y": 95, + "z": 0.5, + }, + { + "x": 247, + "y": 78, + "z": 0.5, + }, + { + "x": 250, + "y": 61, + "z": 0.5, + }, + { + "x": 252, + "y": 46, + "z": 0.5, + }, + { + "x": 253, + "y": 37, + "z": 0.5, + }, + { + "x": 253, + "y": 31, + "z": 0.5, + }, + { + "x": 253, + "y": 24, + "z": 0.5, + }, + { + "x": 251, + "y": 20, + "z": 0.5, + }, + { + "x": 248, + "y": 16, + "z": 0.5, + }, + { + "x": 246, + "y": 16, + "z": 0.5, + }, + { + "x": 243, + "y": 16, + "z": 0.5, + }, + { + "x": 240, + "y": 17, + "z": 0.5, + }, + { + "x": 238, + "y": 19, + "z": 0.5, + }, + { + "x": 236, + "y": 26, + "z": 0.5, + }, + { + "x": 234, + "y": 34, + "z": 0.5, + }, + { + "x": 233, + "y": 45, + "z": 0.5, + }, + { + "x": 232, + "y": 56, + "z": 0.5, + }, + { + "x": 232, + "y": 66, + "z": 0.5, + }, + { + "x": 235, + "y": 79, + "z": 0.5, + }, + { + "x": 241, + "y": 91, + "z": 0.5, + }, + { + "x": 247, + "y": 100, + "z": 0.5, + }, + { + "x": 255, + "y": 109, + "z": 0.5, + }, + { + "x": 260, + "y": 113, + "z": 0.5, + }, + { + "x": 266, + "y": 116, + "z": 0.5, + }, + { + "x": 274, + "y": 118, + "z": 0.5, + }, + { + "x": 280, + "y": 118, + "z": 0.5, + }, + { + "x": 286, + "y": 115, + "z": 0.5, + }, + { + "x": 291, + "y": 105, + "z": 0.5, + }, + { + "x": 296, + "y": 93, + "z": 0.5, + }, + { + "x": 298, + "y": 83, + "z": 0.5, + }, + { + "x": 301, + "y": 70, + "z": 0.5, + }, + { + "x": 303, + "y": 58, + "z": 0.5, + }, + { + "x": 305, + "y": 48, + "z": 0.5, + }, + { + "x": 306, + "y": 38, + "z": 0.5, + }, + { + "x": 307, + "y": 31, + "z": 0.5, + }, + { + "x": 308, + "y": 25, + "z": 0.5, + }, + { + "x": 308, + "y": 22, + "z": 0.5, + }, + { + "x": 308, + "y": 20, + "z": 0.5, + }, + { + "x": 308, + "y": 19, + "z": 0.5, + }, + { + "x": 308, + "y": 22, + "z": 0.5, + }, + { + "x": 308, + "y": 27, + "z": 0.5, + }, + { + "x": 308, + "y": 35, + "z": 0.5, + }, + { + "x": 308, + "y": 44, + "z": 0.5, + }, + { + "x": 308, + "y": 51, + "z": 0.5, + }, + { + "x": 308, + "y": 56, + "z": 0.5, + }, + { + "x": 308, + "y": 61, + "z": 0.5, + }, + { + "x": 309, + "y": 66, + "z": 0.5, + }, + { + "x": 312, + "y": 71, + "z": 0.5, + }, + { + "x": 314, + "y": 74, + "z": 0.5, + }, + { + "x": 317, + "y": 75, + "z": 0.5, + }, + { + "x": 320, + "y": 76, + "z": 0.5, + }, + { + "x": 324, + "y": 76, + "z": 0.5, + }, + { + "x": 329, + "y": 73, + "z": 0.5, + }, + { + "x": 333, + "y": 69, + "z": 0.5, + }, + { + "x": 336, + "y": 66, + "z": 0.5, + }, + { + "x": 339, + "y": 62, + "z": 0.5, + }, + { + "x": 342, + "y": 59, + "z": 0.5, + }, + { + "x": 344, + "y": 57, + "z": 0.5, + }, + { + "x": 346, + "y": 55, + "z": 0.5, + }, + { + "x": 348, + "y": 55, + "z": 0.5, + }, + { + "x": 348, + "y": 55, + "z": 0.5, + }, + { + "x": 349, + "y": 55, + "z": 0.5, + }, + { + "x": 349, + "y": 56, + "z": 0.5, + }, + { + "x": 350, + "y": 57, + "z": 0.5, + }, + { + "x": 351, + "y": 59, + "z": 0.5, + }, + { + "x": 351, + "y": 61, + "z": 0.5, + }, + { + "x": 352, + "y": 62, + "z": 0.5, + }, + { + "x": 352, + "y": 63, + "z": 0.5, + }, + { + "x": 353, + "y": 64, + "z": 0.5, + }, + { + "x": 354, + "y": 64, + "z": 0.5, + }, + { + "x": 355, + "y": 64, + "z": 0.5, + }, + { + "x": 356, + "y": 58, + "z": 0.5, + }, + { + "x": 358, + "y": 49, + "z": 0.5, + }, + { + "x": 360, + "y": 40, + "z": 0.5, + }, + { + "x": 363, + "y": 32, + "z": 0.5, + }, + { + "x": 365, + "y": 26, + "z": 0.5, + }, + { + "x": 367, + "y": 19, + "z": 0.5, + }, + { + "x": 369, + "y": 13, + "z": 0.5, + }, + { + "x": 373, + "y": 7, + "z": 0.5, + }, + { + "x": 376, + "y": 3, + "z": 0.5, + }, + { + "x": 380, + "y": 2, + "z": 0.5, + }, + { + "x": 385, + "y": 2, + "z": 0.5, + }, + { + "x": 390, + "y": 2, + "z": 0.5, + }, + { + "x": 397, + "y": 3, + "z": 0.5, + }, + { + "x": 410, + "y": 11, + "z": 0.5, + }, + { + "x": 424, + "y": 23, + "z": 0.5, + }, + { + "x": 434, + "y": 34, + "z": 0.5, + }, + { + "x": 446, + "y": 49, + "z": 0.5, + }, + { + "x": 456, + "y": 64, + "z": 0.5, + }, + { + "x": 464, + "y": 81, + "z": 0.5, + }, + { + "x": 468, + "y": 95, + "z": 0.5, + }, + { + "x": 470, + "y": 116, + "z": 0.5, + }, + { + "x": 472, + "y": 142, + "z": 0.5, + }, + { + "x": 472, + "y": 162, + "z": 0.5, + }, + { + "x": 468, + "y": 178, + "z": 0.5, + }, + { + "x": 458, + "y": 195, + "z": 0.5, + }, + { + "x": 442, + "y": 213, + "z": 0.5, + }, + { + "x": 423, + "y": 230, + "z": 0.5, + }, + { + "x": 407, + "y": 240, + "z": 0.5, + }, + { + "x": 393, + "y": 245, + "z": 0.5, + }, + { + "x": 377, + "y": 250, + "z": 0.5, + }, + { + "x": 364, + "y": 252, + "z": 0.5, + }, + { + "x": 354, + "y": 252, + "z": 0.5, + }, + { + "x": 346, + "y": 248, + "z": 0.5, + }, + { + "x": 340, + "y": 239, + "z": 0.5, + }, + { + "x": 339, + "y": 225, + "z": 0.5, + }, + { + "x": 339, + "y": 198, + "z": 0.5, + }, + { + "x": 349, + "y": 165, + "z": 0.5, + }, + { + "x": 372, + "y": 130, + "z": 0.5, + }, + { + "x": 403, + "y": 89, + "z": 0.5, + }, + { + "x": 432, + "y": 54, + "z": 0.5, + }, + { + "x": 467, + "y": 16, + "z": 0.5, + }, + { + "x": 504, + "y": -21, + "z": 0.5, + }, + { + "x": 551, + "y": -68, + "z": 0.5, + }, + { + "x": 597, + "y": -115, + "z": 0.5, + }, + { + "x": 619, + "y": -138, + "z": 0.5, + }, + { + "x": 641, + "y": -162, + "z": 0.5, + }, + { + "x": 663, + "y": -188, + "z": 0.5, + }, + { + "x": 675, + "y": -203, + "z": 0.5, + }, + { + "x": 684, + "y": -219, + "z": 0.5, + }, + { + "x": 692, + "y": -237, + "z": 0.5, + }, + { + "x": 693, + "y": -244, + "z": 0.5, + }, + { + "x": 691, + "y": -250, + "z": 0.5, + }, + { + "x": 682, + "y": -254, + "z": 0.5, + }, + { + "x": 664, + "y": -256, + "z": 0.5, + }, + { + "x": 642, + "y": -256, + "z": 0.5, + }, + { + "x": 621, + "y": -253, + "z": 0.5, + }, + { + "x": 589, + "y": -240, + "z": 0.5, + }, + { + "x": 554, + "y": -221, + "z": 0.5, + }, + { + "x": 526, + "y": -201, + "z": 0.5, + }, + { + "x": 502, + "y": -182, + "z": 0.5, + }, + { + "x": 484, + "y": -165, + "z": 0.5, + }, + { + "x": 467, + "y": -146, + "z": 0.5, + }, + { + "x": 456, + "y": -131, + "z": 0.5, + }, + { + "x": 450, + "y": -120, + "z": 0.5, + }, + { + "x": 448, + "y": -112, + "z": 0.5, + }, + { + "x": 448, + "y": -107, + "z": 0.5, + }, + { + "x": 449, + "y": -104, + "z": 0.5, + }, + { + "x": 452, + "y": -103, + "z": 0.5, + }, + { + "x": 458, + "y": -102, + "z": 0.5, + }, + { + "x": 462, + "y": -102, + "z": 0.5, + }, + { + "x": 465, + "y": -103, + "z": 0.5, + }, + { + "x": 470, + "y": -104, + "z": 0.5, + }, + { + "x": 472, + "y": -105, + "z": 0.5, + }, + { + "x": 474, + "y": -106, + "z": 0.5, + }, + { + "x": 475, + "y": -106, + "z": 0.5, + }, + { + "x": 476, + "y": -107, + "z": 0.5, + }, + { + "x": 476, + "y": -107, + "z": 0.5, + }, + { + "x": 477, + "y": -107, + "z": 0.5, + }, + ], + "type": "free", + }, + ], + "size": "m", + }, + "rotation": 0, + "type": "draw", + "typeName": "shape", + "x": 511, + "y": 234, +} +`; diff --git a/packages/tldraw/src/test/drawing.data.ts b/packages/tldraw/src/test/drawing.data.ts new file mode 100644 index 000000000..6345b6645 --- /dev/null +++ b/packages/tldraw/src/test/drawing.data.ts @@ -0,0 +1,1006 @@ +export const TEST_DRAW_SHAPE_SCREEN_POINTS = [ + { + x: 511, + y: 234, + }, + { + x: 512, + y: 234, + }, + { + x: 515, + y: 234, + }, + { + x: 521, + y: 233, + }, + { + x: 530, + y: 230, + }, + { + x: 541, + y: 224, + }, + { + x: 557, + y: 214, + }, + { + x: 572, + y: 204, + }, + { + x: 585, + y: 191, + }, + { + x: 600, + y: 175, + }, + { + x: 613, + y: 157, + }, + { + x: 619, + y: 144, + }, + { + x: 623, + y: 131, + }, + { + x: 628, + y: 115, + }, + { + x: 629, + y: 103, + }, + { + x: 630, + y: 97, + }, + { + x: 630, + y: 89, + }, + { + x: 631, + y: 82, + }, + { + x: 630, + y: 76, + }, + { + x: 628, + y: 71, + }, + { + x: 625, + y: 67, + }, + { + x: 620, + y: 65, + }, + { + x: 614, + y: 64, + }, + { + x: 608, + y: 64, + }, + { + x: 600, + y: 64, + }, + { + x: 591, + y: 68, + }, + { + x: 582, + y: 75, + }, + { + x: 573, + y: 84, + }, + { + x: 565, + y: 96, + }, + { + x: 561, + y: 108, + }, + { + x: 558, + y: 121, + }, + { + x: 557, + y: 135, + }, + { + x: 557, + y: 152, + }, + { + x: 558, + y: 173, + }, + { + x: 564, + y: 193, + }, + { + x: 571, + y: 210, + }, + { + x: 579, + y: 227, + }, + { + x: 590, + y: 246, + }, + { + x: 599, + y: 266, + }, + { + x: 607, + y: 284, + }, + { + x: 614, + y: 303, + }, + { + x: 617, + y: 320, + }, + { + x: 618, + y: 336, + }, + { + x: 618, + y: 354, + }, + { + x: 613, + y: 370, + }, + { + x: 601, + y: 380, + }, + { + x: 585, + y: 388, + }, + { + x: 554, + y: 397, + }, + { + x: 543, + y: 398, + }, + { + x: 532, + y: 398, + }, + { + x: 522, + y: 398, + }, + { + x: 513, + y: 398, + }, + { + x: 504, + y: 396, + }, + { + x: 498, + y: 393, + }, + { + x: 496, + y: 387, + }, + { + x: 496, + y: 381, + }, + { + x: 500, + y: 372, + }, + { + x: 512, + y: 361, + }, + { + x: 526, + y: 346, + }, + { + x: 545, + y: 330, + }, + { + x: 567, + y: 313, + }, + { + x: 592, + y: 292, + }, + { + x: 618, + y: 267, + }, + { + x: 637, + y: 246, + }, + { + x: 656, + y: 224, + }, + { + x: 671, + y: 204, + }, + { + x: 683, + y: 184, + }, + { + x: 696, + y: 161, + }, + { + x: 705, + y: 141, + }, + { + x: 710, + y: 122, + }, + { + x: 713, + y: 107, + }, + { + x: 714, + y: 96, + }, + { + x: 714, + y: 88, + }, + { + x: 712, + y: 82, + }, + { + x: 707, + y: 79, + }, + { + x: 702, + y: 78, + }, + { + x: 697, + y: 77, + }, + { + x: 689, + y: 78, + }, + { + x: 681, + y: 84, + }, + { + x: 675, + y: 94, + }, + { + x: 669, + y: 106, + }, + { + x: 662, + y: 124, + }, + { + x: 655, + y: 145, + }, + { + x: 650, + y: 170, + }, + { + x: 646, + y: 198, + }, + { + x: 643, + y: 227, + }, + { + x: 643, + y: 256, + }, + { + x: 643, + y: 283, + }, + { + x: 644, + y: 308, + }, + { + x: 651, + y: 331, + }, + { + x: 659, + y: 347, + }, + { + x: 667, + y: 358, + }, + { + x: 677, + y: 371, + }, + { + x: 686, + y: 379, + }, + { + x: 694, + y: 384, + }, + { + x: 702, + y: 386, + }, + { + x: 708, + y: 386, + }, + { + x: 716, + y: 385, + }, + { + x: 725, + y: 380, + }, + { + x: 734, + y: 370, + }, + { + x: 741, + y: 359, + }, + { + x: 747, + y: 346, + }, + { + x: 753, + y: 329, + }, + { + x: 758, + y: 312, + }, + { + x: 761, + y: 295, + }, + { + x: 763, + y: 280, + }, + { + x: 764, + y: 271, + }, + { + x: 764, + y: 265, + }, + { + x: 764, + y: 258, + }, + { + x: 762, + y: 254, + }, + { + x: 759, + y: 250, + }, + { + x: 757, + y: 250, + }, + { + x: 754, + y: 250, + }, + { + x: 751, + y: 251, + }, + { + x: 749, + y: 253, + }, + { + x: 747, + y: 260, + }, + { + x: 745, + y: 268, + }, + { + x: 744, + y: 279, + }, + { + x: 743, + y: 290, + }, + { + x: 743, + y: 300, + }, + { + x: 746, + y: 313, + }, + { + x: 752, + y: 325, + }, + { + x: 758, + y: 334, + }, + { + x: 766, + y: 343, + }, + { + x: 771, + y: 347, + }, + { + x: 777, + y: 350, + }, + { + x: 785, + y: 352, + }, + { + x: 791, + y: 352, + }, + { + x: 797, + y: 349, + }, + { + x: 802, + y: 339, + }, + { + x: 807, + y: 327, + }, + { + x: 809, + y: 317, + }, + { + x: 812, + y: 304, + }, + { + x: 814, + y: 292, + }, + { + x: 816, + y: 282, + }, + { + x: 817, + y: 272, + }, + { + x: 818, + y: 265, + }, + { + x: 819, + y: 259, + }, + { + x: 819, + y: 256, + }, + { + x: 819, + y: 254, + }, + { + x: 819, + y: 253, + }, + { + x: 819, + y: 256, + }, + { + x: 819, + y: 261, + }, + { + x: 819, + y: 269, + }, + { + x: 819, + y: 278, + }, + { + x: 819, + y: 285, + }, + { + x: 819, + y: 290, + }, + { + x: 819, + y: 295, + }, + { + x: 820, + y: 300, + }, + { + x: 823, + y: 305, + }, + { + x: 825, + y: 308, + }, + { + x: 828, + y: 309, + }, + { + x: 831, + y: 310, + }, + { + x: 835, + y: 310, + }, + { + x: 840, + y: 307, + }, + { + x: 844, + y: 303, + }, + { + x: 847, + y: 300, + }, + { + x: 850, + y: 296, + }, + { + x: 853, + y: 293, + }, + { + x: 855, + y: 291, + }, + { + x: 857, + y: 289, + }, + { + x: 859, + y: 289, + }, + { + x: 859, + y: 289, + }, + { + x: 860, + y: 289, + }, + { + x: 860, + y: 290, + }, + { + x: 861, + y: 291, + }, + { + x: 862, + y: 293, + }, + { + x: 862, + y: 295, + }, + { + x: 863, + y: 296, + }, + { + x: 863, + y: 297, + }, + { + x: 864, + y: 298, + }, + { + x: 865, + y: 298, + }, + { + x: 866, + y: 298, + }, + { + x: 867, + y: 292, + }, + { + x: 869, + y: 283, + }, + { + x: 871, + y: 274, + }, + { + x: 874, + y: 266, + }, + { + x: 876, + y: 260, + }, + { + x: 878, + y: 253, + }, + { + x: 880, + y: 247, + }, + { + x: 884, + y: 241, + }, + { + x: 887, + y: 237, + }, + { + x: 891, + y: 236, + }, + { + x: 896, + y: 236, + }, + { + x: 901, + y: 236, + }, + { + x: 908, + y: 237, + }, + { + x: 921, + y: 245, + }, + { + x: 935, + y: 257, + }, + { + x: 945, + y: 268, + }, + { + x: 957, + y: 283, + }, + { + x: 967, + y: 298, + }, + { + x: 975, + y: 315, + }, + { + x: 979, + y: 329, + }, + { + x: 981, + y: 350, + }, + { + x: 983, + y: 376, + }, + { + x: 983, + y: 396, + }, + { + x: 979, + y: 412, + }, + { + x: 969, + y: 429, + }, + { + x: 953, + y: 447, + }, + { + x: 934, + y: 464, + }, + { + x: 918, + y: 474, + }, + { + x: 904, + y: 479, + }, + { + x: 888, + y: 484, + }, + { + x: 875, + y: 486, + }, + { + x: 865, + y: 486, + }, + { + x: 857, + y: 482, + }, + { + x: 851, + y: 473, + }, + { + x: 850, + y: 459, + }, + { + x: 850, + y: 432, + }, + { + x: 860, + y: 399, + }, + { + x: 883, + y: 364, + }, + { + x: 914, + y: 323, + }, + { + x: 943, + y: 288, + }, + { + x: 978, + y: 250, + }, + { + x: 1015, + y: 213, + }, + { + x: 1062, + y: 166, + }, + { + x: 1108, + y: 119, + }, + { + x: 1130, + y: 96, + }, + { + x: 1152, + y: 72, + }, + { + x: 1174, + y: 46, + }, + { + x: 1186, + y: 31, + }, + { + x: 1195, + y: 15, + }, + { + x: 1203, + y: -3, + }, + { + x: 1204, + y: -10, + }, + { + x: 1202, + y: -16, + }, + { + x: 1193, + y: -20, + }, + { + x: 1175, + y: -22, + }, + { + x: 1153, + y: -22, + }, + { + x: 1132, + y: -19, + }, + { + x: 1100, + y: -6, + }, + { + x: 1065, + y: 13, + }, + { + x: 1037, + y: 33, + }, + { + x: 1013, + y: 52, + }, + { + x: 995, + y: 69, + }, + { + x: 978, + y: 88, + }, + { + x: 967, + y: 103, + }, + { + x: 961, + y: 114, + }, + { + x: 959, + y: 122, + }, + { + x: 959, + y: 127, + }, + { + x: 960, + y: 130, + }, + { + x: 963, + y: 131, + }, + { + x: 969, + y: 132, + }, + { + x: 973, + y: 132, + }, + { + x: 976, + y: 131, + }, + { + x: 981, + y: 130, + }, + { + x: 983, + y: 129, + }, + { + x: 985, + y: 128, + }, + { + x: 986, + y: 128, + }, + { + x: 987, + y: 127, + }, + { + x: 987, + y: 127, + }, + { + x: 988, + y: 127, + }, +] diff --git a/packages/tldraw/src/test/drawing.test.ts b/packages/tldraw/src/test/drawing.test.ts index eb96dcecb..c2041cf95 100644 --- a/packages/tldraw/src/test/drawing.test.ts +++ b/packages/tldraw/src/test/drawing.test.ts @@ -1,5 +1,6 @@ import { TLDrawShape, TLHighlightShape, last } from '@tldraw/editor' import { TestEditor } from './TestEditor' +import { TEST_DRAW_SHAPE_SCREEN_POINTS } from './drawing.data' jest.useFakeTimers() @@ -260,3 +261,22 @@ for (const toolType of ['draw', 'highlight'] as const) { }) }) } + +it('Draws a bunch', () => { + editor.setCurrentTool('draw').setCamera({ x: 0, y: 0, z: 1 }) + + const [first, ...rest] = TEST_DRAW_SHAPE_SCREEN_POINTS + editor.pointerMove(first.x, first.y).pointerDown() + + for (const point of rest) { + editor.pointerMove(point.x, point.y) + } + + editor.pointerUp() + editor.selectAll() + + const shape = { ...editor.getLastCreatedShape() } + // @ts-expect-error + delete shape.id + expect(shape).toMatchSnapshot('draw shape') +}) diff --git a/packages/tldraw/src/test/getCulledShapes.test.tsx b/packages/tldraw/src/test/getCulledShapes.test.tsx index 53c99bd35..eaf3ed701 100644 --- a/packages/tldraw/src/test/getCulledShapes.test.tsx +++ b/packages/tldraw/src/test/getCulledShapes.test.tsx @@ -136,3 +136,56 @@ it('correctly calculates the culled shapes when adding and deleting shapes', () const culledShapeFromScratch = editor.getCulledShapes() expect(culledShapesIncremental).toEqual(culledShapeFromScratch) }) + +it('works for shapes that are outside of the viewport, but are then moved inside it', () => { + const box1Id = createShapeId() + const box2Id = createShapeId() + const arrowId = createShapeId() + + editor.createShapes([ + { + id: box1Id, + props: { w: 100, h: 100, geo: 'rectangle' }, + type: 'geo', + x: -500, + y: 0, + }, + { + id: box2Id, + type: 'geo', + x: -1000, + y: 200, + props: { w: 100, h: 100, geo: 'rectangle' }, + }, + { + id: arrowId, + type: 'arrow', + props: { + start: { + type: 'binding', + isExact: true, + boundShapeId: box1Id, + normalizedAnchor: { x: 0.5, y: 0.5 }, + isPrecise: false, + }, + end: { + type: 'binding', + isExact: true, + boundShapeId: box2Id, + normalizedAnchor: { x: 0.5, y: 0.5 }, + isPrecise: false, + }, + }, + }, + ]) + + expect(editor.getCulledShapes()).toEqual(new Set([box1Id, box2Id, arrowId])) + + // Move box1 and box2 inside the viewport + editor.updateShapes([ + { id: box1Id, type: 'geo', x: 100 }, + { id: box2Id, type: 'geo', x: 200 }, + ]) + // Arrow should also not be culled + expect(editor.getCulledShapes()).toEqual(new Set()) +}) diff --git a/packages/utils/src/lib/perf.ts b/packages/utils/src/lib/perf.ts index e6ac86450..2f9283fd9 100644 --- a/packages/utils/src/lib/perf.ts +++ b/packages/utils/src/lib/perf.ts @@ -34,15 +34,17 @@ export function measureAverageDuration( const start = performance.now() const result = originalMethod.apply(this, args) const end = performance.now() - const value = averages.get(descriptor.value)! const length = end - start - const total = value.total + length - const count = value.count + 1 - averages.set(descriptor.value, { total, count }) - // eslint-disable-next-line no-console - console.log( - `${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms` - ) + if (length !== 0) { + const value = averages.get(descriptor.value)! + const total = value.total + length + const count = value.count + 1 + averages.set(descriptor.value, { total, count }) + // eslint-disable-next-line no-console + console.log( + `${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms` + ) + } return result } averages.set(descriptor.value, { total: 0, count: 0 }) diff --git a/packages/validate/api-report.md b/packages/validate/api-report.md index 584047196..dbf57dcbb 100644 --- a/packages/validate/api-report.md +++ b/packages/validate/api-report.md @@ -83,6 +83,9 @@ function nullable(validator: Validatable): Validator; // @public const number: Validator; +// @internal (undocumented) +function numberUnion>(key: Key, config: Config): UnionValidator; + // @public function object(config: { readonly [K in keyof Shape]: Validatable; @@ -134,6 +137,7 @@ declare namespace T { jsonDict, dict, union, + numberUnion, model, setEnum, optional, @@ -178,7 +182,7 @@ function union, UnknownValue = never> extends Validator | UnknownValue> { - constructor(key: Key, config: Config, unknownValueValidation: (value: object, variant: string) => UnknownValue); + constructor(key: Key, config: Config, unknownValueValidation: (value: object, variant: string) => UnknownValue, useNumberKeys: boolean); // (undocumented) validateUnknownVariants(unknownValueValidation: (value: object, variant: string) => Unknown): UnionValidator; } diff --git a/packages/validate/api/api.json b/packages/validate/api/api.json index 1bdd27588..0aedb8cb6 100644 --- a/packages/validate/api/api.json +++ b/packages/validate/api/api.json @@ -3027,6 +3027,14 @@ "kind": "Content", "text": "(value: object, variant: string) => UnknownValue" }, + { + "kind": "Content", + "text": ", useNumberKeys: " + }, + { + "kind": "Content", + "text": "boolean" + }, { "kind": "Content", "text": ");" @@ -3059,6 +3067,14 @@ "endIndex": 6 }, "isOptional": false + }, + { + "parameterName": "useNumberKeys", + "parameterTypeTokenRange": { + "startIndex": 7, + "endIndex": 8 + }, + "isOptional": false } ] }, @@ -4260,6 +4276,14 @@ "kind": "Content", "text": "(value: object, variant: string) => UnknownValue" }, + { + "kind": "Content", + "text": ", useNumberKeys: " + }, + { + "kind": "Content", + "text": "boolean" + }, { "kind": "Content", "text": ");" @@ -4292,6 +4316,14 @@ "endIndex": 6 }, "isOptional": false + }, + { + "parameterName": "useNumberKeys", + "parameterTypeTokenRange": { + "startIndex": 7, + "endIndex": 8 + }, + "isOptional": false } ] }, diff --git a/packages/validate/src/lib/validation.ts b/packages/validate/src/lib/validation.ts index b9d4d21f3..145746437 100644 --- a/packages/validate/src/lib/validation.ts +++ b/packages/validate/src/lib/validation.ts @@ -394,7 +394,8 @@ export class UnionValidator< constructor( private readonly key: Key, private readonly config: Config, - private readonly unknownValueValidation: (value: object, variant: string) => UnknownValue + private readonly unknownValueValidation: (value: object, variant: string) => UnknownValue, + private readonly useNumberKeys: boolean ) { super( (input) => { @@ -442,11 +443,13 @@ export class UnionValidator< matchingSchema: Validatable | undefined variant: string } { - const variant = getOwnProperty(object, this.key) as keyof Config | undefined - if (typeof variant !== 'string') { + const variant = getOwnProperty(object, this.key) as string & keyof Config + if (!this.useNumberKeys && typeof variant !== 'string') { throw new ValidationError( `Expected a string for key "${this.key}", got ${typeToString(variant)}` ) + } else if (this.useNumberKeys && !Number.isFinite(Number(variant))) { + throw new ValidationError(`Expected a number for key "${this.key}", got "${variant as any}"`) } const matchingSchema = hasOwnProperty(this.config, variant) ? this.config[variant] : undefined @@ -456,7 +459,7 @@ export class UnionValidator< validateUnknownVariants( unknownValueValidation: (value: object, variant: string) => Unknown ): UnionValidator { - return new UnionValidator(this.key, this.config, unknownValueValidation) + return new UnionValidator(this.key, this.config, unknownValueValidation, this.useNumberKeys) } } @@ -829,14 +832,41 @@ export function union { - return new UnionValidator(key, config, (unknownValue, unknownVariant) => { - throw new ValidationError( - `Expected one of ${Object.keys(config) - .map((key) => JSON.stringify(key)) - .join(' or ')}, got ${JSON.stringify(unknownVariant)}`, - [key] - ) - }) + return new UnionValidator( + key, + config, + (unknownValue, unknownVariant) => { + throw new ValidationError( + `Expected one of ${Object.keys(config) + .map((key) => JSON.stringify(key)) + .join(' or ')}, got ${JSON.stringify(unknownVariant)}`, + [key] + ) + }, + false + ) +} + +/** + * @internal + */ +export function numberUnion>( + key: Key, + config: Config +): UnionValidator { + return new UnionValidator( + key, + config, + (unknownValue, unknownVariant) => { + throw new ValidationError( + `Expected one of ${Object.keys(config) + .map((key) => JSON.stringify(key)) + .join(' or ')}, got ${JSON.stringify(unknownVariant)}`, + [key] + ) + }, + true + ) } /**