tldraw/apps/dotcom/src/hooks/useUrlState.ts
Steve Ruiz fabba66c0f
Camera options (#3282)
This PR implements a camera options API.

- [x] Initial PR
- [x] Updated unit tests
- [x] Feedback / review
- [x] New unit tests
- [x] Update use-case examples
- [x] Ship?

## Public API

A user can provide camera options to the `Tldraw` component via the
`cameraOptions` prop. The prop is also available on the `TldrawEditor`
component and the constructor parameters of the `Editor` class.

```tsx
export default function CameraOptionsExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw cameraOptions={CAMERA_OPTIONS} />
		</div>
	)
}
```

At runtime, a user can:
-  get the current camera options with `Editor.getCameraOptions`
-  update the camera options with `Editor.setCameraOptions`

Setting the camera options automatically applies them to the current
camera.

```ts
editor.setCameraOptions({...editor.getCameraOptions(), isLocked: true })
```

A user can get the "camera fit zoom" via `editor.getCameraFitZoom()`.

# Interface

The camera options themselves can look a few different ways depending on
the `type` provided.


```tsx
export type TLCameraOptions = {
	/** Whether the camera is locked. */
	isLocked: boolean
	/** The speed of a scroll wheel / trackpad pan. Default is 1. */
	panSpeed: number
	/** The speed of a scroll wheel / trackpad zoom. Default is 1. */
	zoomSpeed: number
	/** The steps that a user can zoom between with zoom in / zoom out. The first and last value will determine the min and max zoom. */
	zoomSteps: number[]
	/** Controls whether the wheel pans or zooms.
	 *
	 * - `zoom`: The wheel will zoom in and out.
	 * - `pan`: The wheel will pan the camera.
	 * - `none`: The wheel will do nothing.
	 */
	wheelBehavior: 'zoom' | 'pan' | 'none'
	/** The camera constraints. */
	constraints?: {
		/** The bounds (in page space) of the constrained space */
		bounds: BoxModel
		/** The padding inside of the viewport (in screen space) */
		padding: VecLike
		/** The origin for placement. Used to position the bounds within the viewport when an axis is fixed or contained and zoom is below the axis fit. */
		origin: VecLike
		/** The camera's initial zoom, used also when the camera is reset.
		 *
		 * - `default`: Sets the initial zoom to 100%.
		 * - `fit-x`: The x axis will completely fill the viewport bounds.
		 * - `fit-y`: The y axis will completely fill the viewport bounds.
		 * - `fit-min`: The smaller axis will completely fill the viewport bounds.
		 * - `fit-max`: The larger axis will completely fill the viewport bounds.
		 * - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 */
		initialZoom:
			| 'fit-min'
			| 'fit-max'
			| 'fit-x'
			| 'fit-y'
			| 'fit-min-100'
			| 'fit-max-100'
			| 'fit-x-100'
			| 'fit-y-100'
			| 'default'
		/** The camera's base for its zoom steps.
		 *
		 * - `default`: Sets the initial zoom to 100%.
		 * - `fit-x`: The x axis will completely fill the viewport bounds.
		 * - `fit-y`: The y axis will completely fill the viewport bounds.
		 * - `fit-min`: The smaller axis will completely fill the viewport bounds.
		 * - `fit-max`: The larger axis will completely fill the viewport bounds.
		 * - `fit-x-100`: The x axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-y-100`: The y axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-min-100`: The smaller axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 * - `fit-max-100`: The larger axis will completely fill the viewport bounds, or 100% zoom, whichever is smaller.
		 */
		baseZoom:
			| 'fit-min'
			| 'fit-max'
			| 'fit-x'
			| 'fit-y'
			| 'fit-min-100'
			| 'fit-max-100'
			| 'fit-x-100'
			| 'fit-y-100'
			| 'default'
		/** The behavior for the constraints for both axes or each axis individually.
		 *
		 * - `free`: The bounds are ignored when moving the camera.
		 * - 'fixed': The bounds will be positioned within the viewport based on the origin
		 * - `contain`: The 'fixed' behavior will be used when the zoom is below the zoom level at which the bounds would fill the viewport; and when above this zoom, the bounds will use the 'inside' behavior.
		 * - `inside`: The bounds will stay completely within the viewport.
		 * - `outside`: The bounds will stay touching the viewport.
		 */
		behavior:
			| 'free'
			| 'fixed'
			| 'inside'
			| 'outside'
			| 'contain'
			| {
					x: 'free' | 'fixed' | 'inside' | 'outside' | 'contain'
					y: 'free' | 'fixed' | 'inside' | 'outside' | 'contain'
			  }
	}
}
```

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `feature` — New feature

### Test Plan

These features combine in different ways, so we'll want to write some
more tests to find surprises.

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests

### Release Notes

- SDK: Adds camera options.

---------

Co-authored-by: Mitja Bezenšek <mitja.bezensek@gmail.com>
2024-05-04 17:39:04 +00:00

101 lines
2.9 KiB
TypeScript

import { default as React, useEffect } from 'react'
import { Editor, TLPageId, Vec, clamp, debounce, react, useEditor } from 'tldraw'
const PARAMS = {
// deprecated
viewport: 'viewport',
page: 'page',
// current
v: 'v',
p: 'p',
} as const
export type UrlStateParams = Partial<Record<keyof typeof PARAMS, string>>
const viewportFromString = (str: string) => {
const [x, y, w, h] = str.split(',').map((n) => parseInt(n, 10))
return { x, y, w, h }
}
const viewportToString = (
{ x, y, w, h }: { x: number; y: number; w: number; h: number },
precision = 0
) => {
return `${x.toFixed(precision)},${y.toFixed(precision)},${w.toFixed(precision)},${h.toFixed(
precision
)}`
}
/**
* @param app - The app instance.
* @public
*/
export const getViewportUrlQuery = (editor: Editor): UrlStateParams | null => {
if (!editor.getViewportPageBounds()) return null
return {
[PARAMS.v]: viewportToString(editor.getViewportPageBounds()),
[PARAMS.p]: editor.getCurrentPageId()?.split(':')[1],
}
}
/** @public */
export function useUrlState(onChangeUrl: (params: UrlStateParams) => void) {
const editor = useEditor()
const onChangeUrlRef = React.useRef(onChangeUrl)
onChangeUrlRef.current = onChangeUrl
// Load initial data
useEffect(() => {
if (!editor) return
const url = new URL(location.href)
// We need to check the page first so that any changes to the camera will be applied to the correct page.
if (url.searchParams.has(PARAMS.page) || url.searchParams.has(PARAMS.p)) {
const newPageId =
url.searchParams.get(PARAMS.page) ?? 'page:' + url.searchParams.get(PARAMS.p)
if (newPageId) {
if (editor.store.has(newPageId as TLPageId)) {
editor.history.ignore(() => {
editor.setCurrentPage(newPageId as TLPageId)
})
}
}
}
if (url.searchParams.has(PARAMS.viewport) || url.searchParams.has(PARAMS.v)) {
const newViewportRaw = url.searchParams.get(PARAMS.viewport) ?? url.searchParams.get(PARAMS.v)
if (newViewportRaw) {
try {
const viewport = viewportFromString(newViewportRaw)
const { x, y, w, h } = viewport
const { w: sw, h: sh } = editor.getViewportScreenBounds()
const initialZoom = editor.getInitialZoom()
const { zoomSteps } = editor.getCameraOptions()
const zoomMin = zoomSteps[0]
const zoomMax = zoomSteps[zoomSteps.length - 1]
const zoom = clamp(Math.min(sw / w, sh / h), zoomMin * initialZoom, zoomMax * initialZoom)
editor.setCamera(
new Vec(-x + (sw - w * zoom) / 2 / zoom, -y + (sh - h * zoom) / 2 / zoom, zoom),
{ immediate: true }
)
} catch (err) {
console.error(err)
}
}
}
const handleChange = debounce((params: UrlStateParams | null) => {
if (params) onChangeUrlRef.current(params)
}, 500)
const unsubscribe = react('urlState', () => {
handleChange(getViewportUrlQuery(editor))
})
return () => {
handleChange.cancel()
unsubscribe()
}
}, [editor])
}