@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.
- package/dist-cjs/index.d.ts +157 -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/LiveCollaborators.js +3 -3
- package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
- 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 +29 -12
- 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 +17 -0
- package/dist-cjs/lib/editor/shapes/ShapeUtil.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 +62 -4
- 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/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +157 -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/LiveCollaborators.mjs +3 -3
- package/dist-esm/lib/components/LiveCollaborators.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 +29 -12
- 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 +17 -0
- package/dist-esm/lib/editor/shapes/ShapeUtil.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 +62 -4
- 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/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +2 -1
- package/package.json +7 -7
- package/src/index.ts +14 -2
- package/src/lib/TldrawEditor.tsx +44 -4
- package/src/lib/components/LiveCollaborators.tsx +3 -3
- 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 +52 -24
- 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 +18 -0
- package/src/lib/hooks/useDocumentEvents.ts +51 -3
- package/src/lib/license/LicenseManager.test.ts +204 -0
- package/src/lib/license/LicenseManager.ts +88 -4
- package/src/lib/options.ts +1 -1
- package/src/lib/test/useDocumentEvents.test.tsx +153 -22
- 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>
|
|
172
|
-
<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
|
-
|
|
226
|
+
{msg(messages.cancel)}
|
|
176
227
|
</button>
|
|
177
228
|
<button className="tlui-button tl-error-boundary__reset" onClick={resetLocalState}>
|
|
178
|
-
|
|
229
|
+
{msg(messages.resetData)}
|
|
179
230
|
</button>
|
|
180
231
|
</div>
|
|
181
232
|
</>
|
|
182
233
|
) : (
|
|
183
234
|
<>
|
|
184
|
-
<h2>
|
|
185
|
-
<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>
|
|
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
|
-
|
|
252
|
+
{msg(messages.messageLabel)}
|
|
205
253
|
<h4>
|
|
206
254
|
<code>{errorMessage}</code>
|
|
207
255
|
</h4>
|
|
208
|
-
|
|
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 ?
|
|
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 ?
|
|
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
|
-
|
|
276
|
+
{msg(messages.resetData)}
|
|
229
277
|
</button>
|
|
230
278
|
<button className="tlui-button tl-error-boundary__refresh" onClick={refresh}>
|
|
231
|
-
|
|
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
|
+
}
|
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,
|
|
@@ -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
|
-
|
|
2296
|
-
|
|
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.
|
|
2312
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
8278
|
-
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:
|
|
8286
|
-
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
|
-
//
|
|
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)
|
|
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
|