tldraw 4.3.0-canary.35392ae6dc0d → 4.3.0-canary.37e6bf0fa8c6

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 (124) hide show
  1. package/dist-cjs/index.d.ts +9 -0
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/canvas/TldrawSelectionForeground.js +2 -2
  4. package/dist-cjs/lib/canvas/TldrawSelectionForeground.js.map +2 -2
  5. package/dist-cjs/lib/shapes/arrow/ArrowShapeUtil.js +9 -12
  6. package/dist-cjs/lib/shapes/arrow/ArrowShapeUtil.js.map +2 -2
  7. package/dist-cjs/lib/shapes/arrow/arrow-types.js.map +1 -1
  8. package/dist-cjs/lib/shapes/draw/DrawShapeUtil.js +3 -3
  9. package/dist-cjs/lib/shapes/draw/DrawShapeUtil.js.map +2 -2
  10. package/dist-cjs/lib/shapes/frame/FrameShapeUtil.js +1 -1
  11. package/dist-cjs/lib/shapes/frame/FrameShapeUtil.js.map +2 -2
  12. package/dist-cjs/lib/shapes/geo/GeoShapeUtil.js +10 -6
  13. package/dist-cjs/lib/shapes/geo/GeoShapeUtil.js.map +2 -2
  14. package/dist-cjs/lib/shapes/highlight/HighlightShapeUtil.js +1 -1
  15. package/dist-cjs/lib/shapes/highlight/HighlightShapeUtil.js.map +2 -2
  16. package/dist-cjs/lib/shapes/note/NoteShapeUtil.js +5 -5
  17. package/dist-cjs/lib/shapes/note/NoteShapeUtil.js.map +2 -2
  18. package/dist-cjs/lib/shapes/shared/HyperlinkButton.js +2 -1
  19. package/dist-cjs/lib/shapes/shared/HyperlinkButton.js.map +2 -2
  20. package/dist-cjs/lib/shapes/shared/PlainTextLabel.js +14 -2
  21. package/dist-cjs/lib/shapes/shared/PlainTextLabel.js.map +3 -3
  22. package/dist-cjs/lib/shapes/shared/RichTextLabel.js +11 -3
  23. package/dist-cjs/lib/shapes/shared/RichTextLabel.js.map +3 -3
  24. package/dist-cjs/lib/shapes/shared/ShapeFill.js +2 -2
  25. package/dist-cjs/lib/shapes/shared/ShapeFill.js.map +2 -2
  26. package/dist-cjs/lib/shapes/shared/{useForceSolid.js → useEfficientZoomThreshold.js} +10 -7
  27. package/dist-cjs/lib/shapes/shared/useEfficientZoomThreshold.js.map +7 -0
  28. package/dist-cjs/lib/shapes/shared/useImageOrVideoAsset.js +1 -1
  29. package/dist-cjs/lib/shapes/shared/useImageOrVideoAsset.js.map +2 -2
  30. package/dist-cjs/lib/shapes/text/TextShapeUtil.js +5 -2
  31. package/dist-cjs/lib/shapes/text/TextShapeUtil.js.map +2 -2
  32. package/dist-cjs/lib/shapes/video/VideoShapeUtil.js +1 -1
  33. package/dist-cjs/lib/shapes/video/VideoShapeUtil.js.map +2 -2
  34. package/dist-cjs/lib/tools/SelectTool/childStates/EditingShape.js +30 -10
  35. package/dist-cjs/lib/tools/SelectTool/childStates/EditingShape.js.map +2 -2
  36. package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.js +3 -9
  37. package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.js.map +2 -2
  38. package/dist-cjs/lib/ui/components/ZoomMenu/DefaultZoomMenu.js +1 -1
  39. package/dist-cjs/lib/ui/components/ZoomMenu/DefaultZoomMenu.js.map +2 -2
  40. package/dist-cjs/lib/ui/components/menu-items.js +3 -1
  41. package/dist-cjs/lib/ui/components/menu-items.js.map +2 -2
  42. package/dist-cjs/lib/ui/components/primitives/TldrawUiTooltip.js +1 -13
  43. package/dist-cjs/lib/ui/components/primitives/TldrawUiTooltip.js.map +2 -2
  44. package/dist-cjs/lib/ui/version.js +3 -3
  45. package/dist-cjs/lib/ui/version.js.map +1 -1
  46. package/dist-cjs/lib/utils/text/richText.js +7 -17
  47. package/dist-cjs/lib/utils/text/richText.js.map +3 -3
  48. package/dist-esm/index.d.mts +9 -0
  49. package/dist-esm/index.mjs +1 -1
  50. package/dist-esm/lib/canvas/TldrawSelectionForeground.mjs +2 -2
  51. package/dist-esm/lib/canvas/TldrawSelectionForeground.mjs.map +2 -2
  52. package/dist-esm/lib/shapes/arrow/ArrowShapeUtil.mjs +10 -14
  53. package/dist-esm/lib/shapes/arrow/ArrowShapeUtil.mjs.map +2 -2
  54. package/dist-esm/lib/shapes/draw/DrawShapeUtil.mjs +3 -3
  55. package/dist-esm/lib/shapes/draw/DrawShapeUtil.mjs.map +2 -2
  56. package/dist-esm/lib/shapes/frame/FrameShapeUtil.mjs +1 -1
  57. package/dist-esm/lib/shapes/frame/FrameShapeUtil.mjs.map +2 -2
  58. package/dist-esm/lib/shapes/geo/GeoShapeUtil.mjs +10 -6
  59. package/dist-esm/lib/shapes/geo/GeoShapeUtil.mjs.map +2 -2
  60. package/dist-esm/lib/shapes/highlight/HighlightShapeUtil.mjs +1 -1
  61. package/dist-esm/lib/shapes/highlight/HighlightShapeUtil.mjs.map +2 -2
  62. package/dist-esm/lib/shapes/note/NoteShapeUtil.mjs +5 -5
  63. package/dist-esm/lib/shapes/note/NoteShapeUtil.mjs.map +2 -2
  64. package/dist-esm/lib/shapes/shared/HyperlinkButton.mjs +3 -2
  65. package/dist-esm/lib/shapes/shared/HyperlinkButton.mjs.map +2 -2
  66. package/dist-esm/lib/shapes/shared/PlainTextLabel.mjs +14 -2
  67. package/dist-esm/lib/shapes/shared/PlainTextLabel.mjs.map +2 -2
  68. package/dist-esm/lib/shapes/shared/RichTextLabel.mjs +11 -3
  69. package/dist-esm/lib/shapes/shared/RichTextLabel.mjs.map +2 -2
  70. package/dist-esm/lib/shapes/shared/ShapeFill.mjs +2 -2
  71. package/dist-esm/lib/shapes/shared/ShapeFill.mjs.map +2 -2
  72. package/dist-esm/lib/shapes/shared/useEfficientZoomThreshold.mjs +12 -0
  73. package/dist-esm/lib/shapes/shared/useEfficientZoomThreshold.mjs.map +7 -0
  74. package/dist-esm/lib/shapes/shared/useImageOrVideoAsset.mjs +1 -1
  75. package/dist-esm/lib/shapes/shared/useImageOrVideoAsset.mjs.map +2 -2
  76. package/dist-esm/lib/shapes/text/TextShapeUtil.mjs +5 -2
  77. package/dist-esm/lib/shapes/text/TextShapeUtil.mjs.map +2 -2
  78. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs +1 -1
  79. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs.map +2 -2
  80. package/dist-esm/lib/tools/SelectTool/childStates/EditingShape.mjs +30 -10
  81. package/dist-esm/lib/tools/SelectTool/childStates/EditingShape.mjs.map +2 -2
  82. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.mjs +2 -8
  83. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.mjs.map +2 -2
  84. package/dist-esm/lib/ui/components/ZoomMenu/DefaultZoomMenu.mjs +1 -1
  85. package/dist-esm/lib/ui/components/ZoomMenu/DefaultZoomMenu.mjs.map +2 -2
  86. package/dist-esm/lib/ui/components/menu-items.mjs +3 -1
  87. package/dist-esm/lib/ui/components/menu-items.mjs.map +2 -2
  88. package/dist-esm/lib/ui/components/primitives/TldrawUiTooltip.mjs +9 -14
  89. package/dist-esm/lib/ui/components/primitives/TldrawUiTooltip.mjs.map +2 -2
  90. package/dist-esm/lib/ui/version.mjs +3 -3
  91. package/dist-esm/lib/ui/version.mjs.map +1 -1
  92. package/dist-esm/lib/utils/text/richText.mjs +3 -3
  93. package/dist-esm/lib/utils/text/richText.mjs.map +2 -2
  94. package/package.json +3 -3
  95. package/src/lib/canvas/TldrawSelectionForeground.tsx +2 -2
  96. package/src/lib/shapes/arrow/ArrowShapeUtil.tsx +10 -12
  97. package/src/lib/shapes/arrow/arrow-types.ts +2 -0
  98. package/src/lib/shapes/draw/DrawShapeUtil.tsx +3 -3
  99. package/src/lib/shapes/frame/FrameShapeUtil.tsx +1 -1
  100. package/src/lib/shapes/geo/GeoShapeUtil.tsx +9 -4
  101. package/src/lib/shapes/highlight/HighlightShapeUtil.tsx +1 -1
  102. package/src/lib/shapes/note/NoteShapeUtil.tsx +7 -8
  103. package/src/lib/shapes/shared/HyperlinkButton.tsx +3 -2
  104. package/src/lib/shapes/shared/PlainTextLabel.tsx +10 -1
  105. package/src/lib/shapes/shared/RichTextLabel.tsx +11 -2
  106. package/src/lib/shapes/shared/ShapeFill.tsx +2 -2
  107. package/src/lib/shapes/shared/useEfficientZoomThreshold.ts +10 -0
  108. package/src/lib/shapes/shared/useImageOrVideoAsset.ts +1 -1
  109. package/src/lib/shapes/text/TextShapeUtil.tsx +5 -0
  110. package/src/lib/shapes/video/VideoShapeUtil.tsx +2 -1
  111. package/src/lib/tools/SelectTool/childStates/EditingShape.ts +44 -11
  112. package/src/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.tsx +1 -9
  113. package/src/lib/ui/components/ZoomMenu/DefaultZoomMenu.tsx +1 -1
  114. package/src/lib/ui/components/menu-items.tsx +3 -1
  115. package/src/lib/ui/components/primitives/TldrawUiTooltip.tsx +10 -15
  116. package/src/lib/ui/version.ts +3 -3
  117. package/src/lib/utils/text/richText.ts +3 -3
  118. package/src/test/commands/__snapshots__/getSvgString.test.ts.snap +2 -2
  119. package/src/test/commands/cameraState.test.ts +299 -0
  120. package/tldraw.css +8 -4
  121. package/dist-cjs/lib/shapes/shared/useForceSolid.js.map +0 -7
  122. package/dist-esm/lib/shapes/shared/useForceSolid.mjs +0 -9
  123. package/dist-esm/lib/shapes/shared/useForceSolid.mjs.map +0 -7
  124. package/src/lib/shapes/shared/useForceSolid.ts +0 -6
@@ -43,6 +43,8 @@ const sizeCache = createComputedCache(
43
43
  export interface TextShapeOptions {
44
44
  /** How much addition padding should be added to the horizontal geometry of the shape when binding to an arrow? */
45
45
  extraArrowHorizontalPadding: number
46
+ /** Whether to show the outline of the text shape (using the same color as the canvas). This helps with overlapping shapes. It does not show up on Safari, where text outline is a performance issues. */
47
+ showTextOutline: boolean
46
48
  }
47
49
 
48
50
  /** @public */
@@ -53,6 +55,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
53
55
 
54
56
  override options: TextShapeOptions = {
55
57
  extraArrowHorizontalPadding: 10,
58
+ showTextOutline: true,
56
59
  }
57
60
 
58
61
  getDefaultProps(): TLTextShape['props'] {
@@ -140,6 +143,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
140
143
  isSelected={isSelected}
141
144
  textWidth={width}
142
145
  textHeight={height}
146
+ showTextOutline={this.options.showTextOutline}
143
147
  style={{
144
148
  transform: `scale(${scale})`,
145
149
  transformOrigin: 'top left',
@@ -175,6 +179,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
175
179
  labelColor={getColorValue(theme, shape.props.color, 'solid')}
176
180
  bounds={exportBounds}
177
181
  padding={0}
182
+ showTextOutline={this.options.showTextOutline}
178
183
  />
179
184
  )
180
185
  }
@@ -95,7 +95,8 @@ export class VideoShapeUtil extends BaseBoxShapeUtil<TLVideoShape> {
95
95
 
96
96
  const VideoShape = memo(function VideoShape({ shape }: { shape: TLVideoShape }) {
97
97
  const editor = useEditor()
98
- const showControls = editor.getShapeGeometry(shape).bounds.w * editor.getZoomLevel() >= 110
98
+ const showControls =
99
+ editor.getShapeGeometry(shape).bounds.w * editor.getEfficientZoomLevel() >= 110
99
100
  const isEditing = useIsEditing(shape.id)
100
101
  const prefersReducedMotion = usePrefersReducedMotion()
101
102
  const { Spinner } = useEditorComponents()
@@ -18,13 +18,25 @@ interface EditingShapeInfo {
18
18
  export class EditingShape extends StateNode {
19
19
  static override id = 'editing_shape'
20
20
 
21
- hitShapeForPointerUp: TLShape | null = null
21
+ hitLabelOnShapeForPointerUp: TLShape | null = null
22
22
  private info = {} as EditingShapeInfo
23
+ private didPointerDownOnEditingShape = false
24
+
25
+ private isTextInputFocused(): boolean {
26
+ const container = this.editor.getContainer()
27
+ return (
28
+ container.contains(document.activeElement) &&
29
+ (document.activeElement?.nodeName === 'INPUT' ||
30
+ document.activeElement?.nodeName === 'TEXTAREA' ||
31
+ (document.activeElement as HTMLElement)?.isContentEditable)
32
+ )
33
+ }
23
34
 
24
35
  override onEnter(info: EditingShapeInfo) {
25
36
  const editingShape = this.editor.getEditingShape()
26
37
  if (!editingShape) throw Error('Entered editing state without an editing shape')
27
- this.hitShapeForPointerUp = null
38
+ this.hitLabelOnShapeForPointerUp = null
39
+ this.didPointerDownOnEditingShape = false
28
40
 
29
41
  this.info = info
30
42
 
@@ -54,15 +66,34 @@ export class EditingShape extends StateNode {
54
66
  override onPointerMove(info: TLPointerEventInfo) {
55
67
  // In the case where on pointer down we hit a shape's label, we need to check if the user is dragging.
56
68
  // and if they are, we need to transition to translating instead.
57
- if (this.hitShapeForPointerUp && this.editor.inputs.isDragging) {
69
+ if (this.hitLabelOnShapeForPointerUp && this.editor.inputs.isDragging) {
58
70
  if (this.editor.getIsReadonly()) return
59
- if (this.hitShapeForPointerUp.isLocked) return
60
- this.editor.select(this.hitShapeForPointerUp)
71
+ if (this.hitLabelOnShapeForPointerUp.isLocked) return
72
+
73
+ this.editor.select(this.hitLabelOnShapeForPointerUp)
61
74
  this.parent.transition('translating', info)
62
- this.hitShapeForPointerUp = null
75
+ this.hitLabelOnShapeForPointerUp = null
63
76
  return
64
77
  }
65
78
 
79
+ // Check if dragging from editing shape with blurred input
80
+ if (this.didPointerDownOnEditingShape && this.editor.inputs.isDragging) {
81
+ if (this.editor.getIsReadonly()) return
82
+
83
+ const editingShape = this.editor.getEditingShape()
84
+ if (!editingShape || editingShape.isLocked) return
85
+
86
+ if (!this.isTextInputFocused()) {
87
+ // Input blurred during drag - exit edit mode and start translating
88
+ this.editor.select(editingShape)
89
+ this.parent.transition('translating', info)
90
+ this.didPointerDownOnEditingShape = false
91
+ return
92
+ }
93
+ // Input still focused - user is selecting text, stay in edit mode
94
+ this.didPointerDownOnEditingShape = false
95
+ }
96
+
66
97
  switch (info.target) {
67
98
  case 'shape':
68
99
  case 'canvas': {
@@ -73,7 +104,8 @@ export class EditingShape extends StateNode {
73
104
  }
74
105
 
75
106
  override onPointerDown(info: TLPointerEventInfo) {
76
- this.hitShapeForPointerUp = null
107
+ this.hitLabelOnShapeForPointerUp = null
108
+ this.didPointerDownOnEditingShape = false
77
109
 
78
110
  switch (info.target) {
79
111
  // N.B. This bit of logic has a bit of history to it.
@@ -120,10 +152,11 @@ export class EditingShape extends StateNode {
120
152
  ) {
121
153
  // it's a hit to the label!
122
154
  if (selectingShape.id === editingShape.id) {
123
- // If we clicked on the editing geo / arrow shape's label, do nothing
155
+ // Track click on editing shape for drag detection
156
+ this.didPointerDownOnEditingShape = true
124
157
  return
125
158
  } else {
126
- this.hitShapeForPointerUp = selectingShape
159
+ this.hitLabelOnShapeForPointerUp = selectingShape
127
160
 
128
161
  this.editor.markHistoryStoppingPoint('editing on pointer up')
129
162
  this.editor.select(selectingShape.id)
@@ -157,9 +190,9 @@ export class EditingShape extends StateNode {
157
190
 
158
191
  override onPointerUp(info: TLPointerEventInfo) {
159
192
  // If we're not dragging, and it's a hit to the label, begin editing the shape.
160
- const hitShape = this.hitShapeForPointerUp
193
+ const hitShape = this.hitLabelOnShapeForPointerUp
161
194
  if (!hitShape) return
162
- this.hitShapeForPointerUp = null
195
+ this.hitLabelOnShapeForPointerUp = null
163
196
 
164
197
  // Stay in edit mode to maintain flow of editing.
165
198
  const util = this.editor.getShapeUtil(hitShape)
@@ -1,4 +1,3 @@
1
- import { useEditor, useValue } from '@tldraw/editor'
2
1
  import { PORTRAIT_BREAKPOINT } from '../../constants'
3
2
  import { useBreakpoint } from '../../context/breakpoints'
4
3
  import {
@@ -9,6 +8,7 @@ import {
9
8
  useThreeStackableItems,
10
9
  useUnlockedSelectedShapesCount,
11
10
  } from '../../hooks/menu-hooks'
11
+ import { ZoomTo100MenuItem } from '../menu-items'
12
12
  import { TldrawUiMenuActionItem } from '../primitives/menus/TldrawUiMenuActionItem'
13
13
 
14
14
  /** @public @react */
@@ -99,14 +99,6 @@ export function ZoomOrRotateMenuItem() {
99
99
  }
100
100
  /** @public @react */
101
101
 
102
- export function ZoomTo100MenuItem() {
103
- const editor = useEditor()
104
- const isZoomedTo100 = useValue('zoom is 1', () => editor.getZoomLevel() === 1, [editor])
105
-
106
- return <TldrawUiMenuActionItem actionId="zoom-to-100" disabled={isZoomedTo100} />
107
- }
108
- /** @public @react */
109
-
110
102
  export function RotateCCWMenuItem() {
111
103
  const oneSelected = useUnlockedSelectedShapesCount(1)
112
104
  const isInSelectState = useIsInSelectState()
@@ -48,7 +48,7 @@ export const DefaultZoomMenu = memo(function DefaultZoomMenu({ children }: TLUiZ
48
48
  const ZoomTriggerButton = () => {
49
49
  const editor = useEditor()
50
50
  const breakpoint = useBreakpoint()
51
- const zoom = useValue('zoom', () => editor.getZoomLevel(), [editor])
51
+ const zoom = useValue('zoom', () => editor.getEfficientZoomLevel(), [editor])
52
52
  const msg = useTranslation()
53
53
 
54
54
  const handleDoubleClick = useCallback(() => {
@@ -182,7 +182,9 @@ export function UnlockAllMenuItem() {
182
182
  /** @public @react */
183
183
  export function ZoomTo100MenuItem() {
184
184
  const editor = useEditor()
185
- const isZoomedTo100 = useValue('zoomed to 100', () => editor.getZoomLevel() === 1, [editor])
185
+ const isZoomedTo100 = useValue('zoomed to 100', () => editor.getEfficientZoomLevel() === 1, [
186
+ editor,
187
+ ])
186
188
 
187
189
  return <TldrawUiMenuActionItem actionId="zoom-to-100" noClose disabled={isZoomedTo100} />
188
190
  }
@@ -1,4 +1,12 @@
1
- import { assert, Atom, atom, Editor, uniqueId, useMaybeEditor, useValue } from '@tldraw/editor'
1
+ import {
2
+ assert,
3
+ atom,
4
+ Editor,
5
+ tlenvReactive,
6
+ uniqueId,
7
+ useMaybeEditor,
8
+ useValue,
9
+ } from '@tldraw/editor'
2
10
  import { Tooltip as _Tooltip } from 'radix-ui'
3
11
  import React, {
4
12
  createContext,
@@ -161,22 +169,9 @@ class TooltipManager {
161
169
  }
162
170
 
163
171
  if (!tooltip) return null
164
- if (!this.supportsHover() && !tooltip.showOnMobile) return null
172
+ if (tlenvReactive.get().isCoarsePointer && !tooltip.showOnMobile) return null
165
173
  return tooltip
166
174
  }
167
-
168
- private supportsHoverAtom: Atom<boolean> | null = null
169
- supportsHover() {
170
- if (!this.supportsHoverAtom) {
171
- const mediaQuery = window.matchMedia('(hover: hover)')
172
- const supportsHover = atom('has hover', mediaQuery.matches)
173
- this.supportsHoverAtom = supportsHover
174
- mediaQuery.addEventListener('change', (e) => {
175
- supportsHover.set(e.matches)
176
- })
177
- }
178
- return this.supportsHoverAtom.get()
179
- }
180
175
  }
181
176
 
182
177
  const tooltipManager = TooltipManager.getInstance()
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '4.3.0-canary.35392ae6dc0d'
4
+ export const version = '4.3.0-canary.37e6bf0fa8c6'
5
5
  export const publishDates = {
6
6
  major: '2025-09-18T14:39:22.803Z',
7
- minor: '2025-12-05T09:46:38.266Z',
8
- patch: '2025-12-05T09:46:38.266Z',
7
+ minor: '2025-12-09T10:38:11.018Z',
8
+ patch: '2025-12-09T10:38:11.018Z',
9
9
  }
@@ -6,10 +6,10 @@ import {
6
6
  generateText,
7
7
  JSONContent,
8
8
  } from '@tiptap/core'
9
- import Code from '@tiptap/extension-code'
10
- import Highlight from '@tiptap/extension-highlight'
9
+ import { Code } from '@tiptap/extension-code'
10
+ import { Highlight } from '@tiptap/extension-highlight'
11
11
  import { Node } from '@tiptap/pm/model'
12
- import StarterKit from '@tiptap/starter-kit'
12
+ import { StarterKit } from '@tiptap/starter-kit'
13
13
  import {
14
14
  Editor,
15
15
  getOwnProperty,
@@ -83,7 +83,7 @@ exports[`Matches a snapshot > Basic SVG 1`] = `
83
83
  stroke-width="3.5"
84
84
  />
85
85
  <foreignobject
86
- class="tl-export-embed-styles tl-rich-text tl-rich-text-svg"
86
+ class="tl-export-embed-styles tl-rich-text tl-rich-text-svg tl-text__outline"
87
87
  height="100"
88
88
  width="100"
89
89
  x="0"
@@ -223,7 +223,7 @@ exports[`Returns all shapes when no ids are provided > All shapes 1`] = `
223
223
  stroke-width="3.5"
224
224
  />
225
225
  <foreignobject
226
- class="tl-export-embed-styles tl-rich-text tl-rich-text-svg"
226
+ class="tl-export-embed-styles tl-rich-text tl-rich-text-svg tl-text__outline"
227
227
  height="100"
228
228
  width="100"
229
229
  x="0"
@@ -0,0 +1,299 @@
1
+ import { Box } from '@tldraw/editor'
2
+ import { TestEditor } from '../TestEditor'
3
+
4
+ let editor: TestEditor
5
+
6
+ beforeEach(() => {
7
+ editor = new TestEditor()
8
+ editor.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
9
+ })
10
+
11
+ describe('getCameraState', () => {
12
+ it('starts as idle', () => {
13
+ expect(editor.getCameraState()).toBe('idle')
14
+ })
15
+
16
+ it('becomes moving when the camera changes via setCamera', () => {
17
+ expect(editor.getCameraState()).toBe('idle')
18
+ editor.setCamera({ x: 100, y: 100, z: 1 })
19
+ expect(editor.getCameraState()).toBe('moving')
20
+ })
21
+
22
+ it('becomes moving when the camera changes via pan', () => {
23
+ expect(editor.getCameraState()).toBe('idle')
24
+ editor.pan({ x: 100, y: 100 })
25
+ expect(editor.getCameraState()).toBe('moving')
26
+ })
27
+
28
+ it('becomes moving when the camera changes via zoomIn', () => {
29
+ expect(editor.getCameraState()).toBe('idle')
30
+ editor.zoomIn(undefined, { immediate: true })
31
+ expect(editor.getCameraState()).toBe('moving')
32
+ })
33
+
34
+ it('returns to idle after the timeout elapses', () => {
35
+ expect(editor.getCameraState()).toBe('idle')
36
+ editor.setCamera({ x: 100, y: 100, z: 1 })
37
+ expect(editor.getCameraState()).toBe('moving')
38
+
39
+ // The default timeout is 64ms (options.cameraMovingTimeoutMs)
40
+ // Each tick is 16ms, so we need ~4 ticks to elapse
41
+ editor.forceTick(5)
42
+ expect(editor.getCameraState()).toBe('idle')
43
+ })
44
+
45
+ it('stays moving while camera continues to change', () => {
46
+ expect(editor.getCameraState()).toBe('idle')
47
+ editor.setCamera({ x: 100, y: 100, z: 1 })
48
+ expect(editor.getCameraState()).toBe('moving')
49
+
50
+ // Move again before timeout elapses
51
+ editor.forceTick(2)
52
+ editor.setCamera({ x: 200, y: 200, z: 1 })
53
+ expect(editor.getCameraState()).toBe('moving')
54
+
55
+ // Move again
56
+ editor.forceTick(2)
57
+ editor.setCamera({ x: 300, y: 300, z: 1 })
58
+ expect(editor.getCameraState()).toBe('moving')
59
+
60
+ // Now let it settle
61
+ editor.forceTick(5)
62
+ expect(editor.getCameraState()).toBe('idle')
63
+ })
64
+
65
+ it('stays idle when camera position does not actually change', () => {
66
+ expect(editor.getCameraState()).toBe('idle')
67
+
68
+ // Setting the same camera position should not trigger moving state
69
+ const currentCamera = editor.getCamera()
70
+ editor.setCamera({ x: currentCamera.x, y: currentCamera.y, z: currentCamera.z })
71
+ expect(editor.getCameraState()).toBe('idle')
72
+ })
73
+
74
+ it('does not add multiple tick listeners when camera changes rapidly', () => {
75
+ // This test verifies the fix: we should not have redundant listeners
76
+ expect(editor.getCameraState()).toBe('idle')
77
+
78
+ // Change camera multiple times rapidly
79
+ editor.setCamera({ x: 100, y: 100, z: 1 })
80
+ editor.setCamera({ x: 200, y: 200, z: 1 })
81
+ editor.setCamera({ x: 300, y: 300, z: 1 })
82
+
83
+ expect(editor.getCameraState()).toBe('moving')
84
+
85
+ // After timeout, should return to idle exactly once
86
+ // If there were multiple listeners, the state might behave unexpectedly
87
+ editor.forceTick(5)
88
+ expect(editor.getCameraState()).toBe('idle')
89
+ })
90
+
91
+ it('resets timeout when camera changes while already moving', () => {
92
+ expect(editor.getCameraState()).toBe('idle')
93
+ editor.setCamera({ x: 100, y: 100, z: 1 })
94
+ expect(editor.getCameraState()).toBe('moving')
95
+
96
+ // Wait almost until timeout
97
+ editor.forceTick(3)
98
+ expect(editor.getCameraState()).toBe('moving')
99
+
100
+ // Change camera again - should reset timeout
101
+ editor.setCamera({ x: 200, y: 200, z: 1 })
102
+ expect(editor.getCameraState()).toBe('moving')
103
+
104
+ // Wait 3 more ticks - would have been idle if timeout wasn't reset
105
+ editor.forceTick(3)
106
+ expect(editor.getCameraState()).toBe('moving')
107
+
108
+ // Now let it fully settle
109
+ editor.forceTick(3)
110
+ expect(editor.getCameraState()).toBe('idle')
111
+ })
112
+ })
113
+
114
+ describe('camera state with zoom', () => {
115
+ it('becomes moving on zoomOut', () => {
116
+ expect(editor.getCameraState()).toBe('idle')
117
+ editor.zoomOut(undefined, { immediate: true })
118
+ expect(editor.getCameraState()).toBe('moving')
119
+ })
120
+
121
+ it('becomes moving on centerOnPoint', () => {
122
+ expect(editor.getCameraState()).toBe('idle')
123
+ editor.centerOnPoint({ x: 500, y: 500 })
124
+ expect(editor.getCameraState()).toBe('moving')
125
+ })
126
+
127
+ it('becomes moving on zoomToFit', () => {
128
+ // Create a shape so zoomToFit has something to fit
129
+ editor.createShape({ type: 'geo', x: 100, y: 100, props: { w: 200, h: 200 } })
130
+ expect(editor.getCameraState()).toBe('idle')
131
+ editor.zoomToFit({ immediate: true })
132
+ expect(editor.getCameraState()).toBe('moving')
133
+ })
134
+ })
135
+
136
+ describe('getDebouncedZoomLevel', () => {
137
+ it('returns the current zoom level when camera is idle', () => {
138
+ expect(editor.getCameraState()).toBe('idle')
139
+ expect(editor.getDebouncedZoomLevel()).toBe(editor.getZoomLevel())
140
+
141
+ // Change zoom and let it settle
142
+ editor.zoomIn(undefined, { immediate: true })
143
+ editor.forceTick(5)
144
+ expect(editor.getCameraState()).toBe('idle')
145
+ expect(editor.getDebouncedZoomLevel()).toBe(editor.getZoomLevel())
146
+ })
147
+
148
+ it('captures zoom when camera starts moving', () => {
149
+ expect(editor.getCameraState()).toBe('idle')
150
+
151
+ // Start zooming - the debounced zoom is captured when movement starts
152
+ editor.zoomIn(undefined, { immediate: true })
153
+ expect(editor.getCameraState()).toBe('moving')
154
+
155
+ // The debounced zoom is captured at the moment movement starts (after first change)
156
+ const capturedZoom = editor.getDebouncedZoomLevel()
157
+ expect(capturedZoom).toBe(editor.getZoomLevel())
158
+ })
159
+
160
+ it('keeps captured zoom during continued camera movement', () => {
161
+ // Start zooming
162
+ editor.zoomIn(undefined, { immediate: true })
163
+ const capturedZoom = editor.getDebouncedZoomLevel()
164
+ expect(editor.getCameraState()).toBe('moving')
165
+
166
+ // Zoom again while still moving - debounced value should stay the same
167
+ editor.zoomIn(undefined, { immediate: true })
168
+ expect(editor.getCameraState()).toBe('moving')
169
+ expect(editor.getDebouncedZoomLevel()).toBe(capturedZoom)
170
+
171
+ // But current zoom should have changed
172
+ expect(editor.getZoomLevel()).not.toBe(capturedZoom)
173
+ })
174
+
175
+ it('updates debounced zoom when camera becomes idle again', () => {
176
+ // Start zooming
177
+ editor.zoomIn(undefined, { immediate: true })
178
+ const capturedZoom = editor.getDebouncedZoomLevel()
179
+
180
+ // Zoom again while moving to change the current zoom
181
+ editor.zoomIn(undefined, { immediate: true })
182
+ expect(editor.getDebouncedZoomLevel()).toBe(capturedZoom)
183
+
184
+ // Let camera settle
185
+ editor.forceTick(5)
186
+ expect(editor.getCameraState()).toBe('idle')
187
+
188
+ // Debounced zoom should now match current zoom
189
+ expect(editor.getDebouncedZoomLevel()).toBe(editor.getZoomLevel())
190
+ })
191
+
192
+ it('captures new zoom at the start of each new movement', () => {
193
+ // First zoom and settle
194
+ editor.zoomIn(undefined, { immediate: true })
195
+ const firstCapturedZoom = editor.getDebouncedZoomLevel()
196
+ editor.forceTick(5)
197
+ expect(editor.getCameraState()).toBe('idle')
198
+
199
+ // Second zoom - should capture new zoom level
200
+ editor.zoomIn(undefined, { immediate: true })
201
+ expect(editor.getCameraState()).toBe('moving')
202
+ // The captured zoom should be different from the first capture
203
+ expect(editor.getDebouncedZoomLevel()).not.toBe(firstCapturedZoom)
204
+ // And it should match the current zoom (since we just started moving)
205
+ expect(editor.getDebouncedZoomLevel()).toBe(editor.getZoomLevel())
206
+ })
207
+
208
+ describe('with debouncedZoom option disabled', () => {
209
+ let editorWithoutDebouncedZoom: TestEditor
210
+
211
+ beforeEach(() => {
212
+ editorWithoutDebouncedZoom = new TestEditor({
213
+ options: { debouncedZoom: false },
214
+ })
215
+ editorWithoutDebouncedZoom.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
216
+ })
217
+
218
+ it('always returns the current zoom level even when camera is moving', () => {
219
+ const initialZoom = editorWithoutDebouncedZoom.getZoomLevel()
220
+
221
+ editorWithoutDebouncedZoom.zoomIn(undefined, { immediate: true })
222
+ expect(editorWithoutDebouncedZoom.getCameraState()).toBe('moving')
223
+
224
+ // Should return the current zoom, not the captured one
225
+ expect(editorWithoutDebouncedZoom.getDebouncedZoomLevel()).toBe(
226
+ editorWithoutDebouncedZoom.getZoomLevel()
227
+ )
228
+ expect(editorWithoutDebouncedZoom.getDebouncedZoomLevel()).not.toBe(initialZoom)
229
+ })
230
+ })
231
+ })
232
+
233
+ describe('getEfficientZoomLevel', () => {
234
+ it('returns current zoom level when below shape threshold', () => {
235
+ // Default threshold is 500 shapes, we have 0
236
+ expect(editor.getZoomLevel()).toBe(editor.getEfficientZoomLevel())
237
+
238
+ // Add a few shapes - still below threshold
239
+ for (let i = 0; i < 10; i++) {
240
+ editor.createShape({ type: 'geo', x: i * 100, y: 0, props: { w: 50, h: 50 } })
241
+ }
242
+ expect(editor.getCurrentPageShapeIds().size).toBe(10)
243
+
244
+ // Start zooming
245
+ editor.zoomIn(undefined, { immediate: true })
246
+ expect(editor.getCameraState()).toBe('moving')
247
+
248
+ // Should still return current zoom because we're below threshold
249
+ expect(editor.getEfficientZoomLevel()).toBe(editor.getZoomLevel())
250
+ })
251
+
252
+ describe('with many shapes above threshold', () => {
253
+ let editorWithManyShapes: TestEditor
254
+
255
+ beforeEach(() => {
256
+ // Use a lower threshold for testing
257
+ editorWithManyShapes = new TestEditor({
258
+ options: { debouncedZoomThreshold: 5 },
259
+ })
260
+ editorWithManyShapes.updateViewportScreenBounds(new Box(0, 0, 1600, 900))
261
+
262
+ // Add shapes above the threshold
263
+ for (let i = 0; i < 10; i++) {
264
+ editorWithManyShapes.createShape({
265
+ type: 'geo',
266
+ x: i * 100,
267
+ y: 0,
268
+ props: { w: 50, h: 50 },
269
+ })
270
+ }
271
+ })
272
+
273
+ it('returns debounced zoom level when above shape threshold and camera is moving', () => {
274
+ // First zoom to capture a debounced value
275
+ editorWithManyShapes.zoomIn(undefined, { immediate: true })
276
+ const capturedZoom = editorWithManyShapes.getEfficientZoomLevel()
277
+ expect(editorWithManyShapes.getCameraState()).toBe('moving')
278
+
279
+ // Zoom again while still moving
280
+ editorWithManyShapes.zoomIn(undefined, { immediate: true })
281
+ expect(editorWithManyShapes.getCameraState()).toBe('moving')
282
+
283
+ // Should return the captured zoom, not the current zoom
284
+ expect(editorWithManyShapes.getEfficientZoomLevel()).toBe(capturedZoom)
285
+ expect(editorWithManyShapes.getEfficientZoomLevel()).not.toBe(
286
+ editorWithManyShapes.getZoomLevel()
287
+ )
288
+ })
289
+
290
+ it('returns current zoom level when above threshold but camera is idle', () => {
291
+ editorWithManyShapes.zoomIn(undefined, { immediate: true })
292
+ editorWithManyShapes.forceTick(5)
293
+ expect(editorWithManyShapes.getCameraState()).toBe('idle')
294
+
295
+ // Should return current zoom because camera is idle
296
+ expect(editorWithManyShapes.getEfficientZoomLevel()).toBe(editorWithManyShapes.getZoomLevel())
297
+ })
298
+ })
299
+ })
package/tldraw.css CHANGED
@@ -611,7 +611,6 @@ input,
611
611
  pointer-events: all;
612
612
  white-space: pre-wrap;
613
613
  overflow-wrap: break-word;
614
- text-shadow: var(--tl-text-outline);
615
614
  }
616
615
 
617
616
  .tl-text-wrapper[data-font='draw'] {
@@ -774,7 +773,6 @@ input,
774
773
  justify-content: center;
775
774
  align-items: center;
776
775
  color: var(--tl-color-text);
777
- text-shadow: var(--tl-text-outline);
778
776
  line-height: inherit;
779
777
  position: absolute;
780
778
  inset: 0px;
@@ -974,6 +972,14 @@ input,
974
972
  display: block;
975
973
  }
976
974
 
975
+ .tl-text__outline {
976
+ text-shadow: var(--tl-text-outline);
977
+ }
978
+
979
+ .tl-text__no-outline {
980
+ text-shadow: none;
981
+ }
982
+
977
983
  /* --------------------- Loading -------------------- */
978
984
 
979
985
  .tl-loading {
@@ -1221,7 +1227,6 @@ input,
1221
1227
  align-items: center;
1222
1228
  text-align: center;
1223
1229
  color: var(--tl-color-text);
1224
- text-shadow: var(--tl-text-outline);
1225
1230
  }
1226
1231
 
1227
1232
  .tl-shape[data-shape-type='arrow'] .tl-text-label__inner {
@@ -1450,7 +1455,6 @@ input,
1450
1455
  }
1451
1456
 
1452
1457
  .tl-note__container > .tl-text-label {
1453
- text-shadow: none;
1454
1458
  color: currentColor;
1455
1459
  }
1456
1460
 
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/lib/shapes/shared/useForceSolid.ts"],
4
- "sourcesContent": ["import { useEditor, useValue } from '@tldraw/editor'\n\nexport function useForceSolid() {\n\tconst editor = useEditor()\n\treturn useValue('zoom', () => editor.getZoomLevel() < 0.35, [editor])\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,oBAAoC;AAE7B,SAAS,gBAAgB;AAC/B,QAAM,aAAS,yBAAU;AACzB,aAAO,wBAAS,QAAQ,MAAM,OAAO,aAAa,IAAI,MAAM,CAAC,MAAM,CAAC;AACrE;",
6
- "names": []
7
- }
@@ -1,9 +0,0 @@
1
- import { useEditor, useValue } from "@tldraw/editor";
2
- function useForceSolid() {
3
- const editor = useEditor();
4
- return useValue("zoom", () => editor.getZoomLevel() < 0.35, [editor]);
5
- }
6
- export {
7
- useForceSolid
8
- };
9
- //# sourceMappingURL=useForceSolid.mjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/lib/shapes/shared/useForceSolid.ts"],
4
- "sourcesContent": ["import { useEditor, useValue } from '@tldraw/editor'\n\nexport function useForceSolid() {\n\tconst editor = useEditor()\n\treturn useValue('zoom', () => editor.getZoomLevel() < 0.35, [editor])\n}\n"],
5
- "mappings": "AAAA,SAAS,WAAW,gBAAgB;AAE7B,SAAS,gBAAgB;AAC/B,QAAM,SAAS,UAAU;AACzB,SAAO,SAAS,QAAQ,MAAM,OAAO,aAAa,IAAI,MAAM,CAAC,MAAM,CAAC;AACrE;",
6
- "names": []
7
- }
@@ -1,6 +0,0 @@
1
- import { useEditor, useValue } from '@tldraw/editor'
2
-
3
- export function useForceSolid() {
4
- const editor = useEditor()
5
- return useValue('zoom', () => editor.getZoomLevel() < 0.35, [editor])
6
- }