tldraw 4.3.0-canary.b8f81b08d169 → 4.3.0-canary.b9cf1eed518f

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 (134) hide show
  1. package/dist-cjs/index.d.ts +12 -0
  2. package/dist-cjs/index.js +2 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/canvas/TldrawSelectionForeground.js +2 -2
  5. package/dist-cjs/lib/canvas/TldrawSelectionForeground.js.map +2 -2
  6. package/dist-cjs/lib/shapes/arrow/ArrowShapeUtil.js +9 -12
  7. package/dist-cjs/lib/shapes/arrow/ArrowShapeUtil.js.map +2 -2
  8. package/dist-cjs/lib/shapes/arrow/arrow-types.js.map +1 -1
  9. package/dist-cjs/lib/shapes/draw/DrawShapeUtil.js +3 -3
  10. package/dist-cjs/lib/shapes/draw/DrawShapeUtil.js.map +2 -2
  11. package/dist-cjs/lib/shapes/frame/FrameShapeUtil.js +1 -1
  12. package/dist-cjs/lib/shapes/frame/FrameShapeUtil.js.map +2 -2
  13. package/dist-cjs/lib/shapes/geo/GeoShapeUtil.js +10 -6
  14. package/dist-cjs/lib/shapes/geo/GeoShapeUtil.js.map +2 -2
  15. package/dist-cjs/lib/shapes/highlight/HighlightShapeUtil.js +1 -1
  16. package/dist-cjs/lib/shapes/highlight/HighlightShapeUtil.js.map +2 -2
  17. package/dist-cjs/lib/shapes/note/NoteShapeUtil.js +5 -5
  18. package/dist-cjs/lib/shapes/note/NoteShapeUtil.js.map +2 -2
  19. package/dist-cjs/lib/shapes/shared/HyperlinkButton.js +2 -1
  20. package/dist-cjs/lib/shapes/shared/HyperlinkButton.js.map +2 -2
  21. package/dist-cjs/lib/shapes/shared/PlainTextLabel.js +14 -2
  22. package/dist-cjs/lib/shapes/shared/PlainTextLabel.js.map +3 -3
  23. package/dist-cjs/lib/shapes/shared/RichTextLabel.js +11 -3
  24. package/dist-cjs/lib/shapes/shared/RichTextLabel.js.map +3 -3
  25. package/dist-cjs/lib/shapes/shared/ShapeFill.js +2 -2
  26. package/dist-cjs/lib/shapes/shared/ShapeFill.js.map +2 -2
  27. package/dist-cjs/lib/shapes/shared/{useForceSolid.js → useEfficientZoomThreshold.js} +10 -7
  28. package/dist-cjs/lib/shapes/shared/useEfficientZoomThreshold.js.map +7 -0
  29. package/dist-cjs/lib/shapes/shared/useImageOrVideoAsset.js +1 -1
  30. package/dist-cjs/lib/shapes/shared/useImageOrVideoAsset.js.map +2 -2
  31. package/dist-cjs/lib/shapes/text/TextShapeUtil.js +5 -2
  32. package/dist-cjs/lib/shapes/text/TextShapeUtil.js.map +2 -2
  33. package/dist-cjs/lib/shapes/video/VideoShapeUtil.js +1 -1
  34. package/dist-cjs/lib/shapes/video/VideoShapeUtil.js.map +2 -2
  35. package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.js +3 -9
  36. package/dist-cjs/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.js.map +2 -2
  37. package/dist-cjs/lib/ui/components/ZoomMenu/DefaultZoomMenu.js +1 -1
  38. package/dist-cjs/lib/ui/components/ZoomMenu/DefaultZoomMenu.js.map +2 -2
  39. package/dist-cjs/lib/ui/components/menu-items.js +3 -1
  40. package/dist-cjs/lib/ui/components/menu-items.js.map +2 -2
  41. package/dist-cjs/lib/ui/components/primitives/TldrawUiSlider.js +1 -1
  42. package/dist-cjs/lib/ui/components/primitives/TldrawUiSlider.js.map +2 -2
  43. package/dist-cjs/lib/ui/components/primitives/TldrawUiTooltip.js +143 -88
  44. package/dist-cjs/lib/ui/components/primitives/TldrawUiTooltip.js.map +2 -2
  45. package/dist-cjs/lib/ui/components/primitives/menus/TldrawUiMenuItem.js +1 -1
  46. package/dist-cjs/lib/ui/components/primitives/menus/TldrawUiMenuItem.js.map +2 -2
  47. package/dist-cjs/lib/ui/version.js +3 -3
  48. package/dist-cjs/lib/ui/version.js.map +1 -1
  49. package/dist-cjs/lib/utils/text/richText.js +7 -17
  50. package/dist-cjs/lib/utils/text/richText.js.map +3 -3
  51. package/dist-esm/index.d.mts +12 -0
  52. package/dist-esm/index.mjs +3 -1
  53. package/dist-esm/index.mjs.map +2 -2
  54. package/dist-esm/lib/canvas/TldrawSelectionForeground.mjs +2 -2
  55. package/dist-esm/lib/canvas/TldrawSelectionForeground.mjs.map +2 -2
  56. package/dist-esm/lib/shapes/arrow/ArrowShapeUtil.mjs +10 -14
  57. package/dist-esm/lib/shapes/arrow/ArrowShapeUtil.mjs.map +2 -2
  58. package/dist-esm/lib/shapes/draw/DrawShapeUtil.mjs +3 -3
  59. package/dist-esm/lib/shapes/draw/DrawShapeUtil.mjs.map +2 -2
  60. package/dist-esm/lib/shapes/frame/FrameShapeUtil.mjs +1 -1
  61. package/dist-esm/lib/shapes/frame/FrameShapeUtil.mjs.map +2 -2
  62. package/dist-esm/lib/shapes/geo/GeoShapeUtil.mjs +10 -6
  63. package/dist-esm/lib/shapes/geo/GeoShapeUtil.mjs.map +2 -2
  64. package/dist-esm/lib/shapes/highlight/HighlightShapeUtil.mjs +1 -1
  65. package/dist-esm/lib/shapes/highlight/HighlightShapeUtil.mjs.map +2 -2
  66. package/dist-esm/lib/shapes/note/NoteShapeUtil.mjs +5 -5
  67. package/dist-esm/lib/shapes/note/NoteShapeUtil.mjs.map +2 -2
  68. package/dist-esm/lib/shapes/shared/HyperlinkButton.mjs +3 -2
  69. package/dist-esm/lib/shapes/shared/HyperlinkButton.mjs.map +2 -2
  70. package/dist-esm/lib/shapes/shared/PlainTextLabel.mjs +14 -2
  71. package/dist-esm/lib/shapes/shared/PlainTextLabel.mjs.map +2 -2
  72. package/dist-esm/lib/shapes/shared/RichTextLabel.mjs +11 -3
  73. package/dist-esm/lib/shapes/shared/RichTextLabel.mjs.map +2 -2
  74. package/dist-esm/lib/shapes/shared/ShapeFill.mjs +2 -2
  75. package/dist-esm/lib/shapes/shared/ShapeFill.mjs.map +2 -2
  76. package/dist-esm/lib/shapes/shared/useEfficientZoomThreshold.mjs +12 -0
  77. package/dist-esm/lib/shapes/shared/useEfficientZoomThreshold.mjs.map +7 -0
  78. package/dist-esm/lib/shapes/shared/useImageOrVideoAsset.mjs +1 -1
  79. package/dist-esm/lib/shapes/shared/useImageOrVideoAsset.mjs.map +2 -2
  80. package/dist-esm/lib/shapes/text/TextShapeUtil.mjs +5 -2
  81. package/dist-esm/lib/shapes/text/TextShapeUtil.mjs.map +2 -2
  82. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs +1 -1
  83. package/dist-esm/lib/shapes/video/VideoShapeUtil.mjs.map +2 -2
  84. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.mjs +2 -8
  85. package/dist-esm/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.mjs.map +2 -2
  86. package/dist-esm/lib/ui/components/ZoomMenu/DefaultZoomMenu.mjs +1 -1
  87. package/dist-esm/lib/ui/components/ZoomMenu/DefaultZoomMenu.mjs.map +2 -2
  88. package/dist-esm/lib/ui/components/menu-items.mjs +3 -1
  89. package/dist-esm/lib/ui/components/menu-items.mjs.map +2 -2
  90. package/dist-esm/lib/ui/components/primitives/TldrawUiSlider.mjs +2 -2
  91. package/dist-esm/lib/ui/components/primitives/TldrawUiSlider.mjs.map +2 -2
  92. package/dist-esm/lib/ui/components/primitives/TldrawUiTooltip.mjs +151 -90
  93. package/dist-esm/lib/ui/components/primitives/TldrawUiTooltip.mjs.map +2 -2
  94. package/dist-esm/lib/ui/components/primitives/menus/TldrawUiMenuItem.mjs +2 -2
  95. package/dist-esm/lib/ui/components/primitives/menus/TldrawUiMenuItem.mjs.map +2 -2
  96. package/dist-esm/lib/ui/version.mjs +3 -3
  97. package/dist-esm/lib/ui/version.mjs.map +1 -1
  98. package/dist-esm/lib/utils/text/richText.mjs +3 -3
  99. package/dist-esm/lib/utils/text/richText.mjs.map +2 -2
  100. package/package.json +3 -3
  101. package/src/index.ts +1 -0
  102. package/src/lib/canvas/TldrawSelectionForeground.tsx +2 -2
  103. package/src/lib/shapes/arrow/ArrowShapeUtil.tsx +10 -12
  104. package/src/lib/shapes/arrow/arrow-types.ts +2 -0
  105. package/src/lib/shapes/draw/DrawShapeUtil.tsx +3 -3
  106. package/src/lib/shapes/frame/FrameShapeUtil.tsx +1 -1
  107. package/src/lib/shapes/geo/GeoShapeUtil.tsx +9 -4
  108. package/src/lib/shapes/highlight/HighlightShapeUtil.tsx +1 -1
  109. package/src/lib/shapes/note/NoteShapeUtil.tsx +7 -8
  110. package/src/lib/shapes/shared/HyperlinkButton.tsx +3 -2
  111. package/src/lib/shapes/shared/PlainTextLabel.tsx +10 -1
  112. package/src/lib/shapes/shared/RichTextLabel.tsx +11 -2
  113. package/src/lib/shapes/shared/ShapeFill.tsx +2 -2
  114. package/src/lib/shapes/shared/useEfficientZoomThreshold.ts +10 -0
  115. package/src/lib/shapes/shared/useImageOrVideoAsset.ts +1 -1
  116. package/src/lib/shapes/text/TextShapeUtil.tsx +5 -0
  117. package/src/lib/shapes/video/VideoShapeUtil.tsx +2 -1
  118. package/src/lib/ui/components/ActionsMenu/DefaultActionsMenuContent.tsx +1 -9
  119. package/src/lib/ui/components/ZoomMenu/DefaultZoomMenu.tsx +1 -1
  120. package/src/lib/ui/components/menu-items.tsx +3 -1
  121. package/src/lib/ui/components/primitives/TldrawUiSlider.tsx +2 -2
  122. package/src/lib/ui/components/primitives/TldrawUiTooltip.tsx +196 -108
  123. package/src/lib/ui/components/primitives/menus/TldrawUiMenuItem.tsx +2 -2
  124. package/src/lib/ui/version.ts +3 -3
  125. package/src/lib/utils/text/richText.ts +3 -3
  126. package/src/test/TldrawEditor.test.tsx +3 -2
  127. package/src/test/commands/__snapshots__/getSvgString.test.ts.snap +2 -2
  128. package/src/test/commands/cameraState.test.ts +299 -0
  129. package/src/test/commands/putContent.test.ts +79 -1
  130. package/tldraw.css +8 -4
  131. package/dist-cjs/lib/shapes/shared/useForceSolid.js.map +0 -7
  132. package/dist-esm/lib/shapes/shared/useForceSolid.mjs +0 -9
  133. package/dist-esm/lib/shapes/shared/useForceSolid.mjs.map +0 -7
  134. package/src/lib/shapes/shared/useForceSolid.ts +0 -6
@@ -1,13 +1,14 @@
1
- import { useEditor, useValue } from '@tldraw/editor'
1
+ import { useEditor } from '@tldraw/editor'
2
2
  import classNames from 'classnames'
3
3
  import { PointerEventHandler, useCallback } from 'react'
4
+ import { useEfficientZoomThreshold } from './useEfficientZoomThreshold'
4
5
 
5
6
  const LINK_ICON =
6
7
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' fill='none'%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M13 5H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6M19 5h6m0 0v6m0-6L13 17'/%3E%3C/svg%3E"
7
8
 
8
9
  export function HyperlinkButton({ url }: { url: string }) {
9
10
  const editor = useEditor()
10
- const hideButton = useValue('zoomLevel', () => editor.getZoomLevel() < 0.32, [editor])
11
+ const hideButton = useEfficientZoomThreshold()
11
12
  const markAsHandledOnShiftKey = useCallback<PointerEventHandler>(
12
13
  (e) => {
13
14
  if (!editor.inputs.shiftKey) editor.markEventAsHandled(e)
@@ -7,6 +7,7 @@ import {
7
7
  TLDefaultVerticalAlignStyle,
8
8
  TLShapeId,
9
9
  } from '@tldraw/editor'
10
+ import classNames from 'classnames'
10
11
  import React from 'react'
11
12
  import { PlainTextArea } from '../text/PlainTextArea'
12
13
  import { TextHelpers } from './TextHelpers'
@@ -34,6 +35,7 @@ export interface PlainTextLabelProps {
34
35
  textWidth?: number
35
36
  textHeight?: number
36
37
  padding?: number
38
+ showTextOutline?: boolean
37
39
  }
38
40
 
39
41
  /**
@@ -61,6 +63,7 @@ export const PlainTextLabel = React.memo(function PlainTextLabel({
61
63
  style,
62
64
  textWidth,
63
65
  textHeight,
66
+ showTextOutline = true,
64
67
  }: PlainTextLabelProps) {
65
68
  const { rInput, isEmpty, isEditing, isReadyForEditing, ...editableTextRest } =
66
69
  useEditablePlainText(shapeId, type, plaintext)
@@ -109,7 +112,13 @@ export const PlainTextLabel = React.memo(function PlainTextLabel({
109
112
  height: textHeight ? Math.ceil(textHeight) : undefined,
110
113
  }}
111
114
  >
112
- <div className={`${cssPrefix} tl-text tl-text-content`} dir="auto">
115
+ <div
116
+ className={classNames(
117
+ `${cssPrefix} tl-text tl-text-content`,
118
+ showTextOutline ? 'tl-text__outline' : 'tl-text__no-outline'
119
+ )}
120
+ dir="auto"
121
+ >
113
122
  {finalPlainText.split('\n').map((lineOfText, index) => (
114
123
  <div key={index} dir="auto">
115
124
  {lineOfText}
@@ -15,6 +15,7 @@ import {
15
15
  useReactor,
16
16
  useValue,
17
17
  } from '@tldraw/editor'
18
+ import classNames from 'classnames'
18
19
  import React, { useMemo } from 'react'
19
20
  import { renderHtmlFromRichText } from '../../utils/text/richText'
20
21
  import { RichTextArea } from '../text/RichTextArea'
@@ -44,6 +45,7 @@ export interface RichTextLabelProps {
44
45
  textHeight?: number
45
46
  padding?: number
46
47
  hasCustomTabBehavior?: boolean
48
+ showTextOutline?: boolean
47
49
  }
48
50
 
49
51
  /**
@@ -72,6 +74,7 @@ export const RichTextLabel = React.memo(function RichTextLabel({
72
74
  textWidth,
73
75
  textHeight,
74
76
  hasCustomTabBehavior,
77
+ showTextOutline = true,
75
78
  }: RichTextLabelProps) {
76
79
  const editor = useEditor()
77
80
  const isDragging = React.useRef(false)
@@ -129,7 +132,10 @@ export const RichTextLabel = React.memo(function RichTextLabel({
129
132
  const cssPrefix = classNamePrefix || 'tl-text'
130
133
  return (
131
134
  <div
132
- className={`${cssPrefix}-label tl-text-wrapper tl-rich-text-wrapper`}
135
+ className={classNames(
136
+ `${cssPrefix}-label tl-text-wrapper tl-rich-text-wrapper`,
137
+ showTextOutline ? 'tl-text__outline' : 'tl-text__no-outline'
138
+ )}
133
139
  aria-hidden={!isEditing}
134
140
  data-font={font}
135
141
  data-align={align}
@@ -259,7 +265,10 @@ export function RichTextSVG({
259
265
  y={bounds.minY}
260
266
  width={bounds.w}
261
267
  height={bounds.h}
262
- className="tl-export-embed-styles tl-rich-text tl-rich-text-svg"
268
+ className={classNames(
269
+ 'tl-export-embed-styles tl-rich-text tl-rich-text-svg',
270
+ showTextOutline ? 'tl-text__outline' : 'tl-text__no-outline'
271
+ )}
263
272
  >
264
273
  <div style={wrapperStyle}>
265
274
  <div dangerouslySetInnerHTML={{ __html: html }} style={style} />
@@ -50,10 +50,10 @@ export const ShapeFill = React.memo(function ShapeFill({
50
50
  export function PatternFill({ d, color, theme }: ShapeFillProps) {
51
51
  const editor = useEditor()
52
52
  const svgExport = useSvgExportContext()
53
- const zoomLevel = useValue('zoomLevel', () => editor.getZoomLevel(), [editor])
53
+ const zoomLevel = useValue('zoomLevel', () => editor.getEfficientZoomLevel(), [editor])
54
54
  const getHashPatternZoomName = useGetHashPatternZoomName()
55
55
 
56
- const teenyTiny = editor.getZoomLevel() <= 0.18
56
+ const teenyTiny = zoomLevel <= 0.18
57
57
 
58
58
  return (
59
59
  <>
@@ -0,0 +1,10 @@
1
+ import { useEditor, useValue } from '@tldraw/editor'
2
+
3
+ /** Returns true when zoomed out far enough that shapes should render in a simplified "solid" style. */
4
+ export function useEfficientZoomThreshold(threshold = 0.25) {
5
+ const editor = useEditor()
6
+ return useValue('efficient zoom threshold', () => editor.getEfficientZoomLevel() < threshold, [
7
+ editor,
8
+ threshold,
9
+ ])
10
+ }
@@ -96,7 +96,7 @@ export function useImageOrVideoAsset({ shapeId, assetId, width }: UseImageOrVide
96
96
 
97
97
  const screenScale = exportInfo
98
98
  ? exportInfo.scale * (width / asset.props.w)
99
- : editor.getZoomLevel() * (width / asset.props.w)
99
+ : editor.getEfficientZoomLevel() * (width / asset.props.w)
100
100
 
101
101
  function resolve(asset: TLImageAsset | TLVideoAsset, url: string | null) {
102
102
  if (isCancelled) return // don't update if the hook has remounted
@@ -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()
@@ -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
  }
@@ -3,7 +3,7 @@ import { Slider as _Slider } from 'radix-ui'
3
3
  import React, { useCallback, useEffect, useState } from 'react'
4
4
  import { TLUiTranslationKey } from '../../hooks/useTranslation/TLUiTranslationKey'
5
5
  import { useTranslation } from '../../hooks/useTranslation/useTranslation'
6
- import { TldrawUiTooltip, tooltipManager } from './TldrawUiTooltip'
6
+ import { hideAllTooltips, TldrawUiTooltip } from './TldrawUiTooltip'
7
7
 
8
8
  /** @public */
9
9
  export interface TLUiSliderProps {
@@ -52,7 +52,7 @@ export const TldrawUiSlider = React.forwardRef<HTMLDivElement, TLUiSliderProps>(
52
52
  )
53
53
 
54
54
  const handlePointerDown = useCallback(() => {
55
- tooltipManager.hideAllTooltips()
55
+ hideAllTooltips()
56
56
  onHistoryMark?.('click slider')
57
57
  }, [onHistoryMark])
58
58
 
@@ -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,
@@ -6,7 +14,6 @@ import React, {
6
14
  ReactNode,
7
15
  useContext,
8
16
  useEffect,
9
- useLayoutEffect,
10
17
  useRef,
11
18
  useState,
12
19
  } from 'react'
@@ -25,7 +32,7 @@ export interface TldrawUiTooltipProps {
25
32
  delayDuration?: number
26
33
  }
27
34
 
28
- interface CurrentTooltip {
35
+ interface TooltipData {
29
36
  id: string
30
37
  content: ReactNode
31
38
  side: 'top' | 'right' | 'bottom' | 'left'
@@ -35,11 +42,25 @@ interface CurrentTooltip {
35
42
  delayDuration: number
36
43
  }
37
44
 
38
- // Singleton tooltip manager
45
+ // State machine states
46
+ type TooltipState =
47
+ | { name: 'idle' }
48
+ | { name: 'pointer_down' }
49
+ | { name: 'showing'; tooltip: TooltipData }
50
+ | { name: 'waiting_to_hide'; tooltip: TooltipData; timeoutId: number }
51
+
52
+ // State machine events
53
+ type TooltipEvent =
54
+ | { type: 'pointer_down' }
55
+ | { type: 'pointer_up' }
56
+ | { type: 'show'; tooltip: TooltipData }
57
+ | { type: 'hide'; tooltipId: string; editor: Editor | null; instant: boolean }
58
+ | { type: 'hide_all' }
59
+
60
+ // Singleton tooltip manager using explicit state machine
39
61
  class TooltipManager {
40
62
  private static instance: TooltipManager | null = null
41
- private currentTooltip = atom<CurrentTooltip | null>('current tooltip', null)
42
- private destroyTimeoutId: number | null = null
63
+ private state = atom<TooltipState>('tooltip state', { name: 'idle' })
43
64
 
44
65
  static getInstance(): TooltipManager {
45
66
  if (!TooltipManager.instance) {
@@ -48,86 +69,117 @@ class TooltipManager {
48
69
  return TooltipManager.instance
49
70
  }
50
71
 
51
- showTooltip(
52
- tooltipId: string,
53
- content: string | React.ReactNode,
54
- targetElement: HTMLElement,
55
- side: 'top' | 'right' | 'bottom' | 'left',
56
- sideOffset: number,
57
- showOnMobile: boolean,
58
- delayDuration: number
59
- ) {
60
- // Clear any existing destroy timeout
61
- if (this.destroyTimeoutId) {
62
- clearTimeout(this.destroyTimeoutId)
63
- this.destroyTimeoutId = null
64
- }
65
-
66
- // Update current tooltip
67
- this.currentTooltip.set({
68
- id: tooltipId,
69
- content,
70
- side,
71
- sideOffset,
72
- showOnMobile,
73
- targetElement,
74
- delayDuration,
75
- })
72
+ hideAllTooltips() {
73
+ this.handleEvent({ type: 'hide_all' })
76
74
  }
77
75
 
78
- updateCurrentTooltip(tooltipId: string, update: (tooltip: CurrentTooltip) => CurrentTooltip) {
79
- this.currentTooltip.update((tooltip) => {
80
- if (tooltip?.id === tooltipId) {
81
- return update(tooltip)
76
+ handleEvent(event: TooltipEvent) {
77
+ const currentState = this.state.get()
78
+
79
+ switch (event.type) {
80
+ case 'pointer_down': {
81
+ // Transition to pointer_down from any state
82
+ if (currentState.name === 'waiting_to_hide') {
83
+ clearTimeout(currentState.timeoutId)
84
+ }
85
+ this.state.set({ name: 'pointer_down' })
86
+ break
82
87
  }
83
- return tooltip
84
- })
85
- }
86
88
 
87
- hideTooltip(editor: Editor | null, tooltipId: string, instant: boolean = false) {
88
- const hide = () => {
89
- // Only hide if this is the current tooltip
90
- if (this.currentTooltip.get()?.id === tooltipId) {
91
- this.currentTooltip.set(null)
92
- this.destroyTimeoutId = null
89
+ case 'pointer_up': {
90
+ // Only transition from pointer_down to idle
91
+ if (currentState.name === 'pointer_down') {
92
+ this.state.set({ name: 'idle' })
93
+ }
94
+ break
93
95
  }
94
- }
95
96
 
96
- if (editor && !instant) {
97
- // Start destroy timeout (1 second)
98
- this.destroyTimeoutId = editor.timers.setTimeout(hide, 300)
99
- } else {
100
- hide()
101
- }
102
- }
97
+ case 'show': {
98
+ // Don't show tooltips while pointer is down
99
+ if (currentState.name === 'pointer_down') {
100
+ return
101
+ }
103
102
 
104
- hideAllTooltips() {
105
- this.currentTooltip.set(null)
106
- this.destroyTimeoutId = null
107
- }
103
+ // Clear any existing timeout if transitioning from waiting_to_hide
104
+ if (currentState.name === 'waiting_to_hide') {
105
+ clearTimeout(currentState.timeoutId)
106
+ }
107
+
108
+ // Transition to showing state
109
+ this.state.set({ name: 'showing', tooltip: event.tooltip })
110
+ break
111
+ }
112
+
113
+ case 'hide': {
114
+ const { tooltipId, editor, instant } = event
115
+
116
+ // Only hide if the tooltip matches
117
+ if (currentState.name === 'showing' && currentState.tooltip.id === tooltipId) {
118
+ if (editor && !instant) {
119
+ // Transition to waiting_to_hide state
120
+ const timeoutId = editor.timers.setTimeout(() => {
121
+ const state = this.state.get()
122
+ if (state.name === 'waiting_to_hide' && state.tooltip.id === tooltipId) {
123
+ this.state.set({ name: 'idle' })
124
+ }
125
+ }, 300)
126
+ this.state.set({
127
+ name: 'waiting_to_hide',
128
+ tooltip: currentState.tooltip,
129
+ timeoutId,
130
+ })
131
+ } else {
132
+ this.state.set({ name: 'idle' })
133
+ }
134
+ } else if (
135
+ currentState.name === 'waiting_to_hide' &&
136
+ currentState.tooltip.id === tooltipId
137
+ ) {
138
+ // Already waiting to hide, make it instant if requested
139
+ if (instant) {
140
+ clearTimeout(currentState.timeoutId)
141
+ this.state.set({ name: 'idle' })
142
+ }
143
+ }
144
+ break
145
+ }
108
146
 
109
- getCurrentTooltipData() {
110
- const currentTooltip = this.currentTooltip.get()
111
- if (!currentTooltip) return null
112
- if (!this.supportsHover() && !currentTooltip.showOnMobile) return null
113
- return currentTooltip
147
+ case 'hide_all': {
148
+ if (currentState.name === 'waiting_to_hide') {
149
+ clearTimeout(currentState.timeoutId)
150
+ }
151
+ // Preserve pointer_down state if that's the current state
152
+ if (currentState.name === 'pointer_down') {
153
+ return
154
+ }
155
+ this.state.set({ name: 'idle' })
156
+ break
157
+ }
158
+ }
114
159
  }
115
160
 
116
- private supportsHoverAtom: Atom<boolean> | null = null
117
- supportsHover() {
118
- if (!this.supportsHoverAtom) {
119
- const mediaQuery = window.matchMedia('(hover: hover)')
120
- const supportsHover = atom('has hover', mediaQuery.matches)
121
- this.supportsHoverAtom = supportsHover
122
- mediaQuery.addEventListener('change', (e) => {
123
- supportsHover.set(e.matches)
124
- })
161
+ getCurrentTooltipData(): TooltipData | null {
162
+ const currentState = this.state.get()
163
+ let tooltip: TooltipData | null = null
164
+
165
+ if (currentState.name === 'showing') {
166
+ tooltip = currentState.tooltip
167
+ } else if (currentState.name === 'waiting_to_hide') {
168
+ tooltip = currentState.tooltip
125
169
  }
126
- return this.supportsHoverAtom.get()
170
+
171
+ if (!tooltip) return null
172
+ if (tlenvReactive.get().isCoarsePointer && !tooltip.showOnMobile) return null
173
+ return tooltip
127
174
  }
128
175
  }
129
176
 
130
- export const tooltipManager = TooltipManager.getInstance()
177
+ const tooltipManager = TooltipManager.getInstance()
178
+
179
+ /** @public */
180
+ export function hideAllTooltips() {
181
+ tooltipManager.hideAllTooltips()
182
+ }
131
183
 
132
184
  // Context for the tooltip singleton
133
185
  const TooltipSingletonContext = createContext<boolean>(false)
@@ -167,14 +219,19 @@ function TooltipSingleton() {
167
219
  // Hide tooltip when camera is moving (panning/zooming)
168
220
  useEffect(() => {
169
221
  if (cameraState === 'moving' && isOpen && currentTooltip) {
170
- tooltipManager.hideTooltip(editor, currentTooltip.id, true)
222
+ tooltipManager.handleEvent({
223
+ type: 'hide',
224
+ tooltipId: currentTooltip.id,
225
+ editor,
226
+ instant: true,
227
+ })
171
228
  }
172
229
  }, [cameraState, isOpen, currentTooltip, editor])
173
230
 
174
231
  useEffect(() => {
175
232
  function handleKeyDown(event: KeyboardEvent) {
176
233
  if (event.key === 'Escape' && currentTooltip && isOpen) {
177
- tooltipManager.hideTooltip(editor, currentTooltip.id)
234
+ hideAllTooltips()
178
235
  event.stopPropagation()
179
236
  }
180
237
  }
@@ -183,7 +240,29 @@ function TooltipSingleton() {
183
240
  return () => {
184
241
  document.removeEventListener('keydown', handleKeyDown, { capture: true })
185
242
  }
186
- }, [editor, currentTooltip, isOpen])
243
+ }, [currentTooltip, isOpen])
244
+
245
+ // Hide tooltip and prevent new ones from opening while pointer is down
246
+ useEffect(() => {
247
+ function handlePointerDown() {
248
+ tooltipManager.handleEvent({ type: 'pointer_down' })
249
+ }
250
+
251
+ function handlePointerUp() {
252
+ tooltipManager.handleEvent({ type: 'pointer_up' })
253
+ }
254
+
255
+ document.addEventListener('pointerdown', handlePointerDown, { capture: true })
256
+ document.addEventListener('pointerup', handlePointerUp, { capture: true })
257
+ document.addEventListener('pointercancel', handlePointerUp, { capture: true })
258
+ return () => {
259
+ document.removeEventListener('pointerdown', handlePointerDown, { capture: true })
260
+ document.removeEventListener('pointerup', handlePointerUp, { capture: true })
261
+ document.removeEventListener('pointercancel', handlePointerUp, { capture: true })
262
+ // Reset pointer state on unmount to prevent stuck state
263
+ tooltipManager.handleEvent({ type: 'pointer_up' })
264
+ }
265
+ }, [])
187
266
 
188
267
  // Update open state and trigger position
189
268
  useEffect(() => {
@@ -280,23 +359,16 @@ export const TldrawUiTooltip = forwardRef<HTMLButtonElement, TldrawUiTooltipProp
280
359
  const currentTooltipId = tooltipId.current
281
360
  return () => {
282
361
  if (hasProvider) {
283
- tooltipManager.hideTooltip(editor, currentTooltipId, true)
362
+ tooltipManager.handleEvent({
363
+ type: 'hide',
364
+ tooltipId: currentTooltipId,
365
+ editor,
366
+ instant: true,
367
+ })
284
368
  }
285
369
  }
286
370
  }, [editor, hasProvider])
287
371
 
288
- useLayoutEffect(() => {
289
- if (hasProvider && tooltipManager.getCurrentTooltipData()?.id === tooltipId.current) {
290
- tooltipManager.updateCurrentTooltip(tooltipId.current, (tooltip) => ({
291
- ...tooltip,
292
- content,
293
- side: sideToUse,
294
- sideOffset,
295
- showOnMobile,
296
- }))
297
- }
298
- }, [content, sideToUse, sideOffset, showOnMobile, hasProvider])
299
-
300
372
  // Don't show tooltip if disabled, no content, or enhanced accessibility mode is disabled
301
373
  if (disabled || !content) {
302
374
  return <>{children}</>
@@ -340,38 +412,54 @@ export const TldrawUiTooltip = forwardRef<HTMLButtonElement, TldrawUiTooltipProp
340
412
 
341
413
  const handleMouseEnter = (event: React.MouseEvent<HTMLElement>) => {
342
414
  child.props.onMouseEnter?.(event)
343
- tooltipManager.showTooltip(
344
- tooltipId.current,
345
- content,
346
- event.currentTarget as HTMLElement,
347
- sideToUse,
348
- sideOffset,
349
- showOnMobile,
350
- delayDurationToUse
351
- )
415
+ tooltipManager.handleEvent({
416
+ type: 'show',
417
+ tooltip: {
418
+ id: tooltipId.current,
419
+ content,
420
+ targetElement: event.currentTarget as HTMLElement,
421
+ side: sideToUse,
422
+ sideOffset,
423
+ showOnMobile,
424
+ delayDuration: delayDurationToUse,
425
+ },
426
+ })
352
427
  }
353
428
 
354
429
  const handleMouseLeave = (event: React.MouseEvent<HTMLElement>) => {
355
430
  child.props.onMouseLeave?.(event)
356
- tooltipManager.hideTooltip(editor, tooltipId.current)
431
+ tooltipManager.handleEvent({
432
+ type: 'hide',
433
+ tooltipId: tooltipId.current,
434
+ editor,
435
+ instant: false,
436
+ })
357
437
  }
358
438
 
359
439
  const handleFocus = (event: React.FocusEvent<HTMLElement>) => {
360
440
  child.props.onFocus?.(event)
361
- tooltipManager.showTooltip(
362
- tooltipId.current,
363
- content,
364
- event.currentTarget as HTMLElement,
365
- sideToUse,
366
- sideOffset,
367
- showOnMobile,
368
- delayDurationToUse
369
- )
441
+ tooltipManager.handleEvent({
442
+ type: 'show',
443
+ tooltip: {
444
+ id: tooltipId.current,
445
+ content,
446
+ targetElement: event.currentTarget as HTMLElement,
447
+ side: sideToUse,
448
+ sideOffset,
449
+ showOnMobile,
450
+ delayDuration: delayDurationToUse,
451
+ },
452
+ })
370
453
  }
371
454
 
372
455
  const handleBlur = (event: React.FocusEvent<HTMLElement>) => {
373
456
  child.props.onBlur?.(event)
374
- tooltipManager.hideTooltip(editor, tooltipId.current)
457
+ tooltipManager.handleEvent({
458
+ type: 'hide',
459
+ tooltipId: tooltipId.current,
460
+ editor,
461
+ instant: false,
462
+ })
375
463
  }
376
464
 
377
465
  const childrenWithHandlers = React.cloneElement(children as React.ReactElement, {
@@ -24,7 +24,7 @@ import { TldrawUiDropdownMenuItem } from '../TldrawUiDropdownMenu'
24
24
  import { TLUiIconJsx } from '../TldrawUiIcon'
25
25
  import { TldrawUiKbd } from '../TldrawUiKbd'
26
26
  import { TldrawUiToolbarButton } from '../TldrawUiToolbar'
27
- import { tooltipManager } from '../TldrawUiTooltip'
27
+ import { hideAllTooltips } from '../TldrawUiTooltip'
28
28
  import { useTldrawUiMenuContext } from './TldrawUiMenuContext'
29
29
 
30
30
  /** @public */
@@ -350,7 +350,7 @@ function useDraggableEvents(
350
350
  point: screenSpaceStart,
351
351
  })
352
352
 
353
- tooltipManager.hideAllTooltips()
353
+ hideAllTooltips()
354
354
  editor.getContainer().focus()
355
355
  })
356
356
  }