@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.
Files changed (107) hide show
  1. package/dist-cjs/index.d.ts +167 -2
  2. package/dist-cjs/index.js +7 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +18 -5
  5. package/dist-cjs/lib/TldrawEditor.js.map +3 -3
  6. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +57 -15
  7. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  8. package/dist-cjs/lib/config/TLI18n.js +64 -0
  9. package/dist-cjs/lib/config/TLI18n.js.map +7 -0
  10. package/dist-cjs/lib/editor/Editor.js +44 -16
  11. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  12. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +10 -3
  13. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  14. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +1 -1
  15. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +19 -2
  17. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
  19. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +7 -1
  21. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  22. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +28 -0
  23. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  24. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +3 -0
  25. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +2 -2
  26. package/dist-cjs/lib/editor/shapes/shared/resizeBox.js +21 -16
  27. package/dist-cjs/lib/editor/shapes/shared/resizeBox.js.map +2 -2
  28. package/dist-cjs/lib/hooks/useCursor.js +12 -8
  29. package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
  30. package/dist-cjs/lib/hooks/useDocumentEvents.js +28 -3
  31. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  32. package/dist-cjs/lib/license/LicenseManager.js +64 -1
  33. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  34. package/dist-cjs/lib/options.js +1 -1
  35. package/dist-cjs/lib/options.js.map +1 -1
  36. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +12 -5
  37. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  38. package/dist-cjs/version.js +3 -3
  39. package/dist-cjs/version.js.map +1 -1
  40. package/dist-esm/index.d.mts +167 -2
  41. package/dist-esm/index.mjs +13 -1
  42. package/dist-esm/index.mjs.map +2 -2
  43. package/dist-esm/lib/TldrawEditor.mjs +18 -5
  44. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  45. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +61 -16
  46. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  47. package/dist-esm/lib/config/TLI18n.mjs +44 -0
  48. package/dist-esm/lib/config/TLI18n.mjs.map +7 -0
  49. package/dist-esm/lib/editor/Editor.mjs +46 -17
  50. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  51. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +10 -3
  52. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +1 -1
  54. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +20 -3
  56. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  57. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
  58. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  59. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +7 -1
  60. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  61. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +28 -0
  62. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  63. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +3 -0
  64. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +2 -2
  65. package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs +21 -16
  66. package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs.map +2 -2
  67. package/dist-esm/lib/hooks/useCursor.mjs +12 -8
  68. package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
  69. package/dist-esm/lib/hooks/useDocumentEvents.mjs +28 -3
  70. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  71. package/dist-esm/lib/license/LicenseManager.mjs +64 -1
  72. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  73. package/dist-esm/lib/options.mjs +1 -1
  74. package/dist-esm/lib/options.mjs.map +1 -1
  75. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +13 -6
  76. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  77. package/dist-esm/version.mjs +3 -3
  78. package/dist-esm/version.mjs.map +1 -1
  79. package/editor.css +1 -1
  80. package/package.json +17 -19
  81. package/src/index.ts +14 -2
  82. package/src/lib/TldrawEditor.tsx +44 -4
  83. package/src/lib/components/default-components/DefaultErrorFallback.tsx +66 -18
  84. package/src/lib/config/TLI18n.test.ts +84 -0
  85. package/src/lib/config/TLI18n.ts +172 -0
  86. package/src/lib/editor/Editor.ts +90 -33
  87. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +19 -3
  88. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +17 -0
  89. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +2 -2
  90. package/src/lib/editor/managers/FontManager/FontManager.test.ts +1 -0
  91. package/src/lib/editor/managers/FontManager/FontManager.ts +26 -3
  92. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
  93. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
  94. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +16 -7
  95. package/src/lib/editor/shapes/ShapeUtil.ts +30 -0
  96. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +4 -0
  97. package/src/lib/editor/shapes/shared/resizeBox.ts +21 -16
  98. package/src/lib/hooks/useCursor.ts +14 -7
  99. package/src/lib/hooks/useDocumentEvents.ts +51 -3
  100. package/src/lib/license/LicenseManager.test.ts +220 -0
  101. package/src/lib/license/LicenseManager.ts +89 -1
  102. package/src/lib/options.ts +1 -1
  103. package/src/lib/test/useCursor.test.tsx +36 -0
  104. package/src/lib/test/useDocumentEvents.test.tsx +153 -22
  105. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +73 -0
  106. package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -8
  107. 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
+ }
@@ -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
- // Locked shapes (and children of locked containers) can't be selected by clicking or
2295
- // select all, so traversal skips them too
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. a locked shape is selected and nothing
2311
- // else is unlocked); indexing an empty list would hand getShape undefined
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
- const prevCamera = { ...this.getCamera() }
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
- // If we had content on the previous page, put it on the new page
5141
- return this.putContentOntoCurrentPage(content)
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
- Mat.applyToPoint(pageTransform, new Vec(0, 0)),
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 initial model for situations where the parent has moved during the resize
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
- const initialPageCenterInParentSpace = this.getPointInParentSpace(
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
- initialPageCenter
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: initialShape.x + delta.x,
8272
- y: initialShape.y + delta.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: initialShape.x + delta.x,
8280
- y: initialShape.y + delta.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
- // We need to compute the new local rotation that will result in the negated page rotation.
8346
- // For a shape with local rotation `localRot` and parent page rotation `parentRot`:
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 = -pageRot (we want to negate the page rotation)
8349
- // - newPageRot = parentRot + newLocalRot (parent hasn't changed)
8350
- // - Therefore: newLocalRot = -pageRot - parentRot = -(parentRot + localRot) - parentRot = -localRot - 2*parentRot
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
- const rotation = -options.initialShape.rotation - 2 * parentRotation
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
- // noop
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) && isShapeInPage(store, currentPageId, 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 [_from, to] of Object.values(changes.updated)) {
82
+ for (const [from, to] of Object.values(changes.updated)) {
79
83
  if (isShape(to)) {
80
- if (isShapeInPage(store, currentPageId, to)) {
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
- (editor.options.edgeScrollDelay + editor.options.edgeScrollEaseDuration)
50
+ (this._edgeScrollDuration - editor.options.edgeScrollDelay) /
51
+ editor.options.edgeScrollEaseDuration
52
52
  )
53
53
  )
54
54
  : 1
@@ -101,6 +101,7 @@ describe('FontManager', () => {
101
101
  getCurrentPageShapeIds: vi.fn(() => new Set()),
102
102
  getShape: vi.fn(),
103
103
  getContainerDocument: vi.fn(() => document),
104
+ timers: { requestAnimationFrame: vi.fn() },
104
105
  isDisposed: false,
105
106
  } as any
106
107
 
@@ -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.fontStates.update(font, (s) => ({ ...s, state: 'ready' }))
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.fontStates.update(font, (s) => ({ ...s, state: 'error' }))
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
- // Remove completed items in individual fade mode
447
+ // Keep starting scribbles with no point yet, or addPoint can't find them (#7681).
448
448
  if (session.options.fadeMode === 'individual') {
449
- for (let i = session.items.length - 1; i >= 0; i--) {
450
- if (session.items[i].scribble.points.length === 0) {
451
- session.items.splice(i, 1)
452
- }
453
- }
449
+ session.items = session.items.filter(
450
+ ({ scribble }) => scribble.points.length > 0 || scribble.state === 'starting'
451
+ )
454
452
  }
455
453
  }
456
454