@tldraw/editor 5.6.0-next.c4e9c54a816e → 5.6.0-next.f151f7f3ebd6

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 (90) hide show
  1. package/dist-cjs/index.d.ts +157 -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/LiveCollaborators.js +3 -3
  7. package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +57 -15
  9. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
  10. package/dist-cjs/lib/config/TLI18n.js +64 -0
  11. package/dist-cjs/lib/config/TLI18n.js.map +7 -0
  12. package/dist-cjs/lib/editor/Editor.js +29 -12
  13. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  14. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +10 -3
  15. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  16. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +1 -1
  17. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  18. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +19 -2
  19. package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
  20. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
  21. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  22. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +7 -1
  23. package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
  24. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +17 -0
  25. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  26. package/dist-cjs/lib/hooks/useDocumentEvents.js +28 -3
  27. package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
  28. package/dist-cjs/lib/license/LicenseManager.js +62 -4
  29. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  30. package/dist-cjs/lib/options.js +1 -1
  31. package/dist-cjs/lib/options.js.map +1 -1
  32. package/dist-cjs/version.js +3 -3
  33. package/dist-cjs/version.js.map +1 -1
  34. package/dist-esm/index.d.mts +157 -2
  35. package/dist-esm/index.mjs +13 -1
  36. package/dist-esm/index.mjs.map +2 -2
  37. package/dist-esm/lib/TldrawEditor.mjs +18 -5
  38. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  39. package/dist-esm/lib/components/LiveCollaborators.mjs +3 -3
  40. package/dist-esm/lib/components/LiveCollaborators.mjs.map +2 -2
  41. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +61 -16
  42. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
  43. package/dist-esm/lib/config/TLI18n.mjs +44 -0
  44. package/dist-esm/lib/config/TLI18n.mjs.map +7 -0
  45. package/dist-esm/lib/editor/Editor.mjs +29 -12
  46. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  47. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +10 -3
  48. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  49. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +1 -1
  50. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  51. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +20 -3
  52. package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
  53. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
  54. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +7 -1
  56. package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
  57. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +17 -0
  58. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  59. package/dist-esm/lib/hooks/useDocumentEvents.mjs +28 -3
  60. package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
  61. package/dist-esm/lib/license/LicenseManager.mjs +62 -4
  62. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  63. package/dist-esm/lib/options.mjs +1 -1
  64. package/dist-esm/lib/options.mjs.map +1 -1
  65. package/dist-esm/version.mjs +3 -3
  66. package/dist-esm/version.mjs.map +1 -1
  67. package/editor.css +2 -1
  68. package/package.json +7 -7
  69. package/src/index.ts +14 -2
  70. package/src/lib/TldrawEditor.tsx +44 -4
  71. package/src/lib/components/LiveCollaborators.tsx +3 -3
  72. package/src/lib/components/default-components/DefaultErrorFallback.tsx +66 -18
  73. package/src/lib/config/TLI18n.test.ts +84 -0
  74. package/src/lib/config/TLI18n.ts +172 -0
  75. package/src/lib/editor/Editor.ts +52 -24
  76. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +19 -3
  77. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +17 -0
  78. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +2 -2
  79. package/src/lib/editor/managers/FontManager/FontManager.test.ts +1 -0
  80. package/src/lib/editor/managers/FontManager/FontManager.ts +26 -3
  81. package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
  82. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
  83. package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +16 -7
  84. package/src/lib/editor/shapes/ShapeUtil.ts +18 -0
  85. package/src/lib/hooks/useDocumentEvents.ts +51 -3
  86. package/src/lib/license/LicenseManager.test.ts +204 -0
  87. package/src/lib/license/LicenseManager.ts +88 -4
  88. package/src/lib/options.ts +1 -1
  89. package/src/lib/test/useDocumentEvents.test.tsx +153 -22
  90. package/src/version.ts +3 -3
@@ -1,7 +1,13 @@
1
1
  import { useValue } from '@tldraw/state-react'
2
2
  import { noop } from '@tldraw/utils'
3
3
  import classNames from 'classnames'
4
- import { ComponentType, useEffect, useLayoutEffect, useRef, useState } from 'react'
4
+ import { ComponentType, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
5
+ import {
6
+ defineMessages,
7
+ translateMessage,
8
+ type TLI18nMessage,
9
+ type TLI18nValues,
10
+ } from '../../config/TLI18n'
5
11
  import { Editor } from '../../editor/Editor'
6
12
  import { getOwnerDocument } from '../../exports/domUtils'
7
13
  import { useEditorComponents } from '../../hooks/EditorComponentsContext'
@@ -12,6 +18,50 @@ import { ErrorBoundary } from '../ErrorBoundary'
12
18
 
13
19
  const BASE_ERROR_URL = 'https://github.com/tldraw/tldraw/issues/new'
14
20
 
21
+ const messages = defineMessages({
22
+ confirmResetTitle: { id: 'error.confirm-reset.title', defaultMessage: 'Are you sure?' },
23
+ confirmResetDescription: {
24
+ id: 'error.confirm-reset.description',
25
+ defaultMessage: 'Resetting your data will delete your drawing and cannot be undone.',
26
+ },
27
+ cancel: { id: 'error.cancel', defaultMessage: 'Cancel' },
28
+ resetData: { id: 'error.reset-data', defaultMessage: 'Reset data' },
29
+ title: { id: 'error.title', defaultMessage: 'Something went wrong' },
30
+ refreshHint: { id: 'error.refresh-hint', defaultMessage: 'Please refresh your browser.' },
31
+ resetHint: {
32
+ id: 'error.reset-hint',
33
+ defaultMessage:
34
+ 'If the issue continues after refreshing, you may need to reset the tldraw data stored on your device.',
35
+ },
36
+ noteLabel: { id: 'error.note-label', defaultMessage: 'Note:' },
37
+ resetNote: {
38
+ id: 'error.reset-note',
39
+ defaultMessage: 'Resetting will erase your current project and any unsaved work.',
40
+ },
41
+ messageLabel: { id: 'error.message-label', defaultMessage: 'Message:' },
42
+ stackTraceLabel: { id: 'error.stack-trace-label', defaultMessage: 'Stack trace:' },
43
+ copy: { id: 'error.copy', defaultMessage: 'Copy' },
44
+ copied: { id: 'error.copied', defaultMessage: 'Copied!' },
45
+ showDetails: { id: 'error.show-details', defaultMessage: 'Show details' },
46
+ hideDetails: { id: 'error.hide-details', defaultMessage: 'Hide details' },
47
+ refreshPage: { id: 'error.refresh-page', defaultMessage: 'Refresh page' },
48
+ })
49
+
50
+ // This renders because something already threw, so reading the editor is done defensively the way
51
+ // the rest of this component reads it: English beats a second crash on the error screen.
52
+ function useErrorMsg(editor: Editor | undefined) {
53
+ return useCallback(
54
+ (message: TLI18nMessage, values?: TLI18nValues) => {
55
+ try {
56
+ return translateMessage(editor?.i18n(), message, values)
57
+ } catch {
58
+ return message.defaultMessage
59
+ }
60
+ },
61
+ [editor]
62
+ )
63
+ }
64
+
15
65
  /** @public */
16
66
  export interface TLErrorFallbackProps {
17
67
  /** The error that was caught. */
@@ -28,6 +78,7 @@ export type TLErrorFallbackComponent = ComponentType<TLErrorFallbackProps>
28
78
 
29
79
  /** @public @react */
30
80
  export function DefaultErrorFallback({ error, editor }: TLErrorFallbackProps) {
81
+ const msg = useErrorMsg(editor)
31
82
  const containerRef = useRef<HTMLDivElement>(null)
32
83
  const [shouldShowError, setShouldShowError] = useState(process.env.NODE_ENV === 'development')
33
84
  const [didCopy, setDidCopy] = useState(false)
@@ -168,27 +219,24 @@ My browser: ${navigator.userAgent}`
168
219
  >
169
220
  {shouldShowResetConfirmation ? (
170
221
  <>
171
- <h2>Are you sure?</h2>
172
- <p>Resetting your data will delete your drawing and cannot be undone.</p>
222
+ <h2>{msg(messages.confirmResetTitle)}</h2>
223
+ <p>{msg(messages.confirmResetDescription)}</p>
173
224
  <div className="tl-error-boundary__content__actions">
174
225
  <button className="tlui-button" onClick={() => setShouldShowResetConfirmation(false)}>
175
- Cancel
226
+ {msg(messages.cancel)}
176
227
  </button>
177
228
  <button className="tlui-button tl-error-boundary__reset" onClick={resetLocalState}>
178
- Reset data
229
+ {msg(messages.resetData)}
179
230
  </button>
180
231
  </div>
181
232
  </>
182
233
  ) : (
183
234
  <>
184
- <h2>Something went wrong</h2>
185
- <p>Please refresh your browser.</p>
186
- <p>
187
- If the issue continues after refreshing, you may need to reset the tldraw data stored
188
- on your device.
189
- </p>
235
+ <h2>{msg(messages.title)}</h2>
236
+ <p>{msg(messages.refreshHint)}</p>
237
+ <p>{msg(messages.resetHint)}</p>
190
238
  <p>
191
- <strong>Note:</strong> Resetting will erase your current project and any unsaved work.
239
+ <strong>{msg(messages.noteLabel)}</strong> {msg(messages.resetNote)}
192
240
  </p>
193
241
  {process.env.NODE_ENV !== 'production' && (
194
242
  <p>
@@ -201,34 +249,34 @@ My browser: ${navigator.userAgent}`
201
249
  )}
202
250
  {shouldShowError && (
203
251
  <>
204
- Message:
252
+ {msg(messages.messageLabel)}
205
253
  <h4>
206
254
  <code>{errorMessage}</code>
207
255
  </h4>
208
- Stack trace:
256
+ {msg(messages.stackTraceLabel)}
209
257
  <div className="tl-error-boundary__content__error">
210
258
  <pre>
211
259
  <code>{errorStack ?? errorMessage}</code>
212
260
  </pre>
213
261
  <button className="tlui-button" onClick={copyError}>
214
- {didCopy ? 'Copied!' : 'Copy'}
262
+ {didCopy ? msg(messages.copied) : msg(messages.copy)}
215
263
  </button>
216
264
  </div>
217
265
  </>
218
266
  )}
219
267
  <div className="tl-error-boundary__content__actions">
220
268
  <button className="tlui-button" onClick={() => setShouldShowError(!shouldShowError)}>
221
- {shouldShowError ? 'Hide details' : 'Show details'}
269
+ {shouldShowError ? msg(messages.hideDetails) : msg(messages.showDetails)}
222
270
  </button>
223
271
  <div className="tl-error-boundary__content__actions__group">
224
272
  <button
225
273
  className="tlui-button tl-error-boundary__reset"
226
274
  onClick={() => setShouldShowResetConfirmation(true)}
227
275
  >
228
- Reset data
276
+ {msg(messages.resetData)}
229
277
  </button>
230
278
  <button className="tlui-button tl-error-boundary__refresh" onClick={refresh}>
231
- Refresh page
279
+ {msg(messages.refreshPage)}
232
280
  </button>
233
281
  </div>
234
282
  </div>
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ TLI18n,
4
+ defaultI18n,
5
+ defineMessages,
6
+ getDeclaredMessages,
7
+ translateMessage,
8
+ } from './TLI18n'
9
+
10
+ const messages = defineMessages({
11
+ somethingWentWrong: { id: 'error.title', defaultMessage: 'Something went wrong' },
12
+ shapesSelected: {
13
+ id: 'a11y.multiple-shapes',
14
+ defaultMessage: '{num, plural, one {# shape selected} other {# shapes selected}}',
15
+ },
16
+ // A plain placeholder, which the regex stub below can substitute. The plural above needs a
17
+ // real ICU formatter, so the adapter's own handling of it is covered in packages/tldraw.
18
+ addedFiles: { id: 'test.added-files', defaultMessage: 'Added {total} files' },
19
+ })
20
+
21
+ function i18nWith(catalog: Record<string, string>): TLI18n {
22
+ return {
23
+ locale: 'en',
24
+ dir: 'ltr',
25
+ // Mirrors the real adapter: a message the catalog doesn't have falls back to its own
26
+ // English, and either way the values are substituted.
27
+ translate: (message, values) => {
28
+ const text = catalog[message.id] ?? message.defaultMessage
29
+ return text.replace(/\{(\w+)[^}]*\}/g, (_, name) => String(values?.[name] ?? ''))
30
+ },
31
+ }
32
+ }
33
+
34
+ describe('translateMessage', () => {
35
+ it('uses the translation when the catalog has one', () => {
36
+ const i18n = i18nWith({ 'error.title': 'Etwas ist schiefgelaufen' })
37
+ expect(translateMessage(i18n, messages.somethingWentWrong)).toBe('Etwas ist schiefgelaufen')
38
+ })
39
+
40
+ // @tldraw/editor is usable with none of tldraw's UI, so there may be no i18n at all.
41
+ it('falls back to the English when there is no i18n', () => {
42
+ expect(translateMessage(null, messages.somethingWentWrong)).toBe('Something went wrong')
43
+ expect(translateMessage(undefined, messages.somethingWentWrong)).toBe('Something went wrong')
44
+ })
45
+
46
+ // A message that hasn't been through Lokalise yet is absent from every catalog. Showing the id
47
+ // to someone would be worse than English.
48
+ it('falls back to the English when the catalog has no entry', () => {
49
+ expect(translateMessage(i18nWith({}), messages.somethingWentWrong)).toBe('Something went wrong')
50
+ expect(translateMessage(defaultI18n(), messages.somethingWentWrong)).toBe(
51
+ 'Something went wrong'
52
+ )
53
+ })
54
+
55
+ // The adapter gets the whole message, so it can substitute into the English too. Falling back
56
+ // to the raw source instead put a plural message's own ICU into an upload toast.
57
+ it('substitutes values into the English it falls back to', () => {
58
+ expect(translateMessage(i18nWith({}), messages.addedFiles, { total: 3 })).toBe('Added 3 files')
59
+ })
60
+
61
+ it('passes values through to the translation', () => {
62
+ const i18n = i18nWith({ 'a11y.multiple-shapes': '{num} Formen ausgewählt' })
63
+ expect(translateMessage(i18n, messages.shapesSelected, { num: 3 })).toBe('3 Formen ausgewählt')
64
+ })
65
+ })
66
+
67
+ describe('defineMessages', () => {
68
+ it('returns the messages unchanged', () => {
69
+ const declared = { a: { id: 'x', defaultMessage: 'X' } }
70
+ expect(defineMessages(declared)).toEqual(declared)
71
+ })
72
+
73
+ // The SDK bundles no separate English catalog; a key looked up by id resolves against this.
74
+ it('records the English of each message it declares', () => {
75
+ defineMessages({
76
+ a: { id: 'test.declared', defaultMessage: 'Declared' },
77
+ b: { defaultMessage: 'No id' },
78
+ })
79
+ expect(getDeclaredMessages()).toMatchObject({
80
+ 'test.declared': 'Declared',
81
+ 'No id': 'No id',
82
+ })
83
+ })
84
+ })
@@ -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,
@@ -266,6 +267,11 @@ export interface TLEditorOptions {
266
267
  * given, the body element will be used.
267
268
  */
268
269
  getContainer(): HTMLElement
270
+
271
+ /**
272
+ * An i18n adapter for translating strings.
273
+ */
274
+ i18n?: TLI18nAdapter
269
275
  /**
270
276
  * Provides a way to hide shapes.
271
277
  *
@@ -373,9 +379,11 @@ export class Editor extends EventEmitter<TLEventMap> {
373
379
  fontAssetUrls,
374
380
  themes,
375
381
  initialTheme,
382
+ i18n,
376
383
  }: TLEditorOptions) {
377
384
  super()
378
385
 
386
+ this.i18n = i18n ?? defaultI18n
379
387
  this._getShapeVisibility = getShapeVisibility
380
388
 
381
389
  // Merge deprecated textOptions prop with options.text
@@ -1018,6 +1026,13 @@ export class Editor extends EventEmitter<TLEventMap> {
1018
1026
 
1019
1027
  readonly contextId = uniqueId()
1020
1028
 
1029
+ /**
1030
+ * The i18n adapter for translating strings.
1031
+ *
1032
+ * @public
1033
+ */
1034
+ readonly i18n: TLI18nAdapter
1035
+
1021
1036
  /**
1022
1037
  * The editor's store
1023
1038
  *
@@ -2292,12 +2307,8 @@ export class Editor extends EventEmitter<TLEventMap> {
2292
2307
  firstParentId &&
2293
2308
  selectedShapeIds.every((shapeId) => this.getShape(shapeId)?.parentId === firstParentId) &&
2294
2309
  !isPageId(firstParentId)
2295
- // Locked shapes (and children of locked containers) can't be selected by clicking or
2296
- // select all, so traversal skips them too
2297
- const filteredShapes = this.getCurrentPageShapes().filter(
2298
- (shape) =>
2299
- !this.isShapeOrAncestorLocked(shape) &&
2300
- (isSelectedWithinContainer ? shape.parentId === firstParentId : isPageId(shape.parentId))
2310
+ const filteredShapes = this.getCurrentPageShapes().filter((shape) =>
2311
+ isSelectedWithinContainer ? shape.parentId === firstParentId : isPageId(shape.parentId)
2301
2312
  )
2302
2313
  const readingOrderShapes = this._getShapesInReadingOrder(filteredShapes)
2303
2314
  const currentShapeId: TLShapeId | undefined =
@@ -2308,8 +2319,8 @@ export class Editor extends EventEmitter<TLEventMap> {
2308
2319
  let adjacentShapeId: TLShapeId
2309
2320
  if (direction === 'next' || direction === 'prev') {
2310
2321
  const shapeIds = readingOrderShapes.map((shape) => shape.id)
2311
- // Every candidate can be filtered out (e.g. a locked shape is selected and nothing
2312
- // 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
2313
2324
  if (shapeIds.length === 0) return
2314
2325
 
2315
2326
  const currentIndex = currentShapeId ? shapeIds.indexOf(currentShapeId) : -1
@@ -2333,6 +2344,8 @@ export class Editor extends EventEmitter<TLEventMap> {
2333
2344
  /**
2334
2345
  * Generates a reading order for shapes based on rows grouping.
2335
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.
2336
2349
  *
2337
2350
  * @public
2338
2351
  */
@@ -2514,6 +2527,15 @@ export class Editor extends EventEmitter<TLEventMap> {
2514
2527
  }
2515
2528
 
2516
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
+ }
2517
2539
  this.setSelectedShapes(ids)
2518
2540
  this.zoomToSelectionIfOffscreen(256, {
2519
2541
  animation: {
@@ -8177,10 +8199,12 @@ export class Editor extends EventEmitter<TLEventMap> {
8177
8199
 
8178
8200
  let workingShape: TLShape | null = null
8179
8201
 
8202
+ const initialPagePoint = Mat.applyToPoint(pageTransform, new Vec())
8203
+
8180
8204
  if (util.onResize && util.canResize(initialShape)) {
8181
8205
  // get the model changes from the shape util
8182
8206
  const newPagePoint = this._scalePagePoint(
8183
- Mat.applyToPoint(pageTransform, new Vec(0, 0)),
8207
+ initialPagePoint,
8184
8208
  scaleOrigin,
8185
8209
  scale,
8186
8210
  scaleAxisRotation
@@ -8200,11 +8224,8 @@ export class Editor extends EventEmitter<TLEventMap> {
8200
8224
  myScale.x = areWidthAndHeightAlignedWithCorrectAxis ? scale.x : scale.y
8201
8225
  myScale.y = areWidthAndHeightAlignedWithCorrectAxis ? scale.y : scale.x
8202
8226
 
8203
- // adjust initial model for situations where the parent has moved during the resize
8204
- // e.g. groups
8205
- const initialPagePoint = Mat.applyToPoint(pageTransform, new Vec())
8206
-
8207
- // 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
8208
8229
  const { x, y } = this.getPointInParentSpace(initialShape.id, initialPagePoint)
8209
8230
 
8210
8231
  workingShape = initialShape
@@ -8261,29 +8282,32 @@ export class Editor extends EventEmitter<TLEventMap> {
8261
8282
  scaleAxisRotation
8262
8283
  )
8263
8284
 
8264
- 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(
8265
8292
  initialShape.id,
8266
- initialPageCenter
8293
+ repositionedPagePoint
8267
8294
  )
8268
- const newPageCenterInParentSpace = this.getPointInParentSpace(initialShape.id, newPageCenter)
8269
-
8270
- const delta = Vec.Sub(newPageCenterInParentSpace, initialPageCenterInParentSpace)
8271
8295
 
8272
8296
  if (workingShape) {
8273
8297
  // the util's onResize ran but returned no change; keep the working update (which may
8274
8298
  // include changes from onResizeStart / onResizeEnd) and reposition the shape
8275
8299
  return {
8276
8300
  ...workingShape,
8277
- x: initialShape.x + delta.x,
8278
- y: initialShape.y + delta.y,
8301
+ x: repositionedLocalPoint.x,
8302
+ y: repositionedLocalPoint.y,
8279
8303
  }
8280
8304
  }
8281
8305
 
8282
8306
  return {
8283
8307
  id,
8284
8308
  type: initialShape.type as any,
8285
- x: initialShape.x + delta.x,
8286
- y: initialShape.y + delta.y,
8309
+ x: repositionedLocalPoint.x,
8310
+ y: repositionedLocalPoint.y,
8287
8311
  }
8288
8312
  }
8289
8313
 
@@ -11551,7 +11575,11 @@ export class Editor extends EventEmitter<TLEventMap> {
11551
11575
  break
11552
11576
  }
11553
11577
  case 'key_repeat': {
11554
- // 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)
11555
11583
  break
11556
11584
  }
11557
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