@tldraw/editor 5.5.2 → 5.6.0-canary.0091c91994d6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +167 -2
- package/dist-cjs/index.js +7 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +18 -5
- package/dist-cjs/lib/TldrawEditor.js.map +3 -3
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +57 -15
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
- package/dist-cjs/lib/config/TLI18n.js +64 -0
- package/dist-cjs/lib/config/TLI18n.js.map +7 -0
- package/dist-cjs/lib/editor/Editor.js +44 -16
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +10 -3
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +1 -1
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +19 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +7 -1
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js +28 -0
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +3 -0
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/shared/resizeBox.js +21 -16
- package/dist-cjs/lib/editor/shapes/shared/resizeBox.js.map +2 -2
- package/dist-cjs/lib/hooks/useCursor.js +12 -8
- package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
- package/dist-cjs/lib/hooks/useDocumentEvents.js +28 -3
- package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
- package/dist-cjs/lib/license/LicenseManager.js +64 -1
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/options.js +1 -1
- package/dist-cjs/lib/options.js.map +1 -1
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +12 -5
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +167 -2
- package/dist-esm/index.mjs +13 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +18 -5
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +61 -16
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
- package/dist-esm/lib/config/TLI18n.mjs +44 -0
- package/dist-esm/lib/config/TLI18n.mjs.map +7 -0
- package/dist-esm/lib/editor/Editor.mjs +46 -17
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +10 -3
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +1 -1
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +20 -3
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +7 -1
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +28 -0
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +3 -0
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs +21 -16
- package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCursor.mjs +12 -8
- package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
- package/dist-esm/lib/hooks/useDocumentEvents.mjs +28 -3
- package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseManager.mjs +64 -1
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/options.mjs +1 -1
- package/dist-esm/lib/options.mjs.map +1 -1
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +13 -6
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +1 -1
- package/package.json +17 -19
- package/src/index.ts +14 -2
- package/src/lib/TldrawEditor.tsx +44 -4
- package/src/lib/components/default-components/DefaultErrorFallback.tsx +66 -18
- package/src/lib/config/TLI18n.test.ts +84 -0
- package/src/lib/config/TLI18n.ts +172 -0
- package/src/lib/editor/Editor.ts +90 -33
- package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +19 -3
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +17 -0
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +2 -2
- package/src/lib/editor/managers/FontManager/FontManager.test.ts +1 -0
- package/src/lib/editor/managers/FontManager/FontManager.ts +26 -3
- package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
- package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
- package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +16 -7
- package/src/lib/editor/shapes/ShapeUtil.ts +30 -0
- package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +4 -0
- package/src/lib/editor/shapes/shared/resizeBox.ts +21 -16
- package/src/lib/hooks/useCursor.ts +14 -7
- package/src/lib/hooks/useDocumentEvents.ts +51 -3
- package/src/lib/license/LicenseManager.test.ts +220 -0
- package/src/lib/license/LicenseManager.ts +89 -1
- package/src/lib/options.ts +1 -1
- package/src/lib/test/useCursor.test.tsx +36 -0
- package/src/lib/test/useDocumentEvents.test.tsx +153 -22
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +73 -0
- package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -8
- package/src/version.ts +3 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { atom } from '@tldraw/state'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Values substituted into a message's ICU placeholders.
|
|
5
|
+
*
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
8
|
+
export type TLI18nValues = Record<
|
|
9
|
+
string,
|
|
10
|
+
string | number | bigint | boolean | Date | null | undefined
|
|
11
|
+
>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The editor's view of the host app's translations. Lets code with no access to React context —
|
|
15
|
+
* a {@link ShapeUtil}, say — translate a string in the user's locale.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
export interface TLI18n {
|
|
20
|
+
locale: string
|
|
21
|
+
dir: 'ltr' | 'rtl'
|
|
22
|
+
/**
|
|
23
|
+
* Translate `message` in the current locale, substituting `values` into its ICU placeholders.
|
|
24
|
+
*
|
|
25
|
+
* Takes the whole message rather than its id so an implementation can fall back to the English
|
|
26
|
+
* and still substitute into it. A message absent from the catalog — anything not yet through
|
|
27
|
+
* Lokalise — would otherwise have to come back as its own source text, placeholders and all.
|
|
28
|
+
*/
|
|
29
|
+
translate(message: TLI18nMessage, values?: TLI18nValues): string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Supplies a {@link TLI18n}. Called on each use rather than held as a value, so the editor reads
|
|
34
|
+
* the current locale instead of pinning whichever one was active when it was constructed.
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
export type TLI18nAdapter = () => TLI18n
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The i18n used when the host app supplies none: every message comes back as its own English.
|
|
42
|
+
*
|
|
43
|
+
* Placeholders are left as they are, because formatting ICU needs a formatter and this package
|
|
44
|
+
* deliberately has none — it is the floor for an editor running with no translations at all.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
export function defaultI18n(): TLI18n {
|
|
49
|
+
return {
|
|
50
|
+
locale: 'en',
|
|
51
|
+
dir: 'ltr',
|
|
52
|
+
translate(message: TLI18nMessage) {
|
|
53
|
+
return message.defaultMessage
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One translatable message: a stable `id` naming it in the catalog, and the English it falls back
|
|
60
|
+
* to. `defaultMessage` is required, because this package's strings have to read correctly with no
|
|
61
|
+
* translations loaded at all — `@tldraw/editor` can be used without any of tldraw's UI.
|
|
62
|
+
*
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
export interface TLI18nMessage {
|
|
66
|
+
id: string
|
|
67
|
+
defaultMessage: string
|
|
68
|
+
description?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A message as it's written. The id is optional because a bundler plugin may supply it:
|
|
73
|
+
* `@formatjs/unplugin` with an `idInterpolationPattern` derives one from the message's own text,
|
|
74
|
+
* which is how tldraw.com does it. Everywhere downstream takes a {@link TLI18nMessage}, whose id
|
|
75
|
+
* is required, because {@link defineMessages} fills one in.
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
export type TLI18nMessageInput = Omit<TLI18nMessage, 'id'> & { id?: string }
|
|
80
|
+
|
|
81
|
+
const declaredMessages: Record<string, string> = {}
|
|
82
|
+
// Bumped per `defineMessages` call, so a reader re-renders when a chunk loaded after mount — a
|
|
83
|
+
// dynamically imported @tldraw/commenting, say — declares messages its labels look up by id.
|
|
84
|
+
const declaredVersion = atom('declaredMessagesVersion', 0)
|
|
85
|
+
let snapshot: Readonly<Record<string, string>> = {}
|
|
86
|
+
let snapshotVersion = -1
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Declares messages so the extractor can find them, next to the UI that shows them — the English
|
|
90
|
+
* then lives with the code rather than in a catalog edited by hand. Call it by this name:
|
|
91
|
+
* `formatjs extract` matches the call by name, wherever it was imported from, so a wrapper under
|
|
92
|
+
* another name extracts nothing.
|
|
93
|
+
*
|
|
94
|
+
* Write the id yourself, as the SDK's own messages do — an app overrides by id, so an id has to
|
|
95
|
+
* survive a reworded English string. Leave it out only where a bundler plugin injects one; with no
|
|
96
|
+
* plugin either the English stands in, so the message still renders as itself. An id missing from
|
|
97
|
+
* a real build is caught by `pnpm i18n-extract --check`.
|
|
98
|
+
*
|
|
99
|
+
* The type parameter is `const` so each written `id` keeps its literal type, which is what lets a
|
|
100
|
+
* declared id go where a `TLUiTranslationKey` is expected — a menu item's `label`, say — instead
|
|
101
|
+
* of widening to `string` and needing a cast.
|
|
102
|
+
*
|
|
103
|
+
* For a message rendered as an element, write it inline on the `<F>` instead; the extractor reads
|
|
104
|
+
* those props directly. This is for the ones that can't be: a label the UI takes as data, and
|
|
105
|
+
* anything translated as a string through `useTranslation`.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
*
|
|
109
|
+
* ```ts
|
|
110
|
+
* const messages = defineMessages({
|
|
111
|
+
* copy: { id: 'action.copy', defaultMessage: 'Copy' },
|
|
112
|
+
* })
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* @public
|
|
116
|
+
*/
|
|
117
|
+
export function defineMessages<const Messages extends Record<string, TLI18nMessageInput>>(
|
|
118
|
+
msgs: Messages
|
|
119
|
+
): { [K in keyof Messages]: Messages[K] & TLI18nMessage } {
|
|
120
|
+
const withIds = {} as { [K in keyof Messages]: Messages[K] & TLI18nMessage }
|
|
121
|
+
for (const key in msgs) {
|
|
122
|
+
const message = msgs[key]
|
|
123
|
+
const withId = (
|
|
124
|
+
message.id ? message : { ...message, id: message.defaultMessage }
|
|
125
|
+
) as Messages[typeof key] & TLI18nMessage
|
|
126
|
+
withIds[key] = withId
|
|
127
|
+
declaredMessages[withId.id] = withId.defaultMessage
|
|
128
|
+
}
|
|
129
|
+
declaredVersion.update((v) => v + 1)
|
|
130
|
+
return withIds
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The English of every message declared so far, by id. This is the English catalog: building it
|
|
135
|
+
* from the declarations means it isn't bundled a second time as a generated file. A key looked up
|
|
136
|
+
* by id alone — a menu label, or an app calling `msg('action.copy')` — resolves against it.
|
|
137
|
+
*
|
|
138
|
+
* Declarations run as their modules load, so this is complete once the package has been imported.
|
|
139
|
+
* Reactive: read it inside `useValue` or a `computed` to pick up a module that loads later. The
|
|
140
|
+
* returned object changes identity only when something new is declared.
|
|
141
|
+
*
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
export function getDeclaredMessages(): Readonly<Record<string, string>> {
|
|
145
|
+
const version = declaredVersion.get()
|
|
146
|
+
if (version !== snapshotVersion) {
|
|
147
|
+
snapshot = { ...declaredMessages }
|
|
148
|
+
snapshotVersion = version
|
|
149
|
+
}
|
|
150
|
+
return snapshot
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Translates `message` through an editor's i18n, falling back to its English.
|
|
155
|
+
*
|
|
156
|
+
* Takes the editor's i18n rather than reading a context, so it works in the places this package
|
|
157
|
+
* has to keep working: a `ShapeUtil` with no hooks available, or an error fallback rendered
|
|
158
|
+
* outside the editor it's reporting on.
|
|
159
|
+
*
|
|
160
|
+
* With no i18n at all the English is returned unformatted, for the reason given on
|
|
161
|
+
* {@link defaultI18n}. Every other case goes through the adapter, which does substitute.
|
|
162
|
+
*
|
|
163
|
+
* @public
|
|
164
|
+
*/
|
|
165
|
+
export function translateMessage(
|
|
166
|
+
i18n: TLI18n | null | undefined,
|
|
167
|
+
message: TLI18nMessage,
|
|
168
|
+
values?: TLI18nValues
|
|
169
|
+
): string {
|
|
170
|
+
if (!i18n) return message.defaultMessage
|
|
171
|
+
return i18n.translate(message, values)
|
|
172
|
+
}
|
package/src/lib/editor/Editor.ts
CHANGED
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
getSnapshot,
|
|
108
108
|
loadSnapshot,
|
|
109
109
|
} from '../config/TLEditorSnapshot'
|
|
110
|
+
import { TLI18nAdapter, defaultI18n } from '../config/TLI18n'
|
|
110
111
|
import {
|
|
111
112
|
DEFAULT_ANIMATION_OPTIONS,
|
|
112
113
|
DEFAULT_CAMERA_OPTIONS,
|
|
@@ -137,6 +138,7 @@ import {
|
|
|
137
138
|
areAnglesCompatible,
|
|
138
139
|
clamp,
|
|
139
140
|
pointInPolygon,
|
|
141
|
+
shortAngleDist,
|
|
140
142
|
} from '../primitives/utils'
|
|
141
143
|
import { Vec, VecLike } from '../primitives/Vec'
|
|
142
144
|
import { areShapesContentEqual } from '../utils/areShapesContentEqual'
|
|
@@ -265,6 +267,11 @@ export interface TLEditorOptions {
|
|
|
265
267
|
* given, the body element will be used.
|
|
266
268
|
*/
|
|
267
269
|
getContainer(): HTMLElement
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* An i18n adapter for translating strings.
|
|
273
|
+
*/
|
|
274
|
+
i18n?: TLI18nAdapter
|
|
268
275
|
/**
|
|
269
276
|
* Provides a way to hide shapes.
|
|
270
277
|
*
|
|
@@ -372,9 +379,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
372
379
|
fontAssetUrls,
|
|
373
380
|
themes,
|
|
374
381
|
initialTheme,
|
|
382
|
+
i18n,
|
|
375
383
|
}: TLEditorOptions) {
|
|
376
384
|
super()
|
|
377
385
|
|
|
386
|
+
this.i18n = i18n ?? defaultI18n
|
|
378
387
|
this._getShapeVisibility = getShapeVisibility
|
|
379
388
|
|
|
380
389
|
// Merge deprecated textOptions prop with options.text
|
|
@@ -1017,6 +1026,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
1017
1026
|
|
|
1018
1027
|
readonly contextId = uniqueId()
|
|
1019
1028
|
|
|
1029
|
+
/**
|
|
1030
|
+
* The i18n adapter for translating strings.
|
|
1031
|
+
*
|
|
1032
|
+
* @public
|
|
1033
|
+
*/
|
|
1034
|
+
readonly i18n: TLI18nAdapter
|
|
1035
|
+
|
|
1020
1036
|
/**
|
|
1021
1037
|
* The editor's store
|
|
1022
1038
|
*
|
|
@@ -2291,12 +2307,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
2291
2307
|
firstParentId &&
|
|
2292
2308
|
selectedShapeIds.every((shapeId) => this.getShape(shapeId)?.parentId === firstParentId) &&
|
|
2293
2309
|
!isPageId(firstParentId)
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
const filteredShapes = this.getCurrentPageShapes().filter(
|
|
2297
|
-
(shape) =>
|
|
2298
|
-
!this.isShapeOrAncestorLocked(shape) &&
|
|
2299
|
-
(isSelectedWithinContainer ? shape.parentId === firstParentId : isPageId(shape.parentId))
|
|
2310
|
+
const filteredShapes = this.getCurrentPageShapes().filter((shape) =>
|
|
2311
|
+
isSelectedWithinContainer ? shape.parentId === firstParentId : isPageId(shape.parentId)
|
|
2300
2312
|
)
|
|
2301
2313
|
const readingOrderShapes = this._getShapesInReadingOrder(filteredShapes)
|
|
2302
2314
|
const currentShapeId: TLShapeId | undefined =
|
|
@@ -2307,8 +2319,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
2307
2319
|
let adjacentShapeId: TLShapeId
|
|
2308
2320
|
if (direction === 'next' || direction === 'prev') {
|
|
2309
2321
|
const shapeIds = readingOrderShapes.map((shape) => shape.id)
|
|
2310
|
-
// Every candidate can be filtered out (e.g.
|
|
2311
|
-
//
|
|
2322
|
+
// Every candidate can be filtered out (e.g. every util returns false from canTabTo);
|
|
2323
|
+
// indexing an empty list would hand getShape undefined
|
|
2312
2324
|
if (shapeIds.length === 0) return
|
|
2313
2325
|
|
|
2314
2326
|
const currentIndex = currentShapeId ? shapeIds.indexOf(currentShapeId) : -1
|
|
@@ -2332,6 +2344,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
2332
2344
|
/**
|
|
2333
2345
|
* Generates a reading order for shapes based on rows grouping.
|
|
2334
2346
|
* Tries to keep a natural reading order (left-to-right, top-to-bottom).
|
|
2347
|
+
* Shapes whose util returns `false` from {@link ShapeUtil.canTabTo} are left out. Locked
|
|
2348
|
+
* shapes are included, so keyboard users can reach them to unlock them.
|
|
2335
2349
|
*
|
|
2336
2350
|
* @public
|
|
2337
2351
|
*/
|
|
@@ -2513,6 +2527,15 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
2513
2527
|
}
|
|
2514
2528
|
|
|
2515
2529
|
private _selectShapesAndZoom(ids: TLShapeId[]) {
|
|
2530
|
+
// setSelectedShapes is a no-op for an unchanged selection (Tab with a single shape,
|
|
2531
|
+
// Ctrl+arrow with no neighbour); marking anyway would leave an empty stopping point
|
|
2532
|
+
// that makes undo available with nothing to undo
|
|
2533
|
+
const selectedShapeIds = this.getSelectedShapeIds()
|
|
2534
|
+
const isSelectionChanging =
|
|
2535
|
+
ids.length !== selectedShapeIds.length || ids.some((id) => !selectedShapeIds.includes(id))
|
|
2536
|
+
if (isSelectionChanging) {
|
|
2537
|
+
this.markHistoryStoppingPoint('selecting adjacent shape')
|
|
2538
|
+
}
|
|
2516
2539
|
this.setSelectedShapes(ids)
|
|
2517
2540
|
this.zoomToSelectionIfOffscreen(256, {
|
|
2518
2541
|
animation: {
|
|
@@ -5122,7 +5145,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5122
5145
|
const freshPage = this.getPage(id) // get the most recent version of the page anyway
|
|
5123
5146
|
if (!freshPage) return this
|
|
5124
5147
|
|
|
5125
|
-
|
|
5148
|
+
// Read the source page's own camera rather than getCamera(), which is the
|
|
5149
|
+
// current page's and is wrong when duplicating a page that isn't being viewed
|
|
5150
|
+
const prevCamera = {
|
|
5151
|
+
...(this.store.get(CameraRecordType.createId(freshPage.id)) ?? this.getCamera()),
|
|
5152
|
+
}
|
|
5126
5153
|
const content = this.getContentFromCurrentPage(this.getSortedChildIdsForParent(freshPage.id))
|
|
5127
5154
|
|
|
5128
5155
|
this.run(() => {
|
|
@@ -5137,8 +5164,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5137
5164
|
this.setCamera(prevCamera)
|
|
5138
5165
|
|
|
5139
5166
|
if (content) {
|
|
5140
|
-
//
|
|
5141
|
-
|
|
5167
|
+
// Without preservePosition the paste rule recenters off-screen content
|
|
5168
|
+
// in the viewport, so the copy's layout would not match the original
|
|
5169
|
+
return this.putContentOntoCurrentPage(content, { preservePosition: true })
|
|
5142
5170
|
}
|
|
5143
5171
|
})
|
|
5144
5172
|
|
|
@@ -8171,10 +8199,12 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
8171
8199
|
|
|
8172
8200
|
let workingShape: TLShape | null = null
|
|
8173
8201
|
|
|
8202
|
+
const initialPagePoint = Mat.applyToPoint(pageTransform, new Vec())
|
|
8203
|
+
|
|
8174
8204
|
if (util.onResize && util.canResize(initialShape)) {
|
|
8175
8205
|
// get the model changes from the shape util
|
|
8176
8206
|
const newPagePoint = this._scalePagePoint(
|
|
8177
|
-
|
|
8207
|
+
initialPagePoint,
|
|
8178
8208
|
scaleOrigin,
|
|
8179
8209
|
scale,
|
|
8180
8210
|
scaleAxisRotation
|
|
@@ -8194,11 +8224,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
8194
8224
|
myScale.x = areWidthAndHeightAlignedWithCorrectAxis ? scale.x : scale.y
|
|
8195
8225
|
myScale.y = areWidthAndHeightAlignedWithCorrectAxis ? scale.y : scale.x
|
|
8196
8226
|
|
|
8197
|
-
// adjust
|
|
8198
|
-
// e.g. groups
|
|
8199
|
-
const initialPagePoint = Mat.applyToPoint(pageTransform, new Vec())
|
|
8200
|
-
|
|
8201
|
-
// need to adjust the shape's x and y points in case the parent has moved since start of resizing
|
|
8227
|
+
// need to adjust the shape's x and y points in case the parent has moved since start of
|
|
8228
|
+
// resizing, e.g. groups
|
|
8202
8229
|
const { x, y } = this.getPointInParentSpace(initialShape.id, initialPagePoint)
|
|
8203
8230
|
|
|
8204
8231
|
workingShape = initialShape
|
|
@@ -8255,29 +8282,32 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
8255
8282
|
scaleAxisRotation
|
|
8256
8283
|
)
|
|
8257
8284
|
|
|
8258
|
-
|
|
8285
|
+
// A parent-space delta added to the shape's initial x/y would double-count the parent's own
|
|
8286
|
+
// move during the same gesture — a repositioned group carries its children with it (#10617).
|
|
8287
|
+
const repositionedPagePoint = Vec.Add(
|
|
8288
|
+
newPageCenter,
|
|
8289
|
+
Vec.Sub(initialPagePoint, initialPageCenter)
|
|
8290
|
+
)
|
|
8291
|
+
const repositionedLocalPoint = this.getPointInParentSpace(
|
|
8259
8292
|
initialShape.id,
|
|
8260
|
-
|
|
8293
|
+
repositionedPagePoint
|
|
8261
8294
|
)
|
|
8262
|
-
const newPageCenterInParentSpace = this.getPointInParentSpace(initialShape.id, newPageCenter)
|
|
8263
|
-
|
|
8264
|
-
const delta = Vec.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
|
|
8265
8295
|
|
|
8266
8296
|
if (workingShape) {
|
|
8267
8297
|
// the util's onResize ran but returned no change; keep the working update (which may
|
|
8268
8298
|
// include changes from onResizeStart / onResizeEnd) and reposition the shape
|
|
8269
8299
|
return {
|
|
8270
8300
|
...workingShape,
|
|
8271
|
-
x:
|
|
8272
|
-
y:
|
|
8301
|
+
x: repositionedLocalPoint.x,
|
|
8302
|
+
y: repositionedLocalPoint.y,
|
|
8273
8303
|
}
|
|
8274
8304
|
}
|
|
8275
8305
|
|
|
8276
8306
|
return {
|
|
8277
8307
|
id,
|
|
8278
8308
|
type: initialShape.type as any,
|
|
8279
|
-
x:
|
|
8280
|
-
y:
|
|
8309
|
+
x: repositionedLocalPoint.x,
|
|
8310
|
+
y: repositionedLocalPoint.y,
|
|
8281
8311
|
}
|
|
8282
8312
|
}
|
|
8283
8313
|
|
|
@@ -8313,6 +8343,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
8313
8343
|
initialShape: TLShape
|
|
8314
8344
|
isAspectRatioLocked: boolean
|
|
8315
8345
|
initialPageTransform: MatLike
|
|
8346
|
+
dragHandle?: TLResizeHandle
|
|
8347
|
+
mode?: TLResizeMode
|
|
8348
|
+
skipStartAndEndCallbacks?: boolean
|
|
8316
8349
|
}
|
|
8317
8350
|
) {
|
|
8318
8351
|
const { type } = options.initialShape
|
|
@@ -8337,19 +8370,39 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
8337
8370
|
initialBounds: options.initialBounds,
|
|
8338
8371
|
isAspectRatioLocked: options.isAspectRatioLocked,
|
|
8339
8372
|
initialPageTransform: options.initialPageTransform,
|
|
8373
|
+
dragHandle: options.dragHandle,
|
|
8374
|
+
mode: options.mode,
|
|
8375
|
+
// this is one frame of the same resize, not a new one, so don't fire start/end again
|
|
8376
|
+
skipStartAndEndCallbacks: options.skipStartAndEndCallbacks,
|
|
8340
8377
|
})
|
|
8341
8378
|
|
|
8342
8379
|
// then if the shape is flipped in one axis only, we need to apply an extra rotation
|
|
8343
8380
|
// to make sure the shape is mirrored correctly
|
|
8344
8381
|
if (Math.sign(scale.x) * Math.sign(scale.y) < 0) {
|
|
8345
|
-
//
|
|
8346
|
-
// For a shape with local rotation `localRot`
|
|
8382
|
+
// Mirroring across an axis at angle `axisRot` maps a page rotation `pageRot` to
|
|
8383
|
+
// `2 * axisRot - pageRot`. For a shape with local rotation `localRot` whose parent had
|
|
8384
|
+
// page rotation `parentRot` when the resize began:
|
|
8347
8385
|
// - pageRot = parentRot + localRot
|
|
8348
|
-
// - newPageRot =
|
|
8349
|
-
// - newPageRot = parentRot +
|
|
8350
|
-
// - Therefore: newLocalRot =
|
|
8386
|
+
// - newPageRot = 2 * axisRot - pageRot
|
|
8387
|
+
// - newPageRot = (parentRot + parentDelta) + newLocalRot
|
|
8388
|
+
// - Therefore: newLocalRot = 2 * axisRot - localRot - 2 * parentRot - parentDelta
|
|
8389
|
+
// `parentDelta` is how far the parent has rotated since the resize began. Nested resizes
|
|
8390
|
+
// commit ancestors before descendants, so an unaligned parent may itself have been
|
|
8391
|
+
// flipped by the time its child gets here; reading the parent's current rotation as if
|
|
8392
|
+
// it were the initial one would then double-count that flip.
|
|
8351
8393
|
const parentRotation = this.getShapeParentTransform(id).rotation()
|
|
8352
|
-
|
|
8394
|
+
// take the shortest arc so the stored rotation doesn't drift by 2π when the parent
|
|
8395
|
+
// hasn't moved and the initial page rotation happens to be wrapped
|
|
8396
|
+
const parentDelta = shortAngleDist(
|
|
8397
|
+
Mat.Cast(options.initialPageTransform).rotation() - options.initialShape.rotation,
|
|
8398
|
+
parentRotation
|
|
8399
|
+
)
|
|
8400
|
+
const initialParentRotation = parentRotation - parentDelta
|
|
8401
|
+
const rotation =
|
|
8402
|
+
2 * options.scaleAxisRotation -
|
|
8403
|
+
options.initialShape.rotation -
|
|
8404
|
+
2 * initialParentRotation -
|
|
8405
|
+
parentDelta
|
|
8353
8406
|
this.updateShapes([{ id, type, rotation }])
|
|
8354
8407
|
}
|
|
8355
8408
|
|
|
@@ -11522,7 +11575,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11522
11575
|
break
|
|
11523
11576
|
}
|
|
11524
11577
|
case 'key_repeat': {
|
|
11525
|
-
//
|
|
11578
|
+
// A repeat is the only positive evidence that a key is still physically
|
|
11579
|
+
// down, so it is also the only way one we lost track of can get back in.
|
|
11580
|
+
// A key still held when Meta is released is dropped there on the
|
|
11581
|
+
// assumption macOS ate its keyup; this is what corrects that.
|
|
11582
|
+
inputs.keys.add(info.code)
|
|
11526
11583
|
break
|
|
11527
11584
|
}
|
|
11528
11585
|
}
|
|
@@ -68,16 +68,32 @@ export function deriveShapeIdsInCurrentPage(store: TLStore, getCurrentPageId: ()
|
|
|
68
68
|
prevValue
|
|
69
69
|
) as IncrementalSetConstructor<TLShapeId>
|
|
70
70
|
|
|
71
|
+
const addedIds = new Set<TLShapeId>()
|
|
72
|
+
|
|
71
73
|
for (const changes of diff) {
|
|
72
74
|
for (const record of Object.values(changes.added)) {
|
|
73
|
-
if (isShape(record)
|
|
75
|
+
if (!isShape(record)) continue
|
|
76
|
+
addedIds.add(record.id)
|
|
77
|
+
if (isShapeInPage(store, currentPageId, record)) {
|
|
74
78
|
builder.add(record.id)
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
for (const [
|
|
82
|
+
for (const [from, to] of Object.values(changes.updated)) {
|
|
79
83
|
if (isShape(to)) {
|
|
80
|
-
|
|
84
|
+
const inPage = isShapeInPage(store, currentPageId, to)
|
|
85
|
+
// A reparent that moves a shape onto or off this page takes its descendants
|
|
86
|
+
// with it, but they are not in the diff; rebuild so they don't keep stale membership.
|
|
87
|
+
// A shape created in this diff has no pre-existing descendants, so it can't go stale.
|
|
88
|
+
if (
|
|
89
|
+
isShape(from) &&
|
|
90
|
+
from.parentId !== to.parentId &&
|
|
91
|
+
!addedIds.has(to.id) &&
|
|
92
|
+
inPage !== prevValue.has(to.id)
|
|
93
|
+
) {
|
|
94
|
+
return fromScratch()
|
|
95
|
+
}
|
|
96
|
+
if (inPage) {
|
|
81
97
|
builder.add(to.id)
|
|
82
98
|
} else {
|
|
83
99
|
builder.remove(to.id)
|
|
@@ -164,6 +164,23 @@ describe('EdgeScrollManager', () => {
|
|
|
164
164
|
edgeScrollManager.updateEdgeScrolling(150)
|
|
165
165
|
expect(editor.setCamera).toHaveBeenCalled()
|
|
166
166
|
})
|
|
167
|
+
|
|
168
|
+
it('should start easing after the edge scroll delay', () => {
|
|
169
|
+
mockInputs.setCurrentScreenPoint(new Vec(0, 300))
|
|
170
|
+
|
|
171
|
+
edgeScrollManager.updateEdgeScrolling(200)
|
|
172
|
+
expect(editor.setCamera).not.toHaveBeenCalled()
|
|
173
|
+
|
|
174
|
+
// 20ms into a 200ms cubic ease, the speed factor is 0.1³.
|
|
175
|
+
edgeScrollManager.updateEdgeScrolling(20)
|
|
176
|
+
expect((editor.setCamera.mock.calls[0][0] as Vec).x).toBeCloseTo(0.03)
|
|
177
|
+
|
|
178
|
+
editor.setCamera.mockClear()
|
|
179
|
+
|
|
180
|
+
// The ease reaches full speed 200ms after the delay.
|
|
181
|
+
edgeScrollManager.updateEdgeScrolling(180)
|
|
182
|
+
expect((editor.setCamera.mock.calls[0][0] as Vec).x).toBeCloseTo(270)
|
|
183
|
+
})
|
|
167
184
|
})
|
|
168
185
|
|
|
169
186
|
describe('edge proximity detection', () => {
|
|
@@ -47,8 +47,8 @@ export class EdgeScrollManager {
|
|
|
47
47
|
? EASINGS.easeInCubic(
|
|
48
48
|
Math.min(
|
|
49
49
|
1,
|
|
50
|
-
this._edgeScrollDuration /
|
|
51
|
-
|
|
50
|
+
(this._edgeScrollDuration - editor.options.edgeScrollDelay) /
|
|
51
|
+
editor.options.edgeScrollEaseDuration
|
|
52
52
|
)
|
|
53
53
|
)
|
|
54
54
|
: 1
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, EMPTY_ARRAY, transact } from '@tldraw/state'
|
|
1
|
+
import { atom, computed, EMPTY_ARRAY, transact } from '@tldraw/state'
|
|
2
2
|
import { AtomMap } from '@tldraw/store'
|
|
3
3
|
import { TLFontFace, TLShape, TLShapeId } from '@tldraw/tlschema'
|
|
4
4
|
import {
|
|
@@ -102,6 +102,29 @@ export class FontManager {
|
|
|
102
102
|
return this.fontStates.get(font) ?? null
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// One signal for consumers that deliberately avoid per-shape font tracking (the
|
|
106
|
+
// spatial index); otherwise their cached text bounds stay at the fallback-font size.
|
|
107
|
+
private readonly fontLoadEpoch = atom('font load epoch', 0)
|
|
108
|
+
|
|
109
|
+
/** @internal */
|
|
110
|
+
getFontLoadEpoch(): number {
|
|
111
|
+
return this.fontLoadEpoch.get()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private isFontLoadEpochBumpPending = false
|
|
115
|
+
|
|
116
|
+
private setFontState(font: TLFontFace, state: FontState['state']) {
|
|
117
|
+
this.fontStates.update(font, (s) => ({ ...s, state }))
|
|
118
|
+
// Each bump rebuilds the whole spatial index, and fonts finish loading in
|
|
119
|
+
// separate callbacks, so coalesce loads that land in the same frame.
|
|
120
|
+
if (this.isFontLoadEpochBumpPending) return
|
|
121
|
+
this.isFontLoadEpochBumpPending = true
|
|
122
|
+
this.editor.timers.requestAnimationFrame(() => {
|
|
123
|
+
this.isFontLoadEpochBumpPending = false
|
|
124
|
+
this.fontLoadEpoch.update((n) => n + 1)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
ensureFontIsLoaded(font: TLFontFace): Promise<void> {
|
|
106
129
|
const existingState = this.getFontState(font)
|
|
107
130
|
if (existingState) return existingState.loadingPromise
|
|
@@ -123,7 +146,7 @@ export class FontManager {
|
|
|
123
146
|
return
|
|
124
147
|
}
|
|
125
148
|
this.editor.getContainerDocument().fonts.add(instance)
|
|
126
|
-
this.
|
|
149
|
+
this.setFontState(font, 'ready')
|
|
127
150
|
})
|
|
128
151
|
.catch((err) => {
|
|
129
152
|
if (isStale()) {
|
|
@@ -132,7 +155,7 @@ export class FontManager {
|
|
|
132
155
|
return
|
|
133
156
|
}
|
|
134
157
|
console.error(err)
|
|
135
|
-
this.
|
|
158
|
+
this.setFontState(font, 'error')
|
|
136
159
|
}),
|
|
137
160
|
}
|
|
138
161
|
|
|
@@ -177,6 +177,38 @@ describe(HistoryManager, () => {
|
|
|
177
177
|
expect(editor.getCount()).toBe(3)
|
|
178
178
|
})
|
|
179
179
|
|
|
180
|
+
// Callers mark before code that may change nothing, such as a shape util's double-click
|
|
181
|
+
// handler (#11075), so an empty stopping point must not cost an extra undo or redo
|
|
182
|
+
it('treats back-to-back stopping points as one on undo and redo', () => {
|
|
183
|
+
editor.history._mark('stop at 0')
|
|
184
|
+
editor.increment()
|
|
185
|
+
editor.history._mark('nothing follows this mark')
|
|
186
|
+
editor.history._mark('stop at 1')
|
|
187
|
+
editor.increment()
|
|
188
|
+
expect(editor.getCount()).toBe(2)
|
|
189
|
+
|
|
190
|
+
editor.history.undo()
|
|
191
|
+
expect(editor.getCount()).toBe(1)
|
|
192
|
+
editor.history.undo()
|
|
193
|
+
expect(editor.getCount()).toBe(0)
|
|
194
|
+
|
|
195
|
+
editor.history.redo()
|
|
196
|
+
expect(editor.getCount()).toBe(1)
|
|
197
|
+
editor.history.redo()
|
|
198
|
+
expect(editor.getCount()).toBe(2)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('skips a trailing stopping point with no changes after it on undo', () => {
|
|
202
|
+
editor.history._mark('stop at 0')
|
|
203
|
+
editor.increment()
|
|
204
|
+
editor.history._mark('nothing follows this mark')
|
|
205
|
+
|
|
206
|
+
editor.history.undo()
|
|
207
|
+
expect(editor.getCount()).toBe(0)
|
|
208
|
+
editor.history.redo()
|
|
209
|
+
expect(editor.getCount()).toBe(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
180
212
|
it('allows squashing of commands', () => {
|
|
181
213
|
editor.increment()
|
|
182
214
|
|
|
@@ -444,13 +444,11 @@ export class ScribbleManager {
|
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
//
|
|
447
|
+
// Keep starting scribbles with no point yet, or addPoint can't find them (#7681).
|
|
448
448
|
if (session.options.fadeMode === 'individual') {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
}
|
|
449
|
+
session.items = session.items.filter(
|
|
450
|
+
({ scribble }) => scribble.points.length > 0 || scribble.state === 'starting'
|
|
451
|
+
)
|
|
454
452
|
}
|
|
455
453
|
}
|
|
456
454
|
|