kopia lustrzana https://github.com/Tldraw/Tldraw
alex/camera-zoom-options-2: camera zoom options
rodzic
c20d9fc9d2
commit
2451399462
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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.
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
})
|
|
@ -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"
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -24,6 +24,7 @@ export {
|
|||
boxModelValidator,
|
||||
vecModelValidator,
|
||||
type BoxModel,
|
||||
type Vec3Model,
|
||||
type VecModel,
|
||||
} from './misc/geometry-types'
|
||||
export { idValidator } from './misc/id-validator'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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}`)
|
||||
}
|
|
@ -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.`
|
||||
)
|
||||
}
|
Ładowanie…
Reference in New Issue