Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 4 additions & 32 deletions apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export const components: TLComponents = {
SharePanel: TlaEditorSharePanel,
Dialogs: null,
Toasts: null,
// No loading screen on tla editors: the editor only mounts once it's ready,
// so a spinner would only flash before the content appears.
LoadingScreen: null,
}

interface TlaEditorProps {
Expand All @@ -76,43 +79,12 @@ interface TlaEditorProps {
deepLinks?: boolean
}

// Components for the inert placeholder editor shown while the real file syncs.
// Same UI as the real editor (toolbar, menus, panels) so the chrome appears
// instantly, plus a nulled loading screen to avoid a flash of its own spinner
// before the (instant) empty local store finishes loading.
const placeholderComponents: TLComponents = { ...components, LoadingScreen: null }

/**
* An empty, non-interactive tldraw editor shown immediately while the real file
* connects and syncs. It renders the full editor chrome (toolbar, menus, panels)
* over its own ephemeral empty store, so the UI pops in instantly and only the
* document content fills in once synced. It never autofocuses, and the
* ReadyWrapper overlay disables pointer events so nothing here is interactive.
*/
function TlaEditorLoadingPlaceholder() {
const app = useMaybeApp()
return (
<TlaEditorWrapper>
<Tldraw
className="tla-editor"
licenseKey={getLicenseKey()}
assetUrls={assetUrls}
// Match the user's theme so the placeholder doesn't flash light/dark.
user={app?.tlUser}
autoFocus={false}
components={placeholderComponents}
options={{ actionShortcutsLocation: 'toolbar' }}
/>
</TlaEditorWrapper>
)
}

export function TlaEditor(props: TlaEditorProps) {
// force re-mount when the file slug changes to prevent state from leaking between files
return (
<>
<SneakySetDocumentTitle />
<ReadyWrapper key={props.fileSlug} loadingScreen={<TlaEditorLoadingPlaceholder />}>
<ReadyWrapper key={props.fileSlug}>
<TlaEditorInner {...props} key={props.fileSlug} />
</ReadyWrapper>
</>
Expand Down
49 changes: 0 additions & 49 deletions apps/dotcom/client/src/tla/hooks/useIsReady.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,57 +13,8 @@
.innerContainer {
flex-grow: 1;
height: 100%;
transition: opacity 0.12s ease-in;
}

.innerContainer:not(.isReady) {
opacity: 0;
}

.spinner {
transition: opacity 1s ease-in;
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
}

.spinner:not(.showSpinner) {
opacity: 0;
}

.loadingScreen {
position: absolute;
inset: 0;
/* The placeholder editor is purely visual: never let it receive input. */
pointer-events: none;
}

/*
* When a placeholder editor is shown during loading there's already an editor on
* screen, so the real editor hard-cuts in rather than fading.
*/
.withLoadingScreen .innerContainer {
transition: none;
}

.withLoadingScreen .innerContainer:not(.isReady) {
opacity: 1;
}

/*
* Fade just the file content in once the editor is ready, leaving the main UI,
* background, and grid untouched so they line up with the placeholder.
*/
.withLoadingScreen .innerContainer :global(.tl-shapes),
.withLoadingScreen .innerContainer :global(.tl-canvas-overlays),
.withLoadingScreen .innerContainer :global(.tl-canvas__in-front) {
transition: opacity 0.2s ease-in;
}

.withLoadingScreen .innerContainer:not(.isReady) :global(.tl-shapes),
.withLoadingScreen .innerContainer:not(.isReady) :global(.tl-canvas-overlays),
.withLoadingScreen .innerContainer:not(.isReady) :global(.tl-canvas__in-front) {
opacity: 0;
}
25 changes: 3 additions & 22 deletions apps/dotcom/client/src/tla/hooks/useIsReady.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import classNames from 'classnames'
import React, { PropsWithChildren, ReactNode, useCallback, useContext } from 'react'
import React, { PropsWithChildren, useCallback, useContext } from 'react'
import styles from './useIsReady.module.css'

/*
Expand All @@ -18,13 +18,9 @@ export function useSetIsReady() {
return React.useContext(ReadyContext).setIsReady
}

export function ReadyWrapper({
children,
loadingScreen,
}: PropsWithChildren<{ loadingScreen?: ReactNode }>) {
export function ReadyWrapper({ children }: PropsWithChildren) {
const parent = useContext(ReadyContext)
const [isReady, _setIsReady] = React.useState(false)
const hasLoadingScreen = !!loadingScreen
const setIsReady = useCallback(async () => {
_setIsReady(true)
}, [])
Expand All @@ -36,28 +32,13 @@ export function ReadyWrapper({

return (
<ReadyContext.Provider value={{ isReady, setIsReady, isRoot: false }}>
<div
className={classNames(
styles.container,
isReady && styles.isReady,
// There's already an editor (the placeholder) on screen, so the real
// editor hard-cuts in instead of fading.
hasLoadingScreen && styles.withLoadingScreen
)}
>
<div className={classNames(styles.container, isReady && styles.isReady)}>
<div
className={classNames(styles.innerContainer, isReady && styles.isReady)}
inert={!isReady}
>
{children}
</div>
{/* While loading, show the inert placeholder editor (if provided) with
the spinner layered in front of it. */}
{!isReady && hasLoadingScreen && (
<div className={styles.loadingScreen} inert aria-hidden="true">
{loadingScreen}
</div>
)}
</div>
</ReadyContext.Provider>
)
Expand Down
11 changes: 3 additions & 8 deletions apps/dotcom/client/src/tla/hooks/useUser.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAuth, useUser as useClerkUser } from '@clerk/clerk-react'
import type { UserResource } from '@clerk/types'
import { ReactNode, createContext, useContext, useMemo } from 'react'
import { DefaultSpinner, LoadingScreen, assert, useShallowObjectIdentity } from 'tldraw'
import { assert, useShallowObjectIdentity } from 'tldraw'
import { useMaybeApp } from './useAppState'

interface TldrawUser {
Expand Down Expand Up @@ -45,13 +45,8 @@ export function UserProvider({ children }: { children: ReactNode }) {
}, [getToken, isSignedIn, user, app])

if (!isLoaded || !isAuthLoaded || !app) {
return (
<div className="tldraw__editor">
<LoadingScreen>
<DefaultSpinner />
</LoadingScreen>
</div>
)
// Render a blank editor surface while auth loads, with no spinner or fade.
return <div className="tldraw__editor" />
}

return <UserContext.Provider value={value}>{children}</UserContext.Provider>
Expand Down
4 changes: 4 additions & 0 deletions packages/driver/src/lib/Driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ export class Driver {
...modifiers,
} as TLPointerEventInfo

// In simulated input, a pen is assumed to be a direct-display pen (which auto-enables pen
// mode) unless a test explicitly opts out with `isPenDirect: false`.
if (info.isPenDirect === undefined) info.isPenDirect = info.isPen

if (tlenv.isDarwin && info.button === 0 && info.ctrlKey && !info.metaKey) {
info.button = 2
}
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3962,6 +3962,7 @@ export const tlenv: {
isFirefox: boolean;
isIos: boolean;
isSafari: boolean;
isTouchDevice: boolean;
isWebview: boolean;
};

Expand Down Expand Up @@ -4470,6 +4471,7 @@ export type TLPointerEvent = (info: TLPointerEventInfo) => void;

// @public (undocumented)
export type TLPointerEventInfo = TLBaseEventInfo & {
isPenDirect?: boolean;
button: number;
isPen: boolean;
name: TLPointerEventName;
Expand Down
6 changes: 4 additions & 2 deletions packages/editor/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11223,8 +11223,10 @@ export class Editor extends EventEmitter<TLEventMap> {
inputs.setIsPointing(true)
inputs.setIsDragging(false)

// If pen mode is off but we're not already in pen mode, turn that on
if (!isPenMode && isPen) {
// If pen mode is off, turn it on for direct-display pen input only (e.g. Apple
// Pencil on an iPad or a Surface Pen on a touchscreen). Indirect desktop tablet
// styluses still draw as pens, but should not auto-enable pen mode.
if (!isPenMode && info.isPenDirect) {
this.updateInstanceState({ isPenMode: true })
// Once pen mode is on, touch input is ignored, so we discard the
// in-progress touch interaction .
Expand Down
7 changes: 7 additions & 0 deletions packages/editor/src/lib/editor/types/event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export type TLPointerEventInfo = TLBaseEventInfo & {
pointerId: number
button: number
isPen: boolean
/**
* Whether this pen event appears to be direct manipulation on the display (e.g. Apple Pencil on
* an iPad or a Surface Pen on a touchscreen) rather than indirect input from a desktop graphics
* tablet (e.g. a Wacom Intuos). Only direct-display pens should auto-enable pen mode. Drawing and
* pressure behavior is driven by `isPen` and applies to all pens regardless of this flag.
*/
isPenDirect?: boolean
} & TLPointerEventTarget

/** @public */
Expand Down
7 changes: 7 additions & 0 deletions packages/editor/src/lib/globals/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const tlenv = {
isWebview: false,
isDarwin: false,
hasCanvasSupport: false,
// Whether the device has a touch screen (an integrated coarse pointer, e.g. an iPad or a
// touchscreen laptop). Unlike `isCoarsePointer`, this reflects the device's hardware rather than
// the pointer currently in use, so it stays stable when a pen or mouse is used.
isTouchDevice: false,
}

let isForcedFinePointer = false
Expand All @@ -32,6 +36,9 @@ if (typeof window !== 'undefined') {
}
tlenv.hasCanvasSupport = 'Promise' in window && 'HTMLCanvasElement' in window
isForcedFinePointer = tlenv.isFirefox && !tlenv.isAndroid && !tlenv.isIos
tlenv.isTouchDevice =
('navigator' in window && window.navigator.maxTouchPoints > 0) ||
(typeof window.matchMedia === 'function' && window.matchMedia('(any-pointer: coarse)').matches)
}

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/editor/src/lib/hooks/useCanvasEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
setPointerCapture,
} from '../utils/dom'
import { getPointerInfo } from '../utils/getPointerInfo'
import { getPointerEventButton, isSecondaryClickEvent } from '../utils/pointer'
import { getPointerEventButton, isDirectDisplayPen, isSecondaryClickEvent } from '../utils/pointer'
import { useEditor } from './useEditor'

export function useCanvasEvents() {
Expand Down Expand Up @@ -39,13 +39,18 @@ export function useCanvasEvents() {

if (button !== 0 && button !== 1 && button !== 2 && button !== 5) return

// Detect direct-display pen input (Apple Pencil, Surface Pen on a touchscreen) so we
// only auto-enable pen mode for it, not for an indirect desktop tablet stylus.
const isPenDirect = isDirectDisplayPen(e)

setPointerCapture(e.currentTarget, e)

editor.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_down',
...getPointerInfo(editor, e),
isPenDirect,
})
}

Expand Down
30 changes: 29 additions & 1 deletion packages/editor/src/lib/utils/pointer.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { tlenv } from '../globals/environment'
import { TestEditor } from '../test/TestEditor'
import { getPointerInfo } from './getPointerInfo'
import { isSecondaryClickEvent } from './pointer'
import { isDirectDisplayPen, isSecondaryClickEvent } from './pointer'

const originalIsDarwin = tlenv.isDarwin

Expand All @@ -21,6 +21,34 @@ describe('isSecondaryClickEvent', () => {
})
})

describe('isDirectDisplayPen', () => {
const originalIsTouchDevice = tlenv.isTouchDevice

afterEach(() => {
tlenv.isTouchDevice = originalIsTouchDevice
})

function event(pointerType: string) {
return { pointerType, pointerId: 1 } as unknown as PointerEvent
}

it('treats a pen on a touch-capable device as a direct-display pen', () => {
tlenv.isTouchDevice = true
expect(isDirectDisplayPen(event('pen'))).toBe(true)
})

it('treats a pen on a non-touch device as indirect', () => {
tlenv.isTouchDevice = false
expect(isDirectDisplayPen(event('pen'))).toBe(false)
})

it('is never true for mouse or touch input, even on a touch-capable device', () => {
tlenv.isTouchDevice = true
expect(isDirectDisplayPen(event('mouse'))).toBe(false)
expect(isDirectDisplayPen(event('touch'))).toBe(false)
})
})

describe('ctrl + left-click on macOS fires as a right-click (regression)', () => {
// Regression test for https://github.com/tldraw/tldraw/issues/8217
// On macOS, a ctrl + left-click should be treated as a right-click
Expand Down
24 changes: 24 additions & 0 deletions packages/editor/src/lib/utils/pointer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
import type React from 'react'
import { tlenv } from '../globals/environment'

/**
* Decide whether a pen pointer event looks like direct manipulation on the display (e.g. Apple
* Pencil on an iPad or a Surface Pen on a touchscreen) rather than indirect input from a desktop
* graphics tablet (e.g. a Wacom Intuos).
*
* We can't tell the two apart from the pointer event itself: both report `pointerType: 'pen'`, and
* implicit pointer capture — which in theory distinguishes direct-manipulation pointers — isn't
* reliably observable across browsers (notably WebKit/iPad). Instead we key off the device: a
* direct-display pen draws on a touch-capable screen, while an indirect graphics tablet is used on
* a non-touch desktop alongside a mouse. A device with no touch input therefore can't host a
* direct-display pen.
*
* Note this uses {@link tlenv.isTouchDevice} — the device's fixed touch capability — not the
* editor's dynamic `isCoarsePointer` state, which a pen `pointerdown` flips to coarse regardless
* of device.
*
* @internal
*/
export function isDirectDisplayPen(e: React.PointerEvent | PointerEvent): boolean {
if (e.pointerType !== 'pen') return false
return tlenv.isTouchDevice
}

/** @internal */
interface PointerLike {
button: number
Expand Down
17 changes: 9 additions & 8 deletions packages/tldraw/src/lib/ui/context/asset-urls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,17 @@ export interface AssetUrlsProviderProps {
*/
export function AssetUrlsProvider({ assetUrls, children }: AssetUrlsProviderProps) {
useEffect(() => {
for (const src of Object.values(assetUrls.icons)) {
// Many icon URLs point at the same sprite sheet and differ only by their
// `#fragment` identifier (e.g. `0_merged.svg#tool-arrow`). The fragment
// doesn't change the underlying resource, so preload each unique resource
// once rather than creating hundreds of redundant Image decodes.
const preloaded = new Set<string>()
for (const src of [...Object.values(assetUrls.icons), ...Object.values(assetUrls.embedIcons)]) {
if (!src) continue

const image = Image()
image.crossOrigin = 'anonymous'
image.src = src
image.decode().catch(noop)
}
for (const src of Object.values(assetUrls.embedIcons)) {
if (!src) continue
const resource = src.split('#')[0]
if (preloaded.has(resource)) continue
preloaded.add(resource)

const image = Image()
image.crossOrigin = 'anonymous'
Expand Down
Loading
Loading