alex/camera-zoom-options-2: camera zoom options

pull/3255/head
alex 2024-03-26 15:13:54 +00:00
rodzic c20d9fc9d2
commit 2451399462
29 zmienionych plików z 815 dodań i 237 usunięć

Wyświetl plik

@ -1,5 +1,5 @@
import { default as React, useEffect } from 'react'
import { Editor, MAX_ZOOM, MIN_ZOOM, TLPageId, debounce, react, useEditor } from 'tldraw'
import { Editor, TLPageId, debounce, react, useEditor } from 'tldraw'
const PARAMS = {
// deprecated
@ -69,7 +69,10 @@ export function useUrlState(onChangeUrl: (params: UrlStateParams) => void) {
const { x, y, w, h } = viewport
const { w: sw, h: sh } = editor.getViewportScreenBounds()
const zoom = Math.min(Math.max(Math.min(sw / w, sh / h), MIN_ZOOM), MAX_ZOOM)
const zoom = Math.min(
Math.max(Math.min(sw / w, sh / h), editor.camera.getZoomMin()),
editor.camera.getZoomMax()
)
editor.setCamera({
x: -x + (sw - w * zoom) / 2 / zoom,

Wyświetl plik

@ -0,0 +1,30 @@
import { useId } from 'react'
export function SelectButtons<Items extends { value: any; label: string }[]>({
label,
items,
value,
onChange,
}: {
label: string
items: Items
value: Items[number]['value']
onChange: (value: Items[number]['value']) => void
}) {
const id = useId()
return (
<div className="SelectButtons" role="radiogroup" aria-labelledby={id}>
<div id={id}>{label}</div>
{items.map((item, i) => (
<button
key={i}
role="radio"
aria-checked={item.value === value}
onClick={() => onChange(item.value)}
>
<span>{item.label}</span>
</button>
))}
</div>
)
}

Wyświetl plik

@ -0,0 +1,14 @@
---
title: Zoom options
component: ./ZoomOptionsExample.tsx
category: editor-api
priority: 6
hide: true
---
Control how zooming the camera works.
---
Control the min/max zoom levels, the steps used when zooming in and out with keyboard shortcuts or
menu options, and the sensitivity of mouse-wheel zooming.

Wyświetl plik

@ -0,0 +1,60 @@
import { useState } from 'react'
import { DefaultHelperButtons, DefaultHelperButtonsContent, Tldraw } from 'tldraw'
import { SelectButtons } from '../../components/SelectButtons'
export default function ZoomOptionsExample() {
const [minZoom, setMinZoom] = useState(0.1)
const [maxZoom, setMaxZoom] = useState(8)
const [sensitivity, setSensitivity] = useState(0.01)
return (
<div className="tldraw__editor">
<Tldraw
camera={{
zoom: {
min: minZoom,
max: maxZoom,
stops: [minZoom, (minZoom + maxZoom) / 2, maxZoom],
wheelSensitivity: sensitivity,
},
}}
components={{
HelperButtons: () => (
<DefaultHelperButtons>
<SelectButtons
label="Minimum zoom"
value={minZoom}
onChange={setMinZoom}
items={[
{ value: 0.01, label: '1%' },
{ value: 0.1, label: '10%' },
{ value: 1, label: '100%' },
]}
/>
<SelectButtons
label="Maximum zoom"
value={maxZoom}
onChange={setMaxZoom}
items={[
{ value: 1, label: '100%' },
{ value: 8, label: '800%' },
{ value: 80, label: '8000%' },
]}
/>
<SelectButtons
label="Wheel sensitivity"
value={sensitivity}
onChange={setSensitivity}
items={[
{ value: 0.0025, label: 'Low' },
{ value: 0.01, label: 'Normal' },
{ value: 0.04, label: 'High' },
]}
/>
<DefaultHelperButtonsContent />
</DefaultHelperButtons>
),
}}
/>
</div>
)
}

Wyświetl plik

@ -415,3 +415,48 @@ a.example__sidebar__header-link {
.example__dialog__close {
all: unset;
}
/* --------------------- Select buttons --------------------- */
.SelectButtons {
display: flex;
flex-direction: row;
background: var(--color-background);
border-radius: calc(var(--radius-2) + var(--space-1));
align-items: center;
justify-content: center;
pointer-events: all;
}
.SelectButtons + .SelectButtons {
margin-top: calc(-1 * var(--space-2));
}
.SelectButtons div {
padding: var(--space-3);
}
.SelectButtons button {
background: transparent;
border: none;
font: inherit;
padding: var(--space-3);
position: relative;
cursor: pointer;
}
.SelectButtons button::after {
content: ' ';
position: absolute;
background: transparent;
top: var(--space-1);
bottom: var(--space-1);
left: calc(-1 * var(--space-1));
right: calc(-1 * var(--space-1));
border-radius: var(--radius-2);
}
.SelectButtons button:hover::after {
background: var(--color-hint);
}
.SelectButtons button[aria-checked='true'] {
color: var(--color-primary);
}
.SelectButtons button span {
position: relative;
z-index: 1;
}

Wyświetl plik

@ -74,6 +74,7 @@ import { useComputed } from '@tldraw/state';
import { useQuickReactor } from '@tldraw/state';
import { useReactor } from '@tldraw/state';
import { useValue } from '@tldraw/state';
import { Vec3Model } from '@tldraw/tlschema';
import { VecModel } from '@tldraw/tlschema';
import { whyAmIRunning } from '@tldraw/state';
@ -307,6 +308,35 @@ export type BoxLike = Box | BoxModel;
// @internal (undocumented)
export const CAMERA_SLIDE_FRICTION = 0.09;
// @internal (undocumented)
export class CameraManager {
constructor(editor: Editor);
// (undocumented)
get(): TLCamera;
// (undocumented)
getState(): "idle" | "moving";
getWheelZoomSensitivity(): number;
getZoomMax(): number;
getZoomMin(): number;
getZoomStops(): readonly number[];
// (undocumented)
set(point: Vec3Model): this | undefined;
// (undocumented)
setOptions(options: CameraOptions): void;
// (undocumented)
tickState: () => void;
}
// @internal
export interface CameraOptions {
zoom?: {
min?: number;
max?: number;
stops?: number[];
wheelSensitivity?: number;
};
}
// @public (undocumented)
export function canonicalizeRotation(a: number): number;
@ -593,6 +623,8 @@ export class Editor extends EventEmitter<TLEventMap> {
batch(fn: () => void): this;
bringForward(shapes: TLShape[] | TLShapeId[]): this;
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
// @internal (undocumented)
camera: CameraManager;
cancel(): this;
cancelDoubleClick(): void;
// @internal (undocumented)
@ -1188,9 +1220,6 @@ export function hardReset({ shouldReload }?: {
// @public (undocumented)
export function hardResetEditor(): void;
// @internal (undocumented)
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string>;
// @public (undocumented)
export const HIT_TEST_MARGIN = 8;
@ -1377,12 +1406,6 @@ export const MAX_PAGES = 40;
// @internal (undocumented)
export const MAX_SHAPES_PER_PAGE = 2000;
// @internal (undocumented)
export const MAX_ZOOM = 8;
// @internal (undocumented)
export const MIN_ZOOM = 0.1;
// @public
export function moveCameraWhenCloseToEdge(editor: Editor): void;
@ -1390,7 +1413,9 @@ export function moveCameraWhenCloseToEdge(editor: Editor): void;
export const MULTI_CLICK_DURATION = 200;
// @internal (undocumented)
export function normalizeWheel(event: React.WheelEvent<HTMLElement> | WheelEvent): {
export function normalizeWheel(event: React.WheelEvent<HTMLElement> | WheelEvent, opts: {
zoomSensitivity: number;
}): {
x: number;
y: number;
z: number;
@ -2044,6 +2069,8 @@ export const TldrawEditor: React_2.NamedExoticComponent<TldrawEditorProps>;
// @public
export interface TldrawEditorBaseProps {
autoFocus?: boolean;
// @internal
camera?: CameraOptions;
children?: ReactNode;
className?: string;
components?: TLEditorComponents;
@ -2988,9 +3015,6 @@ export class WeakMapCache<T extends object, K> {
export { whyAmIRunning }
// @internal (undocumented)
export const ZOOMS: number[];
export * from "@tldraw/store";
export * from "@tldraw/tlschema";

Wyświetl plik

@ -118,15 +118,11 @@ export {
DOUBLE_CLICK_DURATION,
DRAG_DISTANCE,
GRID_STEPS,
HASH_PATTERN_ZOOM_NAMES,
HIT_TEST_MARGIN,
MAX_PAGES,
MAX_SHAPES_PER_PAGE,
MAX_ZOOM,
MIN_ZOOM,
MULTI_CLICK_DURATION,
SVG_PADDING,
ZOOMS,
} from './lib/constants'
export {
Editor,
@ -134,6 +130,7 @@ export {
type TLEditorOptions,
type TLResizeShapeOptions,
} from './lib/editor/Editor'
export { CameraManager, type CameraOptions } from './lib/editor/managers/CameraManager'
export type {
TLAfterChangeHandler,
TLAfterCreateHandler,

Wyświetl plik

@ -17,6 +17,7 @@ import { DefaultErrorFallback } from './components/default-components/DefaultErr
import { TLUser, createTLUser } from './config/createTLUser'
import { TLAnyShapeUtilConstructor } from './config/defaultShapes'
import { Editor } from './editor/Editor'
import { CameraOptions } from './editor/managers/CameraManager'
import { TLStateNodeConstructor } from './editor/tools/StateNode'
import { ContainerProvider, useContainer } from './hooks/useContainer'
import { useCursor } from './hooks/useCursor'
@ -113,6 +114,12 @@ export interface TldrawEditorBaseProps {
* Whether to infer dark mode from the user's OS. Defaults to false.
*/
inferDarkMode?: boolean
/**
* Control and constrain the camera. See {@link CameraOptions} for details.
* @internal
*/
camera?: CameraOptions
}
/**
@ -265,6 +272,7 @@ function TldrawEditorWithReadyStore({
initialState,
autoFocus = true,
inferDarkMode,
camera,
}: Required<
TldrawEditorProps & {
store: TLStore
@ -293,6 +301,12 @@ function TldrawEditorWithReadyStore({
}
}, [container, shapeUtils, tools, store, user, initialState, inferDarkMode])
useLayoutEffect(() => {
if (editor && camera) {
editor.camera.setOptions(camera)
}
}, [camera, editor])
const crashingError = useSyncExternalStore(
useCallback(
(onStoreChange) => {

Wyświetl plik

@ -10,13 +10,6 @@ export const ANIMATION_SHORT_MS = 80
/** @internal */
export const ANIMATION_MEDIUM_MS = 320
/** @internal */
export const ZOOMS = [0.1, 0.25, 0.5, 1, 2, 4, 8]
/** @internal */
export const MIN_ZOOM = 0.1
/** @internal */
export const MAX_ZOOM = 8
/** @internal */
export const FOLLOW_CHASE_PROPORTION = 0.5
/** @internal */
@ -42,14 +35,6 @@ export const DRAG_DISTANCE = 4
/** @internal */
export const SVG_PADDING = 32
/** @internal */
export const HASH_PATTERN_ZOOM_NAMES: Record<string, string> = {}
for (let zoom = 1; zoom <= Math.ceil(MAX_ZOOM); zoom++) {
HASH_PATTERN_ZOOM_NAMES[zoom + '_dark'] = `hash_pattern_zoom_${zoom}_dark`
HASH_PATTERN_ZOOM_NAMES[zoom + '_light'] = `hash_pattern_zoom_${zoom}_light`
}
/** @internal */
export const DEFAULT_ANIMATION_OPTIONS = {
duration: 0,

Wyświetl plik

@ -66,8 +66,6 @@ import { TLUser, createTLUser } from '../config/createTLUser'
import { checkShapesAndAddCore } from '../config/defaultShapes'
import {
ANIMATION_MEDIUM_MS,
CAMERA_MAX_RENDERING_INTERVAL,
CAMERA_MOVING_TIMEOUT,
CAMERA_SLIDE_FRICTION,
COARSE_DRAG_DISTANCE,
COLLABORATOR_IDLE_TIMEOUT,
@ -82,9 +80,6 @@ import {
INTERNAL_POINTER_IDS,
MAX_PAGES,
MAX_SHAPES_PER_PAGE,
MAX_ZOOM,
MIN_ZOOM,
ZOOMS,
} from '../constants'
import { Box } from '../primitives/Box'
import { Mat, MatLike, MatModel } from '../primitives/Mat'
@ -105,6 +100,7 @@ import { arrowBindingsIndex } from './derivations/arrowBindingsIndex'
import { parentsToChildren } from './derivations/parentsToChildren'
import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage'
import { getSvgJsx } from './getSvgJsx'
import { CameraManager } from './managers/CameraManager'
import { ClickManager } from './managers/ClickManager'
import { EnvironmentManager } from './managers/EnvironmentManager'
import { HistoryManager } from './managers/HistoryManager'
@ -2062,10 +2058,7 @@ export class Editor extends EventEmitter<TLEventMap> {
/* --------------------- Camera --------------------- */
/** @internal */
@computed
private getCameraId() {
return CameraRecordType.createId(this.getCurrentPageId())
}
camera = new CameraManager(this)
/**
* The current camera.
@ -2073,7 +2066,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
@computed getCamera() {
return this.store.get(this.getCameraId())!
return this.camera.get()
}
/**
@ -2085,42 +2078,6 @@ export class Editor extends EventEmitter<TLEventMap> {
return this.getCamera().z
}
/** @internal */
private _setCamera(point: VecLike): this {
const currentCamera = this.getCamera()
if (currentCamera.x === point.x && currentCamera.y === point.y && currentCamera.z === point.z) {
return this
}
this.batch(() => {
this.store.put([{ ...currentCamera, ...point }]) // include id and meta here
// Dispatch a new pointer move because the pointer's page will have changed
// (its screen position will compute to a new page position given the new camera position)
const { currentScreenPoint } = this.inputs
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
this.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_move',
// weird but true: we need to put the screen point back into client space
point: Vec.AddXY(currentScreenPoint, screenBounds.x, screenBounds.y),
pointerId: INTERNAL_POINTER_IDS.CAMERA_MOVE,
ctrlKey: this.inputs.ctrlKey,
altKey: this.inputs.altKey,
shiftKey: this.inputs.shiftKey,
button: 0,
isPen: this.getInstanceState().isPenMode ?? false,
})
this._tickCameraState()
})
return this
}
/**
* Set the current camera.
*
@ -2153,7 +2110,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const { width, height } = this.getViewportScreenBounds()
return this._animateToViewport(new Box(-x, -y, width / z, height / z), animation)
} else {
this._setCamera({ x, y, z })
this.camera.set({ x, y, z })
}
return this
@ -2279,11 +2236,12 @@ export class Editor extends EventEmitter<TLEventMap> {
const { x: cx, y: cy, z: cz } = this.getCamera()
let zoom = MAX_ZOOM
let zoom = this.camera.getZoomMax()
const stops = this.camera.getZoomStops()
for (let i = 1; i < ZOOMS.length; i++) {
const z1 = ZOOMS[i - 1]
const z2 = ZOOMS[i]
for (let i = 1; i < stops.length; i++) {
const z1 = stops[i - 1]
const z2 = stops[i]
if (z2 - cz <= (z2 - z1) / 2) continue
zoom = z2
break
@ -2317,11 +2275,12 @@ export class Editor extends EventEmitter<TLEventMap> {
const { x: cx, y: cy, z: cz } = this.getCamera()
let zoom = MIN_ZOOM
let zoom = this.camera.getZoomMin()
const stops = this.camera.getZoomStops()
for (let i = ZOOMS.length - 1; i > 0; i--) {
const z1 = ZOOMS[i - 1]
const z2 = ZOOMS[i]
for (let i = stops.length - 1; i > 0; i--) {
const z1 = stops[i - 1]
const z2 = stops[i]
if (z2 - cz >= (z2 - z1) / 2) continue
zoom = z1
break
@ -2452,8 +2411,8 @@ export class Editor extends EventEmitter<TLEventMap> {
(viewportScreenBounds.width - inset) / bounds.width,
(viewportScreenBounds.height - inset) / bounds.height
),
MIN_ZOOM,
MAX_ZOOM
this.camera.getZoomMin(),
this.camera.getZoomMax()
)
if (opts?.targetZoom !== undefined) {
@ -2527,7 +2486,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const { elapsed, easing, duration, start, end } = this._viewportAnimation
if (elapsed > duration) {
this._setCamera({ x: -end.x, y: -end.y, z: this.getViewportScreenBounds().width / end.width })
this.camera.set({ x: -end.x, y: -end.y, z: this.getViewportScreenBounds().width / end.width })
cancel()
return
}
@ -2539,11 +2498,11 @@ export class Editor extends EventEmitter<TLEventMap> {
const top = start.minY + (end.minY - start.minY) * t
const right = start.maxX + (end.maxX - start.maxX) * t
this._setCamera({ x: -left, y: -top, z: this.getViewportScreenBounds().width / (right - left) })
this.camera.set({ x: -left, y: -top, z: this.getViewportScreenBounds().width / (right - left) })
}
/** @internal */
private _animateToViewport(targetViewportPage: Box, opts = {} as TLAnimationOptions) {
private _animateToViewport(targetViewportPage: Box, opts = {} as TLAnimationOptions): this {
const { duration = 0, easing = EASINGS.easeInOutCubic } = opts
const animationSpeed = this.user.getAnimationSpeed()
const viewportPageBounds = this.getViewportPageBounds()
@ -2558,11 +2517,12 @@ export class Editor extends EventEmitter<TLEventMap> {
if (duration === 0 || animationSpeed === 0) {
// If we have no animation, then skip the animation and just set the camera
return this._setCamera({
this.camera.set({
x: -targetViewportPage.x,
y: -targetViewportPage.y,
z: this.getViewportScreenBounds().width / targetViewportPage.width,
})
return this
}
// Set our viewport animation
@ -2621,7 +2581,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (currentSpeed < speedThreshold) {
cancel()
} else {
this._setCamera({ x: cx + movementVec.x, y: cy + movementVec.y, z: cz })
this.camera.set({ x: cx + movementVec.x, y: cy + movementVec.y, z: cz })
}
}
@ -2788,7 +2748,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
}
this._tickCameraState()
this.camera.tickState()
this.updateRenderingBounds()
return this
@ -2985,7 +2945,11 @@ export class Editor extends EventEmitter<TLEventMap> {
? Math.min(width / desiredWidth, height / desiredHeight)
: height / desiredHeight
const targetZoom = clamp(this.getCamera().z * ratio, MIN_ZOOM, MAX_ZOOM)
const targetZoom = clamp(
this.getCamera().z * ratio,
this.camera.getZoomMin(),
this.camera.getZoomMax()
)
const targetWidth = this.getViewportScreenBounds().w / targetZoom
const targetHeight = this.getViewportScreenBounds().h / targetZoom
@ -3017,7 +2981,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// Update the camera!
isCaughtUp = false
this.stopCameraAnimation()
this._setCamera({
this.camera.set({
x: -(targetCenter.x - targetWidth / 2),
y: -(targetCenter.y - targetHeight / 2),
z: targetZoom,
@ -3042,54 +3006,13 @@ export class Editor extends EventEmitter<TLEventMap> {
}
// Camera state
private _cameraState = atom('camera state', 'idle' as 'idle' | 'moving')
/**
* Whether the camera is moving or idle.
*
* @public
*/
getCameraState() {
return this._cameraState.get()
}
// Camera state does two things: first, it allows us to subscribe to whether
// the camera is moving or not; and second, it allows us to update the rendering
// shapes on the canvas. Changing the rendering shapes may cause shapes to
// unmount / remount in the DOM, which is expensive; and computing visibility is
// also expensive in large projects. For this reason, we use a second bounding
// box just for rendering, and we only update after the camera stops moving.
private _cameraStateTimeoutRemaining = 0
private _lastUpdateRenderingBoundsTimestamp = Date.now()
private _decayCameraStateTimeout = (elapsed: number) => {
this._cameraStateTimeoutRemaining -= elapsed
if (this._cameraStateTimeoutRemaining <= 0) {
this.off('tick', this._decayCameraStateTimeout)
this._cameraState.set('idle')
this.updateRenderingBounds()
}
}
private _tickCameraState = () => {
// always reset the timeout
this._cameraStateTimeoutRemaining = CAMERA_MOVING_TIMEOUT
const now = Date.now()
// If the state is idle, then start the tick
if (this._cameraState.__unsafe__getWithoutCapture() === 'idle') {
this._lastUpdateRenderingBoundsTimestamp = now // don't render right away
this._cameraState.set('moving')
this.on('tick', this._decayCameraStateTimeout)
} else {
if (now - this._lastUpdateRenderingBoundsTimestamp > CAMERA_MAX_RENDERING_INTERVAL) {
this.updateRenderingBounds()
}
}
return this.camera.getState()
}
/** @internal */
@ -8456,7 +8379,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const { x: cx, y: cy, z: cz } = this.getCamera()
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))
const zoom = clamp(z, this.camera.getZoomMin(), this.camera.getZoomMax())
this.setCamera({
x: cx + dx / cz - x / cz + x / zoom,
@ -8507,7 +8430,11 @@ export class Editor extends EventEmitter<TLEventMap> {
const { x: cx, y: cy, z: cz } = this.getCamera()
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz))
const zoom = clamp(
cz + (info.delta.z ?? 0) * cz,
this.camera.getZoomMin(),
this.camera.getZoomMax()
)
this.setCamera({
x: cx + (x / zoom - x) - (x / cz - x),

Wyświetl plik

@ -0,0 +1,222 @@
import { atom, computed } from '@tldraw/state'
import { CameraRecordType, TLINSTANCE_ID, Vec3Model } from '@tldraw/tlschema'
import { warnOnce } from '@tldraw/utils'
import {
CAMERA_MAX_RENDERING_INTERVAL,
CAMERA_MOVING_TIMEOUT,
INTERNAL_POINTER_IDS,
} from '../../constants'
import { Vec } from '../../primitives/Vec'
import { clamp } from '../../primitives/utils'
import { Editor } from '../Editor'
const DEFAULT_ZOOM_STOPS = [0.1, 0.25, 0.5, 1, 2, 4, 8]
const DEFAULT_MIN_ZOOM = 0.1
const DEFAULT_MAX_ZOOM = 8
/**
* Configure the camera. (unreleased)
* @internal
*/
export interface CameraOptions {
/**
* Control how the camera zooms. Zoom levels are multipliers - 1 is 100% zoom, 2 is 2x bigger,
* 0.5 is 2x smaller, etc.
*/
zoom?: {
/**
* The minimum zoom level - how far the user can zoom out. Default's to 0.1.
*/
min?: number
/**
* The maximum zoom level - how far the user can zoom in. Default's to 8.
*/
max?: number
/**
* The zoom levels we snap to when zooming in, or out. Default's to
* `[0.1, 0.25, 0.5, 1, 2, 4, 8]`.
*/
stops?: number[]
/**
* When zooming with the wheel, we have to translate the wheel delta into a zoom delta. By
* default, we use 0.01 - so 100px of wheel movement translates into 1 unit of zoom (at
* 100%).
*/
wheelSensitivity?: number
}
}
/** @internal */
export class CameraManager {
constructor(private editor: Editor) {}
// Camera record
/** @internal */
@computed private getId() {
return CameraRecordType.createId(this.editor.getCurrentPageId())
}
/** @internal */
@computed get() {
return this.editor.store.get(this.getId())!
}
/** @internal */
set(point: Vec3Model) {
const currentCamera = this.get()
point = this.constrain(point)
if (currentCamera.x === point.x && currentCamera.y === point.y && currentCamera.z === point.z) {
return
}
this.editor.batch(() => {
this.editor.store.put([{ ...currentCamera, ...point }]) // include id and meta here
// Dispatch a new pointer move because the pointer's page will have changed
// (its screen position will compute to a new page position given the new camera position)
const { currentScreenPoint } = this.editor.inputs
const { screenBounds } = this.editor.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
this.editor.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_move',
// weird but true: we need to put the screen point back into client space
point: Vec.AddXY(currentScreenPoint, screenBounds.x, screenBounds.y),
pointerId: INTERNAL_POINTER_IDS.CAMERA_MOVE,
ctrlKey: this.editor.inputs.ctrlKey,
altKey: this.editor.inputs.altKey,
shiftKey: this.editor.inputs.shiftKey,
button: 0,
isPen: this.editor.getInstanceState().isPenMode ?? false,
})
this.tickState()
})
return this
}
// Camera state
private _state = atom('camera state', 'idle' as 'idle' | 'moving')
/** @internal */
getState() {
return this._state.get()
}
// Camera state does two things: first, it allows us to subscribe to whether
// the camera is moving or not; and second, it allows us to update the rendering
// shapes on the canvas. Changing the rendering shapes may cause shapes to
// unmount / remount in the DOM, which is expensive; and computing visibility is
// also expensive in large projects. For this reason, we use a second bounding
// box just for rendering, and we only update after the camera stops moving.
private _cameraStateTimeoutRemaining = 0
private _lastUpdateRenderingBoundsTimestamp = Date.now()
private _decayCameraStateTimeout = (elapsed: number) => {
this._cameraStateTimeoutRemaining -= elapsed
if (this._cameraStateTimeoutRemaining <= 0) {
this.editor.off('tick', this._decayCameraStateTimeout)
this._state.set('idle')
this.editor.updateRenderingBounds()
}
}
/** @internal */
tickState = () => {
// always reset the timeout
this._cameraStateTimeoutRemaining = CAMERA_MOVING_TIMEOUT
const now = Date.now()
// If the state is idle, then start the tick
if (this._state.__unsafe__getWithoutCapture() === 'idle') {
this._lastUpdateRenderingBoundsTimestamp = now // don't render right away
this._state.set('moving')
this.editor.on('tick', this._decayCameraStateTimeout)
} else {
if (now - this._lastUpdateRenderingBoundsTimestamp > CAMERA_MAX_RENDERING_INTERVAL) {
this.editor.updateRenderingBounds()
}
}
}
// Camera options
private _options = atom<CameraOptions>('camera options', {})
/** @internal */
setOptions(options: CameraOptions) {
if (options.zoom) {
const {
min = DEFAULT_MIN_ZOOM,
max = DEFAULT_MAX_ZOOM,
stops = DEFAULT_ZOOM_STOPS,
} = options.zoom
if (min > max) {
warnOnce('the minimum zoom level is greater than the maximum zoom level')
}
stops.sort()
const lowestStop = stops[0]
const highestStop = stops[stops.length - 1]
if (lowestStop < min) {
warnOnce('the lowest zoom stop is less than the minimum zoom level')
}
if (highestStop > max) {
warnOnce('the highest zoom stop is greater than the maximum zoom level')
}
}
this._options.set(options)
// set the camera to its current value to apply any new constraints
this.set(this.get())
}
/** What's the minimum zoom level? */
@computed getZoomMin() {
return this._options.get().zoom?.min ?? DEFAULT_MIN_ZOOM
}
/** What's the maximum zoom level? */
@computed getZoomMax() {
return this._options.get().zoom?.max ?? DEFAULT_MAX_ZOOM
}
/** Where do we stop when zooming in and out? */
@computed getZoomStops(): readonly number[] {
return this._options.get().zoom?.stops ?? DEFAULT_ZOOM_STOPS
}
/** How sensitive is the wheel when zooming? */
@computed getWheelZoomSensitivity() {
return this._options.get().zoom?.wheelSensitivity ?? 0.01
}
private constrain({ x: cx, y: cy, z: cz }: Vec3Model): Vec3Model {
const center = this.editor.getViewportScreenCenter()
const minZoom = this.getZoomMin()
const maxZoom = this.getZoomMax()
let x = cx
let y = cy
const z = clamp(cz, minZoom, maxZoom)
// if the z has changed, adjust the x and y to keep the center in the same place
if (z !== cz) {
x = cx + (center.x / z - center.x) - (center.x / cz - center.x)
y = cy + (center.y / z - center.y) - (center.y / cz - center.y)
}
return { x, y, z }
}
}

Wyświetl plik

@ -113,7 +113,9 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
preventDefault(event)
stopEventPropagation(event)
const delta = normalizeWheel(event)
const delta = normalizeWheel(event, {
zoomSensitivity: editor.camera.getWheelZoomSensitivity(),
})
if (delta.x === 0 && delta.y === 0) return
@ -300,7 +302,11 @@ export function useGestureEvents(ref: React.RefObject<HTMLDivElement>) {
pinch: {
from: () => [editor.getZoomLevel(), 0], // Return the camera z to use when pinch starts
scaleBounds: () => {
return { from: editor.getZoomLevel(), max: 8, min: 0.05 }
return {
from: editor.getZoomLevel(),
max: editor.camera.getZoomMax(),
min: editor.camera.getZoomMin(),
}
},
},
})

Wyświetl plik

@ -7,7 +7,10 @@ const IS_DARWIN = /Mac|iPod|iPhone|iPad/.test(
// Adapted from https://stackoverflow.com/a/13650579
/** @internal */
export function normalizeWheel(event: WheelEvent | React.WheelEvent<HTMLElement>) {
export function normalizeWheel(
event: WheelEvent | React.WheelEvent<HTMLElement>,
opts: { zoomSensitivity: number }
) {
let { deltaY, deltaX } = event
let deltaZ = 0
@ -21,7 +24,7 @@ export function normalizeWheel(event: WheelEvent | React.WheelEvent<HTMLElement>
dy = MAX_ZOOM_STEP * signY
}
deltaZ = dy / 100
deltaZ = dy * opts.zoomSensitivity
} else {
if (event.shiftKey && !IS_DARWIN) {
deltaX = deltaY

Wyświetl plik

@ -33,6 +33,10 @@ Object.defineProperty(global.URL, 'createObjectURL', {
writable: true,
value: jest.fn(),
})
Object.defineProperty(global.URL, 'revokeObjectURL', {
writable: true,
value: jest.fn(),
})
// Extract verson from package.json
const { version } = require('./package.json')

Wyświetl plik

@ -1,5 +1,4 @@
import {
HASH_PATTERN_ZOOM_NAMES,
TLDefaultColorStyle,
TLDefaultColorTheme,
TLDefaultFillStyle,
@ -10,6 +9,7 @@ import {
useValue,
} from '@tldraw/editor'
import React from 'react'
import { getHashPatternZoomName } from './defaultStyleDefs'
export interface ShapeFillProps {
d: string
@ -53,10 +53,10 @@ const PatternFill = function PatternFill({ d, color, theme }: ShapeFillProps) {
<path
fill={
svgExport
? `url(#${HASH_PATTERN_ZOOM_NAMES[`1_${theme.id}`]})`
? `url(#${getHashPatternZoomName(1, theme.id)})`
: teenyTiny
? theme[color].semi
: `url(#${HASH_PATTERN_ZOOM_NAMES[`${intZoom}_${theme.id}`]})`
: `url(#${getHashPatternZoomName(intZoom, theme.id)})`
}
d={d}
/>

Wyświetl plik

@ -1,18 +1,17 @@
import {
DefaultColorThemePalette,
DefaultFontFamilies,
DefaultFontStyle,
FileHelpers,
HASH_PATTERN_ZOOM_NAMES,
MAX_ZOOM,
SvgExportDef,
TLDefaultColorTheme,
TLDefaultFillStyle,
TLDefaultFontStyle,
TLShapeUtilCanvasSvgDef,
debugFlags,
useEditor,
useValue,
} from '@tldraw/editor'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useDefaultColorTheme } from './ShapeFill'
/** @public */
@ -72,7 +71,7 @@ function HashPatternForExport() {
</g>
</mask>
<pattern
id={HASH_PATTERN_ZOOM_NAMES[`1_${theme.id}`]}
id={getHashPatternZoomName(1, theme.id)}
width="8"
height="8"
patternUnits="userSpaceOnUse"
@ -144,62 +143,82 @@ const canvasBlob = (size: [number, number], fn: (ctx: CanvasRenderingContext2D)
fn(ctx)
return canvas.toDataURL()
}
type PatternDef = { zoom: number; url: string; darkMode: boolean }
type PatternDef = { zoom: number; url: string; theme: 'light' | 'dark' }
const getDefaultPatterns = () => {
const defaultPatterns: PatternDef[] = []
for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) {
const whitePixelBlob = canvasBlob([1, 1], (ctx) => {
ctx.fillStyle = DefaultColorThemePalette.lightMode.black.semi
ctx.fillRect(0, 0, 1, 1)
})
const blackPixelBlob = canvasBlob([1, 1], (ctx) => {
ctx.fillStyle = DefaultColorThemePalette.darkMode.black.semi
ctx.fillRect(0, 0, 1, 1)
})
defaultPatterns.push({
zoom: i,
url: whitePixelBlob,
darkMode: false,
})
defaultPatterns.push({
zoom: i,
url: blackPixelBlob,
darkMode: true,
})
let defaultPixels: { white: string; black: string } | null = null
function getDefaultPixels() {
if (!defaultPixels) {
defaultPixels = {
white: canvasBlob([1, 1], (ctx) => {
ctx.fillStyle = '#f8f9fa'
ctx.fillRect(0, 0, 1, 1)
}),
black: canvasBlob([1, 1], (ctx) => {
ctx.fillStyle = '#212529'
ctx.fillRect(0, 0, 1, 1)
}),
}
}
return defaultPatterns
return defaultPixels
}
function getLodForZoomLevel(zoom: number) {
return Math.ceil(Math.log2(Math.max(1, zoom)))
}
export function getHashPatternZoomName(zoom: number, theme: TLDefaultColorTheme['id']) {
const lod = getLodForZoomLevel(zoom)
return `tldraw_hash_pattern_${theme}_${lod}`
}
function getPatternLodsToGenerate(minZoom: number, maxZoom: number) {
const levels = []
const minLod = 0
const maxLod = getLodForZoomLevel(maxZoom)
for (let i = minLod; i <= maxLod; i++) {
levels.push(Math.pow(2, i))
}
return levels
}
function getDefaultPatterns(minZoom: number, maxZoom: number): PatternDef[] {
const defaultPixels = getDefaultPixels()
return getPatternLodsToGenerate(minZoom, maxZoom).flatMap((zoom) => [
{ zoom, url: defaultPixels.white, theme: 'light' },
{ zoom, url: defaultPixels.black, theme: 'dark' },
])
}
function usePattern() {
const editor = useEditor()
const dpr = editor.getInstanceState().devicePixelRatio
const [isReady, setIsReady] = useState(false)
const defaultPatterns = useMemo(() => getDefaultPatterns(), [])
const [backgroundUrls, setBackgroundUrls] = useState<PatternDef[]>(defaultPatterns)
const minZoom = useValue('minZoom', () => Math.max(1, Math.floor(editor.camera.getZoomMin())), [
editor,
])
const maxZoom = useValue('maxZoom', () => Math.ceil(editor.camera.getZoomMax()), [editor])
const [backgroundUrls, setBackgroundUrls] = useState<PatternDef[]>(() =>
getDefaultPatterns(minZoom, maxZoom)
)
useEffect(() => {
const promises: Promise<{ zoom: number; url: string; darkMode: boolean }>[] = []
for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) {
promises.push(
generateImage(dpr, i, false).then((blob) => ({
zoom: i,
const promise = Promise.all(
getPatternLodsToGenerate(minZoom, maxZoom).flatMap<Promise<PatternDef>>((zoom) => [
generateImage(dpr, zoom, false).then((blob) => ({
zoom,
theme: 'light',
url: URL.createObjectURL(blob),
darkMode: false,
}))
)
promises.push(
generateImage(dpr, i, true).then((blob) => ({
zoom: i,
})),
generateImage(dpr, zoom, true).then((blob) => ({
zoom,
theme: 'dark',
url: URL.createObjectURL(blob),
darkMode: true,
}))
)
}
})),
])
)
let isCancelled = false
Promise.all(promises).then((urls) => {
promise.then((urls) => {
if (isCancelled) return
setBackgroundUrls(urls)
setIsReady(true)
@ -208,17 +227,22 @@ function usePattern() {
return () => {
isCancelled = true
setIsReady(false)
promise.then((patterns) => {
for (const { url } of patterns) {
URL.revokeObjectURL(url)
}
})
}
}, [dpr])
}, [dpr, maxZoom, minZoom])
const defs = (
<>
{backgroundUrls.map((item) => {
const key = item.zoom + (item.darkMode ? '_dark' : '_light')
const id = getHashPatternZoomName(item.zoom, item.theme)
return (
<pattern
key={key}
id={HASH_PATTERN_ZOOM_NAMES[key]}
key={id}
id={id}
width={TILE_PATTERN_SIZE}
height={TILE_PATTERN_SIZE}
patternUnits="userSpaceOnUse"

Wyświetl plik

@ -135,7 +135,7 @@ export function DefaultMinimap() {
const onWheel = React.useCallback(
(e: React.WheelEvent<HTMLCanvasElement>) => {
const offset = normalizeWheel(e)
const offset = normalizeWheel(e, { zoomSensitivity: editor.camera.getWheelZoomSensitivity() })
editor.dispatch({
type: 'wheel',

Wyświetl plik

@ -0,0 +1,55 @@
import { noop } from '@tldraw/editor'
import { TestEditor } from './TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('setOptions', () => {
it('warns if min zoom is greater than max', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(noop)
editor.camera.setOptions({ zoom: { min: 2, max: 1 } })
expect(warn).toHaveBeenCalledWith(
'[tldraw] the minimum zoom level is greater than the maximum zoom level'
)
})
it('warns if min zoom is greater than smallest zoom step', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(noop)
editor.camera.setOptions({ zoom: { min: 1.5, stops: [1, 2, 3] } })
expect(warn).toHaveBeenCalledWith(
'[tldraw] the lowest zoom stop is less than the minimum zoom level'
)
})
it('warns if max zoom is less than largest zoom step', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(noop)
editor.camera.setOptions({ zoom: { max: 2.5, stops: [1, 2, 3] } })
expect(warn).toHaveBeenCalledWith(
'[tldraw] the highest zoom stop is greater than the maximum zoom level'
)
})
it('sorts zoom levels', () => {
editor.camera.setOptions({ zoom: { stops: [3, 1, 2] } })
expect(editor.camera.getZoomStops()).toEqual([1, 2, 3])
})
it('zooms out if the min zoom increases past the current zoom', () => {
expect(editor.camera.get()).toMatchObject({ x: 0, y: 0, z: 1 })
editor.camera.setOptions({ zoom: { min: 2, stops: [] } })
expect(editor.camera.get()).toMatchObject({ x: -270, y: -180, z: 2 })
})
it('zooms in if the max zoom decreases past the current zoom', () => {
expect(editor.camera.get()).toMatchObject({ x: 0, y: 0, z: 1 })
editor.camera.setOptions({ zoom: { max: 0.5, stops: [] } })
expect(editor.camera.get()).toMatchObject({ x: 540, y: 360, z: 0.5 })
})
})

Wyświetl plik

@ -49,7 +49,7 @@ exports[`Matches a snapshot: Basic SVG 1`] = `
</mask>
<pattern
height="8"
id="hash_pattern_zoom_1_light"
id="tldraw_hash_pattern_light_0"
patternUnits="userSpaceOnUse"
width="8"
>
@ -131,7 +131,7 @@ exports[`Matches a snapshot: Basic SVG 1`] = `
/>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="url(#hash_pattern_zoom_1_light)"
fill="url(#tldraw_hash_pattern_light_0)"
/>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"

Wyświetl plik

@ -1,4 +1,3 @@
import { ZOOMS } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
@ -8,21 +7,35 @@ beforeEach(() => {
})
it('zooms by increments', () => {
const zoomStops = editor.camera.getZoomStops()
// Starts at 1
expect(editor.getZoomLevel()).toBe(1)
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
expect(editor.getZoomLevel()).toBe(zoomStops[3])
// zooms in
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
expect(editor.getZoomLevel()).toBe(zoomStops[3])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[4])
expect(editor.getZoomLevel()).toBe(zoomStops[4])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[5])
expect(editor.getZoomLevel()).toBe(zoomStops[5])
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[6])
expect(editor.getZoomLevel()).toBe(zoomStops[6])
// does not zoom in past max
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[6])
expect(editor.getZoomLevel()).toBe(zoomStops[6])
})
it('allows customizing the zoom stops', () => {
const stops = []
for (let stop = 1; stop <= 8; stop += Math.random()) {
stops.push(stop)
}
editor.camera.setOptions({ zoom: { stops } })
for (let i = 1; i < stops.length; i++) {
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(stops[i])
}
})
it('preserves the screen center', () => {
@ -48,12 +61,13 @@ it('preserves the screen center when offset', () => {
})
it('zooms to from B to D when B >= (C - A)/2, else zooms from B to C', () => {
editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 })
const zoomStops = editor.camera.getZoomStops()
editor.setCamera({ x: 0, y: 0, z: (zoomStops[2] + zoomStops[3]) / 2 })
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[4])
editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 - 0.1 })
expect(editor.getZoomLevel()).toBe(zoomStops[4])
editor.setCamera({ x: 0, y: 0, z: (zoomStops[2] + zoomStops[3]) / 2 - 0.1 })
editor.zoomIn()
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
expect(editor.getZoomLevel()).toBe(zoomStops[3])
})
it('does not zoom when camera is frozen', () => {

Wyświetl plik

@ -1,4 +1,3 @@
import { ZOOMS } from '@tldraw/editor'
import { TestEditor } from '../TestEditor'
let editor: TestEditor
@ -8,18 +7,33 @@ beforeEach(() => {
})
it('zooms by increments', () => {
const zoomStops = editor.camera.getZoomStops()
// Starts at 1
expect(editor.getZoomLevel()).toBe(1)
expect(editor.getZoomLevel()).toBe(ZOOMS[3])
expect(editor.getZoomLevel()).toBe(zoomStops[3])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(ZOOMS[2])
expect(editor.getZoomLevel()).toBe(zoomStops[2])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(ZOOMS[1])
expect(editor.getZoomLevel()).toBe(zoomStops[1])
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(ZOOMS[0])
expect(editor.getZoomLevel()).toBe(zoomStops[0])
// does not zoom out past min
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(ZOOMS[0])
expect(editor.getZoomLevel()).toBe(zoomStops[0])
})
it('allows customizing the zoom stops', () => {
const stops = []
for (let stop = 1; stop >= 0.1; stop *= 0.5 + 0.5 * Math.random()) {
stops.push(stop)
}
stops.reverse()
editor.camera.setOptions({ zoom: { stops } })
for (let i = stops.length - 2; i >= 0; i--) {
editor.zoomOut()
expect(editor.getZoomLevel()).toBe(stops[i])
}
})
it('does not zoom out when camera is frozen', () => {

Wyświetl plik

@ -1262,6 +1262,16 @@ export type TLVideoAsset = TLBaseAsset<'video', {
// @public (undocumented)
export type TLVideoShape = TLBaseShape<'video', TLVideoShapeProps>;
// @public
export interface Vec3Model {
// (undocumented)
x: number;
// (undocumented)
y: number;
// (undocumented)
z: number;
}
// @public
export interface VecModel {
// (undocumented)

Wyświetl plik

@ -9532,6 +9532,105 @@
"endIndex": 5
}
},
{
"kind": "Interface",
"canonicalReference": "@tldraw/tlschema!Vec3Model:interface",
"docComment": "/**\n * A serializable model for 3D vectors.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export interface Vec3Model "
}
],
"fileUrlPath": "packages/tlschema/src/misc/geometry-types.ts",
"releaseTag": "Public",
"name": "Vec3Model",
"preserveMemberOrder": false,
"members": [
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!Vec3Model#x:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "x: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "x",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!Vec3Model#y:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "y: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "y",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!Vec3Model#z:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "z: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "z",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
}
],
"extendsTokenRanges": []
},
{
"kind": "Interface",
"canonicalReference": "@tldraw/tlschema!VecModel:interface",

Wyświetl plik

@ -24,6 +24,7 @@ export {
boxModelValidator,
vecModelValidator,
type BoxModel,
type Vec3Model,
type VecModel,
} from './misc/geometry-types'
export { idValidator } from './misc/id-validator'

Wyświetl plik

@ -21,6 +21,16 @@ export interface VecModel {
z?: number
}
/**
* A serializable model for 3D vectors.
*
* @public */
export interface Vec3Model {
x: number
y: number
z: number
}
/** @public */
export const vecModelValidator: T.Validator<VecModel> = T.object({
x: T.number,

Wyświetl plik

@ -326,6 +326,12 @@ export function validateIndexKey(key: string): asserts key is IndexKey;
// @internal (undocumented)
export function warnDeprecatedGetter(name: string): void;
// @internal (undocumented)
export function warnOnce(message: string): void;
// @internal (undocumented)
export function warnOnce(key: string, message: string): void;
// @public
export const ZERO_INDEX_KEY: IndexKey;

Wyświetl plik

@ -23,6 +23,7 @@ export { noop, omitFromStackTrace, throttle } from './lib/function'
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
export { getFirstFromIterable } from './lib/iterable'
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
export { warnDeprecatedGetter, warnOnce } from './lib/log'
export { MediaHelpers } from './lib/media'
export { invLerp, lerp, modulate, rng } from './lib/number'
export {
@ -71,4 +72,3 @@ export {
isNonNullish,
structuredClone,
} from './lib/value'
export { warnDeprecatedGetter } from './lib/warnDeprecatedGetter'

Wyświetl plik

@ -0,0 +1,26 @@
const usedWarnings = new Set<string>()
/**
* @internal
*/
export function warnDeprecatedGetter(name: string) {
const newName = `get${name[0].toLocaleUpperCase()}${name.slice(1)}`
warnOnce(
`deprecatedGetter:${name}`,
`Using '${name}' is deprecated and will be removed in the near future. Please refactor to use '${newName}' instead.`
)
}
/** @internal */
export function warnOnce(message: string): void
/** @internal */
export function warnOnce(key: string, message: string): void
/** @internal */
export function warnOnce(key: string, message?: string) {
if (message === undefined) {
message = key
}
if (usedWarnings.has(key)) return
console.warn(`[tldraw] ${message}`)
}

Wyświetl plik

@ -1,15 +0,0 @@
const warnedNames = new Set<string>()
/**
* @internal
*/
export function warnDeprecatedGetter(name: string) {
if (warnedNames.has(name)) return
warnedNames.add(name)
console.warn(
`Using '${name}' is deprecated and will be removed in the near future. Please refactor to use 'get${name[0].toLocaleUpperCase()}${name.slice(
1
)}' instead.`
)
}