Merge branch 'main' into alex/auto-undo-redo

pull/3364/head
alex 2024-04-22 12:12:25 +01:00
commit 6c333f9c7c
299 zmienionych plików z 117660 dodań i 108489 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

2
.gitignore vendored
Wyświetl plik

@ -7,6 +7,8 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.rooms
node_modules
dist
dist-cjs

Wyświetl plik

@ -77,7 +77,17 @@ Please see our [contributing guide](https://github.com/tldraw/tldraw/blob/main/C
## Star History
<a href="https://star-history.com/#tldraw/tldraw">
<img src="https://api.star-history.com/svg?repos=tldraw/tldraw&type=Date" alt="Star History Chart" width="100%" />
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://api.star-history.com/svg?repos=tldraw/tldraw&type=Date&theme=dark"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://api.star-history.com/svg?repos=tldraw/tldraw&type=Date"
/>
<img src="https://api.star-history.com/svg?repos=tldraw/tldraw&type=Date" alt="Star History Chart" width="100%" />
</picture>
</a>
## Contact

Wyświetl plik

@ -8,7 +8,6 @@
- Minor version bumps are released on a regular cadence. At the time of writing that cadence is monthly. **They may contain breaking changes**. We aim to make breaking changes as minimally disruptive as possible by providing warnings several releases in advance, and by providing tooling to help you migrate your code. We recommend updating tldraw at a similar pace to our release cadence, and be sure to check the release notes.
- Patch version bumps are for bugfixes and hotfixes that can't wait for the next cadence release.
## How to publish a new major or minor release
New cadence releases are published from `main`. You trigger a release manually by running the workflow defined in `publish-new.yml`.

Plik diff jest za duży Load Diff

Plik diff jest za duży Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -1,9 +1,75 @@
---
title: Collaboration
status: published
author: steveruizok
author: ds300
date: 3/22/2023
order: 8
---
See the [tldraw-yjs example](https://github.com/tldraw/tldraw-yjs-example) for an example of how to use yjs with the `tldraw` library.
We've designed the tldraw SDK to work with any collaboration backend. Depending on which backend you choose, you will need an interface that pipes changes coming from the editor to the backend and then merge changes from the backend back to the editor.
The best way to get started is by adapting one of our examples.
### Yjs sync example
We created a [tldraw-yjs example](https://github.com/tldraw/tldraw-yjs-example) to illustrate a way of using the [yjs](https://yjs.dev) library with the tldraw SDK. If you need a "drop in solution" for prototyping multiplayer experiences with tldraw, start here.
### Sockets example
We have a [sockets example](https://github.com/tldraw/tldraw-sockets-example) that uses [PartyKit](https://www.partykit.io/) as a backend. Unlike the yjs example, this example does not use any special data structures to handle conflicts. It should be a good starting point if you needed to write your own conflict-resolution logic.
### Our own sync engine
We developed our own sync engine for use on tldraw.com based on a push/pull/rebase-style algorithm. It powers our "shared projects", such as [this one](https://tldraw.com/r). The engine's source code can be found [here](https://github.com/tldraw/tldraw/tree/main/packages/tlsync). It was designed to be hosted on Cloudflare workers with [DurableObjects](https://developers.cloudflare.com/durable-objects/).
We don't suggest using this code directly. However, like our other examples, it may serve as a good reference for your own sync engine.
## Store data
For information about how to synchronize the store with other processes, i.e. how to get data out and put data in, including from remote sources, see the (Persistence)[/docs/persistence] page.
## User presence
Tldraw has support for displaying the 'presence' of other users. Presence information consists of:
- The user's pointer position
- The user's set of selected shapes
- The user's viewport bounds (the part of the canvas they are currently viewing)
- The user's name, id, and a color to represent them
This information will usually come from two sources:
- The tldraw editor state (e.g. pointer position, selected shapes)
- The data layer of whichever app tldraw has been embedded in (e.g. user name, user id)
Tldraw is agnostic about how this data is shared among users. However, in order for tldraw to use the presence data it needs to be put into the editor's store as `instance_presence` records.
We provide a helper for constructing a reactive signal for an `instance_presence` record locally, which can then be sent to other clients somehow. It is called [createPresenceStateDerivation](?).
```ts
import { createPresenceStateDerivation, react, atom } from 'tldraw'
// First you need to create a Signal containing the basic user details: id, name, and color
const user = atom<{ id: string; color: string; name: string }>('user', {
id: myUser.id,
color: myUser.color,
name: myUser.name,
})
// if you don't have your own user data backend, you can use our localStorage-only user preferences store
// import { getUserPreferences, computed } from 'tldraw'
// const user = computed('user', getUserPreferences)
// Then, with access to your store instance, you can create a presence signal
const userPresence = createPresenceStateDerivation(user)(store)
// Then you can listen for changes to the presence signal and send them to other clients
const unsub = react('update presence', () => {
const presence = userPresence.get()
broadcastPresence(presence)
})
```
The other clients would then call `store.put([presence])` to add the presence information to their store.
Any such `instance_presence` records tldraw finds in the store that have a different user `id` than the editor's configured user id will cause the presence information to be rendered on the canvas.

Wyświetl plik

@ -50,21 +50,9 @@ editor.getSelectedShapeIds() // [myShapeId, myOtherShapeId]
Each change to the state happens within a transaction. You can batch changes into a single transaction using the [Editor#batch](?) method. It's a good idea to batch wherever possible, as this reduces the overhead for persisting or distributing those changes.
### Listening for changes
### Listening for changes, and merging changes from other sources
You can subscribe to changes using the [Store#listen](?) method on [Editor#store](?). Each time a transaction completes, the editor will call the callback with a history entry. This entry contains information about the records that were added, changed, or deleted, as well as whether the change was caused by the user or from a remote change.
```ts
editor.store.listen((entry) => {
entry // { changes, source }
})
```
### Remote changes
By default, changes to the editor's store are assumed to have come from the editor itself. You can use the [Store#mergeRemoteChanges](?) method of the editor's [Editor#store](?) to make changes in the store that will be emitted via [Store#listen](?) with the `source` property as `'remote'`.
If you're setting up some kind of multiplayer backend, you would want to send only the `'user'` changes to the server and merge the changes from the server using [Store#mergeRemoteChanges](?) (`editor.store.mergeRemoteChanges`).
For information about how to synchronize the store with other processes, i.e. how to get data out and put data in, see the (Persistence)[/docs/persistence] page.
### Undo and redo

Wyświetl plik

@ -17,7 +17,7 @@ Persistence in tldraw means storing information about the editor's state to a da
## The `"persistenceKey"` prop
Both the `<Tldraw>` or `<TldrawEditor>` components support local persitence and cross-tab synchronization via the `persistenceKey` prop. Passing a value to this prop will persist the contents of the editor locally to the browser's IndexedDb.
Both the `<Tldraw>` or `<TldrawEditor>` components support local persistence and cross-tab synchronization via the `persistenceKey` prop. Passing a value to this prop will persist the contents of the editor locally to the browser's IndexedDb.
```tsx
import { Tldraw } from 'tldraw'
@ -54,7 +54,7 @@ export default function () {
In the example above, both editors would synchronize their document locally. They would still have two independent instance states (e.g. selections) but the document would be kept in sync and persisted under the same key.
## Snapshots
## Document Snapshots
You can get a JSON snapshot of the editor's content using the [Editor#store](?)'s [Store#getSnapshot](?) method.
@ -96,7 +96,7 @@ function LoadButton() {
A [snapshot](/reference/store/StoreSnapshot) includes both the store's [serialized records](/reference/store/SerializedStore) and its [serialized schema](/reference/store/SerializedSchema), which is used for migrations.
> By default, the `getSnapshot` method returns only the editor's document data. If you want to get records from a different scope, You can pass in `session`, `document`, `presence`, or else `all` for all scopes.
> By default, the `getSnapshot` method returns only the editor's document data. If you want to get records from a different scope, you can pass in `session`, `document`, `presence`, or else `all` for all scopes.
Note that loading a snapshot does not reset the editor's in memory state or UI state. For example, loading a snapshot during a resizing operation may lead to a crash. This is because the resizing state maintains its own cache of information about which shapes it is resizing, and its possible that those shapes may no longer exist!
@ -170,3 +170,242 @@ export default function () {
```
For a good example of this pattern, see the [yjs-example](https://github.com/tldraw/tldraw-yjs-example).
## Listening for changes
You can listen for incremental updates to the document state by calling `editor.store.listen`, e.g.
```ts
const unlisten = editor.store.listen(
(update) => {
console.log('update', update)
},
{ scope: 'document', source: 'user' }
)
```
These updates contain information about which records were added, removed, and updated. See [HistoryEntry](?)
The `scope` filter can be used to listen for changes to a specific record scope, e.g. `document`, `session`, `presence`, or `all`.
The `source` filter can be used to listen for changes from a specific source, e.g. `user`, `remote`, or `all`. (See [Store#mergeRemoteChanges](?) for more information on remote changes.)
Note that these incremental updates do not include the schema version. You should make sure that you keep a record of the latest schema version for your snapshots.
You can get the schema version by calling `editor.store.schema.serialize()` and the returned value can replace the `schema` property in the snapshot next time you need to load a snapshot. The schema does not change at runtime so you only need to do this once per session.
## Handling remote changes
If you need to synchronize changes from a remote source, e.g. a multiplayer backend, you can use the `editor.store.mergeRemoteChanges` method. This will 'tag' the changes with the `source` property as `'remote'` so you can filter them out when listening for changes.
```ts
myRemoteSource.on('change', (changes) => {
editor.store.mergeRemoteChanges(() => {
changes.forEach((change) => {
// Apply the changes to the store
editor.store.put(/* ... */)
})
})
})
```
## Migrations
Tldraw uses migrations to bring data from old snapshots up to date. These run automatically when calling `editor.store.loadSnapshot`.
### Running migrations manually
If you need to run migrations on a snapshot without loading it into the store, you can call [StoreSchema#migrateStoreSnapshot](?) directly.
```ts
import { createTLSchema } from 'tldraw'
const snapshot = await getSnapshotFromSomewhere()
const migrationResult = createTLSchema().migrateStoreSnapshot(snapshot)
if (migrationResult.type === 'success') {
console.log('Migrated snapshot', migrationResult.value)
} else {
console.error('Migration failed', migrationResult.reason)
}
```
### Custom migrations
Tldraw supports a couple of ways of adding custom data types to the tldraw store:
- [Custom shape types](/docs/shapes#Custom-shapes-1)
- [`meta` properties](/docs/shapes#Meta-information) on all of our built-in record types.
You might wish to migrate your custom data types over time as you make changes to them.
To enable this, tldraw provides two ways to add custom migrations:
1. **Shape props migrations**, specifically for migrating the shape.props objects on your custom shape types.
2. **The `migrations` config option**, which is more general purpose but much less commonly needed. This will allow you to migrate any data in the store.
#### Shape props migrations
If you have a custom shape type, you can define a `migrations` property on the shape util class. Use the `createShapePropsMigrationSequence` helper to define this property.
```ts
import { createShapePropsMigrationSequence, createShapePropsMigrationIds, ShapeUtil } from 'tldraw'
// Migrations must start a 1 and be sequential integers.
const Versions = createShapePropMigrationIds('custom-shape', {
AddColor: 1,
})
class MyCustomShapeUtil extends ShapeUtil {
static type = 'custom-shape'
static migrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddColor,
up(props) {
// set the default color
props.color = 'black'
},
},
],
})
// ...
}
```
#### The `migrations` config option
First create a set of migration ids.
```ts
import { createMigrationIds } from 'tldraw'
// The first argument is a unique namespace for your migration sequence.
// We recommend using a reverse domain name, e.g. we use 'com.tldraw.foo.bar'
const SEQUENCE_ID = 'com.example.my-app'
const Versions = createMigrationIds(SEQUENCE_ID, {
// Migrations must start at 1 and be sequential integers.
AddColor: 1,
})
```
Then create a migration sequence.
```ts
import { createMigrationSequence, isShape } from 'tldraw'
const myMigrations = createMigrationSequence({
sequenceId: SEQUENCE_ID,
sequence: [
{
id: Versions.AddColor,
// Scope can be one of
// - 'store' to have the up function called on the whole snapshot at once
// - 'record' to have the up function called on each record individually
scope: 'record',
// if scope is 'record', you can filter which records the migration runs on
filter: (record) => isShape(record) && record.type === 'custom-shape',
up(record) {
record.props.color = 'black'
},
},
],
})
```
And finally pass your migrations in to tldraw via the `migrations` config option. There are a few places where you might need to do this, depending on how specialized your usage of Tldraw is:
```tsx
// When rendering the Tldraw component
<Tldraw
...
migrations={[myMigrations]}
/>
// or when creating the store
store = createTLStore({
...
migrations: [myMigrations],
})
// or when creating the schema
schema = createTLSchema({
...
migrations: [myMigrations],
})
```
### Updating legacy shape migrations (defineMigrations)
You can convert your legacy migrations to the new migrations format by the following process:
1. Wrap your version numbers in `createShapePropsMigrationIds`
```diff
- const Versions = {
+ const Versions = createShapePropMigrationIds('custom-shape', {
AddColor: 1
- }
+ })
```
2. Replace your `defineMigrations` call with `createShapePropsMigrationSequence`
```ts
const migrations = defineMigrations({
currentVersion: Versions.AddColor,
migrators: {
[Versions.AddColor]: {
up: (shape: any) => ({ ...shape, props: { ...shape.props, color: 'black' } }),
down: ({ props: { color, ...props }, ...shape }: any) => ({ ...shape, props }),
},
},
})
```
Becomes
```ts
const migrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddColor,
// [!!!] You no longer have access to the top-level shape object.
// Only the shape.props object is passed in to the migrator function.
up(props) {
// [!!!] You no longer need to return a new copy of the shape object.
// Instead, you can modify the props object in place.
props.color = 'black'
},
// [!!!] You no longer need to specify a down migration.
},
],
})
```
## Examples
### Local persistence
Tldraw ships with a local-only sync engine based on `IndexedDb` and `BroadcastChannel` called [`TLLocalSyncClient`](https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts).
### Tldraw.com sync engine
[tldraw.com/r](https://tldraw.com/r) currently uses a simple custom sync engine based on a push/pull/rebase-style algorithm.
It can be found [here](https://github.com/tldraw/tldraw/tree/main/packages/tlsync).
It was optimized for Cloudflare workers with [DurableObjects](https://developers.cloudflare.com/durable-objects/)
We don't suggest using our code directly yet, but it may serve as a good reference for your own sync engine.
### Yjs sync example
We created a [tldraw-yjs example](https://github.com/tldraw/tldraw-yjs-example) to illustrate a way of using yjs with the tldraw SDK.
### Shape props migrations example
Our [custom-config example](/examples/shapes/tools/custom-config) shows how to add custom shape props migrations to the tldraw store.
### Meta properties migrations example
Our [custom-config example](/examples/shapes/tools/custom-config) shows how to add custom migrations to the tldraw store.

Wyświetl plik

@ -239,4 +239,6 @@ You can turn on `pointer-events` to allow users to interact inside of the shape.
You can make shapes "editable" to help decide when they're interactive or not.
...and more!
### Migrations
You can add migrations for your shape props by adding a `migrations` property to your shape's util class. See [the persistence docs](/docs/persistence#Shape-props-migrations) for more information.

Wyświetl plik

@ -304,7 +304,10 @@ const DocumentNameEditor = track(function DocumentNameEditor({
) : (
<div
className="tlui-document-name__text"
onDoubleClick={() => setState((prev) => ({ ...prev, isEditing: true }))}
onDoubleClick={() => {
editor.setEditingShape(null)
setState((prev) => ({ ...prev, isEditing: true }))
}}
>
{addRealSpaceForWhitespace(name)}
</div>

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

@ -6,9 +6,7 @@ export function CursorChatMenuItem() {
const actions = useActions()
const shouldShow = useValue(
'show cursor chat',
() => {
return !editor.getInstanceState().isCoarsePointer
},
() => editor.getCurrentToolId() === 'select' && !editor.getInstanceState().isCoarsePointer,
[editor]
)

Wyświetl plik

@ -6,13 +6,6 @@ const RELEASE_INFO = `${env} ${process.env.NEXT_PUBLIC_TLDRAW_RELEASE_INFO ?? 'u
export function DebugMenuItems() {
return (
<TldrawUiMenuGroup id="release">
<TldrawUiMenuItem
id="release-info"
label={`Version ${RELEASE_INFO}`}
onSelect={() => {
window.alert(`${RELEASE_INFO}`)
}}
/>
<TldrawUiMenuItem
id="v1"
label="Test v1 content"
@ -22,6 +15,13 @@ export function DebugMenuItems() {
window.location.reload()
}}
/>
<TldrawUiMenuItem
id="release-info"
label={'Release info'}
onSelect={() => {
window.alert(`${RELEASE_INFO}`)
}}
/>
</TldrawUiMenuGroup>
)
}

Wyświetl plik

@ -140,7 +140,7 @@ describe(ClientWebSocketAdapter, () => {
const message: TLSocketClientSentEvent<TLRecord> = {
type: 'connect',
connectRequestId: 'test',
schema: { schemaVersion: 0, storeVersion: 0, recordVersions: {} },
schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
protocolVersion: TLSYNC_PROTOCOL_VERSION,
lastServerClock: 0,
}

Wyświetl plik

@ -41,9 +41,9 @@ export async function setupPageWithShapes(page: PlaywrightTestArgs['page']) {
await page.mouse.click(200, 250)
await page.keyboard.press('r')
await page.mouse.click(250, 300)
// deselect everything
await page.evaluate(() => editor.selectNone())
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
}
export async function cleanupPage(page: PlaywrightTestArgs['page']) {

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 5.2 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.9 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 5.2 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.9 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 5.2 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 5.0 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 5.2 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 5.0 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 4.3 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.2 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 4.3 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.2 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 4.4 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 4.4 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 4.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.6 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.6 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 3.4 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.1 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 3.1 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.9 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.9 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.8 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.8 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.4 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.8 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.4 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.8 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.5 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.7 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.7 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.7 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.7 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.6 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.6 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.6 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.6 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.5 KiB

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 1.5 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.5 KiB

Wyświetl plik

@ -1,5 +1,5 @@
import test, { Page, expect } from '@playwright/test'
import { BoxModel, Editor } from 'tldraw'
import { BoxModel, Editor, TLNoteShape, TLShapeId } from 'tldraw'
import { setupPage } from '../shared-e2e'
export function sleep(ms: number) {
@ -242,4 +242,92 @@ test.describe('text measurement', () => {
expect(formatLines(spans)).toEqual([[' \n'], [' \n'], [' \n'], [' ']])
})
test('for auto-font-sizing shapes, should do normal font size for text that does not have long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
text: 'this is just some regular text',
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(32)
})
test('for auto-font-sizing shapes, should auto-size text that have slightly long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
text: 'Amsterdam',
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(27)
})
test('for auto-font-sizing shapes, should auto-size text that have long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
text: 'this is a tentoonstelling',
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(20)
})
test('for auto-font-sizing shapes, should wrap text that has words that are way too long', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
text: 'a very long dutch word like ziekenhuisinrichtingsmaatschappij',
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(14)
})
})

Wyświetl plik

@ -1,4 +1,4 @@
import { Editor, Tldraw } from 'tldraw'
import { Editor, TLStoreSnapshot, Tldraw } from 'tldraw'
import { PlayingCardTool } from './PlayingCardShape/playing-card-tool'
import { PlayingCardUtil } from './PlayingCardShape/playing-card-util'
import snapshot from './snapshot.json'
@ -27,7 +27,7 @@ export default function BoundsSnappingShapeExample() {
// [c]
onMount={handleMount}
// [d]
snapshot={snapshot}
snapshot={snapshot as TLStoreSnapshot}
/>
</div>
)

Wyświetl plik

@ -1,21 +1,26 @@
import { defineMigrations } from 'tldraw'
import { createShapePropsMigrationIds } from '@tldraw/tlschema/src/records/TLShape'
import { createShapePropsMigrationSequence } from 'tldraw'
const versions = createShapePropsMigrationIds(
// this must match the shape type in the shape definition
'card',
{
AddSomeProperty: 1,
}
)
// Migrations for the custom card shape (optional but very helpful)
export const cardShapeMigrations = defineMigrations({
currentVersion: 1,
migrators: {
1: {
// for example, removing a property from the shape
up(shape) {
const migratedUpShape = { ...shape }
delete migratedUpShape._somePropertyToRemove
return migratedUpShape
export const cardShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: versions.AddSomeProperty,
up(props) {
// it is safe to mutate the props object here
props.someProperty = 'some value'
},
down(shape) {
const migratedDownShape = { ...shape }
migratedDownShape._somePropertyToRemove = 'some value'
return migratedDownShape
down(props) {
delete props.someProperty
},
},
},
],
})

Wyświetl plik

@ -0,0 +1,151 @@
import {
Circle2d,
Geometry2d,
HTMLContainer,
Rectangle2d,
ShapeUtil,
TLBaseShape,
TLShape,
Tldraw,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
// [1]
type MyGridShape = TLBaseShape<'my-grid-shape', Record<string, never>>
type MyCounterShape = TLBaseShape<'my-counter-shape', Record<string, never>>
// [2]
const SLOT_SIZE = 100
class MyCounterShapeUtil extends ShapeUtil<MyCounterShape> {
static override type = 'my-counter-shape' as const
override canResize = () => false
override hideResizeHandles = () => true
getDefaultProps(): MyCounterShape['props'] {
return {}
}
getGeometry(): Geometry2d {
return new Circle2d({ radius: SLOT_SIZE / 2 - 10, isFilled: true })
}
component() {
return (
<HTMLContainer
style={{
backgroundColor: '#e03131',
border: '1px solid #ff8787',
borderRadius: '50%',
}}
/>
)
}
indicator() {
return <circle r={SLOT_SIZE / 2 - 10} cx={SLOT_SIZE / 2 - 10} cy={SLOT_SIZE / 2 - 10} />
}
}
// [3]
class MyGridShapeUtil extends ShapeUtil<MyGridShape> {
static override type = 'my-grid-shape' as const
getDefaultProps(): MyGridShape['props'] {
return {}
}
getGeometry(): Geometry2d {
return new Rectangle2d({
width: SLOT_SIZE * 5,
height: SLOT_SIZE * 2,
isFilled: true,
})
}
override canResize = () => false
override hideResizeHandles = () => true
// [a]
override canDropShapes = (shape: MyGridShape, shapes: TLShape[]) => {
if (shapes.every((s) => s.type === 'my-counter-shape')) {
return true
}
return false
}
// [b]
override onDragShapesOver = (shape: MyGridShape, shapes: TLShape[]) => {
if (!shapes.every((child) => child.parentId === shape.id)) {
this.editor.reparentShapes(shapes, shape.id)
}
}
// [c]
override onDragShapesOut = (shape: MyGridShape, shapes: TLShape[]) => {
this.editor.reparentShapes(shapes, this.editor.getCurrentPageId())
}
component() {
return (
<HTMLContainer
style={{
backgroundColor: '#efefef',
borderRight: '1px solid #ccc',
borderBottom: '1px solid #ccc',
backgroundSize: `${SLOT_SIZE}px ${SLOT_SIZE}px`,
backgroundImage: `
linear-gradient(to right, #ccc 1px, transparent 1px),
linear-gradient(to bottom, #ccc 1px, transparent 1px)
`,
}}
/>
)
}
indicator() {
return <rect width={SLOT_SIZE * 5} height={SLOT_SIZE * 2} />
}
}
export default function DragAndDropExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={[MyGridShapeUtil, MyCounterShapeUtil]}
onMount={(editor) => {
editor.createShape({ type: 'my-grid-shape', x: 100, y: 100 })
editor.createShape({ type: 'my-counter-shape', x: 700, y: 100 })
editor.createShape({ type: 'my-counter-shape', x: 750, y: 200 })
editor.createShape({ type: 'my-counter-shape', x: 770, y: 300 })
}}
/>
</div>
)
}
/*
This example demonstrates how to use the drag-and-drop system.
[1] Define some shape types. For the purposes of this example, we'll define two
shapes: a grid and a counter.
[2] Make a shape util for the first shape. For this example, we'll make a simple
red circle that you drag and drop onto the other shape.
[3] Make the other shape util. In this example, we'll make a grid that you can
place the the circle counters onto.
[a] Use the `canDropShapes` method to specify which shapes can be dropped onto
the grid shape.
[b] Use the `onDragShapesOver` method to reparent counters to the grid shape
when they are dragged on top.
[c] Use the `onDragShapesOut` method to reparent counters back to the page
when they are dragged off.
*/

Wyświetl plik

@ -0,0 +1,12 @@
---
title: Drag and drop
component: ./DragAndDropExample.tsx
category: shapes/tools
priority: 1
---
Shapes that can be dragged and dropped onto each other.
---
You can create custom shapes that can be dragged and dropped onto each other.

Wyświetl plik

@ -3,7 +3,6 @@ import {
DefaultContextMenuContent,
TldrawEditor,
TldrawHandles,
TldrawHoveredShapeIndicator,
TldrawScribble,
TldrawSelectionBackground,
TldrawSelectionForeground,
@ -23,7 +22,6 @@ const defaultComponents = {
SelectionForeground: TldrawSelectionForeground,
SelectionBackground: TldrawSelectionBackground,
Handles: TldrawHandles,
HoveredShapeIndicator: TldrawHoveredShapeIndicator,
}
//[2]

Wyświetl plik

@ -1,5 +1,14 @@
import { useState } from 'react'
import { Box, Editor, StoreSnapshot, TLPageId, TLRecord, Tldraw, TldrawImage } from 'tldraw'
import {
Box,
Editor,
StoreSnapshot,
TLPageId,
TLRecord,
TLStoreSnapshot,
Tldraw,
TldrawImage,
} from 'tldraw'
import 'tldraw/tldraw.css'
import initialSnapshot from './snapshot.json'
@ -7,7 +16,9 @@ import initialSnapshot from './snapshot.json'
export default function TldrawImageExample() {
const [editor, setEditor] = useState<Editor>()
const [snapshot, setSnapshot] = useState<StoreSnapshot<TLRecord>>(initialSnapshot)
const [snapshot, setSnapshot] = useState<StoreSnapshot<TLRecord>>(
initialSnapshot as TLStoreSnapshot
)
const [currentPageId, setCurrentPageId] = useState<TLPageId | undefined>()
const [showBackground, setShowBackground] = useState(true)
const [isDarkMode, setIsDarkMode] = useState(false)

Wyświetl plik

@ -0,0 +1,74 @@
import { Tldraw, createMigrationIds, createMigrationSequence } from 'tldraw'
import 'tldraw/tldraw.css'
import { snapshot } from './snapshot'
import { components } from './ui-overrides'
/**
* This example demonstrates how to add custom migrations for `meta` properties, or any other
* data in your store snapshots.
*
* If you have a custom shape type and you want to add migrations for its `props` object,
* there is a simpler dedicated API for that. Check out [the docs](https://tldraw.dev/docs/persistence#Shape-props-migrations) for more info.
*/
/**
* Let's say you added some page metadata, e.g. to allow setting the background color of a page independently.
*/
interface _PageMetaV1 {
backgroundTheme?: 'red' | 'blue' | 'green' | 'purple'
}
/**
* And then perhaps later on you decided to remove support for 'purple' because it's an ugly color.
* So all purple pages will become blue.
*/
export interface PageMetaV2 {
backgroundTheme?: 'red' | 'blue' | 'green'
}
/**
* You would then create a migration to update the metadata from v1 to v2.
*/
// First pick a 'sequence id' that is unique to your app
const sequenceId = 'com.example.my-app'
// Then create a 'migration id' for each version of your metadata
const versions = createMigrationIds(sequenceId, {
// the numbers must start at 1 and increment by 1
RemovePurple: 1,
})
const migrations = createMigrationSequence({
sequenceId,
sequence: [
{
id: versions.RemovePurple,
// `scope: 'record` tells the schema to call this migration on individual records.
// `scope: 'store'` would call it on the entire snapshot, to allow for actions like deleting/creating records.
scope: 'record',
// When `scope` is 'record', you can specify a filter function to only apply the migration to records that match the filter.
filter: (record) => record.typeName === 'page',
// This up function will be called on all records that match the filter
up(page: any) {
if (page.meta.backgroundTheme === 'purple') {
page.meta.backgroundTheme = 'blue'
page.name += ' (was purple)'
}
},
},
],
})
export default function MetaMigrationsExample() {
return (
<div className="tldraw__editor">
<Tldraw
// Pass in the custom migrations
migrations={[migrations]}
// When you load a snapshot from a previous version, the migrations will be applied automatically
snapshot={snapshot}
// This adds a dropdown to the canvas for changing the backgroundTheme property
components={components}
/>
</div>
)
}

Wyświetl plik

@ -0,0 +1,12 @@
---
title: Meta Migrations
component: ./MetaMigrations.tsx
category: data/assets
priority: 6
---
Create custom migrations for `meta` properties.
---
You can add arbitrary data migrations for tldraw snapshot data. This is mainly useful for updating the `meta` property of a record as your data types evolve.

Wyświetl plik

@ -0,0 +1,57 @@
import { TLStoreSnapshot } from 'tldraw'
export const snapshot = {
store: {
'document:document': {
gridSize: 10,
name: '',
meta: {},
id: 'document:document',
typeName: 'document',
},
'page:red': {
meta: {
backgroundTheme: 'red',
},
id: 'page:red',
name: 'Red',
index: 'a1',
typeName: 'page',
},
'page:green': {
meta: {
backgroundTheme: 'green',
},
id: 'page:green',
name: 'Green',
index: 'a2',
typeName: 'page',
},
'page:blue': {
meta: {
backgroundTheme: 'blue',
},
id: 'page:blue',
name: 'Blue',
index: 'a3',
typeName: 'page',
},
'page:purple': {
meta: {
backgroundTheme: 'purple',
},
id: 'page:purple',
name: 'Purple',
index: 'a0',
typeName: 'page',
},
},
schema: {
schemaVersion: 2,
sequences: {
'com.tldraw.store': 4,
'com.tldraw.document': 2,
'com.tldraw.page': 1,
},
},
} as TLStoreSnapshot

Wyświetl plik

@ -0,0 +1,41 @@
import { useLayoutEffect } from 'react'
import { TLComponents, track, useEditor } from 'tldraw'
import { PageMetaV2 } from './MetaMigrations'
export const components: TLComponents = {
TopPanel: track(() => {
const editor = useEditor()
const currentPage = editor.getCurrentPage()
const meta: PageMetaV2 = currentPage.meta
useLayoutEffect(() => {
const elem = document.querySelector('.tl-background') as HTMLElement
if (!elem) return
elem.style.backgroundColor = meta.backgroundTheme ?? 'unset'
}, [meta.backgroundTheme])
return (
<span style={{ pointerEvents: 'all', padding: '5px 15px', margin: 10, fontSize: 18 }}>
bg: &nbsp;
<select
value={meta.backgroundTheme ?? 'none'}
onChange={(e) => {
if (e.currentTarget.value === 'none') {
editor.updatePage({ ...currentPage, meta: {} })
} else {
editor.updatePage({
...currentPage,
meta: { backgroundTheme: e.currentTarget.value },
})
}
}}
>
<option value="none">None</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
</select>
</span>
)
}),
}

Wyświetl plik

@ -84,7 +84,7 @@ deletes that shape.
[1]
This is where we define our state node by extending the StateNode class. Since
there are no children states We can simply give it an id and define methods we
there are no children states We can give it an id and define methods we
want to override to handle events.

Wyświetl plik

@ -4,6 +4,7 @@ import {
T,
TLBaseShape,
TLOnResizeHandler,
TLStoreSnapshot,
Tldraw,
resizeBox,
} from 'tldraw'
@ -94,7 +95,7 @@ export default function ShapeWithMigrationsExample() {
// Pass in the array of custom shape classes
shapeUtils={customShapeUtils}
// Use a snapshot to load an old version of the shape
snapshot={snapshot}
snapshot={snapshot as TLStoreSnapshot}
/>
</div>
)

Wyświetl plik

@ -0,0 +1,12 @@
---
title: Slideshow
component: ./SlidesExample.tsx
category: use-cases
priority: 1
---
Slideshow example.
---
Make slides for a presentation.

Wyświetl plik

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from 'tldraw'
export class SlideShapeTool extends BaseBoxShapeTool {
static override id = 'slide'
static override initial = 'idle'
override shapeType = 'slide'
}

Wyświetl plik

@ -0,0 +1,127 @@
import { useCallback } from 'react'
import {
Geometry2d,
Rectangle2d,
SVGContainer,
ShapeProps,
ShapeUtil,
T,
TLBaseShape,
TLOnResizeHandler,
resizeBox,
useValue,
} from 'tldraw'
import { getPerfectDashProps } from 'tldraw/src/lib/shapes/shared/getPerfectDashProps'
import { moveToSlide, useSlides } from './useSlides'
export type SlideShape = TLBaseShape<
'slide',
{
w: number
h: number
}
>
export class SlideShapeUtil extends ShapeUtil<SlideShape> {
static override type = 'slide' as const
static override props: ShapeProps<SlideShape> = {
w: T.number,
h: T.number,
}
override canBind = () => false
override hideRotateHandle = () => true
getDefaultProps(): SlideShape['props'] {
return {
w: 720,
h: 480,
}
}
getGeometry(shape: SlideShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false,
})
}
override onRotate = (initial: SlideShape) => initial
override onResize: TLOnResizeHandler<SlideShape> = (shape, info) => {
return resizeBox(shape, info)
}
override onDoubleClick = (shape: SlideShape) => {
moveToSlide(this.editor, shape)
this.editor.selectNone()
}
override onDoubleClickEdge = (shape: SlideShape) => {
moveToSlide(this.editor, shape)
this.editor.selectNone()
}
component(shape: SlideShape) {
const bounds = this.editor.getShapeGeometry(shape).bounds
// eslint-disable-next-line react-hooks/rules-of-hooks
const zoomLevel = useValue('zoom level', () => this.editor.getZoomLevel(), [this.editor])
// eslint-disable-next-line react-hooks/rules-of-hooks
const slides = useSlides()
const index = slides.findIndex((s) => s.id === shape.id)
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleLabelPointerDown = useCallback(() => this.editor.select(shape.id), [shape.id])
if (!bounds) return null
return (
<>
<div onPointerDown={handleLabelPointerDown} className="slide-shape-label">
{`Slide ${index + 1}`}
</div>
<SVGContainer>
<g
style={{
stroke: 'var(--color-text)',
strokeWidth: 'calc(1px * var(--tl-scale))',
opacity: 0.25,
}}
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
>
{bounds.sides.map((side, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
side[0].dist(side[1]),
1 / zoomLevel,
{
style: 'dashed',
lengthRatio: 6,
}
)
return (
<line
key={i}
x1={side[0].x}
y1={side[0].y}
x2={side[1].x}
y2={side[1].y}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</SVGContainer>
</>
)
}
indicator(shape: SlideShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

Wyświetl plik

@ -0,0 +1,109 @@
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiOverrides,
Tldraw,
TldrawUiMenuItem,
computed,
track,
useIsToolSelected,
useTools,
} from 'tldraw'
import 'tldraw/tldraw.css'
import { SlideShapeTool } from './SlideShapeTool'
import { SlideShapeUtil } from './SlideShapeUtil'
import { SlidesPanel } from './SlidesPanel'
import './slides.css'
import { $currentSlide, getSlides, moveToSlide } from './useSlides'
const components: TLComponents = {
HelperButtons: SlidesPanel,
Minimap: null,
Toolbar: (props) => {
const tools = useTools()
const isSlideSelected = useIsToolSelected(tools['slide'])
return (
<DefaultToolbar {...props}>
<TldrawUiMenuItem {...tools['slide']} isSelected={isSlideSelected} />
<DefaultToolbarContent />
</DefaultToolbar>
)
},
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<TldrawUiMenuItem {...tools['slide']} />
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
},
}
const overrides: TLUiOverrides = {
actions(editor, actions) {
const $slides = computed('slides', () => getSlides(editor))
return {
...actions,
'next-slide': {
id: 'next-slide',
label: 'Next slide',
kbd: 'right',
onSelect() {
const slides = $slides.get()
const currentSlide = $currentSlide.get()
const index = slides.findIndex((s) => s.id === currentSlide?.id)
const nextSlide = slides[index + 1] ?? currentSlide ?? slides[0]
if (nextSlide) {
editor.stopCameraAnimation()
moveToSlide(editor, nextSlide)
}
},
},
'previous-slide': {
id: 'previous-slide',
label: 'Previous slide',
kbd: 'left',
onSelect() {
const slides = $slides.get()
const currentSlide = $currentSlide.get()
const index = slides.findIndex((s) => s.id === currentSlide?.id)
const previousSlide = slides[index - 1] ?? currentSlide ?? slides[slides.length - 1]
if (previousSlide) {
editor.stopCameraAnimation()
moveToSlide(editor, previousSlide)
}
},
},
}
},
tools(editor, tools) {
tools.slide = {
id: 'slide',
icon: 'group',
label: 'Slide',
kbd: 's',
onSelect: () => editor.setCurrentTool('slide'),
}
return tools
},
}
const SlidesExample = track(() => {
return (
<div className="tldraw__editor">
<Tldraw
persistenceKey="slideshow_example"
shapeUtils={[SlideShapeUtil]}
tools={[SlideShapeTool]}
components={components}
overrides={overrides}
/>
</div>
)
})
export default SlidesExample

Wyświetl plik

@ -0,0 +1,32 @@
import { TldrawUiButton, stopEventPropagation, track, useEditor, useValue } from 'tldraw'
import { moveToSlide, useCurrentSlide, useSlides } from './useSlides'
export const SlidesPanel = track(() => {
const editor = useEditor()
const slides = useSlides()
const currentSlide = useCurrentSlide()
const selectedShapes = useValue('selected shapes', () => editor.getSelectedShapes(), [editor])
if (slides.length === 0) return null
return (
<div className="slides-panel scroll-light" onPointerDown={(e) => stopEventPropagation(e)}>
{slides.map((slide, i) => {
const isSelected = selectedShapes.includes(slide)
return (
<TldrawUiButton
key={'slides-panel-button:' + slide.id}
type="normal"
className="slides-panel-button"
onClick={() => moveToSlide(editor, slide)}
style={{
background: currentSlide?.id === slide.id ? 'var(--color-background)' : 'transparent',
outline: isSelected ? 'var(--color-selection-stroke) solid 1.5px' : 'none',
}}
>
{`Slide ${i + 1}`}
</TldrawUiButton>
)
})}
</div>
)
})

Wyświetl plik

@ -0,0 +1,32 @@
.slides-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: calc(100% - 110px);
margin: 50px 0px;
padding: 4px;
background-color: var(--color-low);
pointer-events: all;
border-top-right-radius: var(--radius-4);
border-bottom-right-radius: var(--radius-4);
overflow: auto;
border-right: 2px solid var(--color-background);
border-bottom: 2px solid var(--color-background);
border-top: 2px solid var(--color-background);
}
.slides-panel-button {
border-radius: var(--radius-4);
outline-offset: -1px;
}
.slide-shape-label {
pointer-events: all;
position: absolute;
background: var(--color-low);
padding: calc(12px * var(--tl-scale));
border-bottom-right-radius: calc(var(--radius-4) * var(--tl-scale));
font-size: calc(12px * var(--tl-scale));
color: var(--color-text);
white-space: nowrap;
}

Wyświetl plik

@ -0,0 +1,28 @@
import { EASINGS, Editor, atom, useEditor, useValue } from 'tldraw'
import { SlideShape } from './SlideShapeUtil'
export const $currentSlide = atom<SlideShape | null>('current slide', null)
export function moveToSlide(editor: Editor, slide: SlideShape) {
const bounds = editor.getShapePageBounds(slide.id)
if (!bounds) return
$currentSlide.set(slide)
editor.selectNone()
editor.zoomToBounds(bounds, { duration: 500, easing: EASINGS.easeInOutCubic, inset: 0 })
}
export function useSlides() {
const editor = useEditor()
return useValue<SlideShape[]>('slide shapes', () => getSlides(editor), [editor])
}
export function useCurrentSlide() {
return useValue($currentSlide)
}
export function getSlides(editor: Editor) {
return editor
.getSortedChildIdsForParent(editor.getCurrentPageId())
.map((id) => editor.getShape(id))
.filter((s) => s?.type === 'slide') as SlideShape[]
}

Wyświetl plik

@ -1,6 +1,8 @@
import { Tldraw } from 'tldraw'
import { TLStoreSnapshot, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import jsonSnapshot from './snapshot.json'
import _jsonSnapshot from './snapshot.json'
const jsonSnapshot = _jsonSnapshot as TLStoreSnapshot
// There's a guide at the bottom of this file!

Wyświetl plik

@ -1,4 +1,3 @@
import { ShapePropsType } from '@tldraw/tlschema/src/shapes/TLBaseShape'
import {
DefaultColorStyle,
DefaultFontStyle,
@ -9,6 +8,7 @@ import {
Geometry2d,
LABEL_FONT_SIZES,
Polygon2d,
ShapePropsType,
ShapeUtil,
T,
TEXT_PROPS,
@ -20,11 +20,11 @@ import {
TextLabel,
Vec,
ZERO_INDEX_KEY,
getDefaultColorTheme,
resizeBox,
structuredClone,
vecModelValidator,
} from 'tldraw'
import { useDefaultColorTheme } from 'tldraw/src/lib/shapes/shared/ShapeFill'
import { getSpeechBubbleVertices, getTailIntersectionPoint } from './helpers'
// Copied from tldraw/tldraw
@ -176,11 +176,11 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
type,
props: { color, font, size, align, text },
} = shape
const theme = getDefaultColorTheme({
isDarkMode: this.editor.user.getIsDarkMode(),
})
const vertices = getSpeechBubbleVertices(shape)
const pathData = 'M' + vertices[0] + 'L' + vertices.slice(1) + 'Z'
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
return (
<>
@ -192,7 +192,6 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
fill={'none'}
/>
</svg>
<TextLabel
id={id}
type={type}
@ -202,7 +201,8 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
align={align}
verticalAlign="start"
text={text}
labelColor={color}
labelColor={theme[color].solid}
isSelected={isSelected}
wrap
/>
</>

Wyświetl plik

@ -21,7 +21,7 @@ setDefaultEditorAssetUrls(assetUrls)
setDefaultUiAssetUrls(assetUrls)
const gettingStartedExamples = examples.find((e) => e.id === 'Getting started')
if (!gettingStartedExamples) throw new Error('Could not find getting started exmaples')
const basicExample = gettingStartedExamples.value.find((e) => e.title === 'Persistence key')
const basicExample = gettingStartedExamples.value.find((e) => e.title === 'Tldraw component')
if (!basicExample) throw new Error('Could not find initial example')
const router = createBrowserRouter([

Wyświetl plik

@ -1,3 +1,17 @@
## 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.
## 2.0.27
- Bug fixes and performance improvements.

Wyświetl plik

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

Wyświetl plik

@ -1,3 +1,4 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.8539 0.315423C24.2744 -0.105141 24.9563 -0.105141 25.3769 0.315423L29.6846 4.62312C30.1051 5.04368 30.1051 5.72555 29.6846 6.14612L21.1928 14.6379C21.0291 14.8016 20.84 14.9379 20.633 15.0414L12.1739 19.2709C11.7593 19.4782 11.2585 19.397 10.9308 19.0692C10.603 18.7414 10.5217 18.2407 10.729 17.8261L14.9586 9.367C15.0621 9.15995 15.1984 8.97093 15.362 8.80723L23.8539 0.315423ZM24.6154 2.59992L16.8851 10.3302L14.6488 14.8027L15.1973 15.3511L19.6697 13.1149L27.4001 5.38462L24.6154 2.59992ZM19.2308 2.15384L17.0769 4.30769H8.24617C7.32369 4.30769 6.69661 4.30853 6.2119 4.34813C5.73976 4.38671 5.4983 4.45663 5.32987 4.54245C4.9246 4.74894 4.5951 5.07844 4.38861 5.48371C4.30279 5.65214 4.23287 5.89359 4.19429 6.36573C4.15469 6.85044 4.15385 7.47753 4.15385 8.4V21.7538C4.15385 22.6763 4.15469 23.3034 4.19429 23.7881C4.23287 24.2603 4.30279 24.5017 4.38861 24.6701C4.5951 25.0754 4.9246 25.4049 5.32987 25.6114C5.4983 25.6972 5.73976 25.7671 6.2119 25.8057C6.69661 25.8453 7.32369 25.8462 8.24617 25.8462H21.6C22.5225 25.8462 23.1496 25.8453 23.6343 25.8057C24.1064 25.7671 24.3479 25.6972 24.5163 25.6114C24.9216 25.4049 25.2511 25.0754 25.4576 24.6701C25.5434 24.5017 25.6133 24.2603 25.6519 23.7881C25.6915 23.3034 25.6923 22.6763 25.6923 21.7538V12.923L27.8462 10.7692V21.7538V21.7983C27.8462 22.6652 27.8462 23.3807 27.7986 23.9635C27.7491 24.5688 27.643 25.1253 27.3767 25.648C26.9637 26.4585 26.3047 27.1175 25.4941 27.5305C24.9715 27.7968 24.415 27.9029 23.8097 27.9524C23.2269 28 22.5114 28 21.6445 28H21.6H8.24617H8.20166C7.33478 28 6.61932 28 6.0365 27.9524C5.43117 27.9029 4.87472 27.7968 4.35205 27.5305C3.5415 27.1175 2.88251 26.4585 2.46951 25.648C2.2032 25.1253 2.09705 24.5688 2.0476 23.9635C1.99998 23.3807 1.99999 22.6652 2 21.7984V21.7983V21.7538V8.4V8.35552V8.35546V8.35545C1.99999 7.48859 1.99998 6.77315 2.0476 6.19034C2.09705 5.585 2.2032 5.02855 2.46951 4.50589C2.88251 3.69534 3.5415 3.03635 4.35205 2.62336C4.87472 2.35704 5.43117 2.2509 6.0365 2.20144C6.61932 2.15382 7.33476 2.15383 8.20163 2.15384H8.20169H8.24617H19.2308Z" fill="black"/>
<path d="M17 27V19C17 17.8954 17.8954 17 19 17H27" stroke="black" stroke-width="2"/>
<path d="M17.5789 26.45L26.3775 18.0914C26.775 17.7138 27 17.1896 27 16.6414V5C27 3.89543 26.1046 3 25 3H5C3.89543 3 3 3.89543 3 5V25C3 26.1046 3.89543 27 5 27H16.2014C16.7141 27 17.2072 26.8031 17.5789 26.45Z" stroke="black" stroke-width="2"/>
</svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.2 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 433 B

Wyświetl plik

@ -21,7 +21,7 @@ module.exports = {
},
],
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
testRegex: '.+\\.(test|spec)\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: [
'<rootDir>/test/__fixtures__',

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

@ -71,7 +71,7 @@
]
},
"devDependencies": {
"@microsoft/api-extractor": "^7.41.0",
"@microsoft/api-extractor": "^7.43.1",
"@next/eslint-plugin-next": "^13.3.0",
"@swc/core": "^1.3.55",
"@swc/jest": "^0.2.34",

Wyświetl plik

@ -30,6 +30,12 @@
--layer-overlays: 400;
--layer-following-indicator: 1000;
--layer-blocker: 10000;
/* z index for text editors */
--layer-text-container: 1;
--layer-text-content: 3;
--layer-text-editor: 4;
/* Misc */
--tl-zoom: 1;
@ -145,11 +151,11 @@
.tl-theme__dark {
--color-accent: hsl(0, 76%, 60%);
--color-background: hsl(240, 5%, 8%);
--color-background: hsl(240, 5%, 6.5%);
--color-brush-fill: hsl(0, 0%, 71%, 5.1%);
--color-brush-stroke: hsl(0, 0%, 71%, 25.1%);
--color-grid: hsl(0, 0%, 56%);
--color-low: hsl(260, 5%, 12.5%);
--color-grid: hsl(0, 0%, 40%);
--color-low: hsl(260, 4.5%, 10.5%);
--color-low-border: hsl(207, 10%, 10%);
--color-culled: hsl(210, 11%, 19%);
--color-muted-none: hsl(0, 0%, 100%, 0%);
@ -158,10 +164,10 @@
--color-muted-2: hsl(0, 0%, 100%, 5%);
--color-hint: hsl(0, 0%, 100%, 7%);
--color-overlay: hsl(0, 0%, 0%, 50%);
--color-divider: hsl(240, 9%, 25%);
--color-panel-contrast: hsl(240, 13%, 22%);
--color-panel: hsl(220, 8%, 15%);
--color-panel-overlay: hsl(210, 11%, 24%, 82%);
--color-divider: hsl(240, 9%, 22%);
--color-panel-contrast: hsl(245, 12%, 23%);
--color-panel: hsl(235, 6.8%, 13.5%);
--color-panel-overlay: hsl(210, 10%, 24%, 82%);
--color-focus: hsl(217, 76%, 80%);
--color-selected: hsl(217, 89%, 61%);
--color-selected-contrast: hsl(0, 0%, 100%);
@ -465,7 +471,7 @@ input,
transform-origin: top left;
fill: none;
stroke-width: calc(1.5px * var(--tl-scale));
contain: size;
contain: size layout;
}
/* ------------------ SelectionBox ------------------ */
@ -549,19 +555,16 @@ input,
.tl-handle__create {
opacity: 0;
}
.tl-handle__create:hover {
opacity: 1;
.tl-handle__clone > .tl-handle__fg {
fill: var(--color-selection-stroke);
stroke: none;
}
.tl-handle__bg:active {
fill: none;
}
.tl-handle__bg:hover {
cursor: var(--tl-cursor-grab);
fill: var(--color-selection-fill);
}
@media (pointer: coarse) {
.tl-handle__bg:active {
fill: var(--color-selection-fill);
@ -790,7 +793,6 @@ input,
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
pointer-events: all;
text-rendering: auto;
text-transform: none;
text-indent: 0px;
@ -856,6 +858,12 @@ input,
cursor: var(--tl-cursor-text);
}
.tl-text-wrapper[data-isediting='false'] .tl-text-input,
.tl-arrow-label[data-isediting='false'] .tl-text-input {
opacity: 0;
cursor: var(--tl-cursor-default);
}
.tl-text-input::selection {
background: var(--color-selected);
color: var(--color-selected-contrast);
@ -967,10 +975,6 @@ input,
cursor: var(--tl-cursor-pointer);
}
.tl-bookmark__link:hover {
color: var(--color-selected);
}
/* ---------------- Hyperlink Button ---------------- */
.tl-hyperlink-button {
@ -1009,10 +1013,6 @@ input,
pointer-events: none;
}
.tl-hyperlink-button:hover {
color: var(--color-selected);
}
.tl-hyperlink-button:focus-visible {
color: var(--color-selected);
}
@ -1053,6 +1053,11 @@ input,
pointer-events: all;
}
.tl-text-wrapper .tl-text-content {
pointer-events: all;
z-index: var(--layer-text-content);
}
.tl-text-label__inner > .tl-text-content {
position: relative;
top: 0px;
@ -1062,7 +1067,6 @@ input,
width: fit-content;
border-radius: var(--radius-1);
max-width: 100%;
z-index: 3;
}
.tl-text-label__inner > .tl-text-input {
@ -1071,7 +1075,27 @@ input,
height: 100%;
width: 100%;
padding: 16px;
z-index: 4;
}
.tl-text-wrapper[data-isselected='true'] .tl-text-input {
z-index: var(--layer-text-editor);
pointer-events: all;
}
/* This part of the rule helps preserve the occlusion rules for the shapes so we
* don't click on shapes that are behind other shapes.
* One extra nuance is we don't use this behavior for:
* - arrows which have weird geometry and just gets in the way.
* - draw/line shapes, because it feels restrictive to have them be 'in the way' of clicking on a textfield
*/
.tl-canvas[data-iseditinganything='true']
.tl-shape:not(
[data-shape-type='arrow'],
[data-shape-type='draw'],
[data-shape-type='line'],
[data-shape-type='highlight']
) {
pointer-events: all;
}
.tl-text-label[data-textwrap='true'] > .tl-text-label__inner {
@ -1125,7 +1149,7 @@ input,
position: relative;
height: max-content;
width: max-content;
pointer-events: all;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
@ -1134,13 +1158,11 @@ input,
.tl-arrow-label .tl-arrow {
position: relative;
height: max-content;
z-index: 2;
padding: 4px;
overflow: visible;
}
.tl-arrow-label textarea {
z-index: 3;
padding: 4px;
/* Don't allow textarea to be zero width */
min-width: 4px;
@ -1152,27 +1174,18 @@ input,
position: relative;
width: 100%;
height: 100%;
border-radius: var(--radius-2);
box-shadow: var(--shadow-1);
overflow: hidden;
border-color: currentColor;
border-style: solid;
border-width: 1px;
pointer-events: all;
opacity: 1;
z-index: var(--layer-text-container);
border-radius: 1px;
}
.tl-note__container .tl-text-label {
.tl-note__container > .tl-text-label {
text-shadow: none;
color: currentColor;
}
.tl-note__scrim {
position: absolute;
z-index: 1;
inset: 0px;
height: 100%;
width: 100%;
background-color: var(--color-background);
opacity: 0.28;
}
/* --------------------- Loading -------------------- */
.tl-loading {
background-color: var(--color-background);
@ -1440,18 +1453,12 @@ it from receiving any pointer events or affecting the cursor. */
color: inherit;
background-color: transparent;
}
.tl-error-boundary__content button:hover {
background-color: var(--color-low);
}
.tl-error-boundary__content a {
color: var(--color-text-1);
font-weight: 500;
text-decoration: none;
}
.tl-error-boundary__content a:hover {
color: var(--color-text-1);
}
.tl-error-boundary__content__error {
position: relative;
@ -1486,11 +1493,6 @@ it from receiving any pointer events or affecting the cursor. */
background-color: var(--color-primary);
color: var(--color-selected-contrast);
}
.tl-error-boundary__content .tl-error-boundary__refresh:hover {
background-color: var(--color-primary);
opacity: 0.9;
}
/* --------------------- Coarse --------------------- */
.tl-hidden {
@ -1521,3 +1523,40 @@ it from receiving any pointer events or affecting the cursor. */
.tl-hit-test-blocker__hidden {
display: none;
}
@media (hover: hover) {
.tl-handle__create:hover {
opacity: 1;
}
.tl-handle__bg:hover {
cursor: var(--tl-cursor-grab);
fill: var(--color-selection-fill);
}
.tl-bookmark__link:hover {
color: var(--color-selected);
}
.tl-hyperlink-button:hover {
color: var(--color-selected);
}
.tl-error-boundary__content button:hover {
background-color: var(--color-low);
}
.tl-error-boundary__content a:hover {
color: var(--color-text-1);
}
.tl-error-boundary__content .tl-error-boundary__refresh:hover {
background-color: var(--color-primary);
opacity: 0.9;
}
/* These three rules help preserve clicking into specific points in text areas *while*
* already in edit mode when jumping from shape to shape. */
.tl-canvas[data-iseditinganything='true'] .tl-text-wrapper:hover .tl-text-input {
z-index: var(--layer-text-editor);
pointer-events: all;
}
}

Wyświetl plik

@ -61,10 +61,6 @@ export {
DefaultHandles,
type TLHandlesProps,
} from './lib/components/default-components/DefaultHandles'
export {
DefaultHoveredShapeIndicator,
type TLHoveredShapeIndicatorProps,
} from './lib/components/default-components/DefaultHoveredShapeIndicator'
export {
DefaultScribble,
type TLScribbleProps,
@ -124,6 +120,7 @@ export {
MAX_ZOOM,
MIN_ZOOM,
MULTI_CLICK_DURATION,
SIDES,
SVG_PADDING,
ZOOMS,
} from './lib/constants'
@ -294,6 +291,7 @@ export {
intersectPolygonBounds,
intersectPolygonPolygon,
linesIntersect,
polygonIntersectsPolyline,
polygonsIntersect,
} from './lib/primitives/intersect'
export {

Wyświetl plik

@ -1,4 +1,4 @@
import { SerializedStore, Store, StoreSnapshot } from '@tldraw/store'
import { MigrationSequence, SerializedStore, Store, StoreSnapshot } from '@tldraw/store'
import { TLRecord, TLStore } from '@tldraw/tlschema'
import { Expand, Required, annotateError } from '@tldraw/utils'
import React, {
@ -49,6 +49,7 @@ export type TldrawEditorProps = Expand<
}
| {
store?: undefined
migrations?: readonly MigrationSequence[]
snapshot?: StoreSnapshot<TLRecord>
initialData?: SerializedStore<TLRecord>
persistenceKey?: string

Wyświetl plik

@ -1,3 +1,4 @@
import classNames from 'classnames'
import * as React from 'react'
/** @public */
@ -6,7 +7,7 @@ export type HTMLContainerProps = React.HTMLAttributes<HTMLDivElement>
/** @public */
export function HTMLContainer({ children, className = '', ...rest }: HTMLContainerProps) {
return (
<div {...rest} className={`tl-html-container ${className}`}>
<div {...rest} className={classNames('tl-html-container', className)}>
{children}
</div>
)

Wyświetl plik

@ -1,3 +1,4 @@
import classNames from 'classnames'
import * as React from 'react'
/** @public */
@ -6,7 +7,7 @@ export type SVGContainerProps = React.HTMLAttributes<SVGElement>
/** @public */
export function SVGContainer({ children, className = '', ...rest }: SVGContainerProps) {
return (
<svg {...rest} className={`tl-svg-container ${className}`}>
<svg {...rest} className={classNames('tl-svg-container', className)}>
{children}
</svg>
)

Wyświetl plik

@ -116,11 +116,17 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
const debugGeometry = useValue('debug_geometry', () => debugFlags.debugGeometry.get(), [
debugFlags,
])
const isEditingAnything = useValue(
'isEditingAnything',
() => editor.getEditingShapeId() !== null,
[editor]
)
return (
<div
ref={rCanvas}
draggable={false}
data-iseditinganything={isEditingAnything}
className={classNames('tl-canvas', className)}
data-testid="canvas"
{...events}
@ -151,8 +157,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
<BrushWrapper />
<ScribbleWrapper />
<ZoomBrushWrapper />
<SelectedIdIndicators />
<HoveredShapeIndicator />
<ShapeIndicators />
<HintedShapeIndicator />
<SnapIndicatorWrapper />
<SelectionForegroundWrapper />
@ -431,16 +436,17 @@ function ShapesToDisplay() {
)
}
function SelectedIdIndicators() {
function ShapeIndicators() {
const editor = useEditor()
const selectedShapeIds = useValue('selectedShapeIds', () => editor.getSelectedShapeIds(), [
editor,
])
const shouldDisplay = useValue(
const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
const rPreviousSelectedShapeIds = useRef<Set<TLShapeId>>(new Set())
const idsToDisplay = useValue(
'should display selected ids',
() => {
// todo: move to tldraw selected ids wrapper
return (
// todo: move to tldraw selected ids wrappe
const prev = rPreviousSelectedShapeIds.current
const next = new Set<TLShapeId>()
if (
editor.isInAny(
'select.idle',
'select.brushing',
@ -449,52 +455,51 @@ function SelectedIdIndicators() {
'select.pointing_shape',
'select.pointing_selection',
'select.pointing_handle'
) && !editor.getInstanceState().isChangingStyle
)
) &&
!editor.getInstanceState().isChangingStyle
) {
const selected = editor.getSelectedShapeIds()
for (const id of selected) {
next.add(id)
}
if (editor.isInAny('select.idle', 'select.editing_shape')) {
const instanceState = editor.getInstanceState()
if (instanceState.isHoveringCanvas && !instanceState.isCoarsePointer) {
const hovered = editor.getHoveredShapeId()
if (hovered) next.add(hovered)
}
}
}
if (prev.size !== next.size) {
rPreviousSelectedShapeIds.current = next
return next
}
for (const id of next) {
if (!prev.has(id)) {
rPreviousSelectedShapeIds.current = next
return next
}
}
return prev
},
[editor]
)
const { ShapeIndicator } = useEditorComponents()
if (!ShapeIndicator) return null
if (!shouldDisplay) return null
return (
<>
{selectedShapeIds.map((id) => (
<ShapeIndicator
key={id + '_indicator'}
className="tl-user-indicator__selected"
shapeId={id}
/>
{renderingShapes.map(({ id }) => (
<ShapeIndicator key={id + '_indicator'} shapeId={id} hidden={!idsToDisplay.has(id)} />
))}
</>
)
}
const HoveredShapeIndicator = function HoveredShapeIndicator() {
const editor = useEditor()
const { HoveredShapeIndicator } = useEditorComponents()
const isCoarsePointer = useValue(
'coarse pointer',
() => editor.getInstanceState().isCoarsePointer,
[editor]
)
const isHoveringCanvas = useValue(
'hovering canvas',
() => editor.getInstanceState().isHoveringCanvas,
[editor]
)
const hoveredShapeId = useValue('hovered id', () => editor.getCurrentPageState().hoveredShapeId, [
editor,
])
if (isCoarsePointer || !isHoveringCanvas || !hoveredShapeId || !HoveredShapeIndicator) return null
return <HoveredShapeIndicator shapeId={hoveredShapeId} />
}
function HintedShapeIndicator() {
const editor = useEditor()
const { ShapeIndicator } = useEditorComponents()
@ -560,7 +565,10 @@ function DebugSvgCopy({ id }: { id: TLShapeId }) {
const isSingleFrame = editor.isShapeOfType(id, 'frame')
const padding = isSingleFrame ? 0 : 10
const bounds = editor.getShapePageBounds(id)!.clone().expandBy(padding)
let bounds = editor.getShapePageBounds(id)
if (!bounds) return
bounds = bounds.clone().expandBy(padding)
const result = await editor.getSvgString([id], {
padding,
background: editor.getInstanceState().exportBackground,

Wyświetl plik

@ -1,6 +1,6 @@
import { TLHandle, TLShapeId } from '@tldraw/tlschema'
import classNames from 'classnames'
import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS } from '../../constants'
import { COARSE_HANDLE_RADIUS, HANDLE_RADIUS, SIDES } from '../../constants'
/** @public */
export type TLHandleProps = {
@ -13,22 +13,31 @@ export type TLHandleProps = {
/** @public */
export function DefaultHandle({ handle, isCoarse, className, zoom }: TLHandleProps) {
const bgRadius = (isCoarse ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS) / zoom
const fgRadius = (handle.type === 'create' && isCoarse ? 3 : 4) / zoom
const br = (isCoarse ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS) / zoom
if (handle.type === 'clone') {
// bouba
const fr = 3 / Math.max(zoom, 0.35)
const path = `M0,${-fr} A${fr},${fr} 0 0,1 0,${fr}`
// kiki
// const fr = 4 / Math.max(zoom, 0.35)
// const path = `M0,${-fr} L${fr},0 L0,${fr} Z`
const index = SIDES.indexOf(handle.id as (typeof SIDES)[number])
return (
<g className={classNames(`tl-handle tl-handle__${handle.type}`, className)}>
<circle className="tl-handle__bg" r={br} />
{/* Half circle */}
<path className="tl-handle__fg" d={path} transform={`rotate(${-90 + 90 * index})`} />
</g>
)
}
const fr = (handle.type === 'create' && isCoarse ? 3 : 4) / Math.max(zoom, 0.35)
return (
<g
className={classNames(
'tl-handle',
{
'tl-handle__virtual': handle.type === 'virtual',
'tl-handle__create': handle.type === 'create',
},
className
)}
>
<circle className="tl-handle__bg" r={bgRadius} />
<circle className="tl-handle__fg" r={fgRadius} />
<g className={classNames(`tl-handle tl-handle__${handle.type}`, className)}>
<circle className="tl-handle__bg" r={br} />
<circle className="tl-handle__fg" r={fr} />
</g>
)
}

Wyświetl plik

@ -1,14 +0,0 @@
import { TLShapeId } from '@tldraw/tlschema'
import { useEditorComponents } from '../../hooks/useEditorComponents'
/** @public */
export type TLHoveredShapeIndicatorProps = {
shapeId: TLShapeId
}
/** @public */
export function DefaultHoveredShapeIndicator({ shapeId }: TLHoveredShapeIndicatorProps) {
const { ShapeIndicator } = useEditorComponents()
if (!ShapeIndicator) return null
return <ShapeIndicator className="tl-user-indicator__hovered" shapeId={shapeId} />
}

Wyświetl plik

@ -1,7 +1,7 @@
import { useStateTracking, useValue } from '@tldraw/state'
import { useQuickReactor, useStateTracking, useValue } from '@tldraw/state'
import { TLShape, TLShapeId } from '@tldraw/tlschema'
import classNames from 'classnames'
import { memo } from 'react'
import { memo, useLayoutEffect, useRef } from 'react'
import type { Editor } from '../../editor/Editor'
import { ShapeUtil } from '../../editor/shapes/ShapeUtil'
import { useEditor } from '../../hooks/useEditor'
@ -38,6 +38,7 @@ export type TLShapeIndicatorProps = {
color?: string | undefined
opacity?: number
className?: string
hidden?: boolean
}
/** @public */
@ -45,28 +46,34 @@ export const DefaultShapeIndicator = memo(function DefaultShapeIndicator({
shapeId,
className,
color,
hidden,
opacity,
}: TLShapeIndicatorProps) {
const editor = useEditor()
const transform = useValue(
const rIndicator = useRef<SVGSVGElement>(null)
useQuickReactor(
'indicator transform',
() => {
const elm = rIndicator.current
if (!elm) return
const pageTransform = editor.getShapePageTransform(shapeId)
if (!pageTransform) return ''
return pageTransform.toCssString()
if (!pageTransform) return
elm.style.setProperty('transform', pageTransform.toCssString())
},
[editor, shapeId]
)
useLayoutEffect(() => {
const elm = rIndicator.current
if (!elm) return
elm.style.setProperty('display', hidden ? 'none' : 'block')
}, [hidden])
return (
<svg className={classNames('tl-overlays__item', className)}>
<g
className="tl-shape-indicator"
transform={transform}
stroke={color ?? 'var(--color-selected)'}
opacity={opacity}
>
<svg ref={rIndicator} className={classNames('tl-overlays__item', className)}>
<g className="tl-shape-indicator" stroke={color ?? 'var(--color-selected)'} opacity={opacity}>
<InnerIndicator editor={editor} id={shapeId} />
</g>
</svg>

Wyświetl plik

@ -1,11 +1,5 @@
import { Signal, computed, transact } from '@tldraw/state'
import {
RecordsDiff,
UnknownRecord,
defineMigrations,
migrate,
squashRecordDiffs,
} from '@tldraw/store'
import { RecordsDiff, UnknownRecord, squashRecordDiffs } from '@tldraw/store'
import {
CameraRecordType,
InstancePageStateRecordType,
@ -22,6 +16,7 @@ import {
getFromSessionStorage,
objectMapFromEntries,
setInSessionStorage,
structuredClone,
} from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { uniqueId } from '../utils/uniqueId'
@ -79,7 +74,18 @@ const Versions = {
Initial: 0,
} as const
const CURRENT_SESSION_STATE_SNAPSHOT_VERSION = Versions.Initial
const CURRENT_SESSION_STATE_SNAPSHOT_VERSION = Math.max(...Object.values(Versions))
function migrate(snapshot: any) {
if (snapshot.version < Versions.Initial) {
// initial version
// noop
}
// add further migrations down here. see TLUserPreferences.ts for an example.
// finally
snapshot.version = CURRENT_SESSION_STATE_SNAPSHOT_VERSION
}
/**
* The state of the editor instance, not including any document state.
@ -124,10 +130,6 @@ const sessionStateSnapshotValidator: T.Validator<TLSessionStateSnapshot> = T.obj
),
})
const sessionStateSnapshotMigrations = defineMigrations({
currentVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
})
function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateSnapshot | null {
if (!state || typeof state !== 'object') {
console.warn('Invalid instance state')
@ -137,27 +139,17 @@ function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateS
console.warn('No version in instance state')
return null
}
const result = migrate<TLSessionStateSnapshot>({
value: state,
fromVersion: state.version,
toVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
migrations: sessionStateSnapshotMigrations,
})
if (result.type === 'error') {
console.warn(result.reason)
return null
if (state.version !== CURRENT_SESSION_STATE_SNAPSHOT_VERSION) {
state = structuredClone(state)
migrate(state)
}
const value = { ...result.value, version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION }
try {
sessionStateSnapshotValidator.validate(value)
return sessionStateSnapshotValidator.validate(state)
} catch (e) {
console.warn(e)
return null
}
return value
}
/**

Wyświetl plik

@ -1,7 +1,6 @@
import { atom } from '@tldraw/state'
import { defineMigrations, migrate } from '@tldraw/store'
import { getDefaultTranslationLocale } from '@tldraw/tlschema'
import { getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
import { getFromLocalStorage, setInLocalStorage, structuredClone } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { uniqueId } from '../utils/uniqueId'
@ -55,66 +54,28 @@ const Versions = {
AddExcalidrawSelectMode: 5,
} as const
const userMigrations = defineMigrations({
currentVersion: Versions.AddExcalidrawSelectMode,
migrators: {
[Versions.AddAnimationSpeed]: {
up: (user) => {
return {
...user,
animationSpeed: 1,
}
},
down: ({ animationSpeed: _, ...user }) => {
return user
},
},
[Versions.AddIsSnapMode]: {
up: (user: TLUserPreferences) => {
return { ...user, isSnapMode: false }
},
down: ({ isSnapMode: _, ...user }: TLUserPreferences) => {
return user
},
},
[Versions.MakeFieldsNullable]: {
up: (user: TLUserPreferences) => {
return user
},
down: (user: TLUserPreferences) => {
return {
id: user.id,
name: user.name ?? defaultUserPreferences.name,
locale: user.locale ?? defaultUserPreferences.locale,
color: user.color ?? defaultUserPreferences.color,
animationSpeed: user.animationSpeed ?? defaultUserPreferences.animationSpeed,
isDarkMode: user.isDarkMode ?? defaultUserPreferences.isDarkMode,
isSnapMode: user.isSnapMode ?? defaultUserPreferences.isSnapMode,
isWrapMode: user.isWrapMode ?? defaultUserPreferences.isWrapMode,
}
},
},
[Versions.AddEdgeScrollSpeed]: {
up: (user: TLUserPreferences) => {
return {
...user,
edgeScrollSpeed: 1,
}
},
down: ({ edgeScrollSpeed: _, ...user }: TLUserPreferences) => {
return user
},
},
[Versions.AddExcalidrawSelectMode]: {
up: (user: TLUserPreferences) => {
return { ...user, isWrapMode: false }
},
down: ({ isWrapMode: _, ...user }: TLUserPreferences) => {
return user
},
},
},
})
const CURRENT_VERSION = Math.max(...Object.values(Versions))
function migrateSnapshot(data: { version: number; user: any }) {
if (data.version < Versions.AddAnimationSpeed) {
data.user.animationSpeed = 1
}
if (data.version < Versions.AddIsSnapMode) {
data.user.isSnapMode = false
}
if (data.version < Versions.MakeFieldsNullable) {
// noop
}
if (data.version < Versions.AddEdgeScrollSpeed) {
data.user.edgeScrollSpeed = 1
}
if (data.version < Versions.AddExcalidrawSelectMode) {
data.user.isWrapMode = false
}
// finally
data.version = CURRENT_VERSION
}
/** @internal */
export const USER_COLORS = [
@ -171,7 +132,7 @@ export function getFreshUserPreferences(): TLUserPreferences {
}
}
function migrateUserPreferences(userData: unknown) {
function migrateUserPreferences(userData: unknown): TLUserPreferences {
if (userData === null || typeof userData !== 'object') {
return getFreshUserPreferences()
}
@ -180,24 +141,15 @@ function migrateUserPreferences(userData: unknown) {
return getFreshUserPreferences()
}
const migrationResult = migrate<TLUserPreferences>({
value: userData.user,
fromVersion: userData.version,
toVersion: userMigrations.currentVersion ?? 0,
migrations: userMigrations,
})
const snapshot = structuredClone(userData) as any
if (migrationResult.type === 'error') {
return getFreshUserPreferences()
}
migrateSnapshot(snapshot)
try {
userTypeValidator.validate(migrationResult.value)
return userTypeValidator.validate(snapshot.user)
} catch (e) {
return getFreshUserPreferences()
}
return migrationResult.value
}
function loadUserPreferences(): TLUserPreferences {
@ -212,7 +164,10 @@ const globalUserPreferences = atom<TLUserPreferences | null>('globalUserData', n
function storeUserPreferences() {
setInLocalStorage(
USER_DATA_KEY,
JSON.stringify({ version: userMigrations.currentVersion, user: globalUserPreferences.get() })
JSON.stringify({
version: CURRENT_VERSION,
user: globalUserPreferences.get(),
})
)
}
@ -253,7 +208,7 @@ function broadcastUserPreferencesChange() {
origin: getBroadcastOrigin(),
data: {
user: getUserPreferences(),
version: userMigrations.currentVersion,
version: CURRENT_VERSION,
},
} satisfies UserChangeBroadcastMessage)
}

Wyświetl plik

@ -1,4 +1,4 @@
import { HistoryEntry, SerializedStore, Store, StoreSchema } from '@tldraw/store'
import { HistoryEntry, MigrationSequence, SerializedStore, Store, StoreSchema } from '@tldraw/store'
import {
SchemaShapeInfo,
TLRecord,
@ -16,7 +16,7 @@ export type TLStoreOptions = {
defaultName?: string
id?: string
} & (
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[] }
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[]; migrations?: readonly MigrationSequence[] }
| { schema?: StoreSchema<TLRecord, TLStoreProps> }
)
@ -44,6 +44,7 @@ export function createTLStore({
shapes: currentPageShapesToShapeMap(
checkShapesAndAddCore('shapeUtils' in rest && rest.shapeUtils ? rest.shapeUtils : [])
),
migrations: 'migrations' in rest ? rest.migrations : [],
})
return new Store({

Wyświetl plik

@ -105,6 +105,9 @@ export const COARSE_HANDLE_RADIUS = 20
/** @internal */
export const HANDLE_RADIUS = 12
/** @public */
export const SIDES = ['top', 'right', 'bottom', 'left'] as const
/** @internal */
export const LONG_PRESS_DURATION = 500

Wyświetl plik

@ -36,7 +36,6 @@ import {
createShapeId,
getShapePropKeysByStyle,
isPageId,
isShape,
isShapeId,
} from '@tldraw/tlschema'
import {
@ -60,7 +59,6 @@ import {
import { EventEmitter } from 'eventemitter3'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { TLUser, createTLUser } from '../config/createTLUser'
import { checkShapesAndAddCore } from '../config/defaultShapes'
import {
@ -225,24 +223,6 @@ export class Editor extends EventEmitter<TLEventMap> {
const allShapeUtils = checkShapesAndAddCore(shapeUtils)
const shapeTypesInSchema = new Set(
Object.keys(store.schema.types.shape.migrations.subTypeMigrations!)
)
for (const shapeUtil of allShapeUtils) {
if (!shapeTypesInSchema.has(shapeUtil.type)) {
throw Error(
`Editor and store have different shapes: "${shapeUtil.type}" was passed into the editor but not the schema`
)
}
shapeTypesInSchema.delete(shapeUtil.type)
}
if (shapeTypesInSchema.size > 0) {
throw Error(
`Editor and store have different shapes: "${
[...shapeTypesInSchema][0]
}" is present in the store schema but not provided to the editor`
)
}
const _shapeUtils = {} as Record<string, ShapeUtil<any>>
const _styleProps = {} as Record<string, Map<StyleProp<unknown>, string>>
const allStylesById = new Map<string, StyleProp<unknown>>()
@ -832,6 +812,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
undo(): this {
this._flushEventsForTick(0)
this.history.undo()
return this
}
@ -856,6 +837,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
redo(): this {
this._flushEventsForTick(0)
this.history.redo()
return this
}
@ -919,19 +901,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
/**
* Run a function in a batch, which will be undone/redone as a single action.
*
* @example
* ```ts
* editor.batch(() => {
* editor.selectAll()
* editor.deleteShapes(editor.getSelectedShapeIds())
* editor.createShapes(myShapes)
* editor.selectNone()
* })
*
* editor.undo() // will undo all of the above
* ```
* Run a function in a batch.
*
* @public
*/
@ -1356,7 +1326,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @example
* ```ts
* editor.isMenuOpen()
* editor.getIsMenuOpen()
* ```
*
* @public
@ -1479,18 +1449,15 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
setSelectedShapes(shapes: TLShapeId[] | TLShape[]): this {
return this.batch(
() => {
const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id))
const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState()
const prevSet = new Set(prevSelectedShapeIds)
return this.batch(() => {
const ids = shapes.map((shape) => (typeof shape === 'string' ? shape : shape.id))
const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState()
const prevSet = new Set(prevSelectedShapeIds)
if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return
if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return null
this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }])
},
{ history: 'record-preserveRedoStack' }
)
this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }])
})
}
/**
@ -1589,11 +1556,22 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
/**
* The id of the app's only selected shape.
*
* @returns Null if there is no shape or more than one selected shape, otherwise the selected shape's id.
*
* @public
* @readonly
*/
@computed getOnlySelectedShapeId(): TLShapeId | null {
return this.getOnlySelectedShape()?.id ?? null
}
/**
* The app's only selected shape.
*
* @returns Null if there is no shape or more than one selected shape, otherwise the selected
* shape.
* @returns Null if there is no shape or more than one selected shape, otherwise the selected shape.
*
* @public
* @readonly
@ -2608,15 +2586,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
@ -2863,6 +2833,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
@ -2874,9 +2883,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()
@ -2885,7 +2894,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
}
@ -2904,7 +2913,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
})
@ -3261,6 +3270,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.
*
@ -3689,7 +3706,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()
}
@ -3832,22 +3849,25 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
getShapeMaskedPageBounds(shape: TLShapeId | TLShape): Box | undefined {
if (typeof shape !== 'string') shape = shape.id
const pageBounds = this._getShapePageBoundsCache().get(shape)
if (!pageBounds) return
const pageMask = this._getShapeMaskCache().get(shape)
if (pageMask) {
if (pageMask.length === 0) return undefined
return this._getShapeMaskedPageBoundsCache().get(shape)
}
const { corners } = pageBounds
if (corners.every((p, i) => p && Vec.Equals(p, pageMask[i]))) return pageBounds.clone()
// todo: find out why intersect polygon polygon for identical polygons produces zero w/h intersections
const intersection = intersectPolygonPolygon(pageMask, corners)
if (!intersection) return
return Box.FromPoints(intersection)
}
return pageBounds
/** @internal */
@computed private _getShapeMaskedPageBoundsCache(): ComputedCache<Box, TLShape> {
return this.store.createComputedCache('shapeMaskedPageBoundsCache', (shape) => {
const pageBounds = this._getShapePageBoundsCache().get(shape.id)
if (!pageBounds) return
const pageMask = this._getShapeMaskCache().get(shape.id)
if (pageMask) {
if (pageMask.length === 0) return undefined
const { corners } = pageBounds
if (corners.every((p, i) => p && Vec.Equals(p, pageMask[i]))) return pageBounds.clone()
const intersection = intersectPolygonPolygon(pageMask, corners)
if (!intersection) return
return Box.FromPoints(intersection)
}
return pageBounds
})
}
/**
@ -4020,7 +4040,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) {
@ -4062,6 +4082,8 @@ export class Editor extends EventEmitter<TLEventMap> {
renderingOnly?: boolean
margin?: number
hitInside?: boolean
// TODO: we probably need to rename this, we don't quite _always_
// respect this esp. in the part below that does "Check labels first"
hitLabels?: boolean
hitFrameInside?: boolean
filter?: (shape: TLShape) => boolean
@ -4265,7 +4287,6 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @public
*/
isPointInShape(
shape: TLShape | TLShapeId,
point: VecLike,
@ -4348,31 +4369,14 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
@computed getCurrentPageShapesSorted(): TLShape[] {
// todo: consider making into a function call that includes options for selected-only, rendering, etc.
// todo: consider making a derivation or something, or merging with rendering shapes
const shapes = new Set(this.getCurrentPageShapes().sort(sortByIndex))
const result: TLShape[] = []
const topLevelShapes = this.getSortedChildIdsForParent(this.getCurrentPageId())
const results: TLShape[] = []
function pushShapeWithDescendants(shape: TLShape): void {
results.push(shape)
shapes.delete(shape)
shapes.forEach((otherShape) => {
if (otherShape.parentId === shape.id) {
pushShapeWithDescendants(otherShape)
}
})
for (let i = 0, n = topLevelShapes.length; i < n; i++) {
pushShapeWithDescendants(this, topLevelShapes[i], result)
}
shapes.forEach((shape) => {
const parent = this.getShape(shape.parentId)
if (!isShape(parent)) {
pushShapeWithDescendants(shape)
}
})
return results
return result
}
/**
@ -4408,7 +4412,8 @@ export class Editor extends EventEmitter<TLEventMap> {
arg: TLUnknownShape | TLUnknownShape['id'],
type: T['type']
) {
const shape = typeof arg === 'string' ? this.getShape(arg)! : arg
const shape = typeof arg === 'string' ? this.getShape(arg) : arg
if (!shape) return false
return shape.type === type
}
@ -4767,6 +4772,8 @@ export class Editor extends EventEmitter<TLEventMap> {
const shape = currentPageShapesSorted[i]
if (
// don't allow dropping on selected shapes
this.getSelectedShapeIds().includes(shape.id) ||
// only allow shapes that can receive children
!this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) ||
// don't allow dropping a shape on itself or one of it's children
@ -6809,7 +6816,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @example
* ```ts
* editor.deleteShapes(['box1', 'box2'])
* editor.deleteShape(shape.id)
* ```
*
* @param id - The id of the shape to delete.
@ -7707,6 +7714,33 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
/**
* Get an exported SVG element of the given shapes.
*
* @param ids - The shapes (or shape ids) to export.
* @param opts - Options for the export.
*
* @returns The SVG element.
*
* @public
*/
async getSvgElement(shapes: TLShapeId[] | TLShape[], opts = {} as Partial<TLSvgOptions>) {
const result = await getSvgJsx(this, shapes, opts)
if (!result) return undefined
const fragment = document.createDocumentFragment()
const root = createRoot(fragment)
flushSync(() => {
root.render(result.jsx)
})
const svg = fragment.firstElementChild
assert(svg instanceof SVGSVGElement, 'Expected an SVG element')
root.unmount()
return { svg, width: result.width, height: result.height }
}
/**
* Get an exported SVG string of the given shapes.
*
@ -7718,21 +7752,22 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
async getSvgString(shapes: TLShapeId[] | TLShape[], opts = {} as Partial<TLSvgOptions>) {
const svg = await getSvgJsx(this, shapes, opts)
if (!svg) return undefined
return { svg: renderToStaticMarkup(svg.jsx), width: svg.width, height: svg.height }
const result = await this.getSvgElement(shapes, opts)
if (!result) return undefined
const serializer = new XMLSerializer()
return {
svg: serializer.serializeToString(result.svg),
width: result.width,
height: result.height,
}
}
/** @deprecated Use {@link Editor.getSvgString} instead */
/** @deprecated Use {@link Editor.getSvgString} or {@link Editor.getSvgElement} instead. */
async getSvg(shapes: TLShapeId[] | TLShape[], opts = {} as Partial<TLSvgOptions>) {
const svg = await getSvgJsx(this, shapes, opts)
if (!svg) return undefined
const fragment = new DocumentFragment()
const root = createRoot(fragment)
flushSync(() => root.render(svg.jsx))
const rendered = fragment.firstElementChild
root.unmount()
return rendered as SVGSVGElement
const result = await this.getSvgElement(shapes, opts)
if (!result) return undefined
return result.svg
}
/* --------------------- Events --------------------- */
@ -7789,15 +7824,20 @@ export class Editor extends EventEmitter<TLEventMap> {
private _updateInputsFromEvent(
info: TLPointerEventInfo | TLPinchEventInfo | TLWheelEventInfo
): void {
const { previousScreenPoint, previousPagePoint, currentScreenPoint, currentPagePoint } =
this.inputs
const {
pointerVelocity,
previousScreenPoint,
previousPagePoint,
currentScreenPoint,
currentPagePoint,
} = this.inputs
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const { x: cx, y: cy, z: cz } = this.getCamera()
const { x: cx, y: cy, z: cz } = this.store.unsafeGetWithoutCapture(this.getCameraId())!
const sx = info.point.x - screenBounds.x
const sy = info.point.y - screenBounds.y
const sz = info.point.z
const sz = info.point.z ?? 0.5
previousScreenPoint.setTo(currentScreenPoint)
previousPagePoint.setTo(currentPagePoint)
@ -7807,33 +7847,36 @@ 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 ?? 0.5)
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
// Reset velocity on pointer down
if (info.name === 'pointer_down') {
this.inputs.pointerVelocity.set(0, 0)
// Reset velocity on pointer down, or when a pinch starts or ends
if (info.name === 'pointer_down' || this.inputs.isPinching) {
pointerVelocity.set(0, 0)
}
// todo: We only have to do this if there are multiple users in the document
this.history.ignore(() => {
this.store.put([
{
id: TLPOINTER_ID,
typeName: 'pointer',
x: currentPagePoint.x,
y: currentPagePoint.y,
lastActivityTimestamp:
// If our pointer moved only because we're following some other user, then don't
// update our last activity timestamp; otherwise, update it to the current timestamp.
info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE
? this.store.get(TLPOINTER_ID)?.lastActivityTimestamp ?? Date.now()
: Date.now(),
meta: {},
},
])
})
this.store.put([
{
id: TLPOINTER_ID,
typeName: 'pointer',
x: currentPagePoint.x,
y: currentPagePoint.y,
lastActivityTimestamp:
// If our pointer moved only because we're following some other user, then don't
// update our last activity timestamp; otherwise, update it to the current timestamp.
info.type === 'pointer' && info.pointerId === INTERNAL_POINTER_IDS.CAMERA_MOVE
? this.store.unsafeGetWithoutCapture(TLPOINTER_ID)?.lastActivityTimestamp ??
this._tickManager.now
: this._tickManager.now,
meta: {},
},
])
}
/**
@ -8001,7 +8044,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _pendingEventsForNextTick: TLEventInfo[] = []
private _flushEventsForTick = (elapsed: number) => {
private _flushEventsForTick(elapsed: number) {
this.batch(() => {
if (this._pendingEventsForNextTick.length > 0) {
const events = [...this._pendingEventsForNextTick]
@ -8275,6 +8318,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
if (this.inputs.isPanning && this.inputs.isPointing) {
clearTimeout(this._longPressTimeout)
// Handle panning
const { currentScreenPoint, previousScreenPoint } = this.inputs
this.pan(Vec.Sub(currentScreenPoint, previousScreenPoint))
@ -8500,3 +8544,13 @@ function applyPartialToShape<T extends TLShape>(prev: T, partial?: TLShapePartia
if (!next) return prev
return next
}
function pushShapeWithDescendants(editor: Editor, id: TLShapeId, result: TLShape[]): void {
const shape = editor.getShape(id)
if (!shape) return
result.push(shape)
const childIds = editor.getSortedChildIdsForParent(id)
for (let i = 0, n = childIds.length; i < n; i++) {
pushShapeWithDescendants(editor, childIds[i], result)
}
}

Some files were not shown because too many files have changed in this diff Show More