Porównaj commity

...

19 Commity

Autor SHA1 Wiadomość Data
Steve Ruiz 4fce838cda
Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-9e0581ad53 2024-04-21 13:34:01 +01:00
Steve Ruiz 128ae312fc Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-9e0581ad53 2024-04-21 13:33:43 +01:00
Steve Ruiz a6d2ab05d2
Perf: minor drawing speedup (#3464)
Tiny changes as I walk through freehand code. These would only really
make a difference on pages with many freehand shapes.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` — Improving existing features

### Release Notes

- Improve performance of draw shapes.
2024-04-21 11:46:35 +00:00
Steve Ruiz b5fab15c6d
Prevent default on native clipboard events (#3536)
This PR calls prevent default on native clipboard events. This prevents
the error sound on Safari.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `bugfix` — Bug fix

### Test Plan

1. Use the cut, copy, and paste events on Safari.
2. Everything should still work, but no sounds should play.

### Release Notes

- Fix copy sound on clipboard events.
2024-04-21 11:45:55 +00:00
David Sheldrick b5dfd81540
WebGL Minimap (#3510)
This PR replaces our current minimap implementation with one that uses
WebGL

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
2024-04-19 13:56:55 +00:00
Steve Ruiz f6a2e352de
Improve back to content (#3532)
This PR improves the "back to content" behavior. Rather than using an
interval, we now add a "camera-stopped" event that triggers the check.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` 

### Test Plan

1. Create some shapes, then move the camera to an empty part of the
canvas.
2. Check that the back to content button appears.
3. Ensure that the back to content button does not appear when the
canvas is empty.
2024-04-19 12:07:33 +00:00
Mitja Bezenšek 1fc68975e2
Fix version (#3521)
We were using react's version instead of the version of our packages.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [x] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
2024-04-18 13:38:57 +00:00
Mitja Bezenšek 47070ec109
Use computed cache for getting the parent child relationships (#3508)
Use the existing computed cache for parent child relationships instead
of creating it.

Tiny bit faster, less memory, and simpler.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [x] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
2024-04-18 08:01:46 +00:00
David Sheldrick 741ed00bda
[signia] Smart dirty checking of active computeds (#3516)
This is a huge perf win, and it came to me while procrastinating on
making dinner.

The idea is that we can skip checking the parents of a computed value if

- it is being dereferenced during a reaction cycle
- the computed value was not traversed during the current reaction cycle

This more than doubles the speed of the webgl minimap render on my
machine (from 2ms down to like 0.8ms).

This will make the biggest difference for anything that derives a value
from a large collection of other computed values where typically only a
small amount of them change at one time (e.g. iterating over all the
shape page bounds to compile an RBush)

Most code paths where we see a big chunk of `haveParentsChanged` in
flame graphs should be much faster after this.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` — Improving existing features
2024-04-18 07:57:37 +00:00
Mitja Bezenšek dd0b7b882d
VS Code 2.0.30 (#3519)
Version bump for the hotfix.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [x] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
2024-04-17 20:16:40 +00:00
David Sheldrick 625f4abc3b
[fix] allow loading files (#3517)
I messed up the schema validator for loading files.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
2024-04-17 19:38:31 +00:00
Mitja Bezenšek f70fd2729d
VS Code 2.0.29 (#3515)
Version bump.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [x] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [x] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
2024-04-17 15:31:40 +00:00
Mime Čuvalo d247b5dc53
arrows: fix bound arrow labels going over text shape (#3512)
Fixes https://github.com/tldraw/tldraw/issues/3433

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Release Notes

- Arrows: fix label positioning when bound.
2024-04-17 14:35:25 +00:00
Mime Čuvalo f9bafb2f8a
textfields: fix Safari cursor rendering bug, take 2 (#3513)
Take 2 on what this PR was trying to do:
https://github.com/tldraw/tldraw/pull/3373
Fixes https://github.com/tldraw/tldraw/issues/3398 hopefully this time
without the infinite recursion 🙃

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
2024-04-17 14:31:35 +00:00
Mime Čuvalo f754bebc32
geo: fix double unique id on DOM (#3514)
Minor thing, but there's two nodes with the same ID. I got rid of the
one on the HTMLContainer, either one seems fine to remove though
¯\\_(ツ)_/¯

<img width="546" alt="Screenshot 2024-04-17 at 14 53 32"
src="https://github.com/tldraw/tldraw/assets/469604/5c4acdef-842c-4c4a-b9fd-504e23837efe">


### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
2024-04-17 14:01:12 +00:00
Mime Čuvalo f44ea90da6
arrows: still use Dist instead of Dist2 (#3511)
A little regression from https://github.com/tldraw/tldraw/pull/3454. We
still need the exact distance here.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know

### Release Notes

- Fix arrow label positioning
2024-04-17 13:16:22 +00:00
Mitja Bezenšek 0b44a8b47a
Fix culling. (#3504)
Fixes culling for cases when another user would drag shapes inside your
viewport. We weren't correctly calculating the culling status for arrows
that might be bound to those shapes and also for shapes within dragged
in groups / frames.


### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [x] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Open the same room in two browsers / tabs.
2. Have some shapes that are visible in one browser, but not the other.
3. Drag these shapes so that they are visible in the other browser as
well.
4. They should correctly get unculled.
5. Do this by dragging shapes that have arrows bound to them (arrows
should uncull), groups (shapes within them should uncull), frames.

- [x] Unit Tests
- [ ] End to end tests

### Release Notes

- Fix culling.
2024-04-17 11:39:09 +00:00
Mime Čuvalo 34ad856873
textfields: nix disableTab option; make TextShapes have custom Tab behavior as intended (#3506)
We shouldn't be making this something you have to negate everytime you
use `useEditableText`. The TextShape can just have its custom behavior
since that's the intended usecase. (although I think that Tab there
doesn't do much anyway, but whatevs)

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [x] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
2024-04-17 11:11:08 +00:00
Steve Ruiz 1450454873
"Soft preload" icons (#3507)
This PR includes a "soft preload" feature for icons, where icons will be
loaded when the canvas first mounts. The component will not wait for
icons to finish loading before showing the editor, but this should help
with "pop in" on menu icons.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` — Improving existing features

### Test Plan

1. Load the component
2. After load, open a menu for the first time
3. The icons should immediately be visible

### Release Notes

- Improve icon preloading
2024-04-17 10:57:08 +00:00
46 zmienionych plików z 3411 dodań i 739 usunięć

Wyświetl plik

@ -19,7 +19,7 @@ body:
id: reproduction
attributes:
label: How can we reproduce the bug?
description: If you can make the bug happen again, please share the steps involved.
description: If you can make the bug happen again, please share the steps involved. You can [fork this CodeSandbox](https://codesandbox.io/p/sandbox/tldraw-example-n539u) to make a reproduction.
validations:
required: false
- type: dropdown

Wyświetl plik

@ -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({
<div className="tldraw__editor tl-container">
<div className="iframe-warning__container">
<a className="iframe-warning__link" href={url} target="_blank">
{'Visit this page on tldraw.com '}
{'Visit this page on tldraw.com'}
<svg
width="15"
height="15"

Wyświetl plik

@ -203,7 +203,6 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
text={text}
labelColor={theme[color].solid}
isSelected={isSelected}
disableTab
wrap
/>
</>

Wyświetl plik

@ -1,3 +1,13 @@
## 2.0.30
- Fixes a bug that prevented opening some files.
## 2.0.29
- Improved note shapes.
- Color improvements for both light and dark mode.
- Bug fixes and performance improvements.
## 2.0.28
- Fix an issue with panning the canvas.

Wyświetl plik

@ -1,7 +1,7 @@
{
"name": "tldraw-vscode",
"description": "The tldraw extension for VS Code.",
"version": "2.0.28",
"version": "2.0.30",
"private": true,
"author": {
"name": "tldraw Inc.",

Wyświetl plik

@ -11,6 +11,10 @@ import { TextDecoder, TextEncoder } from 'util'
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
Image.prototype.decode = async function () {
return true
}
function convertNumbersInObject(obj: any, roundToNearest: number) {
if (!obj) return obj
if (Array.isArray(obj)) {

Wyświetl plik

@ -682,6 +682,8 @@ export class Editor extends EventEmitter<TLEventMap> {
getCameraState(): "idle" | "moving";
getCanRedo(): boolean;
getCanUndo(): boolean;
getCollaborators(): TLInstancePresence[];
getCollaboratorsOnCurrentPage(): TLInstancePresence[];
getContainer: () => HTMLElement;
getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined;
// @internal
@ -693,6 +695,8 @@ export class Editor extends EventEmitter<TLEventMap> {
getCurrentPageId(): TLPageId;
getCurrentPageRenderingShapesSorted(): TLShape[];
getCurrentPageShapeIds(): Set<TLShapeId>;
// @internal (undocumented)
getCurrentPageShapeIdsSorted(): TLShapeId[];
getCurrentPageShapes(): TLShape[];
getCurrentPageShapesSorted(): TLShape[];
getCurrentPageState(): TLInstancePageState;

Wyświetl plik

@ -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",

Wyświetl plik

@ -2619,15 +2619,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @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
@ -2883,6 +2875,45 @@ export class Editor extends EventEmitter<TLEventMap> {
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
@ -2894,9 +2925,9 @@ export class Editor extends EventEmitter<TLEventMap> {
* @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()
@ -2905,7 +2936,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
// 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
}
@ -2924,7 +2955,7 @@ export class Editor extends EventEmitter<TLEventMap> {
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
})
@ -3281,6 +3312,14 @@ export class Editor extends EventEmitter<TLEventMap> {
return this._currentPageShapeIds.get()
}
/**
* @internal
*/
@computed
getCurrentPageShapeIdsSorted() {
return Array.from(this.getCurrentPageShapeIds()).sort()
}
/**
* Get the ids of shapes on a page.
*
@ -3893,7 +3932,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @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()
}
@ -4227,7 +4266,7 @@ export class Editor extends EventEmitter<TLEventMap> {
@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) {
@ -4556,28 +4595,11 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
@computed getCurrentPageShapesSorted(): TLShape[] {
const shapes = this.getCurrentPageShapes().sort(sortByIndex)
const parentChildMap = new Map<TLShapeId, TLShape[]>()
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
@ -8176,7 +8198,11 @@ export class Editor extends EventEmitter<TLEventMap> {
// 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
@ -8875,16 +8901,12 @@ function applyPartialToShape<T extends TLShape>(prev: T, partial?: TLShapePartia
return next
}
function pushShapeWithDescendants(
shape: TLShape,
parentChildMap: Map<TLShapeId, TLShape[]>,
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)
}
}

Wyświetl plik

@ -1,5 +1,5 @@
import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema'
import { computed, isUninitialized } from '@tldraw/state'
import { TLShapeId } from '@tldraw/tlschema'
import { Box } from '../../primitives/Box'
import { Editor } from '../Editor'
@ -21,15 +21,10 @@ function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Bo
*/
export const notVisibleShapes = (editor: Editor) => {
const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin)
const shapeHistory = editor.store.query.filterHistory('shape')
let lastPageId: TLPageId | null = null
let prevViewportPageBounds: Box
function fromScratch(editor: Editor): Set<TLShapeId> {
const shapes = editor.getCurrentPageShapeIds()
lastPageId = editor.getCurrentPageId()
const viewportPageBounds = editor.getViewportPageBounds()
prevViewportPageBounds = viewportPageBounds.clone()
const notVisibleShapes = new Set<TLShapeId>()
shapes.forEach((id) => {
if (isShapeNotVisible(editor, id, viewportPageBounds)) {
@ -38,68 +33,21 @@ export const notVisibleShapes = (editor: Editor) => {
})
return notVisibleShapes
}
return computed<Set<TLShapeId>>('getCulledShapes', (prevValue, lastComputedEpoch) => {
return computed<Set<TLShapeId>>('getCulledShapes', (prevValue) => {
if (!isCullingOffScreenShapes) return new Set<TLShapeId>()
if (isUninitialized(prevValue)) {
return fromScratch(editor)
}
const diff = shapeHistory.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) {
return fromScratch(editor)
}
const nextValue = fromScratch(editor)
const currentPageId = editor.getCurrentPageId()
if (lastPageId !== currentPageId) {
return fromScratch(editor)
}
const viewportPageBounds = editor.getViewportPageBounds()
if (!prevViewportPageBounds || !viewportPageBounds.equals(prevViewportPageBounds)) {
return fromScratch(editor)
}
let nextValue = null as null | Set<TLShapeId>
const addId = (id: TLShapeId) => {
// Already added
if (prevValue.has(id)) return
if (!nextValue) nextValue = new Set(prevValue)
nextValue.add(id)
}
const deleteId = (id: TLShapeId) => {
// No need to delete since it's not there
if (!prevValue.has(id)) return
if (!nextValue) nextValue = new Set(prevValue)
nextValue.delete(id)
}
for (const changes of diff) {
for (const record of Object.values(changes.added)) {
if (isShape(record)) {
const isCulled = isShapeNotVisible(editor, record.id, viewportPageBounds)
if (isCulled) {
addId(record.id)
}
}
}
for (const [_from, to] of Object.values(changes.updated)) {
if (isShape(to)) {
const isCulled = isShapeNotVisible(editor, to.id, viewportPageBounds)
if (isCulled) {
addId(to.id)
} else {
deleteId(to.id)
}
}
}
for (const id of Object.keys(changes.removed)) {
if (isShapeId(id)) {
deleteId(id)
}
if (prevValue.size !== nextValue.size) return nextValue
for (const prev of prevValue) {
if (!nextValue.has(prev)) {
return nextValue
}
}
return nextValue ?? prevValue
return prevValue
})
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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)
)
}

Wyświetl plik

@ -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<Value, Diff = unknown> implements Computed<Value, Diff>
__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

Wyświetl plik

@ -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.
*

Wyświetl plik

@ -2507,9 +2507,7 @@ export function useDefaultHelpers(): {
export function useDialogs(): TLUiDialogsContextType;
// @public (undocumented)
export function useEditableText(id: TLShapeId, type: string, text: string, opts?: {
disableTab: boolean;
}): {
export function useEditableText(id: TLShapeId, type: string, text: string): {
handleBlur: () => void;
handleChange: (e: React_2.ChangeEvent<HTMLTextAreaElement>) => void;
handleDoubleClick: (e: any) => any;

Wyświetl plik

@ -15735,7 +15735,7 @@
},
{
"kind": "Content",
"text": ") => {\n id: import(\"@tldraw/editor\")."
"text": ") => {\n id: "
},
{
"kind": "Reference",
@ -15819,7 +15819,7 @@
},
{
"kind": "Content",
"text": ") => {\n id: import(\"@tldraw/editor\")."
"text": ") => {\n id: "
},
{
"kind": "Reference",
@ -15894,7 +15894,7 @@
},
{
"kind": "Content",
"text": ") => {\n id: import(\"@tldraw/editor\")."
"text": ") => {\n id: "
},
{
"kind": "Reference",
@ -15903,7 +15903,7 @@
},
{
"kind": "Content",
"text": ";\n props: {\n autoSize: boolean;\n scale?: undefined;\n };\n type: \"text\";\n } | {\n id: import(\"@tldraw/editor\")."
"text": ";\n props: {\n autoSize: boolean;\n scale?: undefined;\n };\n type: \"text\";\n } | {\n id: "
},
{
"kind": "Reference",
@ -27480,14 +27480,6 @@
"kind": "Content",
"text": "string"
},
{
"kind": "Content",
"text": ", opts?: "
},
{
"kind": "Content",
"text": "{\n disableTab: boolean;\n}"
},
{
"kind": "Content",
"text": "): "
@ -27575,8 +27567,8 @@
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/useEditableText.ts",
"returnTypeTokenRange": {
"startIndex": 9,
"endIndex": 26
"startIndex": 7,
"endIndex": 24
},
"releaseTag": "Public",
"overloadIndex": 1,
@ -27604,14 +27596,6 @@
"endIndex": 6
},
"isOptional": false
},
{
"parameterName": "opts",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": true
}
],
"name": "useEditableText"

Wyświetl plik

@ -109,13 +109,10 @@ export function Tldraw(props: TldrawProps) {
)
const assets = useDefaultEditorAssetsWithOverrides(rest.assetUrls)
const { done: preloadingComplete, error: preloadingError } = usePreloadAssets(assets)
if (preloadingError) {
return <ErrorScreen>Could not load assets. Please refresh the page.</ErrorScreen>
}
if (!preloadingComplete) {
return <LoadingScreen>Loading assets...</LoadingScreen>
}

Wyświetl plik

@ -268,14 +268,16 @@ export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) {
const debugGeom: Geometry2d[] = []
const info = editor.getArrowInfo(shape)!
const hasStartBinding = shape.props.start.type === 'binding'
const hasEndBinding = shape.props.end.type === 'binding'
const hasStartArrowhead = info.start.arrowhead !== 'none'
const hasEndArrowhead = info.end.arrowhead !== 'none'
if (info.isStraight) {
const range = getStraightArrowLabelRange(editor, shape, info)
let clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead ? range.start : 0,
hasEndArrowhead ? range.end : 1
hasStartArrowhead || hasStartBinding ? range.start : 0,
hasEndArrowhead || hasEndBinding ? range.end : 1
)
// This makes the position snap in the middle.
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
@ -285,8 +287,8 @@ export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) {
if (range.dbg) debugGeom.push(...range.dbg)
let clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead ? range.start : 0,
hasEndArrowhead ? range.end : 1
hasStartArrowhead || hasStartBinding ? range.start : 0,
hasEndArrowhead || hasEndBinding ? range.end : 1
)
// This makes the position snap in the middle.
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition

Wyświetl plik

@ -35,7 +35,6 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
labelColor={theme[labelColor].solid}
textWidth={width}
isSelected={isSelected}
disableTab
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}

Wyświetl plik

@ -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<DrawableShape>(id)
}
private updateShapes() {
private updateDrawingShape() {
const { initialShape } = this
const { inputs } = this.editor

Wyświetl plik

@ -402,7 +402,6 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
</SVGContainer>
{showHtmlContainer && (
<HTMLContainer
id={shape.id}
style={{
overflow: 'hidden',
width: shape.props.w,
@ -421,7 +420,6 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
text={text}
isSelected={isSelected}
labelColor={theme[props.labelColor].solid}
disableTab
wrap
/>
</HTMLContainer>

Wyświetl plik

@ -190,7 +190,6 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
isNote
isSelected={isSelected}
labelColor={theme[color].note.text}
disableTab
wrap
onKeyDown={handleKeyDown}
/>

Wyświetl plik

@ -27,7 +27,6 @@ type TextLabelProps = {
bounds?: Box
isNote?: boolean
isSelected: boolean
disableTab?: boolean
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
classNamePrefix?: string
style?: React.CSSProperties
@ -51,15 +50,13 @@ export const TextLabel = React.memo(function TextLabel({
onKeyDown: handleKeyDownCustom,
classNamePrefix,
style,
disableTab = false,
textWidth,
textHeight,
}: TextLabelProps) {
const { rInput, isEmpty, isEditing, isEditingAnything, ...editableTextRest } = useEditableText(
id,
type,
text,
{ disableTab }
text
)
const [initialText, setInitialText] = useState(text)

Wyświetl plik

@ -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

Wyświetl plik

@ -2,7 +2,6 @@ import {
TLShapeId,
TLUnknownShape,
getPointerInfo,
preventDefault,
stopEventPropagation,
useEditor,
useValue,
@ -11,31 +10,14 @@ import React, { useCallback, useEffect, useRef } from 'react'
import { INDENT, TextHelpers } from './TextHelpers'
/** @public */
export function useEditableText(
id: TLShapeId,
type: string,
text: string,
opts = { disableTab: false } as { disableTab: boolean }
) {
export function useEditableText(id: TLShapeId, type: string, text: string) {
const editor = useEditor()
const rInput = useRef<HTMLTextAreaElement>(null)
const isEditing = useValue(
'isEditing',
() => {
return editor.getEditingShapeId() === id
},
[editor]
)
const isEditingAnything = useValue(
'isEditingAnything',
() => {
return editor.getEditingShapeId() !== null
},
[editor]
)
const rSelectionRanges = useRef<Range[] | null>()
const isEditing = useValue('isEditing', () => editor.getEditingShapeId() === id, [editor])
const isEditingAnything = useValue('isEditingAnything', () => !!editor.getEditingShapeId(), [
editor,
])
useEffect(() => {
function selectAllIfEditing({ shapeId }: { shapeId: TLShapeId }) {
@ -52,14 +34,13 @@ export function useEditableText(
}
})
}
editor.on('select-all-text', selectAllIfEditing)
return () => {
editor.off('select-all-text', selectAllIfEditing)
}
}, [editor, id])
const rSelectionRanges = useRef<Range[] | null>()
useEffect(() => {
if (!isEditing) return
@ -69,10 +50,18 @@ export function useEditableText(
// Focus if we're not already focused
if (document.activeElement !== elm) {
elm.focus()
// On mobile etc, just select all the text when we start focusing
if (editor.getInstanceState().isCoarsePointer) {
elm.select()
}
} else {
// This fixes iOS not showing the cursor sometimes. This "shakes" the cursor
// awake.
if (editor.environment.isSafari) {
elm.blur()
elm.focus()
}
}
// When the selection changes, save the selection ranges
@ -103,12 +92,14 @@ export function useEditableText(
requestAnimationFrame(() => {
const elm = rInput.current
const editingShapeId = editor.getEditingShapeId()
// Did we move to a different shape?
if (editingShapeId) {
// important! these ^v are two different things
// is that shape OUR shape?
if (elm && editingShapeId === id) {
elm.focus()
if (ranges && ranges.length) {
const selection = window.getSelection()
if (selection) {
@ -134,20 +125,9 @@ export function useEditableText(
}
break
}
case 'Tab': {
if (!opts.disableTab) {
preventDefault(e)
if (e.shiftKey) {
TextHelpers.unindent(e.currentTarget)
} else {
TextHelpers.indent(e.currentTarget)
}
}
break
}
}
},
[editor, id, opts.disableTab]
[editor, id]
)
// When the text changes, update the text value.
@ -198,8 +178,6 @@ export function useEditableText(
[editor, id, isEditing]
)
const handleDoubleClick = stopEventPropagation
return {
rInput,
handleFocus: noop,
@ -207,7 +185,7 @@ export function useEditableText(
handleKeyDown,
handleChange,
handleInputPointerDown,
handleDoubleClick,
handleDoubleClick: stopEventPropagation,
isEmpty: text.trim().length === 0,
isEditing,
isEditingAnything,

Wyświetl plik

@ -7,18 +7,22 @@ import {
SvgExportContext,
TLOnEditEndHandler,
TLOnResizeHandler,
TLShapeId,
TLShapeUtilFlag,
TLTextShape,
Vec,
WeakMapCache,
getDefaultColorTheme,
preventDefault,
textShapeMigrations,
textShapeProps,
toDomPrecision,
useEditor,
} from '@tldraw/editor'
import { useCallback } from 'react'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextHelpers } from '../shared/TextHelpers'
import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs'
@ -73,6 +77,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
const { width, height } = this.getMinDimensions(shape)
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const theme = useDefaultColorTheme()
const handleKeyDown = useTextShapeKeydownHandler(id)
return (
<TextLabel
@ -94,6 +99,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
transformOrigin: 'top left',
}}
wrap
onKeyDown={handleKeyDown}
/>
)
}
@ -332,3 +338,32 @@ function getTextSize(editor: Editor, props: TLTextShape['props']) {
height: Math.max(fontSize, result.h),
}
}
function useTextShapeKeydownHandler(id: TLShapeId) {
const editor = useEditor()
return useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (editor.getEditingShapeId() !== id) return
switch (e.key) {
case 'Enter': {
if (e.ctrlKey || e.metaKey) {
editor.complete()
}
break
}
case 'Tab': {
preventDefault(e)
if (e.shiftKey) {
TextHelpers.unindent(e.currentTarget)
} else {
TextHelpers.indent(e.currentTarget)
}
break
}
}
},
[editor, id]
)
}

Wyświetl plik

@ -92,8 +92,8 @@ export class PointingArrowLabel extends StateNode {
let nextLabelPosition
if (info.isStraight) {
// straight arrows
const lineLength = Vec.Dist2(info.start.point, info.end.point)
const segmentLength = Vec.Dist2(info.end.point, nearestPoint)
const lineLength = Vec.Dist(info.start.point, info.end.point)
const segmentLength = Vec.Dist(info.end.point, nearestPoint)
nextLabelPosition = 1 - segmentLength / lineLength
} else {
const { _center, measure, angleEnd, angleStart } = groupGeometry.children[0] as Arc2d

Wyświetl plik

@ -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

Wyświetl plik

@ -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<HTMLCanvasElement>(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<MinimapManager>()
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<div className="tlui-minimap">

Wyświetl plik

@ -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<typeof setupWebGl>
shapeGeometryCache: ComputedCache<Float32Array | null, TLShape>
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<MinimapManager['_getColors']>
// 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
}
}
}

Wyświetl plik

@ -0,0 +1,16 @@
const memo = {} as Record<string, Float32Array>
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
}

Wyświetl plik

@ -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<typeof allocateBuffer>
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)
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -1,4 +1,4 @@
import { createContext, useContext } from 'react'
import { createContext, useContext, useEffect } from 'react'
import { TLUiAssetUrls } from '../assetUrls'
/** @internal */
@ -14,6 +14,19 @@ export function AssetUrlsProvider({
assetUrls: TLUiAssetUrls
children: React.ReactNode
}) {
useEffect(() => {
for (const src of Object.values(assetUrls.icons)) {
const image = new Image()
image.src = src
image.decode()
}
for (const src of Object.values(assetUrls.embedIcons)) {
const image = new Image()
image.src = src
image.decode()
}
}, [assetUrls])
return <AssetUrlsContext.Provider value={assetUrls}>{children}</AssetUrlsContext.Provider>
}

Wyświetl plik

@ -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' })
}

Wyświetl plik

@ -1,38 +0,0 @@
import { useEffect, useState } from 'react'
import { useAssetUrls } from '../context/asset-urls'
import { iconTypes } from '../icon-types'
/** @internal */
export function usePreloadIcons(): boolean {
const [isLoaded, setIsLoaded] = useState<boolean>(false)
const assetUrls = useAssetUrls()
useEffect(() => {
let cancelled = false
async function loadImages() {
// Run through all of the icons and load them. It doesn't matter
// if any of the images don't load; though we expect that they would.
// Instead, we just want to make sure that the browser has cached
// all of the icons it can so that they're available when we need them.
await Promise.allSettled(
iconTypes.map((icon) => {
const image = new Image()
image.src = assetUrls.icons[icon]
return image.decode()
})
)
if (cancelled) return
setIsLoaded(true)
}
loadImages()
return () => {
cancelled = true
}
}, [isLoaded, assetUrls])
return isLoaded
}

Wyświetl plik

@ -62,7 +62,7 @@ const schemaV2 = T.object<SerializedSchemaV2>({
const tldrawFileValidator: T.Validator<TldrawFile> = T.object({
tldrawFileFormatVersion: T.nonZeroInteger,
schema: T.union('schemaVersion', {
schema: T.numberUnion('schemaVersion', {
1: schemaV1,
2: schemaV2,
}),

Wyświetl plik

@ -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')
})

Wyświetl plik

@ -136,3 +136,56 @@ it('correctly calculates the culled shapes when adding and deleting shapes', ()
const culledShapeFromScratch = editor.getCulledShapes()
expect(culledShapesIncremental).toEqual(culledShapeFromScratch)
})
it('works for shapes that are outside of the viewport, but are then moved inside it', () => {
const box1Id = createShapeId()
const box2Id = createShapeId()
const arrowId = createShapeId()
editor.createShapes([
{
id: box1Id,
props: { w: 100, h: 100, geo: 'rectangle' },
type: 'geo',
x: -500,
y: 0,
},
{
id: box2Id,
type: 'geo',
x: -1000,
y: 200,
props: { w: 100, h: 100, geo: 'rectangle' },
},
{
id: arrowId,
type: 'arrow',
props: {
start: {
type: 'binding',
isExact: true,
boundShapeId: box1Id,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
end: {
type: 'binding',
isExact: true,
boundShapeId: box2Id,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
},
},
])
expect(editor.getCulledShapes()).toEqual(new Set([box1Id, box2Id, arrowId]))
// Move box1 and box2 inside the viewport
editor.updateShapes([
{ id: box1Id, type: 'geo', x: 100 },
{ id: box2Id, type: 'geo', x: 200 },
])
// Arrow should also not be culled
expect(editor.getCulledShapes()).toEqual(new Set())
})

Wyświetl plik

@ -34,15 +34,17 @@ export function measureAverageDuration(
const start = performance.now()
const result = originalMethod.apply(this, args)
const end = performance.now()
const value = averages.get(descriptor.value)!
const length = end - start
const total = value.total + length
const count = value.count + 1
averages.set(descriptor.value, { total, count })
// eslint-disable-next-line no-console
console.log(
`${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`
)
if (length !== 0) {
const value = averages.get(descriptor.value)!
const total = value.total + length
const count = value.count + 1
averages.set(descriptor.value, { total, count })
// eslint-disable-next-line no-console
console.log(
`${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`
)
}
return result
}
averages.set(descriptor.value, { total: 0, count: 0 })

Wyświetl plik

@ -83,6 +83,9 @@ function nullable<T>(validator: Validatable<T>): Validator<null | T>;
// @public
const number: Validator<number>;
// @internal (undocumented)
function numberUnion<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(key: Key, config: Config): UnionValidator<Key, Config>;
// @public
function object<Shape extends object>(config: {
readonly [K in keyof Shape]: Validatable<Shape[K]>;
@ -134,6 +137,7 @@ declare namespace T {
jsonDict,
dict,
union,
numberUnion,
model,
setEnum,
optional,
@ -178,7 +182,7 @@ function union<Key extends string, Config extends UnionValidatorConfig<Key, Conf
// @public (undocumented)
export class UnionValidator<Key extends string, Config extends UnionValidatorConfig<Key, Config>, UnknownValue = never> extends Validator<TypeOf<Config[keyof Config]> | 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<Unknown>(unknownValueValidation: (value: object, variant: string) => Unknown): UnionValidator<Key, Config, Unknown>;
}

Wyświetl plik

@ -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
}
]
},

Wyświetl plik

@ -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<any> | 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<Unknown>(
unknownValueValidation: (value: object, variant: string) => Unknown
): UnionValidator<Key, Config, Unknown> {
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<Key extends string, Config extends UnionValidatorConfig<Ke
key: Key,
config: Config
): UnionValidator<Key, Config> {
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 extends string, Config extends UnionValidatorConfig<Key, Config>>(
key: Key,
config: Config
): UnionValidator<Key, Config> {
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
)
}
/**