
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>
101 lines
2.9 KiB
TypeScript
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])
|
|
}
|