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'} Could not load assets. + } + + if (!assetLoading.done) { + return Loading assets... + } + return (
{ getCameraState(): "idle" | "moving"; getCanRedo(): boolean; getCanUndo(): boolean; + getCollaborators(): TLInstancePresence[]; + getCollaboratorsOnCurrentPage(): TLInstancePresence[]; getContainer: () => HTMLElement; getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined; // @internal @@ -695,6 +697,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 3fb758678..e05933d1c 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -10059,6 +10059,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 cedc1af9b..f3f3dee21 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -2623,15 +2623,7 @@ export class Editor extends EventEmitter { * @public */ animateToUser(userId: string): 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 @@ -2887,6 +2879,45 @@ export class Editor extends EventEmitter { z: 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 @@ -2898,9 +2929,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() @@ -2909,7 +2940,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 } @@ -2928,7 +2959,7 @@ export class Editor extends EventEmitter { const moveTowardsUser = () => { // Stop following if we can't find the user - const leaderPresence = [...leaderPresences.get()] + const leaderPresence = [...leaderPresences] .sort((a, b) => { return a.lastActivityTimestamp - b.lastActivityTimestamp }) @@ -3285,6 +3316,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. * @@ -3897,7 +3936,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() } @@ -4231,7 +4270,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) { @@ -4560,28 +4599,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 @@ -8180,7 +8202,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 @@ -8904,16 +8930,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/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 d5bc01f14..d48e31f58 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -2545,6 +2545,12 @@ export function useMenuIsOpen(id: string, cb?: (isOpen: boolean) => void): reado // @public (undocumented) export function useNativeClipboardEvents(): void; +// @public (undocumented) +export function usePreloadAssets(assetUrls: TLEditorAssetUrls): { + done: boolean; + error: boolean; +}; + // @public (undocumented) export function useReadonly(): boolean; diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index a68690d00..1210a98fc 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -27982,6 +27982,52 @@ "parameters": [], "name": "useNativeClipboardEvents" }, + { + "kind": "Function", + "canonicalReference": "tldraw!usePreloadAssets:function(1)", + "docComment": "/**\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export declare function usePreloadAssets(assetUrls: " + }, + { + "kind": "Reference", + "text": "TLEditorAssetUrls", + "canonicalReference": "tldraw!~TLEditorAssetUrls:type" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "{\n done: boolean;\n error: boolean;\n}" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "packages/tldraw/src/lib/ui/hooks/usePreloadAssets.ts", + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "assetUrls", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "name": "usePreloadAssets" + }, { "kind": "Function", "canonicalReference": "tldraw!useReadonly:function(1)", diff --git a/packages/tldraw/src/index.ts b/packages/tldraw/src/index.ts index cc2a98d15..6f37098e5 100644 --- a/packages/tldraw/src/index.ts +++ b/packages/tldraw/src/index.ts @@ -87,6 +87,7 @@ export { useExportAs } from './lib/ui/hooks/useExportAs' export { useKeyboardShortcuts } from './lib/ui/hooks/useKeyboardShortcuts' export { useLocalStorageState } from './lib/ui/hooks/useLocalStorageState' export { useMenuIsOpen } from './lib/ui/hooks/useMenuIsOpen' +export { usePreloadAssets } from './lib/ui/hooks/usePreloadAssets' export { useReadonly } from './lib/ui/hooks/useReadonly' export { useRelevantStyles } from './lib/ui/hooks/useRelevantStyles' export { 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/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/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 a84eba262..82436126a 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, { 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, { 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/hooks/useClipboardEvents.ts b/packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts index f57dbd81f..b26f39ab4 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' }) @@ -648,9 +655,9 @@ export function useNativeClipboardEvents() { } } - const paste = (event: ClipboardEvent) => { + const paste = (e: ClipboardEvent) => { if (disablingMiddleClickPaste) { - event.stopPropagation() + stopEventPropagation(e) return } @@ -660,8 +667,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) => { @@ -671,6 +678,7 @@ export function useNativeClipboardEvents() { }) } + preventDefault(e) trackEvent('paste', { source: 'kbd' }) } diff --git a/packages/tldraw/src/lib/ui/hooks/usePreloadAssets.ts b/packages/tldraw/src/lib/ui/hooks/usePreloadAssets.ts index 7aa1f23e3..0adc2f9ba 100644 --- a/packages/tldraw/src/lib/ui/hooks/usePreloadAssets.ts +++ b/packages/tldraw/src/lib/ui/hooks/usePreloadAssets.ts @@ -56,6 +56,7 @@ function getTypefaces(assetUrls: TLEditorAssetUrls) { } } +/** @public */ export function usePreloadAssets(assetUrls: TLEditorAssetUrls) { const typefaces = useMemo(() => getTypefaces(assetUrls), [assetUrls]) 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/utils/src/lib/perf.ts b/packages/utils/src/lib/perf.ts index e8a7982ec..b97e50ef3 100644 --- a/packages/utils/src/lib/perf.ts +++ b/packages/utils/src/lib/perf.ts @@ -49,17 +49,19 @@ 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.debug( - `%cPerf%c ${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`, - `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, - 'font-weight: normal' - ) + 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.debug( + `%cPerf%c ${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal' + ) + } 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 + ) } /** diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 4c39f387d..ee15826cb 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -515,7 +515,7 @@ async function coalesceWithPreviousAssets(assetsDir: string) { // and it will mess up the inline source viewer on sentry errors. const out = tar.x({ cwd: assetsDir, 'keep-existing': true }) for await (const chunk of Body?.transformToWebStream() as any as AsyncIterable) { - out.write(chunk) + out.write(Buffer.from(chunk.buffer)) } out.end() } diff --git a/scripts/lib/didAnyPackageChange.ts b/scripts/lib/didAnyPackageChange.ts index db4ad3872..ce20bba0f 100644 --- a/scripts/lib/didAnyPackageChange.ts +++ b/scripts/lib/didAnyPackageChange.ts @@ -18,12 +18,12 @@ async function hasPackageChanged(pkg: PackageDetails) { } const publishedTarballPath = `${dirPath}/published-package.tgz` writeFileSync(publishedTarballPath, Buffer.from(await res.arrayBuffer())) - const publishedManifest = await getTarballManifest(publishedTarballPath) + const publishedManifest = getTarballManifestSync(publishedTarballPath) const localTarballPath = `${dirPath}/local-package.tgz` await exec('yarn', ['pack', '--out', localTarballPath], { pwd: pkg.dir }) - const localManifest = await getTarballManifest(localTarballPath) + const localManifest = getTarballManifestSync(localTarballPath) return !manifestsAreEqual(publishedManifest, localManifest) } finally { @@ -48,34 +48,25 @@ function manifestsAreEqual(a: Record, b: Record) return true } -function getTarballManifest(tarballPath: string): Promise> { +function getTarballManifestSync(tarballPath: string) { const manifest: Record = {} - return new Promise((resolve, reject) => - tar.list( - { - // @ts-expect-error bad typings - file: tarballPath, - onentry: (entry) => { - entry.on('data', (data) => { - // we could hash these to reduce memory but it's probably fine - const existing = manifest[entry.path] - if (existing) { - manifest[entry.path] = Buffer.concat([existing, data]) - } else { - manifest[entry.path] = data - } - }) - }, - }, - (err: any) => { - if (err) { - reject(err) + tar.list({ + file: tarballPath, + onentry: (entry) => { + entry.on('data', (data) => { + // we could hash these to reduce memory but it's probably fine + const existing = manifest[entry.path] + if (existing) { + manifest[entry.path] = Buffer.concat([existing, data]) } else { - resolve(manifest) + manifest[entry.path] = data } - } - ) - ) + }) + }, + sync: true, + }) + + return manifest } export async function didAnyPackageChange() { diff --git a/scripts/package.json b/scripts/package.json index e1bf07393..091173913 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -59,7 +59,7 @@ "@types/tmp": "^0.2.6", "ignore": "^5.2.4", "minimist": "^1.2.8", - "tar": "^6.2.0", + "tar": "^7.0.1", "tmp": "^0.2.3" } } diff --git a/yarn.lock b/yarn.lock index 0b94e4b30..ef4b8a595 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3680,6 +3680,15 @@ __metadata: languageName: node linkType: hard +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.0 + resolution: "@isaacs/fs-minipass@npm:4.0.0" + dependencies: + minipass: "npm:^7.0.4" + checksum: 7444d7a3c9211c27494630e2bff8545e3494a1598624a4871ee7ef3a9e592a61fed3abd85d118f966673bd0b4401c266d45441f89c00c420e9d0cfbf1042dbd5 + languageName: node + linkType: hard + "@istanbuljs/load-nyc-config@npm:^1.0.0": version: 1.1.0 resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" @@ -7589,7 +7598,7 @@ __metadata: rimraf: "npm:^4.4.0" semver: "npm:^7.3.8" svgo: "npm:^3.0.2" - tar: "npm:^6.2.0" + tar: "npm:^7.0.1" tmp: "npm:^0.2.3" typescript: "npm:^5.3.3" languageName: unknown @@ -10700,6 +10709,13 @@ __metadata: languageName: node linkType: hard +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: b63cb1f73d171d140a2ed8154ee6566c8ab775d3196b0e03a2a94b5f6a0ce7777ee5685ca56849403c8d17bd457a6540672f9a60696a6137c7a409097495b82c + languageName: node + linkType: hard + "chrome-trace-event@npm:^1.0.2": version: 1.0.3 resolution: "chrome-trace-event@npm:1.0.3" @@ -14645,18 +14661,18 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10": - version: 10.3.10 - resolution: "glob@npm:10.3.10" +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": + version: 10.3.12 + resolution: "glob@npm:10.3.12" dependencies: foreground-child: "npm:^3.1.0" - jackspeak: "npm:^2.3.5" + jackspeak: "npm:^2.3.6" minimatch: "npm:^9.0.1" - minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" - path-scurry: "npm:^1.10.1" + minipass: "npm:^7.0.4" + path-scurry: "npm:^1.10.2" bin: glob: dist/esm/bin.mjs - checksum: 38bdb2c9ce75eb5ed168f309d4ed05b0798f640b637034800a6bf306f39d35409bf278b0eaaffaec07591085d3acb7184a201eae791468f0f617771c2486a6a8 + checksum: 9e8186abc22dc824b5dd86cefd8e6b5621a72d1be7f68bacc0fd681e8c162ec5546660a6ec0553d6a74757a585e655956c7f8f1a6d24570e8d865c307323d178 languageName: node linkType: hard @@ -16275,7 +16291,7 @@ __metadata: languageName: node linkType: hard -"jackspeak@npm:^2.3.5": +"jackspeak@npm:^2.3.6": version: 2.3.6 resolution: "jackspeak@npm:2.3.6" dependencies: @@ -17721,10 +17737,10 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^10.0.0, lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0": - version: 10.1.0 - resolution: "lru-cache@npm:10.1.0" - checksum: 207278d6fa711fb1f94a0835d4d4737441d2475302482a14785b10515e4c906a57ebf9f35bf060740c9560e91c7c1ad5a04fd7ed030972a9ba18bce2a228e95b +"lru-cache@npm:^10.0.0, lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": + version: 10.2.0 + resolution: "lru-cache@npm:10.2.0" + checksum: 502ec42c3309c0eae1ce41afca471f831c278566d45a5273a0c51102dee31e0e250a62fa9029c3370988df33a14188a38e682c16143b794de78668de3643e302 languageName: node linkType: hard @@ -19131,7 +19147,7 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3": +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4": version: 7.0.4 resolution: "minipass@npm:7.0.4" checksum: e864bd02ceb5e0707696d58f7ce3a0b89233f0d686ef0d447a66db705c0846a8dc6f34865cd85256c1472ff623665f616b90b8ff58058b2ad996c5de747d2d18 @@ -19148,6 +19164,16 @@ __metadata: languageName: node linkType: hard +"minizlib@npm:^3.0.1": + version: 3.0.1 + resolution: "minizlib@npm:3.0.1" + dependencies: + minipass: "npm:^7.0.4" + rimraf: "npm:^5.0.5" + checksum: 622cb85f51e5c206a080a62d20db0d7b4066f308cb6ce82a9644da112367c3416ae7062017e631eb7ac8588191cfa4a9a279b8651c399265202b298e98c4acef + languageName: node + linkType: hard + "mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": version: 0.5.3 resolution: "mkdirp-classic@npm:0.5.3" @@ -19164,6 +19190,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^3.0.1": + version: 3.0.1 + resolution: "mkdirp@npm:3.0.1" + bin: + mkdirp: dist/cjs/src/bin.js + checksum: 16fd79c28645759505914561e249b9a1f5fe3362279ad95487a4501e4467abeb714fd35b95307326b8fd03f3c7719065ef11a6f97b7285d7888306d1bd2232ba + languageName: node + linkType: hard + "mlly@npm:^1.1.0, mlly@npm:^1.2.0": version: 1.5.0 resolution: "mlly@npm:1.5.0" @@ -20327,13 +20362,13 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.10.1, path-scurry@npm:^1.6.1": - version: 1.10.1 - resolution: "path-scurry@npm:1.10.1" +"path-scurry@npm:^1.10.2, path-scurry@npm:^1.6.1": + version: 1.10.2 + resolution: "path-scurry@npm:1.10.2" dependencies: - lru-cache: "npm:^9.1.1 || ^10.0.0" + lru-cache: "npm:^10.2.0" minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" - checksum: eebfb8304fef1d4f7e1486df987e4fd77413de4fce16508dea69fcf8eb318c09a6b15a7a2f4c22877cec1cb7ecbd3071d18ca9de79eeece0df874a00f1f0bdc8 + checksum: a2bbbe8dc284c49dd9be78ca25f3a8b89300e0acc24a77e6c74824d353ef50efbf163e64a69f4330b301afca42d0e2229be0560d6d616ac4e99d48b4062016b1 languageName: node linkType: hard @@ -22045,6 +22080,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:^5.0.5": + version: 5.0.5 + resolution: "rimraf@npm:5.0.5" + dependencies: + glob: "npm:^10.3.7" + bin: + rimraf: dist/esm/bin.mjs + checksum: a612c7184f96258b7d1328c486b12ca7b60aa30e04229a08bbfa7e964486deb1e9a1b52d917809311bdc39a808a4055c0f950c0280fba194ba0a09e6f0d404f6 + languageName: node + linkType: hard + "rollup-plugin-inject@npm:^3.0.0": version: 3.0.2 resolution: "rollup-plugin-inject@npm:3.0.2" @@ -23378,7 +23424,7 @@ __metadata: languageName: node linkType: hard -"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2, tar@npm:^6.2.0": +"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2": version: 6.2.1 resolution: "tar@npm:6.2.1" dependencies: @@ -23392,6 +23438,20 @@ __metadata: languageName: node linkType: hard +"tar@npm:^7.0.1": + version: 7.0.1 + resolution: "tar@npm:7.0.1" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^5.0.0" + minizlib: "npm:^3.0.1" + mkdirp: "npm:^3.0.1" + yallist: "npm:^5.0.0" + checksum: 6fd89ef8051d12975f66a2f3932a80479bdc6c9f3bcdf04b8b57784e942ed860708ccecf79bcbb30659b14ab52eef2095d2c3af377545ff9df30de28036671dc + languageName: node + linkType: hard + "terminal-link@npm:^2.1.1": version: 2.1.1 resolution: "terminal-link@npm:2.1.1" @@ -24964,8 +25024,8 @@ __metadata: linkType: hard "vite@npm:^5.0.0": - version: 5.2.8 - resolution: "vite@npm:5.2.8" + version: 5.2.9 + resolution: "vite@npm:5.2.9" dependencies: esbuild: "npm:^0.20.1" fsevents: "npm:~2.3.3" @@ -24999,7 +25059,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: caa40343c2c4e6d8e257fccb4c3029f62909c319a86063ce727ed550925c0a834460b0d1ca20c4d6c915f35302aa1052f6ec5193099a47ce21d74b9b817e69e1 + checksum: 26342c8dde540e4161fdad2c9c8f2f0e23567f051c7a40abb8e4796d6c4292fbd118ab7a4ac252515e78c4f99525b557731e6117287b2bccde0ea61d73bcff27 languageName: node linkType: hard @@ -25666,6 +25726,13 @@ __metadata: languageName: node linkType: hard +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 1884d272d485845ad04759a255c71775db0fac56308764b4c77ea56a20d56679fad340213054c8c9c9c26fcfd4c4b2a90df993b7e0aaf3cdb73c618d1d1a802a + languageName: node + linkType: hard + "yaml@npm:2.3.4, yaml@npm:^2.0.0, yaml@npm:^2.2.1, yaml@npm:^2.2.2, yaml@npm:^2.3.4": version: 2.3.4 resolution: "yaml@npm:2.3.4"