@tldraw/editor 3.9.0 → 3.10.0-canary.3176429f9d1b

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 (137) hide show
  1. package/dist-cjs/index.d.ts +243 -5
  2. package/dist-cjs/index.js +9 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +32 -6
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/LiveCollaborators.js +5 -0
  7. package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
  8. package/dist-cjs/lib/components/Shape.js +7 -0
  9. package/dist-cjs/lib/components/Shape.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultBrush.js.map +2 -2
  11. package/dist-cjs/lib/components/default-components/DefaultCollaboratorHint.js.map +2 -2
  12. package/dist-cjs/lib/components/default-components/DefaultCursor.js.map +2 -2
  13. package/dist-cjs/lib/components/default-components/DefaultScribble.js.map +2 -2
  14. package/dist-cjs/lib/components/default-components/DefaultShapeIndicator.js.map +2 -2
  15. package/dist-cjs/lib/editor/Editor.js +68 -13
  16. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/FontManager.js +166 -0
  18. package/dist-cjs/lib/editor/managers/FontManager.js.map +7 -0
  19. package/dist-cjs/lib/editor/managers/TextManager.js +23 -17
  20. package/dist-cjs/lib/editor/managers/TextManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/shapes/ShapeUtil.js +11 -0
  22. package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
  23. package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
  24. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  25. package/dist-cjs/lib/exports/FontEmbedder.js +7 -2
  26. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  27. package/dist-cjs/lib/exports/StyleEmbedder.js +1 -1
  28. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  29. package/dist-cjs/lib/exports/exportToSvg.js +3 -2
  30. package/dist-cjs/lib/exports/exportToSvg.js.map +2 -2
  31. package/dist-cjs/lib/exports/getSvgJsx.js +18 -1
  32. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  33. package/dist-cjs/lib/exports/parseCss.js +1 -0
  34. package/dist-cjs/lib/exports/parseCss.js.map +2 -2
  35. package/dist-cjs/lib/hooks/useCanvasEvents.js +2 -2
  36. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  37. package/dist-cjs/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.js +1 -1
  38. package/dist-cjs/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.js.map +2 -2
  39. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js +48 -0
  40. package/dist-cjs/lib/hooks/usePassThroughMouseOverEvents.js.map +7 -0
  41. package/dist-cjs/lib/hooks/usePassThroughWheelEvents.js.map +2 -2
  42. package/dist-cjs/lib/hooks/usePeerIds.js.map +1 -1
  43. package/dist-cjs/lib/hooks/usePresence.js.map +1 -1
  44. package/dist-cjs/lib/hooks/useViewportHeight.js +56 -0
  45. package/dist-cjs/lib/hooks/useViewportHeight.js.map +7 -0
  46. package/dist-cjs/lib/options.js +2 -1
  47. package/dist-cjs/lib/options.js.map +2 -2
  48. package/dist-cjs/lib/utils/dom.js +1 -1
  49. package/dist-cjs/lib/utils/dom.js.map +2 -2
  50. package/dist-cjs/lib/utils/richText.js +46 -0
  51. package/dist-cjs/lib/utils/richText.js.map +7 -0
  52. package/dist-cjs/version.js +3 -3
  53. package/dist-cjs/version.js.map +1 -1
  54. package/dist-esm/index.d.mts +243 -5
  55. package/dist-esm/index.mjs +13 -1
  56. package/dist-esm/index.mjs.map +2 -2
  57. package/dist-esm/lib/TldrawEditor.mjs +33 -7
  58. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  59. package/dist-esm/lib/components/LiveCollaborators.mjs +5 -0
  60. package/dist-esm/lib/components/LiveCollaborators.mjs.map +2 -2
  61. package/dist-esm/lib/components/Shape.mjs +8 -1
  62. package/dist-esm/lib/components/Shape.mjs.map +2 -2
  63. package/dist-esm/lib/components/default-components/DefaultBrush.mjs.map +2 -2
  64. package/dist-esm/lib/components/default-components/DefaultCollaboratorHint.mjs.map +2 -2
  65. package/dist-esm/lib/components/default-components/DefaultCursor.mjs.map +2 -2
  66. package/dist-esm/lib/components/default-components/DefaultScribble.mjs.map +2 -2
  67. package/dist-esm/lib/components/default-components/DefaultShapeIndicator.mjs.map +2 -2
  68. package/dist-esm/lib/editor/Editor.mjs +71 -9
  69. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  70. package/dist-esm/lib/editor/managers/FontManager.mjs +152 -0
  71. package/dist-esm/lib/editor/managers/FontManager.mjs.map +7 -0
  72. package/dist-esm/lib/editor/managers/TextManager.mjs +23 -17
  73. package/dist-esm/lib/editor/managers/TextManager.mjs.map +2 -2
  74. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +11 -0
  75. package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
  76. package/dist-esm/lib/exports/FontEmbedder.mjs +7 -2
  77. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  78. package/dist-esm/lib/exports/StyleEmbedder.mjs +1 -1
  79. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  80. package/dist-esm/lib/exports/exportToSvg.mjs +3 -2
  81. package/dist-esm/lib/exports/exportToSvg.mjs.map +2 -2
  82. package/dist-esm/lib/exports/getSvgJsx.mjs +19 -2
  83. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  84. package/dist-esm/lib/exports/parseCss.mjs +1 -0
  85. package/dist-esm/lib/exports/parseCss.mjs.map +2 -2
  86. package/dist-esm/lib/hooks/useCanvasEvents.mjs +2 -2
  87. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  88. package/dist-esm/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.mjs +1 -1
  89. package/dist-esm/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.mjs.map +2 -2
  90. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs +28 -0
  91. package/dist-esm/lib/hooks/usePassThroughMouseOverEvents.mjs.map +7 -0
  92. package/dist-esm/lib/hooks/usePassThroughWheelEvents.mjs.map +2 -2
  93. package/dist-esm/lib/hooks/usePeerIds.mjs.map +1 -1
  94. package/dist-esm/lib/hooks/usePresence.mjs.map +1 -1
  95. package/dist-esm/lib/hooks/useViewportHeight.mjs +36 -0
  96. package/dist-esm/lib/hooks/useViewportHeight.mjs.map +7 -0
  97. package/dist-esm/lib/options.mjs +2 -1
  98. package/dist-esm/lib/options.mjs.map +2 -2
  99. package/dist-esm/lib/utils/dom.mjs +1 -1
  100. package/dist-esm/lib/utils/dom.mjs.map +2 -2
  101. package/dist-esm/lib/utils/richText.mjs +26 -0
  102. package/dist-esm/lib/utils/richText.mjs.map +7 -0
  103. package/dist-esm/version.mjs +3 -3
  104. package/dist-esm/version.mjs.map +1 -1
  105. package/editor.css +127 -13
  106. package/package.json +10 -7
  107. package/src/index.ts +15 -0
  108. package/src/lib/TldrawEditor.tsx +52 -4
  109. package/src/lib/components/LiveCollaborators.tsx +5 -0
  110. package/src/lib/components/Shape.tsx +9 -1
  111. package/src/lib/components/default-components/DefaultBrush.tsx +1 -0
  112. package/src/lib/components/default-components/DefaultCollaboratorHint.tsx +1 -0
  113. package/src/lib/components/default-components/DefaultCursor.tsx +1 -0
  114. package/src/lib/components/default-components/DefaultScribble.tsx +1 -0
  115. package/src/lib/components/default-components/DefaultShapeIndicator.tsx +1 -0
  116. package/src/lib/editor/Editor.ts +91 -7
  117. package/src/lib/editor/managers/FontManager.ts +251 -0
  118. package/src/lib/editor/managers/TextManager.ts +42 -17
  119. package/src/lib/editor/shapes/ShapeUtil.ts +13 -0
  120. package/src/lib/editor/types/emit-types.ts +1 -0
  121. package/src/lib/editor/types/external-content.ts +1 -0
  122. package/src/lib/exports/FontEmbedder.ts +13 -1
  123. package/src/lib/exports/StyleEmbedder.ts +1 -1
  124. package/src/lib/exports/exportToSvg.tsx +4 -3
  125. package/src/lib/exports/getSvgJsx.tsx +22 -2
  126. package/src/lib/exports/parseCss.ts +1 -0
  127. package/src/lib/hooks/useCanvasEvents.ts +2 -1
  128. package/src/lib/hooks/useFixSafariDoubleTapZoomPencilEvents.ts +1 -0
  129. package/src/lib/hooks/usePassThroughMouseOverEvents.ts +29 -0
  130. package/src/lib/hooks/usePassThroughWheelEvents.ts +0 -1
  131. package/src/lib/hooks/usePeerIds.ts +1 -1
  132. package/src/lib/hooks/usePresence.ts +2 -2
  133. package/src/lib/hooks/useViewportHeight.ts +37 -0
  134. package/src/lib/options.ts +7 -0
  135. package/src/lib/utils/dom.ts +1 -1
  136. package/src/lib/utils/richText.ts +72 -0
  137. package/src/version.ts +3 -3
@@ -1,4 +1,12 @@
1
- import { EMPTY_ARRAY, atom, computed, react, transact, unsafe__withoutCapture } from '@tldraw/state'
1
+ import {
2
+ Atom,
3
+ EMPTY_ARRAY,
4
+ atom,
5
+ computed,
6
+ react,
7
+ transact,
8
+ unsafe__withoutCapture,
9
+ } from '@tldraw/state'
2
10
  import {
3
11
  ComputedCache,
4
12
  RecordType,
@@ -34,6 +42,7 @@ import {
34
42
  TLImageAsset,
35
43
  TLInstance,
36
44
  TLInstancePageState,
45
+ TLNoteShape,
37
46
  TLPOINTER_ID,
38
47
  TLPage,
39
48
  TLPageId,
@@ -83,7 +92,6 @@ import {
83
92
  structuredClone,
84
93
  uniqueId,
85
94
  } from '@tldraw/utils'
86
- import { Number } from 'core-js'
87
95
  import EventEmitter from 'eventemitter3'
88
96
  import {
89
97
  TLEditorSnapshot,
@@ -130,6 +138,7 @@ import {
130
138
  import { getIncrementedName } from '../utils/getIncrementedName'
131
139
  import { isAccelKey } from '../utils/keyboard'
132
140
  import { getReorderingShapesChanges } from '../utils/reorderShapes'
141
+ import { TLTextOptions, TiptapEditor } from '../utils/richText'
133
142
  import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
134
143
  import { BindingOnDeleteOptions, BindingUtil } from './bindings/BindingUtil'
135
144
  import { bindingsIndex } from './derivations/bindingsIndex'
@@ -139,6 +148,7 @@ import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage
139
148
  import { ClickManager } from './managers/ClickManager'
140
149
  import { EdgeScrollManager } from './managers/EdgeScrollManager'
141
150
  import { FocusManager } from './managers/FocusManager'
151
+ import { FontManager } from './managers/FontManager'
142
152
  import { HistoryManager } from './managers/HistoryManager'
143
153
  import { ScribbleManager } from './managers/ScribbleManager'
144
154
  import { SnapManager } from './managers/SnapManager/SnapManager'
@@ -225,8 +235,10 @@ export interface TLEditorOptions {
225
235
  * Options for the editor's camera.
226
236
  */
227
237
  cameraOptions?: Partial<TLCameraOptions>
238
+ textOptions?: TLTextOptions
228
239
  options?: Partial<TldrawOptions>
229
240
  licenseKey?: string
241
+ fontAssetUrls?: { [key: string]: string | undefined }
230
242
  /**
231
243
  * A predicate that should return true if the given shape should be hidden.
232
244
  * @param shape - The shape to check.
@@ -255,6 +267,7 @@ export interface TLRenderingShape {
255
267
 
256
268
  /** @public */
257
269
  export class Editor extends EventEmitter<TLEventMap> {
270
+ readonly id = uniqueId()
258
271
  constructor({
259
272
  store,
260
273
  user,
@@ -263,11 +276,13 @@ export class Editor extends EventEmitter<TLEventMap> {
263
276
  tools,
264
277
  getContainer,
265
278
  cameraOptions,
279
+ textOptions,
266
280
  initialState,
267
281
  autoFocus,
268
282
  inferDarkMode,
269
283
  options,
270
284
  isShapeHidden,
285
+ fontAssetUrls,
271
286
  }: TLEditorOptions) {
272
287
  super()
273
288
 
@@ -291,12 +306,16 @@ export class Editor extends EventEmitter<TLEventMap> {
291
306
 
292
307
  this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions })
293
308
 
309
+ this._textOptions = atom('text options', textOptions ?? null)
310
+
294
311
  this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false)
295
312
  this.disposables.add(() => this.user.dispose())
296
313
 
297
314
  this.getContainer = getContainer
298
315
 
299
316
  this.textMeasure = new TextManager(this)
317
+ this.fonts = new FontManager(this, fontAssetUrls)
318
+
300
319
  this._tickManager = new TickManager(this)
301
320
 
302
321
  class NewRoot extends RootState {
@@ -835,6 +854,13 @@ export class Editor extends EventEmitter<TLEventMap> {
835
854
  */
836
855
  readonly textMeasure: TextManager
837
856
 
857
+ /**
858
+ * A utility for managing the set of fonts that should be rendered in the document.
859
+ *
860
+ * @public
861
+ */
862
+ readonly fonts: FontManager
863
+
838
864
  /**
839
865
  * A manager for the editor's environment.
840
866
  *
@@ -2023,6 +2049,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2023
2049
  */
2024
2050
  setEditingShape(shape: TLShapeId | TLShape | null): this {
2025
2051
  const id = typeof shape === 'string' ? shape : (shape?.id ?? null)
2052
+ this.setRichTextEditor(null)
2026
2053
  if (id !== this.getEditingShapeId()) {
2027
2054
  if (id) {
2028
2055
  const shape = this.getShape(id)
@@ -2041,6 +2068,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2041
2068
  this.run(
2042
2069
  () => {
2043
2070
  this._updateCurrentPageState({ editingShapeId: null })
2071
+ this._currentRichTextEditor.set(null)
2044
2072
  },
2045
2073
  { history: 'ignore' }
2046
2074
  )
@@ -2048,6 +2076,42 @@ export class Editor extends EventEmitter<TLEventMap> {
2048
2076
  return this
2049
2077
  }
2050
2078
 
2079
+ // Rich text editor
2080
+
2081
+ private _currentRichTextEditor = atom('rich text editor', null as TiptapEditor | null)
2082
+
2083
+ /**
2084
+ * The current editing shape's text editor.
2085
+ *
2086
+ * @public
2087
+ */
2088
+ @computed getRichTextEditor(): TiptapEditor | null {
2089
+ return this._currentRichTextEditor.get()
2090
+ }
2091
+
2092
+ /**
2093
+ * Set the current editing shape's rich text editor.
2094
+ *
2095
+ * @example
2096
+ * ```ts
2097
+ * editor.setRichTextEditor(richTextEditorView)
2098
+ * ```
2099
+ *
2100
+ * @param textEditor - The text editor to set as the current editing shape's text editor.
2101
+ *
2102
+ * @public
2103
+ */
2104
+ setRichTextEditor(textEditor: TiptapEditor | null) {
2105
+ // If the new editor is different from the current one, destroy the current one
2106
+ const current = this._currentRichTextEditor.__unsafe__getWithoutCapture()
2107
+ if (current !== textEditor) {
2108
+ current?.destroy()
2109
+ }
2110
+
2111
+ this._currentRichTextEditor.set(textEditor)
2112
+ return this
2113
+ }
2114
+
2051
2115
  // Hovered
2052
2116
 
2053
2117
  /**
@@ -2104,6 +2168,7 @@ export class Editor extends EventEmitter<TLEventMap> {
2104
2168
  @computed getHintingShapeIds() {
2105
2169
  return this.getCurrentPageState().hintingShapeIds
2106
2170
  }
2171
+
2107
2172
  /**
2108
2173
  * The editor's current hinting shapes.
2109
2174
  *
@@ -2252,6 +2317,21 @@ export class Editor extends EventEmitter<TLEventMap> {
2252
2317
  return this
2253
2318
  }
2254
2319
 
2320
+ private _textOptions: Atom<TLTextOptions | null>
2321
+
2322
+ /**
2323
+ * Get the current text options.
2324
+ *
2325
+ * @example
2326
+ * ```ts
2327
+ * editor.getTextOptions()
2328
+ * ```
2329
+ *
2330
+ * @public */
2331
+ getTextOptions() {
2332
+ return assertExists(this._textOptions.get(), 'Cannot use text without setting textOptions')
2333
+ }
2334
+
2255
2335
  /* --------------------- Camera --------------------- */
2256
2336
 
2257
2337
  /** @internal */
@@ -4226,7 +4306,10 @@ export class Editor extends EventEmitter<TLEventMap> {
4226
4306
  if (!this._shapeGeometryCaches[context]) {
4227
4307
  this._shapeGeometryCaches[context] = this.store.createComputedCache(
4228
4308
  'bounds',
4229
- (shape) => this.getShapeUtil(shape).getGeometry(shape, opts),
4309
+ (shape) => {
4310
+ this.fonts.trackFontsForShape(shape)
4311
+ return this.getShapeUtil(shape).getGeometry(shape, opts)
4312
+ },
4230
4313
  { areRecordsEqual: (a, b) => a.props === b.props }
4231
4314
  )
4232
4315
  }
@@ -4764,9 +4847,10 @@ export class Editor extends EventEmitter<TLEventMap> {
4764
4847
  // Check labels first
4765
4848
  if (
4766
4849
  this.isShapeOfType<TLFrameShape>(shape, 'frame') ||
4767
- ((this.isShapeOfType<TLArrowShape>(shape, 'arrow') ||
4850
+ (this.isShapeOfType<TLArrowShape>(shape, 'arrow') && shape.props.text.trim()) ||
4851
+ ((this.isShapeOfType<TLNoteShape>(shape, 'note') ||
4768
4852
  (this.isShapeOfType<TLGeoShape>(shape, 'geo') && shape.props.fill === 'none')) &&
4769
- shape.props.text.trim())
4853
+ this.getShapeUtil(shape).getText(shape)?.trim())
4770
4854
  ) {
4771
4855
  for (const childGeometry of (geometry as Group2d).children) {
4772
4856
  if (childGeometry.isLabel && childGeometry.isPointInBounds(pointInShapeSpace)) {
@@ -7290,7 +7374,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7290
7374
  * @example
7291
7375
  * ```ts
7292
7376
  * editor.createShape(myShape)
7293
- * editor.createShape({ id: 'box1', type: 'text', props: { text: "ok" } })
7377
+ * editor.createShape({ id: 'box1', type: 'text', props: { richText: toRichText("ok") } })
7294
7378
  * ```
7295
7379
  *
7296
7380
  * @param shape - The shape (or shape partial) to create.
@@ -7308,7 +7392,7 @@ export class Editor extends EventEmitter<TLEventMap> {
7308
7392
  * @example
7309
7393
  * ```ts
7310
7394
  * editor.createShapes([myShape])
7311
- * editor.createShapes([{ id: 'box1', type: 'text', props: { text: "ok" } }])
7395
+ * editor.createShapes([{ id: 'box1', type: 'text', props: { richText: toRichText("ok") } }])
7312
7396
  * ```
7313
7397
  *
7314
7398
  * @param shapes - The shapes (or shape partials) to create.
@@ -0,0 +1,251 @@
1
+ import { computed, EMPTY_ARRAY, transact } from '@tldraw/state'
2
+ import { AtomMap } from '@tldraw/store'
3
+ import { TLShape, TLShapeId } from '@tldraw/tlschema'
4
+ import {
5
+ areArraysShallowEqual,
6
+ compact,
7
+ FileHelpers,
8
+ mapObjectMapValues,
9
+ objectMapEntries,
10
+ } from '@tldraw/utils'
11
+ import { Editor } from '../Editor'
12
+
13
+ /**
14
+ * Represents the `src` property of a {@link TLFontFace}.
15
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src | `src`} for details of the properties here.
16
+ * @public
17
+ */
18
+ export interface TLFontFaceSource {
19
+ /**
20
+ * A URL from which to load the font. If the value here is a key in
21
+ * {@link tldraw#TLEditorAssetUrls.fonts}, the value from there will be used instead.
22
+ */
23
+ url: string
24
+ format?: string
25
+ tech?: string
26
+ }
27
+
28
+ /**
29
+ * A font face that can be used in the editor. The properties of this are largely the same as the
30
+ * ones in the
31
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face | css `@font-face` rule}.
32
+ * @public
33
+ */
34
+ export interface TLFontFace {
35
+ /**
36
+ * How this font can be referred to in CSS.
37
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-family | `font-family`}.
38
+ */
39
+ readonly family: string
40
+ /**
41
+ * The source of the font. This
42
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src | `src`}.
43
+ */
44
+ readonly src: TLFontFaceSource
45
+ /**
46
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/ascent-override | `ascent-override`}.
47
+ */
48
+ readonly ascentOverride?: string
49
+ /**
50
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/descent-override | `descent-override`}.
51
+ */
52
+ readonly descentOverride?: string
53
+ /**
54
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-stretch | `font-stretch`}.
55
+ */
56
+ readonly stretch?: string
57
+ /**
58
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-style | `font-style`}.
59
+ */
60
+ readonly style?: string
61
+ /**
62
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-weight | `font-weight`}.
63
+ */
64
+ readonly weight?: string
65
+ /**
66
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-feature-settings | `font-feature-settings`}.
67
+ */
68
+ readonly featureSettings?: string
69
+ /**
70
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/line-gap-override | `line-gap-override`}.
71
+ */
72
+ readonly lineGapOverride?: string
73
+ /**
74
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range | `unicode-range`}.
75
+ */
76
+ readonly unicodeRange?: string
77
+ }
78
+
79
+ interface FontState {
80
+ readonly state: 'loading' | 'ready' | 'error'
81
+ readonly instance: FontFace
82
+ readonly loadingPromise: Promise<void>
83
+ }
84
+
85
+ /** @public */
86
+ export class FontManager {
87
+ constructor(
88
+ private readonly editor: Editor,
89
+ private readonly assetUrls?: { [key: string]: string | undefined }
90
+ ) {
91
+ this.shapeFontFacesCache = editor.store.createComputedCache(
92
+ 'shape font faces',
93
+ (shape: TLShape) => {
94
+ const shapeUtil = this.editor.getShapeUtil(shape)
95
+ return shapeUtil.getFontFaces(shape)
96
+ },
97
+ { areResultsEqual: areArraysShallowEqual }
98
+ )
99
+
100
+ this.shapeFontLoadStateCache = editor.store.createCache<(FontState | null)[], TLShape>(
101
+ (id: TLShapeId) => {
102
+ const fontFacesComputed = computed('font faces', () => this.getShapeFontFaces(id))
103
+ return computed(
104
+ 'font load state',
105
+ () => {
106
+ const states = fontFacesComputed.get().map((face) => this.getFontState(face))
107
+ return states
108
+ },
109
+ { isEqual: areArraysShallowEqual }
110
+ )
111
+ }
112
+ )
113
+ }
114
+
115
+ private readonly shapeFontFacesCache
116
+ private readonly shapeFontLoadStateCache
117
+
118
+ getShapeFontFaces(shape: TLShape | TLShapeId): TLFontFace[] {
119
+ const shapeId = typeof shape === 'string' ? shape : shape.id
120
+ return this.shapeFontFacesCache.get(shapeId) ?? EMPTY_ARRAY
121
+ }
122
+
123
+ trackFontsForShape(shape: TLShape | TLShapeId) {
124
+ const shapeId = typeof shape === 'string' ? shape : shape.id
125
+ this.shapeFontLoadStateCache.get(shapeId)
126
+ }
127
+
128
+ async loadRequiredFontsForCurrentPage(limit = Infinity) {
129
+ const neededFonts = new Set<TLFontFace>()
130
+ for (const shapeId of this.editor.getCurrentPageShapeIds()) {
131
+ for (const font of this.getShapeFontFaces(this.editor.getShape(shapeId)!)) {
132
+ neededFonts.add(font)
133
+ }
134
+ }
135
+
136
+ if (neededFonts.size > limit) {
137
+ return
138
+ }
139
+
140
+ const promises = Array.from(neededFonts, (font) => this.ensureFontIsLoaded(font))
141
+ await Promise.all(promises)
142
+ }
143
+
144
+ private readonly fontStates = new AtomMap<TLFontFace, FontState>('font states')
145
+ private getFontState(font: TLFontFace): FontState | null {
146
+ return this.fontStates.get(font) ?? null
147
+ }
148
+
149
+ ensureFontIsLoaded(font: TLFontFace): Promise<void> {
150
+ const existingState = this.getFontState(font)
151
+ if (existingState) return existingState.loadingPromise
152
+
153
+ const instance = this.findOrCreateFontFace(font)
154
+ const state: FontState = {
155
+ state: 'loading',
156
+ instance,
157
+ loadingPromise: instance
158
+ .load()
159
+ .then(() => {
160
+ document.fonts.add(instance)
161
+ this.fontStates.update(font, (s) => ({ ...s, state: 'ready' }))
162
+ })
163
+ .catch((err) => {
164
+ console.error(err)
165
+ this.fontStates.update(font, (s) => ({ ...s, state: 'error' }))
166
+ }),
167
+ }
168
+
169
+ this.fontStates.set(font, state)
170
+ return state.loadingPromise
171
+ }
172
+
173
+ private fontsToLoad = new Set<TLFontFace>()
174
+ requestFonts(fonts: TLFontFace[]) {
175
+ if (!this.fontsToLoad.size) {
176
+ queueMicrotask(() => {
177
+ if (this.editor.isDisposed) return
178
+ const toLoad = this.fontsToLoad
179
+ this.fontsToLoad = new Set()
180
+ transact(() => {
181
+ for (const font of toLoad) {
182
+ this.ensureFontIsLoaded(font)
183
+ }
184
+ })
185
+ })
186
+ }
187
+ for (const font of fonts) {
188
+ this.fontsToLoad.add(font)
189
+ }
190
+ }
191
+
192
+ private findOrCreateFontFace(font: TLFontFace) {
193
+ for (const existing of document.fonts) {
194
+ if (
195
+ existing.family === font.family &&
196
+ objectMapEntries(defaultFontFaceDescriptors).every(
197
+ ([key, defaultValue]) => existing[key] === (font[key] ?? defaultValue)
198
+ )
199
+ ) {
200
+ return existing
201
+ }
202
+ }
203
+
204
+ const url = this.assetUrls?.[font.src.url] ?? font.src.url
205
+ const instance = new FontFace(font.family, `url(${JSON.stringify(url)})`, {
206
+ ...mapObjectMapValues(defaultFontFaceDescriptors, (key) => font[key]),
207
+ display: 'swap',
208
+ })
209
+
210
+ document.fonts.add(instance)
211
+
212
+ return instance
213
+ }
214
+
215
+ async toEmbeddedCssDeclaration(font: TLFontFace) {
216
+ const url = this.assetUrls?.[font.src.url] ?? font.src.url
217
+ const dataUrl = await FileHelpers.urlToDataUrl(url)
218
+
219
+ const src = compact([
220
+ `url("${dataUrl}")`,
221
+ font.src.format ? `format(${font.src.format})` : null,
222
+ font.src.tech ? `tech(${font.src.tech})` : null,
223
+ ]).join(' ')
224
+ return compact([
225
+ `@font-face {`,
226
+ ` font-family: "${font.family}";`,
227
+ font.ascentOverride ? ` ascent-override: ${font.ascentOverride};` : null,
228
+ font.descentOverride ? ` descent-override: ${font.descentOverride};` : null,
229
+ font.stretch ? ` font-stretch: ${font.stretch};` : null,
230
+ font.style ? ` font-style: ${font.style};` : null,
231
+ font.weight ? ` font-weight: ${font.weight};` : null,
232
+ font.featureSettings ? ` font-feature-settings: ${font.featureSettings};` : null,
233
+ font.lineGapOverride ? ` line-gap-override: ${font.lineGapOverride};` : null,
234
+ font.unicodeRange ? ` unicode-range: ${font.unicodeRange};` : null,
235
+ ` src: ${src};`,
236
+ `}`,
237
+ ]).join('\n')
238
+ }
239
+ }
240
+
241
+ // From https://drafts.csswg.org/css-font-loading/#fontface-interface
242
+ const defaultFontFaceDescriptors = {
243
+ style: 'normal',
244
+ weight: 'normal',
245
+ stretch: 'normal',
246
+ unicodeRange: 'U+0-10FFFF',
247
+ featureSettings: 'normal',
248
+ ascentOverride: 'normal',
249
+ descentOverride: 'normal',
250
+ lineGapOverride: 'normal',
251
+ }
@@ -65,32 +65,57 @@ export class TextManager {
65
65
  padding: string
66
66
  disableOverflowWrapBreaking?: boolean
67
67
  }
68
+ ): BoxModel & { scrollWidth: number } {
69
+ const div = document.createElement('div')
70
+ div.textContent = normalizeTextForDom(textToMeasure)
71
+ return this.measureHtml(div.innerHTML, opts)
72
+ }
73
+
74
+ measureHtml(
75
+ html: string,
76
+ opts: {
77
+ fontStyle: string
78
+ fontWeight: string
79
+ fontFamily: string
80
+ fontSize: number
81
+ lineHeight: number
82
+ /**
83
+ * When maxWidth is a number, the text will be wrapped to that maxWidth. When maxWidth
84
+ * is null, the text will be measured without wrapping, but explicit line breaks and
85
+ * space are preserved.
86
+ */
87
+ maxWidth: null | number
88
+ minWidth?: null | number
89
+ padding: string
90
+ disableOverflowWrapBreaking?: boolean
91
+ }
68
92
  ): BoxModel & { scrollWidth: number } {
69
93
  // Duplicate our base element; we don't need to clone deep
70
- const elm = this.baseElem.cloneNode() as HTMLDivElement
71
- this.editor.getContainer().appendChild(elm)
94
+ const wrapperElm = this.baseElem.cloneNode() as HTMLDivElement
95
+ this.editor.getContainer().appendChild(wrapperElm)
96
+ wrapperElm.innerHTML = html
97
+ this.baseElem.insertAdjacentElement('afterend', wrapperElm)
72
98
 
73
- elm.setAttribute('dir', 'auto')
99
+ wrapperElm.setAttribute('dir', 'auto')
74
100
  // N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers")
75
101
  // is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs.
76
- elm.style.setProperty('unicode-bidi', 'plaintext')
77
- elm.style.setProperty('font-family', opts.fontFamily)
78
- elm.style.setProperty('font-style', opts.fontStyle)
79
- elm.style.setProperty('font-weight', opts.fontWeight)
80
- elm.style.setProperty('font-size', opts.fontSize + 'px')
81
- elm.style.setProperty('line-height', opts.lineHeight * opts.fontSize + 'px')
82
- elm.style.setProperty('max-width', opts.maxWidth === null ? null : opts.maxWidth + 'px')
83
- elm.style.setProperty('min-width', opts.minWidth === null ? null : opts.minWidth + 'px')
84
- elm.style.setProperty('padding', opts.padding)
85
- elm.style.setProperty(
102
+ wrapperElm.style.setProperty('unicode-bidi', 'plaintext')
103
+ wrapperElm.style.setProperty('font-family', opts.fontFamily)
104
+ wrapperElm.style.setProperty('font-style', opts.fontStyle)
105
+ wrapperElm.style.setProperty('font-weight', opts.fontWeight)
106
+ wrapperElm.style.setProperty('font-size', opts.fontSize + 'px')
107
+ wrapperElm.style.setProperty('line-height', opts.lineHeight * opts.fontSize + 'px')
108
+ wrapperElm.style.setProperty('max-width', opts.maxWidth === null ? null : opts.maxWidth + 'px')
109
+ wrapperElm.style.setProperty('min-width', opts.minWidth === null ? null : opts.minWidth + 'px')
110
+ wrapperElm.style.setProperty('padding', opts.padding)
111
+ wrapperElm.style.setProperty(
86
112
  'overflow-wrap',
87
113
  opts.disableOverflowWrapBreaking ? 'normal' : 'break-word'
88
114
  )
89
115
 
90
- elm.textContent = normalizeTextForDom(textToMeasure)
91
- const scrollWidth = elm.scrollWidth
92
- const rect = elm.getBoundingClientRect()
93
- elm.remove()
116
+ const scrollWidth = wrapperElm.scrollWidth
117
+ const rect = wrapperElm.getBoundingClientRect()
118
+ wrapperElm.remove()
94
119
 
95
120
  return {
96
121
  x: 0,
@@ -1,4 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import { EMPTY_ARRAY } from '@tldraw/state'
2
3
  import { LegacyMigrations, MigrationSequence } from '@tldraw/store'
3
4
  import {
4
5
  RecordProps,
@@ -14,6 +15,7 @@ import { Box, SelectionHandle } from '../../primitives/Box'
14
15
  import { Vec } from '../../primitives/Vec'
15
16
  import { Geometry2d } from '../../primitives/geometry/Geometry2d'
16
17
  import type { Editor } from '../Editor'
18
+ import { TLFontFace } from '../managers/FontManager'
17
19
  import { BoundsSnapGeometry } from '../managers/SnapManager/BoundsSnaps'
18
20
  import { HandleSnapGeometry } from '../managers/SnapManager/HandleSnaps'
19
21
  import { SvgExportContext } from '../types/SvgExportContext'
@@ -170,6 +172,17 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
170
172
  */
171
173
  abstract indicator(shape: Shape): any
172
174
 
175
+ /**
176
+ * Get the font faces that should be rendered in the document in order for this shape to render
177
+ * correctly.
178
+ *
179
+ * @param shape - The shape.
180
+ * @public
181
+ */
182
+ getFontFaces(shape: Shape): TLFontFace[] {
183
+ return EMPTY_ARRAY
184
+ }
185
+
173
186
  /**
174
187
  * Whether the shape can be snapped to by another shape.
175
188
  *
@@ -17,6 +17,7 @@ export interface TLEventMap {
17
17
  tick: [number]
18
18
  frame: [number]
19
19
  'select-all-text': [{ shapeId: TLShapeId }]
20
+ 'place-caret': [{ shapeId: TLShapeId; point: { x: number; y: number } }]
20
21
  }
21
22
 
22
23
  /** @public */
@@ -45,6 +45,7 @@ export interface TLBaseExternalContent {
45
45
  export interface TLTextExternalContent extends TLBaseExternalContent {
46
46
  type: 'text'
47
47
  text: string
48
+ html?: string
48
49
  }
49
50
 
50
51
  /** @public */
@@ -2,6 +2,8 @@ import { assert, bind, compact } from '@tldraw/utils'
2
2
  import { fetchCache, resourceToDataUrl } from './fetchCache'
3
3
  import { ParsedFontFace, parseCss, parseCssFontFaces, parseCssFontFamilyValue } from './parseCss'
4
4
 
5
+ export const SVG_EXPORT_CLASSNAME = 'tldraw-svg-export'
6
+
5
7
  /**
6
8
  * Because SVGs cannot refer to external CSS/font resources, any web fonts used in the SVG must be
7
9
  * embedded as data URLs in inlined @font-face declarations. This class is responsible for
@@ -81,7 +83,17 @@ export class FontEmbedder {
81
83
  async function getCurrentDocumentFontFaces() {
82
84
  const fontFaces: (ParsedFontFace[] | Promise<ParsedFontFace[] | null>)[] = []
83
85
 
84
- for (const styleSheet of document.styleSheets) {
86
+ // In exportToSvg we add the exported node to the DOM temporarily.
87
+ // Because of this, and because we do a setTimeout to delay removing that node from the
88
+ // DOM, when looking at document.styleSheets the number of nodes and stylesheets
89
+ // can grow unbounded (especially when using "Debug svg" and moving shapes around).
90
+ // To avoid this, we filter out the stylesheets that are part of the SVG export.
91
+ const styleSheetsWithoutSvgExports = Array.from(document.styleSheets).filter(
92
+ (styleSheet) =>
93
+ !(styleSheet.ownerNode as HTMLElement | null)?.closest(`.${SVG_EXPORT_CLASSNAME}`)
94
+ )
95
+
96
+ for (const styleSheet of styleSheetsWithoutSvgExports) {
85
97
  let cssRules
86
98
  try {
87
99
  cssRules = styleSheet.cssRules
@@ -242,7 +242,7 @@ function styleFromComputedStyle(
242
242
  { defaultStyles, parentStyles }: ReadStyleOpts
243
243
  ) {
244
244
  const styles: Record<string, string> = {}
245
- for (const property of style) {
245
+ for (const [property, _] of Object.entries(style)) {
246
246
  if (!shouldIncludeCssProperty(property)) continue
247
247
 
248
248
  const value = style.getPropertyValue(property)
@@ -4,6 +4,7 @@ import { flushSync } from 'react-dom'
4
4
  import { createRoot } from 'react-dom/client'
5
5
  import { Editor } from '../editor/Editor'
6
6
  import { TLSvgExportOptions } from '../editor/types/misc-types'
7
+ import { SVG_EXPORT_CLASSNAME } from './FontEmbedder'
7
8
  import { StyleEmbedder } from './StyleEmbedder'
8
9
  import { embedMedia } from './embedMedia'
9
10
  import { getSvgJsx } from './getSvgJsx'
@@ -26,7 +27,7 @@ export async function exportToSvg(
26
27
  // <foreignObject> elements have their styles and content inlined correctly.
27
28
  const container = editor.getContainer()
28
29
  const renderTarget = document.createElement('div')
29
- renderTarget.className = 'tldraw-svg-export'
30
+ renderTarget.className = SVG_EXPORT_CLASSNAME
30
31
  // we hide the element visually, but we don't want it to be focusable or interactive in any way either
31
32
  renderTarget.inert = true
32
33
  renderTarget.tabIndex = -1
@@ -83,9 +84,9 @@ export async function exportToSvg(
83
84
 
84
85
  async function applyChangesToForeignObjects(svg: SVGSVGElement) {
85
86
  // If any shapes have their own <foreignObject> elements, we don't want to mess with them. Our
86
- // ones that we need to embed will have a class of `tl-shape-foreign-object`.
87
+ // ones that we need to embed will have a class of `tl-export-embed-styles`.
87
88
  const foreignObjectChildren = [
88
- ...svg.querySelectorAll('foreignObject.tl-shape-foreign-object > *'),
89
+ ...svg.querySelectorAll('foreignObject.tl-export-embed-styles > *'),
89
90
  ]
90
91
  if (!foreignObjectChildren.length) return
91
92