Camera APIs (#1786)

This PR updates camera APIs:
- removes animateCamera
- adds animation support to setCamera
- makes camera commands accept points rather than an x/y
  - `centerOnPoint`
  - `pageToScreen`
  - `screenToPoint`
  - `pan`
  - `setCamera`
- makes `zoomToBounds` accept a `Box2d` rather than x/y/w/h
- removes the `getBoundingClientRects` call from `getPointerInfo`
- removes the resize observer from `useScreenBounds`, uses an interval
instead when focused

A big (unexpected) improvement here is that `getBoundingClientRects` was
being called on every pointer move. This is a relatively expensive call
(it forces reflow) which could impact interactions. It's now called at
most once per second, and we could probably improve on that too if we
needed by only updating while in the select state.

### Change Type

- [x] `major` — Breaking change

### Test Plan

1. Try the multiple editors example after scrolling / resizing
2. Use the camera commands (zoom in, etc)

- [x] Unit Tests

### Release Notes

- (editor) improve camera commands
This commit is contained in:
Steve Ruiz 2023-08-02 16:56:33 +01:00 committed by GitHub
parent 507bba82fd
commit 39dbbca90e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1625 additions and 640 deletions

View file

@ -530,7 +530,6 @@ export class Editor extends EventEmitter<TLEventMap> {
alignShapes(shapes: TLShape[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this; alignShapes(shapes: TLShape[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
// (undocumented) // (undocumented)
alignShapes(ids: TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this; alignShapes(ids: TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
animateCamera(x: number, y: number, z?: number, opts?: TLAnimationOptions): this;
animateShape(partial: null | TLShapePartial | undefined, options?: Partial<{ animateShape(partial: null | TLShapePartial | undefined, options?: Partial<{
duration: number; duration: number;
ease: (t: number) => number; ease: (t: number) => number;
@ -568,7 +567,7 @@ export class Editor extends EventEmitter<TLEventMap> {
get canUndo(): boolean; get canUndo(): boolean;
// @internal (undocumented) // @internal (undocumented)
capturedPointerId: null | number; capturedPointerId: null | number;
centerOnPoint(x: number, y: number, opts?: TLAnimationOptions): this; centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this;
// @internal // @internal
protected _clickManager: ClickManager; protected _clickManager: ClickManager;
get commonBoundsOfAllShapesOnCurrentPage(): Box2d | undefined; get commonBoundsOfAllShapesOnCurrentPage(): Box2d | undefined;
@ -841,13 +840,13 @@ export class Editor extends EventEmitter<TLEventMap> {
packShapes(ids: TLShapeId[], gap: number): this; packShapes(ids: TLShapeId[], gap: number): this;
get pages(): TLPage[]; get pages(): TLPage[];
get pageStates(): TLInstancePageState[]; get pageStates(): TLInstancePageState[];
pageToScreen(x: number, y: number, z?: number, camera?: VecLike): { pageToScreen(point: VecLike): {
x: number; x: number;
y: number; y: number;
z: number; z: number;
}; };
pan(dx: number, dy: number, opts?: TLAnimationOptions): this; pan(offset: VecLike, animation?: TLAnimationOptions): this;
panZoomIntoView(ids: TLShapeId[], opts?: TLAnimationOptions): this; panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this;
popFocusLayer(): this; popFocusLayer(): this;
putContent(content: TLContent, options?: { putContent(content: TLContent, options?: {
point?: VecLike; point?: VecLike;
@ -882,7 +881,7 @@ export class Editor extends EventEmitter<TLEventMap> {
reparentShapes(shapes: TLShape[], parentId: TLParentId, insertIndex?: string): this; reparentShapes(shapes: TLShape[], parentId: TLParentId, insertIndex?: string): this;
// (undocumented) // (undocumented)
reparentShapes(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this; reparentShapes(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this;
resetZoom(point?: Vec2d, opts?: TLAnimationOptions): this; resetZoom(point?: Vec2d, animation?: TLAnimationOptions): this;
resizeShape(id: TLShapeId, scale: VecLike, options?: { resizeShape(id: TLShapeId, scale: VecLike, options?: {
initialBounds?: Box2d; initialBounds?: Box2d;
scaleOrigin?: VecLike; scaleOrigin?: VecLike;
@ -896,7 +895,7 @@ export class Editor extends EventEmitter<TLEventMap> {
rotateShapesBy(shapes: TLShape[], delta: number): this; rotateShapesBy(shapes: TLShape[], delta: number): this;
// (undocumented) // (undocumented)
rotateShapesBy(ids: TLShapeId[], delta: number): this; rotateShapesBy(ids: TLShapeId[], delta: number): this;
screenToPage(x: number, y: number, z?: number, camera?: VecLike): { screenToPage(point: VecLike): {
x: number; x: number;
y: number; y: number;
z: number; z: number;
@ -917,7 +916,7 @@ export class Editor extends EventEmitter<TLEventMap> {
sendToBack(shapes: TLShape[]): this; sendToBack(shapes: TLShape[]): this;
// (undocumented) // (undocumented)
sendToBack(ids: TLShapeId[]): this; sendToBack(ids: TLShapeId[]): this;
setCamera(x: number, y: number, z?: number, { stopFollowing }?: TLViewportOptions): this; setCamera(point: VecLike, animation?: TLAnimationOptions): this;
// (undocumented) // (undocumented)
setCroppingId(id: null | TLShapeId): this; setCroppingId(id: null | TLShapeId): this;
setCurrentPage(page: TLPage, opts?: TLViewportOptions): this; setCurrentPage(page: TLPage, opts?: TLViewportOptions): this;
@ -993,13 +992,13 @@ export class Editor extends EventEmitter<TLEventMap> {
visitDescendants(parent: TLPage | TLShape, visitor: (id: TLShapeId) => false | void): this; visitDescendants(parent: TLPage | TLShape, visitor: (id: TLShapeId) => false | void): this;
// (undocumented) // (undocumented)
visitDescendants(parentId: TLParentId, visitor: (id: TLShapeId) => false | void): this; visitDescendants(parentId: TLParentId, visitor: (id: TLShapeId) => false | void): this;
zoomIn(point?: Vec2d, opts?: TLAnimationOptions): this; zoomIn(point?: Vec2d, animation?: TLAnimationOptions): this;
get zoomLevel(): number; get zoomLevel(): number;
zoomOut(point?: Vec2d, opts?: TLAnimationOptions): this; zoomOut(point?: Vec2d, animation?: TLAnimationOptions): this;
zoomToBounds(x: number, y: number, width: number, height: number, targetZoom?: number, opts?: TLAnimationOptions): this; zoomToBounds(bounds: Box2d, targetZoom?: number, animation?: TLAnimationOptions): this;
zoomToContent(): this; zoomToContent(): this;
zoomToFit(opts?: TLAnimationOptions): this; zoomToFit(animation?: TLAnimationOptions): this;
zoomToSelection(opts?: TLAnimationOptions): this; zoomToSelection(animation?: TLAnimationOptions): this;
} }
// @public (undocumented) // @public (undocumented)
@ -1179,7 +1178,7 @@ export function getIndicesBelow(above: string, n: number): string[];
export function getIndicesBetween(below: string | undefined, above: string | undefined, n: number): string[]; export function getIndicesBetween(below: string | undefined, above: string | undefined, n: number): string[];
// @public (undocumented) // @public (undocumented)
export function getPointerInfo(e: PointerEvent | React.PointerEvent, container: HTMLElement): { export function getPointerInfo(e: PointerEvent | React.PointerEvent): {
point: { point: {
x: number; x: number;
y: number; y: number;
@ -1625,7 +1624,7 @@ export function refreshPage(): void;
export function releasePointerCapture(element: Element, event: PointerEvent | React_2.PointerEvent<Element>): void; export function releasePointerCapture(element: Element, event: PointerEvent | React_2.PointerEvent<Element>): void;
// @public (undocumented) // @public (undocumented)
export type RequiredKeys<T, K extends keyof T> = Pick<T, K> & Partial<T>; export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>;
// @public (undocumented) // @public (undocumented)
export function resizeBox(shape: TLBaseBoxShape, info: { export function resizeBox(shape: TLBaseBoxShape, info: {

View file

@ -590,6 +590,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const hasFocus = container === activeElement || container.contains(activeElement) const hasFocus = container === activeElement || container.contains(activeElement)
if ((!isFocused && hasFocus) || (isFocused && !hasFocus)) { if ((!isFocused && hasFocus) || (isFocused && !hasFocus)) {
this.updateInstanceState({ isFocused: hasFocus }) this.updateInstanceState({ isFocused: hasFocus })
this.updateViewportScreenBounds()
} }
}, 32) }, 32)
@ -1851,17 +1852,18 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
/** @internal */ /** @internal */
private _willSetInitialBounds = true private _setCamera(point: VecLike): this {
/** @internal */
private _setCamera(x: number, y: number, z = this.camera.z): this {
const currentCamera = this.camera const currentCamera = this.camera
if (currentCamera.x === x && currentCamera.y === y && currentCamera.z === z) return this
const nextCamera = { ...currentCamera, x, y, z } if (currentCamera.x === point.x && currentCamera.y === point.y && currentCamera.z === point.z) {
return this
}
this.batch(() => { this.batch(() => {
this.store.put([nextCamera]) 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 { currentScreenPoint } = this.inputs
this.dispatch({ this.dispatch({
@ -1888,84 +1890,54 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @example * @example
* ```ts * ```ts
* editor.setCamera(0, 0) * editor.setCamera({ x: 0, y: 0})
* editor.setCamera(0, 0, 1) * editor.setCamera({ x: 0, y: 0, z: 1.5})
* editor.setCamera({ x: 0, y: 0, z: 1.5}, { duration: 1000, easing: (t) => t * t })
* ``` * ```
* *
* @param x - The camera's x position. * @param point - The new camera position.
* @param y - The camera's y position. * @param animation - (optional) Options for an animation.
* @param z - The camera's z position. Defaults to the current zoom.
* @param options - Options for the camera change.
* *
* @public * @public
*/ */
setCamera( setCamera(point: VecLike, animation?: TLAnimationOptions): this {
x: number, const x = Number.isFinite(point.x) ? point.x : 0
y: number, const y = Number.isFinite(point.y) ? point.y : 0
z = this.camera.z, const z = Number.isFinite(point.z) ? point.z! : this.zoomLevel
{ stopFollowing = true }: TLViewportOptions = {}
): this { // Stop any camera animations
this.stopCameraAnimation() this.stopCameraAnimation()
if (stopFollowing && this.instanceState.followingUserId) {
// Stop following any user
if (this.instanceState.followingUserId) {
this.stopFollowingUser() this.stopFollowingUser()
} }
x = Number.isNaN(x) ? 0 : x
y = Number.isNaN(y) ? 0 : y if (animation) {
z = Number.isNaN(z) ? 1 : z const { width, height } = this.viewportScreenBounds
this._setCamera(x, y, z) return this._animateToViewport(new Box2d(-x, -y, width / z, height / z), animation)
} else {
this._setCamera({ x, y, z })
}
return this return this
} }
/**
* Animate the camera.
*
* @example
* ```ts
* editor.animateCamera(0, 0)
* editor.animateCamera(0, 0, 1)
* editor.animateCamera(0, 0, 1, { duration: 1000, easing: (t) => t * t })
* ```
*
* @param x - The camera's x position.
* @param y - The camera's y position.
* @param z - The camera's z position. Defaults to the current zoom.
* @param opts - Options for the animation.
*
* @public
*/
animateCamera(
x: number,
y: number,
z = this.camera.z,
opts: TLAnimationOptions = DEFAULT_ANIMATION_OPTIONS
): this {
x = Number.isNaN(x) ? 0 : x
y = Number.isNaN(y) ? 0 : y
z = Number.isNaN(z) ? 1 : z
const { width, height } = this.viewportScreenBounds
const w = width / z
const h = height / z
const targetViewport = new Box2d(-x, -y, w, h)
return this._animateToViewport(targetViewport, opts)
}
/** /**
* Center the camera on a point (in page space). * Center the camera on a point (in page space).
* *
* @example * @example
* ```ts * ```ts
* editor.centerOnPoint(100, 100) * editor.centerOnPoint({ x: 100, y: 100 })
* editor.centerOnPoint({ x: 100, y: 100 }, { duration: 200 })
* ``` * ```
* *
* @param x - The x position of the point. * @param point - The point in page space to center on.
* @param y - The y position of the point. * @param animation - (optional) The options for an animation.
* @param opts - The options for an animation.
* *
* @public * @public
*/ */
centerOnPoint(x: number, y: number, opts?: TLAnimationOptions): this { centerOnPoint(point: VecLike, animation?: TLAnimationOptions): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
const { const {
@ -1973,31 +1945,28 @@ export class Editor extends EventEmitter<TLEventMap> {
camera, camera,
} = this } = this
if (opts?.duration) { this.setCamera({ x: -(point.x - pw / 2), y: -(point.y - ph / 2), z: camera.z }, animation)
this.animateCamera(-(x - pw / 2), -(y - ph / 2), camera.z, opts)
} else {
this.setCamera(-(x - pw / 2), -(y - ph / 2), camera.z)
}
return this return this
} }
/** /**
* Move the camera to the nearest content. * Move the camera to the nearest content.
* *
* @example
* ```ts
* editor.zoomToContent()
* editor.zoomToContent({ duration: 200 })
* ```
*
* @param opts - (optional) The options for an animation.
*
* @public * @public
*/ */
zoomToContent() { zoomToContent() {
const bounds = this.selectionPageBounds ?? this.commonBoundsOfAllShapesOnCurrentPage const bounds = this.selectionPageBounds ?? this.commonBoundsOfAllShapesOnCurrentPage
if (bounds) { if (bounds) {
this.zoomToBounds( this.zoomToBounds(bounds, Math.min(1, this.zoomLevel), { duration: 220 })
bounds.minX,
bounds.minY,
bounds.width,
bounds.height,
Math.min(1, this.zoomLevel),
{ duration: 220 }
)
} }
return this return this
@ -2009,25 +1978,21 @@ export class Editor extends EventEmitter<TLEventMap> {
* @example * @example
* ```ts * ```ts
* editor.zoomToFit() * editor.zoomToFit()
* editor.zoomToFit({ duration: 200 })
* ``` * ```
* *
* @param animation - (optional) The options for an animation.
*
* @public * @public
*/ */
zoomToFit(opts?: TLAnimationOptions): this { zoomToFit(animation?: TLAnimationOptions): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
const ids = [...this.shapeIdsOnCurrentPage] const ids = [...this.shapeIdsOnCurrentPage]
if (ids.length <= 0) return this if (ids.length <= 0) return this
const pageBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id)))) const pageBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id))))
this.zoomToBounds( this.zoomToBounds(pageBounds, undefined, animation)
pageBounds.minX,
pageBounds.minY,
pageBounds.width,
pageBounds.height,
undefined,
opts
)
return this return this
} }
@ -2037,22 +2002,24 @@ export class Editor extends EventEmitter<TLEventMap> {
* @example * @example
* ```ts * ```ts
* editor.resetZoom() * editor.resetZoom()
* editor.resetZoom(editor.viewportScreenCenter)
* editor.resetZoom(editor.viewportScreenCenter, { duration: 200 })
* ``` * ```
* *
* @param opts - The options for an animation. * @param point - (optional) The screen point to zoom out on. Defaults to the viewport screen center.
* @param animation - (optional) The options for an animation.
* *
* @public * @public
*/ */
resetZoom(point = this.viewportScreenCenter, opts?: TLAnimationOptions): this { resetZoom(point = this.viewportScreenCenter, animation?: TLAnimationOptions): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
const { x: cx, y: cy, z: cz } = this.camera const { x: cx, y: cy, z: cz } = this.camera
const { x, y } = point const { x, y } = point
if (opts?.duration) { this.setCamera(
this.animateCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1, opts) { x: cx + (x / 1 - x) - (x / cz - x), y: cy + (y / 1 - y) - (y / cz - y), z: 1 },
} else { animation
this.setCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1) )
}
return this return this
} }
@ -2067,11 +2034,26 @@ export class Editor extends EventEmitter<TLEventMap> {
* editor.zoomIn(editor.inputs.currentScreenPoint, { duration: 120 }) * editor.zoomIn(editor.inputs.currentScreenPoint, { duration: 120 })
* ``` * ```
* *
* @param opts - The options for an animation. * @param animation - (optional) The options for an animation.
* *
* @public * @public
*/ */
zoomIn(point = this.viewportScreenCenter, opts?: TLAnimationOptions): this {
/**
* Zoom the camera in.
*
* @example
* ```ts
* editor.zoomIn()
* editor.zoomIn(editor.viewportScreenCenter, { duration: 120 })
* editor.zoomIn(editor.inputs.currentScreenPoint, { duration: 120 })
* ```
*
* @param animation - (optional) The options for an animation.
*
* @public
*/
zoomIn(point = this.viewportScreenCenter, animation?: TLAnimationOptions): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
const { x: cx, y: cy, z: cz } = this.camera const { x: cx, y: cy, z: cz } = this.camera
@ -2087,16 +2069,10 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
const { x, y } = point const { x, y } = point
if (opts?.duration) { this.setCamera(
this.animateCamera( { x: cx + (x / zoom - x) - (x / cz - x), y: cy + (y / zoom - y) - (y / cz - y), z: zoom },
cx + (x / zoom - x) - (x / cz - x), animation
cy + (y / zoom - y) - (y / cz - y), )
zoom,
opts
)
} else {
this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom)
}
return this return this
} }
@ -2111,11 +2087,11 @@ export class Editor extends EventEmitter<TLEventMap> {
* editor.zoomOut(editor.inputs.currentScreenPoint, { duration: 120 }) * editor.zoomOut(editor.inputs.currentScreenPoint, { duration: 120 })
* ``` * ```
* *
* @param opts - The options for an animation. * @param animation - (optional) The options for an animation.
* *
* @public * @public
*/ */
zoomOut(point = this.viewportScreenCenter, opts?: TLAnimationOptions): this { zoomOut(point = this.viewportScreenCenter, animation?: TLAnimationOptions): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
const { x: cx, y: cy, z: cz } = this.camera const { x: cx, y: cy, z: cz } = this.camera
@ -2132,16 +2108,14 @@ export class Editor extends EventEmitter<TLEventMap> {
const { x, y } = point const { x, y } = point
if (opts?.duration) { this.setCamera(
this.animateCamera( {
cx + (x / zoom - x) - (x / cz - x), x: cx + (x / zoom - x) - (x / cz - x),
cy + (y / zoom - y) - (y / cz - y), y: cy + (y / zoom - y) - (y / cz - y),
zoom, z: zoom,
opts },
) animation
} else { )
this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom)
}
return this return this
} }
@ -2154,26 +2128,19 @@ export class Editor extends EventEmitter<TLEventMap> {
* editor.zoomToSelection() * editor.zoomToSelection()
* ``` * ```
* *
* @param opts - The options for an animation. * @param animation - (optional) The options for an animation.
* *
* @public * @public
*/ */
zoomToSelection(opts?: TLAnimationOptions): this { zoomToSelection(animation?: TLAnimationOptions): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
const ids = this.selectedShapeIds const ids = this.selectedShapeIds
if (ids.length <= 0) return this if (ids.length <= 0) return this
const selectedBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id)))) const selectionBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id))))
this.zoomToBounds( this.zoomToBounds(selectionBounds, Math.max(1, this.camera.z), animation)
selectedBounds.minX,
selectedBounds.minY,
selectedBounds.width,
selectedBounds.height,
Math.max(1, this.camera.z),
opts
)
return this return this
} }
@ -2182,27 +2149,20 @@ export class Editor extends EventEmitter<TLEventMap> {
* Pan or pan/zoom the selected ids into view. This method tries to not change the zoom if possible. * Pan or pan/zoom the selected ids into view. This method tries to not change the zoom if possible.
* *
* @param ids - The ids of the shapes to pan and zoom into view. * @param ids - The ids of the shapes to pan and zoom into view.
* @param opts - The options for an animation. * @param animation - The options for an animation.
* *
* @public * @public
*/ */
panZoomIntoView(ids: TLShapeId[], opts?: TLAnimationOptions): this { panZoomIntoView(ids: TLShapeId[], animation?: TLAnimationOptions): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
if (ids.length <= 0) return this if (ids.length <= 0) return this
const selectedBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id)))) const selectionBounds = Box2d.Common(compact(ids.map((id) => this.getPageBounds(id))))
const { viewportPageBounds } = this const { viewportPageBounds } = this
if (viewportPageBounds.h < selectedBounds.h || viewportPageBounds.w < selectedBounds.w) { if (viewportPageBounds.h < selectionBounds.h || viewportPageBounds.w < selectionBounds.w) {
this.zoomToBounds( this.zoomToBounds(selectionBounds, this.camera.z, animation)
selectedBounds.minX,
selectedBounds.minY,
selectedBounds.width,
selectedBounds.height,
this.camera.z,
opts
)
return this return this
} else { } else {
@ -2210,33 +2170,28 @@ export class Editor extends EventEmitter<TLEventMap> {
let offsetX = 0 let offsetX = 0
let offsetY = 0 let offsetY = 0
if (insetViewport.maxY < selectedBounds.maxY) { if (insetViewport.maxY < selectionBounds.maxY) {
// off bottom // off bottom
offsetY = insetViewport.maxY - selectedBounds.maxY offsetY = insetViewport.maxY - selectionBounds.maxY
} else if (insetViewport.minY > selectedBounds.minY) { } else if (insetViewport.minY > selectionBounds.minY) {
// off top // off top
offsetY = insetViewport.minY - selectedBounds.minY offsetY = insetViewport.minY - selectionBounds.minY
} else { } else {
// inside y-bounds // inside y-bounds
} }
if (insetViewport.maxX < selectedBounds.maxX) { if (insetViewport.maxX < selectionBounds.maxX) {
// off right // off right
offsetX = insetViewport.maxX - selectedBounds.maxX offsetX = insetViewport.maxX - selectionBounds.maxX
} else if (insetViewport.minX > selectedBounds.minX) { } else if (insetViewport.minX > selectionBounds.minX) {
// off left // off left
offsetX = insetViewport.minX - selectedBounds.minX offsetX = insetViewport.minX - selectionBounds.minX
} else { } else {
// inside x-bounds // inside x-bounds
} }
const { camera } = this const { camera } = this
this.setCamera({ x: camera.x + offsetX, y: camera.y + offsetY, z: camera.z }, animation)
if (opts?.duration) {
this.animateCamera(camera.x + offsetX, camera.y + offsetY, camera.z, opts)
} else {
this.setCamera(camera.x + offsetX, camera.y + offsetY, camera.z)
}
} }
return this return this
@ -2247,25 +2202,18 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @example * @example
* ```ts * ```ts
* editor.zoomToBounds(0, 0, 100, 100) * editor.zoomToBounds(myBounds)
* editor.zoomToBounds(myBounds, 1)
* editor.zoomToBounds(myBounds, 1, { duration: 100 })
* ``` * ```
* *
* @param x - The bounding box's x position. * @param bounds - The bounding box.
* @param y - The bounding box's y position.
* @param width - The bounding box's width.
* @param height - The bounding box's height.
* @param targetZoom - The desired zoom level. Defaults to 0.1. * @param targetZoom - The desired zoom level. Defaults to 0.1.
* @param animation - (optional) The options for an animation.
* *
* @public * @public
*/ */
zoomToBounds( zoomToBounds(bounds: Box2d, targetZoom?: number, animation?: TLAnimationOptions): this {
x: number,
y: number,
width: number,
height: number,
targetZoom?: number,
opts?: TLAnimationOptions
): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
const { viewportScreenBounds } = this const { viewportScreenBounds } = this
@ -2274,8 +2222,8 @@ export class Editor extends EventEmitter<TLEventMap> {
let zoom = clamp( let zoom = clamp(
Math.min( Math.min(
(viewportScreenBounds.width - inset) / width, (viewportScreenBounds.width - inset) / bounds.width,
(viewportScreenBounds.height - inset) / height (viewportScreenBounds.height - inset) / bounds.height
), ),
MIN_ZOOM, MIN_ZOOM,
MAX_ZOOM MAX_ZOOM
@ -2285,20 +2233,14 @@ export class Editor extends EventEmitter<TLEventMap> {
zoom = Math.min(targetZoom, zoom) zoom = Math.min(targetZoom, zoom)
} }
if (opts?.duration) { this.setCamera(
this.animateCamera( {
-x + (viewportScreenBounds.width - width * zoom) / 2 / zoom, x: -bounds.minX + (viewportScreenBounds.width - bounds.width * zoom) / 2 / zoom,
-y + (viewportScreenBounds.height - height * zoom) / 2 / zoom, y: -bounds.minY + (viewportScreenBounds.height - bounds.height * zoom) / 2 / zoom,
zoom, z: zoom,
opts },
) animation
} else { )
this.setCamera(
-x + (viewportScreenBounds.width - width * zoom) / 2 / zoom,
-y + (viewportScreenBounds.height - height * zoom) / 2 / zoom,
zoom
)
}
return this return this
} }
@ -2308,27 +2250,17 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @example * @example
* ```ts * ```ts
* editor.pan(100, 100) * editor.pan({ x: 100, y: 100 })
* editor.pan(100, 100, { duration: 1000 }) * editor.pan({ x: 100, y: 100 }, { duration: 1000 })
* ``` * ```
* *
* @param dx - The amount to pan on the x axis. * @param offset - The offset in page space.
* @param dy - The amount to pan on the y axis. * @param animation - (optional) The animation options.
* @param opts - The animation options
*/ */
pan(dx: number, dy: number, opts?: TLAnimationOptions): this { pan(offset: VecLike, animation?: TLAnimationOptions): this {
if (!this.instanceState.canMoveCamera) return this if (!this.instanceState.canMoveCamera) return this
const { x: cx, y: cy, z: cz } = this.camera
const { camera } = this this.setCamera({ x: cx + offset.x / cz, y: cy + offset.y / cz, z: cz }, animation)
const { x: cx, y: cy, z: cz } = camera
const d = new Vec2d(dx, dy).div(cz)
if (opts?.duration ?? 0 > 0) {
return this.animateCamera(cx + d.x, cy + d.y, cz, opts)
} else {
this.setCamera(cx + d.x, cy + d.y, cz)
}
return this return this
} }
@ -2369,11 +2301,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const { elapsed, easing, duration, start, end } = this._viewportAnimation const { elapsed, easing, duration, start, end } = this._viewportAnimation
if (elapsed > duration) { if (elapsed > duration) {
const z = this.viewportScreenBounds.width / end.width this._setCamera({ x: -end.x, y: -end.y, z: this.viewportScreenBounds.width / end.width })
const x = -end.x
const y = -end.y
this._setCamera(x, y, z)
cancel() cancel()
return return
} }
@ -2384,15 +2312,8 @@ export class Editor extends EventEmitter<TLEventMap> {
const left = start.minX + (end.minX - start.minX) * t const left = start.minX + (end.minX - start.minX) * t
const top = start.minY + (end.minY - start.minY) * t const top = start.minY + (end.minY - start.minY) * t
const right = start.maxX + (end.maxX - start.maxX) * t const right = start.maxX + (end.maxX - start.maxX) * t
const bottom = start.maxY + (end.maxY - start.maxY) * t
const easedViewport = new Box2d(left, top, right - left, bottom - top) this._setCamera({ x: -left, y: -top, z: this.viewportScreenBounds.width / (right - left) })
const z = this.viewportScreenBounds.width / easedViewport.width
const x = -easedViewport.x
const y = -easedViewport.y
this._setCamera(x, y, z)
} }
/** @internal */ /** @internal */
@ -2411,11 +2332,11 @@ export class Editor extends EventEmitter<TLEventMap> {
if (duration === 0 || animationSpeed === 0) { if (duration === 0 || animationSpeed === 0) {
// If we have no animation, then skip the animation and just set the camera // If we have no animation, then skip the animation and just set the camera
return this._setCamera( return this._setCamera({
-targetViewportPage.x, x: -targetViewportPage.x,
-targetViewportPage.y, y: -targetViewportPage.y,
this.viewportScreenBounds.width / targetViewportPage.width z: this.viewportScreenBounds.width / targetViewportPage.width,
) })
} }
// Set our viewport animation // Set our viewport animation
@ -2424,7 +2345,7 @@ export class Editor extends EventEmitter<TLEventMap> {
duration: duration / animationSpeed, duration: duration / animationSpeed,
easing, easing,
start: viewportPageBounds.clone(), start: viewportPageBounds.clone(),
end: targetViewportPage, end: targetViewportPage.clone(),
} }
// On each tick, animate the viewport // On each tick, animate the viewport
@ -2474,7 +2395,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (currentSpeed < speedThreshold) { if (currentSpeed < speedThreshold) {
cancel() cancel()
} else { } else {
this._setCamera(cx + movementVec.x, cy + movementVec.y, cz) this._setCamera({ x: cx + movementVec.x, y: cy + movementVec.y, z: cz })
} }
} }
@ -2518,9 +2439,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// Only animate the camera if the user is on the same page as us // Only animate the camera if the user is on the same page as us
const options = isOnSamePage ? { duration: 500 } : undefined const options = isOnSamePage ? { duration: 500 } : undefined
const position = presence.cursor this.centerOnPoint(presence.cursor, options)
this.centerOnPoint(position.x, position.y, options)
// Highlight the user's cursor // Highlight the user's cursor
const { highlightedUserIds } = this.instanceState const { highlightedUserIds } = this.instanceState
@ -2575,6 +2494,9 @@ export class Editor extends EventEmitter<TLEventMap> {
// Viewport // Viewport
/** @internal */
private _willSetInitialBounds = true
/** /**
* Update the viewport. The viewport will measure the size and screen position of its container * Update the viewport. The viewport will measure the size and screen position of its container
* element. This should be done whenever the container's position on the screen changes. * element. This should be done whenever the container's position on the screen changes.
@ -2594,11 +2516,14 @@ export class Editor extends EventEmitter<TLEventMap> {
if (!container) return this if (!container) return this
const rect = container.getBoundingClientRect() const rect = container.getBoundingClientRect()
const screenBounds = new Box2d(0, 0, Math.max(rect.width, 1), Math.max(rect.height, 1)) const screenBounds = new Box2d(
rect.left || rect.x,
rect.top || rect.y,
Math.max(rect.width, 1),
Math.max(rect.height, 1)
)
const boundsAreEqual = screenBounds.equals(this.viewportScreenBounds) const boundsAreEqual = screenBounds.equals(this.viewportScreenBounds)
// Get the current value
const { _willSetInitialBounds } = this const { _willSetInitialBounds } = this
if (boundsAreEqual) { if (boundsAreEqual) {
@ -2609,21 +2534,14 @@ export class Editor extends EventEmitter<TLEventMap> {
this._willSetInitialBounds = false this._willSetInitialBounds = false
this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true) this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
} else { } else {
const { zoomLevel } = this if (center && !this.instanceState.followingUserId) {
if (center) { // Get the page center before the change, make the change, and restore it
const before = this.viewportPageCenter const before = this.viewportPageCenter
this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true) this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
const after = this.viewportPageCenter this.centerOnPoint(before)
if (!this.instanceState.followingUserId) {
this.pan((after.x - before.x) * zoomLevel, (after.y - before.y) * zoomLevel)
}
} else { } else {
const before = this.screenToPage(0, 0) // Otherwise,
this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true) this.updateInstanceState({ screenBounds: screenBounds.toJson() }, true, true)
const after = this.screenToPage(0, 0)
if (!this.instanceState.followingUserId) {
this.pan((after.x - before.x) * zoomLevel, (after.y - before.y) * zoomLevel)
}
} }
} }
} }
@ -2665,10 +2583,9 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
@computed get viewportPageBounds() { @computed get viewportPageBounds() {
const { x, y, w, h } = this.viewportScreenBounds const { w, h } = this.viewportScreenBounds
const tl = this.screenToPage(x, y) const { x: cx, y: cy, z: cz } = this.camera
const br = this.screenToPage(x + w, y + h) return new Box2d(-cx, -cy, w / cz, h / cz)
return new Box2d(tl.x, tl.y, br.x - tl.x, br.y - tl.y)
} }
/** /**
@ -2685,45 +2602,43 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @example * @example
* ```ts * ```ts
* editor.screenToPage(100, 100) * editor.screenToPage({ x: 100, y: 100 })
* ``` * ```
* *
* @param x - The x coordinate of the point in screen space. * @param point - The point in screen space.
* @param y - The y coordinate of the point in screen space.
* @param camera - The camera to use. Defaults to the current camera.
* *
* @public * @public
*/ */
screenToPage(x: number, y: number, z = 0.5, camera: VecLike = this.camera) { screenToPage(point: VecLike) {
const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)! const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const { x: cx, y: cy, z: cz = 1 } = camera const { x: cx, y: cy, z: cz = 1 } = this.camera
return { return {
x: (x - screenBounds.x) / cz - cx, x: (point.x - screenBounds.x - cx) / cz,
y: (y - screenBounds.y) / cz - cy, y: (point.y - screenBounds.y - cy) / cz,
z, z: point.z ?? 0.5,
} }
} }
/** /**
* Convert a point in page space to a point in screen space. * Convert a point in page space to a point in current screen space.
* *
* @example * @example
* ```ts * ```ts
* editor.pageToScreen(100, 100) * editor.pageToScreen({ x: 100, y: 100 })
* ``` * ```
* *
* @param x - The x coordinate of the point in screen space. * @param point - The point in screen space.
* @param y - The y coordinate of the point in screen space.
* @param camera - The camera to use. Defaults to the current camera.
* *
* @public * @public
*/ */
pageToScreen(x: number, y: number, z = 0.5, camera: VecLike = this.camera) { pageToScreen(point: VecLike) {
const { x: cx, y: cy, z: cz = 1 } = camera const { screenBounds } = this.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const { x: cx, y: cy, z: cz = 1 } = this.camera
return { return {
x: x + cx * cz, x: point.x * cz + cx + screenBounds.x,
y: y + cy * cz, y: point.y * cz + cy + screenBounds.y,
z, z: point.z ?? 0.5,
} }
} }
@ -2781,7 +2696,10 @@ export class Editor extends EventEmitter<TLEventMap> {
const isOnSamePage = leaderPresence.currentPageId === this.currentPageId const isOnSamePage = leaderPresence.currentPageId === this.currentPageId
const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1 const chaseProportion = isOnSamePage ? FOLLOW_CHASE_PROPORTION : 1
if (!isOnSamePage) { if (!isOnSamePage) {
this.stopFollowingUser()
this.setCurrentPage(leaderPresence.currentPageId, { stopFollowing: false }) this.setCurrentPage(leaderPresence.currentPageId, { stopFollowing: false })
this.startFollowingUser(userId)
return
} }
// Get the bounds of the follower (me) and the leader (them) // Get the bounds of the follower (me) and the leader (them)
@ -2838,12 +2756,11 @@ export class Editor extends EventEmitter<TLEventMap> {
// Update the camera! // Update the camera!
isCaughtUp = false isCaughtUp = false
this.stopCameraAnimation() this.stopCameraAnimation()
this.setCamera( this._setCamera({
-(targetCenter.x - targetWidth / 2), x: -(targetCenter.x - targetWidth / 2),
-(targetCenter.y - targetHeight / 2), y: -(targetCenter.y - targetHeight / 2),
targetZoom, z: targetZoom,
{ stopFollowing: false } })
)
} }
this.once('stop-following', cancel) this.once('stop-following', cancel)
@ -3483,7 +3400,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this.batch(() => { this.batch(() => {
this.createPage(page.name + ' Copy', createId, page.index) this.createPage(page.name + ' Copy', createId, page.index)
this.setCurrentPage(createId) this.setCurrentPage(createId)
this.setCamera(camera.x, camera.y, camera.z) this.setCamera(camera)
// will change page automatically // will change page automatically
if (content) { if (content) {
@ -5186,7 +5103,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// new shapes. // new shapes.
const { viewportPageBounds, selectionPageBounds: selectionPageBounds } = this const { viewportPageBounds, selectionPageBounds: selectionPageBounds } = this
if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) {
this.centerOnPoint(selectionPageBounds.center.x, selectionPageBounds.center.y, { this.centerOnPoint(selectionPageBounds.center, {
duration: ANIMATION_MEDIUM_MS, duration: ANIMATION_MEDIUM_MS,
}) })
} }
@ -5255,11 +5172,8 @@ export class Editor extends EventEmitter<TLEventMap> {
// Force the new page's camera to be at the same zoom level as the // Force the new page's camera to be at the same zoom level as the
// "from" page's camera, then center the "to" page's camera on the // "from" page's camera, then center the "to" page's camera on the
// pasted shapes // pasted shapes
const { this.setCamera({ ...this.camera, z: fromPageZ })
center: { x, y }, this.centerOnPoint(this.selectionBounds!.center)
} = this.selectionBounds!
this.setCamera(this.camera.x, this.camera.y, fromPageZ)
this.centerOnPoint(x, y)
}) })
return this return this
@ -5294,16 +5208,22 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
} }
} }
if (allUnlocked) { this.batch(() => {
this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))) if (allUnlocked) {
this.setSelectedShapeIds([]) this.updateShapes(
} else if (allLocked) { shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))
this.updateShapes( )
shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false })) this.setSelectedShapeIds([])
) } else if (allLocked) {
} else { this.updateShapes(
this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))) shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false }))
} )
} else {
this.updateShapes(
shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true }))
)
}
})
return this return this
} }
@ -8487,11 +8407,11 @@ export class Editor extends EventEmitter<TLEventMap> {
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)) const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))
this.setCamera( this.setCamera({
cx + dx / cz - x / cz + x / zoom, x: cx + dx / cz - x / cz + x / zoom,
cy + dy / cz - y / cz + y / zoom, y: cy + dy / cz - y / cz + y / zoom,
zoom z: zoom,
) })
return // Stop here! return // Stop here!
} }
@ -8521,10 +8441,12 @@ export class Editor extends EventEmitter<TLEventMap> {
if (zoom !== undefined) { if (zoom !== undefined) {
const { x, y } = this.viewportScreenCenter const { x, y } = this.viewportScreenCenter
this.animateCamera( this.setCamera(
cx + (x / zoom - x) - (x / cz - x), {
cy + (y / zoom - y) - (y / cz - y), x: cx + (x / zoom - x) - (x / cz - x),
zoom, y: cy + (y / zoom - y) - (y / cz - y),
z: zoom,
},
{ duration: 100 } { duration: 100 }
) )
} }
@ -8558,11 +8480,11 @@ export class Editor extends EventEmitter<TLEventMap> {
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz)) const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, cz + (info.delta.z ?? 0) * cz))
this.setCamera( this.setCamera({
cx + (x / zoom - x) - (x / cz - x), x: cx + (x / zoom - x) - (x / cz - x),
cy + (y / zoom - y) - (y / cz - y), y: cy + (y / zoom - y) - (y / cz - y),
zoom z: zoom,
) })
// We want to return here because none of the states in our // We want to return here because none of the states in our
// statechart should respond to this event (a camera zoom) // statechart should respond to this event (a camera zoom)
@ -8571,7 +8493,7 @@ export class Editor extends EventEmitter<TLEventMap> {
// Update the camera here, which will dispatch a pointer move... // Update the camera here, which will dispatch a pointer move...
// this will also update the pointer position, etc // this will also update the pointer position, etc
this.pan(info.delta.x, info.delta.y) this.pan(info.delta)
if ( if (
!inputs.isDragging && !inputs.isDragging &&
@ -8657,8 +8579,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (this.inputs.isPanning && this.inputs.isPointing) { if (this.inputs.isPanning && this.inputs.isPointing) {
// Handle panning // Handle panning
const { currentScreenPoint, previousScreenPoint } = this.inputs const { currentScreenPoint, previousScreenPoint } = this.inputs
const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint) this.pan(Vec2d.Sub(currentScreenPoint, previousScreenPoint))
this.pan(delta.x, delta.y)
return return
} }

View file

@ -1,2 +1,4 @@
/** @public */ /** @public */
export type RequiredKeys<T, K extends keyof T> = Pick<T, K> & Partial<T> export type RequiredKeys<T, K extends keyof T> = Partial<Omit<T, K>> & Pick<T, K>
/** @public */
export type OptionalKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

View file

@ -21,7 +21,7 @@ export function useCanvasEvents() {
type: 'pointer', type: 'pointer',
target: 'canvas', target: 'canvas',
name: 'pointer_down', name: 'pointer_down',
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
} }
@ -36,7 +36,7 @@ export function useCanvasEvents() {
type: 'pointer', type: 'pointer',
target: 'canvas', target: 'canvas',
name: 'pointer_move', name: 'pointer_move',
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
} }
@ -52,7 +52,7 @@ export function useCanvasEvents() {
type: 'pointer', type: 'pointer',
target: 'canvas', target: 'canvas',
name: 'pointer_up', name: 'pointer_up',
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
} }
@ -84,12 +84,10 @@ export function useCanvasEvents() {
const files = Array.from(e.dataTransfer.files) const files = Array.from(e.dataTransfer.files)
const rect = editor.getContainer().getBoundingClientRect()
await editor.putExternalContent({ await editor.putExternalContent({
type: 'files', type: 'files',
files, files,
point: editor.screenToPage(e.clientX - rect.x, e.clientY - rect.y), point: editor.screenToPage({ x: e.clientX, y: e.clientY }),
ignoreParent: false, ignoreParent: false,
}) })
} }

View file

@ -32,7 +32,7 @@ export function useHandleEvents(id: TLShapeId, handleId: string) {
handle, handle,
shape, shape,
name: 'pointer_down', name: 'pointer_down',
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
} }
@ -55,7 +55,7 @@ export function useHandleEvents(id: TLShapeId, handleId: string) {
handle, handle,
shape, shape,
name: 'pointer_move', name: 'pointer_move',
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
} }
@ -75,7 +75,7 @@ export function useHandleEvents(id: TLShapeId, handleId: string) {
handle, handle,
shape, shape,
name: 'pointer_up', name: 'pointer_up',
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
} }

View file

@ -1,35 +1,34 @@
import throttle from 'lodash.throttle' import throttle from 'lodash.throttle'
import { useLayoutEffect } from 'react' import { useLayoutEffect } from 'react'
import { useContainer } from './useContainer'
import { useEditor } from './useEditor' import { useEditor } from './useEditor'
export function useScreenBounds() { export function useScreenBounds() {
const editor = useEditor() const editor = useEditor()
const container = useContainer()
useLayoutEffect(() => { useLayoutEffect(() => {
const updateBounds = throttle( const updateBounds = throttle(
() => { () => {
editor.updateViewportScreenBounds() if (editor.instanceState.isFocused) {
editor.updateViewportScreenBounds()
}
}, },
200, 200,
{ trailing: true } {
trailing: true,
}
) )
const resizeObserver = new ResizeObserver((entries) => { // Rather than running getClientRects on every frame, we'll
if (entries[0].contentRect) { // run it once a second or when the window resizes / scrolls.
updateBounds()
}
})
if (container) {
resizeObserver.observe(container)
}
updateBounds() updateBounds()
const interval = setInterval(updateBounds, 1000)
window.addEventListener('resize', updateBounds)
window.addEventListener('scroll', updateBounds)
return () => { return () => {
resizeObserver.disconnect() clearInterval(interval)
window.removeEventListener('resize', updateBounds)
window.removeEventListener('scroll', updateBounds)
} }
}, [editor, container]) })
} }

View file

@ -34,7 +34,7 @@ export function useSelectionEvents(handle: TLSelectionHandle) {
type: 'pointer', type: 'pointer',
target: 'selection', target: 'selection',
handle, handle,
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
e.stopPropagation() e.stopPropagation()
} }
@ -54,7 +54,7 @@ export function useSelectionEvents(handle: TLSelectionHandle) {
type: 'pointer', type: 'pointer',
target: 'selection', target: 'selection',
handle, handle,
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
} }
@ -67,7 +67,7 @@ export function useSelectionEvents(handle: TLSelectionHandle) {
type: 'pointer', type: 'pointer',
target: 'selection', target: 'selection',
handle, handle,
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
}) })
} }

View file

@ -1,13 +1,11 @@
/** @public */ /** @public */
export function getPointerInfo(e: React.PointerEvent | PointerEvent, container: HTMLElement) { export function getPointerInfo(e: React.PointerEvent | PointerEvent) {
;(e as any).isKilled = true ;(e as any).isKilled = true
const { top, left } = container.getBoundingClientRect()
return { return {
point: { point: {
x: e.clientX - left, x: e.clientX,
y: e.clientY - top, y: e.clientY,
z: e.pressure, z: e.pressure,
}, },
shiftKey: e.shiftKey, shiftKey: e.shiftKey,

View file

@ -30,7 +30,7 @@ export const FrameHeading = function FrameHeading({
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
const event = getPointerInfo(e, editor.getContainer()) const event = getPointerInfo(e)
editor.dispatch({ editor.dispatch({
type: 'pointer', type: 'pointer',
name: 'pointer_down', name: 'pointer_down',

View file

@ -225,7 +225,7 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
const handleContentPointerDown = useCallback( const handleContentPointerDown = useCallback(
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
editor.dispatch({ editor.dispatch({
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
type: 'pointer', type: 'pointer',
name: 'pointer_down', name: 'pointer_down',
target: 'shape', target: 'shape',

View file

@ -29,7 +29,7 @@ export class Dragging extends StateNode {
const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint) const delta = Vec2d.Sub(currentScreenPoint, previousScreenPoint)
if (Math.abs(delta.x) > 0 || Math.abs(delta.y) > 0) { if (Math.abs(delta.x) > 0 || Math.abs(delta.y) > 0) {
this.editor.pan(delta.x, delta.y) this.editor.pan(delta)
} }
} }

View file

@ -54,14 +54,7 @@ export class ZoomBrushing extends StateNode {
} }
} else { } else {
const zoomLevel = this.editor.inputs.altKey ? this.editor.zoomLevel / 2 : undefined const zoomLevel = this.editor.inputs.altKey ? this.editor.zoomLevel / 2 : undefined
this.editor.zoomToBounds( this.editor.zoomToBounds(zoomBrush, zoomLevel, { duration: 220 })
zoomBrush.x,
zoomBrush.y,
zoomBrush.width,
zoomBrush.height,
zoomLevel,
{ duration: 220 }
)
} }
this.parent.transition('idle', this.info) this.parent.transition('idle', this.info)

View file

@ -56,14 +56,14 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
(e: React.MouseEvent<HTMLCanvasElement>) => { (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!editor.shapeIdsOnCurrentPage.size) return if (!editor.shapeIdsOnCurrentPage.size) return
const { x, y } = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false)
const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true)
minimap.originPagePoint.setTo(clampedPoint) minimap.originPagePoint.setTo(clampedPoint)
minimap.originPageCenter.setTo(editor.viewportPageBounds.center) minimap.originPageCenter.setTo(editor.viewportPageBounds.center)
editor.centerOnPoint(x, y, { duration: ANIMATION_MEDIUM_MS }) editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
}, },
[editor, minimap] [editor, minimap]
) )
@ -77,7 +77,7 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
minimap.isInViewport = false minimap.isInViewport = false
const { x, y } = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false)
const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true)
@ -89,7 +89,7 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint) minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint)
if (!minimap.isInViewport) { if (!minimap.isInViewport) {
editor.centerOnPoint(x, y, { duration: ANIMATION_MEDIUM_MS }) editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
} }
}, },
[editor, minimap] [editor, minimap]
@ -98,32 +98,27 @@ export function Minimap({ shapeFill, selectFill, viewportFill }: MinimapProps) {
const onPointerMove = React.useCallback( const onPointerMove = React.useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => { (e: React.PointerEvent<HTMLCanvasElement>) => {
if (rPointing.current) { if (rPointing.current) {
const { x, y } = minimap.minimapScreenPointToPagePoint( const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, e.shiftKey, true)
e.clientX,
e.clientY,
e.shiftKey,
true
)
if (minimap.isInViewport) { if (minimap.isInViewport) {
const delta = Vec2d.Sub({ x, y }, minimap.originPagePoint) const delta = point.clone().sub(minimap.originPagePoint).add(minimap.originPageCenter)
const center = Vec2d.Add(minimap.originPageCenter, delta) const center = Vec2d.Add(minimap.originPageCenter, delta)
editor.centerOnPoint(center.x, center.y) editor.centerOnPoint(center)
return return
} }
editor.centerOnPoint(x, y) editor.centerOnPoint(point)
} }
const pagePoint = minimap.getPagePoint(e.clientX, e.clientY) const pagePoint = minimap.getPagePoint(e.clientX, e.clientY)
const screenPoint = editor.pageToScreen(pagePoint.x, pagePoint.y) const screenPoint = editor.pageToScreen(pagePoint)
const info: TLPointerEventInfo = { const info: TLPointerEventInfo = {
type: 'pointer', type: 'pointer',
target: 'canvas', target: 'canvas',
name: 'pointer_move', name: 'pointer_move',
...getPointerInfo(e, editor.getContainer()), ...getPointerInfo(e),
point: screenPoint, point: screenPoint,
isPen: editor.instanceState.isPenMode, isPen: editor.instanceState.isPenMode,
} }

View file

@ -104,11 +104,11 @@ export class MinimapManager {
const { x: screenX, y: screenY } = this.getScreenPoint(x, y) const { x: screenX, y: screenY } = this.getScreenPoint(x, y)
return { return new Vec2d(
x: canvasPageBounds.minX + (screenX * contentPageBounds.width) / contentScreenBounds.width, canvasPageBounds.minX + (screenX * contentPageBounds.width) / contentScreenBounds.width,
y: canvasPageBounds.minY + (screenY * contentPageBounds.height) / contentScreenBounds.height, canvasPageBounds.minY + (screenY * contentPageBounds.height) / contentScreenBounds.height,
z: 1, 1
} )
} }
minimapScreenPointToPagePoint = ( minimapScreenPointToPagePoint = (
@ -168,7 +168,7 @@ export class MinimapManager {
} }
} }
return { x: px, y: py } return new Vec2d(px, py)
} }
render = () => { render = () => {

View file

@ -593,7 +593,7 @@ export function buildFromV1Document(editor: Editor, document: LegacyTldrawDocume
const bounds = editor.commonBoundsOfAllShapesOnCurrentPage const bounds = editor.commonBoundsOfAllShapesOnCurrentPage
if (bounds) { if (bounds) {
editor.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1) editor.zoomToBounds(bounds, 1)
} }
}) })
} }

View file

@ -293,7 +293,7 @@ export async function parseAndLoadDocument(
const bounds = editor.commonBoundsOfAllShapesOnCurrentPage const bounds = editor.commonBoundsOfAllShapesOnCurrentPage
if (bounds) { if (bounds) {
editor.zoomToBounds(bounds.minX, bounds.minY, bounds.width, bounds.height, 1) editor.zoomToBounds(bounds, 1)
} }
}) })

View file

@ -40,7 +40,7 @@ describe('TLSelectTool.Zooming', () => {
it('Correctly zooms in when clicking', () => { it('Correctly zooms in when clicking', () => {
editor.keyDown('z') editor.keyDown('z')
expect(editor.zoomLevel).toBe(1) expect(editor.zoomLevel).toBe(1)
expect(editor.viewportPageBounds).toMatchObject({ x: 0, y: 0, w: 1080, h: 720 }) expect(editor.viewportPageBounds).toMatchObject({ x: -0, y: -0, w: 1080, h: 720 })
expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.click() editor.click()
editor.expectToBeIn('zoom.idle') editor.expectToBeIn('zoom.idle')
@ -52,7 +52,7 @@ describe('TLSelectTool.Zooming', () => {
editor.keyDown('z') editor.keyDown('z')
editor.keyDown('Alt') editor.keyDown('Alt')
expect(editor.zoomLevel).toBe(1) expect(editor.zoomLevel).toBe(1)
expect(editor.viewportPageBounds).toMatchObject({ x: 0, y: 0, w: 1080, h: 720 }) expect(editor.viewportPageBounds).toMatchObject({ x: -0, y: -0, w: 1080, h: 720 })
expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.click() editor.click()
jest.advanceTimersByTime(300) jest.advanceTimersByTime(300)
@ -109,7 +109,7 @@ describe('TLSelectTool.Zooming', () => {
it('When the dragged area is small it zooms in instead of zooming to the area', () => { it('When the dragged area is small it zooms in instead of zooming to the area', () => {
const originalCenter = { x: 540, y: 360 } const originalCenter = { x: 540, y: 360 }
const originalPageBounds = { x: 0, y: 0, w: 1080, h: 720 } const originalPageBounds = { x: -0, y: -0, w: 1080, h: 720 }
const change = 6 const change = 6
expect(editor.zoomLevel).toBe(1) expect(editor.zoomLevel).toBe(1)
expect(editor.viewportPageBounds).toMatchObject(originalPageBounds) expect(editor.viewportPageBounds).toMatchObject(originalPageBounds)
@ -143,7 +143,7 @@ describe('TLSelectTool.Zooming', () => {
const newBoundsY = 200 const newBoundsY = 200
editor.expectToBeIn('select.idle') editor.expectToBeIn('select.idle')
expect(editor.zoomLevel).toBe(1) expect(editor.zoomLevel).toBe(1)
expect(editor.viewportPageBounds).toMatchObject({ x: 0, y: 0, w: 1080, h: 720 }) expect(editor.viewportPageBounds).toMatchObject({ x: -0, y: -0, w: 1080, h: 720 })
expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.keyDown('z') editor.keyDown('z')
editor.expectToBeIn('zoom.idle') editor.expectToBeIn('zoom.idle')
@ -179,7 +179,7 @@ describe('TLSelectTool.Zooming', () => {
editor.expectToBeIn('select.idle') editor.expectToBeIn('select.idle')
const originalZoomLevel = 1 const originalZoomLevel = 1
expect(editor.zoomLevel).toBe(originalZoomLevel) expect(editor.zoomLevel).toBe(originalZoomLevel)
expect(editor.viewportPageBounds).toMatchObject({ x: 0, y: 0, w: 1080, h: 720 }) expect(editor.viewportPageBounds).toMatchObject({ x: -0, y: -0, w: 1080, h: 720 })
expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.keyDown('z') editor.keyDown('z')
editor.expectToBeIn('zoom.idle') editor.expectToBeIn('zoom.idle')

View file

@ -7,6 +7,15 @@ beforeEach(() => {
}) })
it('centers on the point', () => { it('centers on the point', () => {
editor.centerOnPoint(400, 400) editor.centerOnPoint({ x: 400, y: 400 })
expect(editor.viewportPageCenter).toMatchObject({ x: 400, y: 400 })
})
it('centers on the point with animation', () => {
editor.centerOnPoint({ x: 400, y: 400 }, { duration: 200 })
expect(editor.viewportPageCenter).not.toMatchObject({ x: 400, y: 400 })
jest.advanceTimersByTime(100)
expect(editor.viewportPageCenter).not.toMatchObject({ x: 400, y: 400 })
jest.advanceTimersByTime(200)
expect(editor.viewportPageCenter).toMatchObject({ x: 400, y: 400 }) expect(editor.viewportPageCenter).toMatchObject({ x: 400, y: 400 })
}) })

View file

@ -75,7 +75,11 @@ describe('When copying and pasting', () => {
const testOffsetX = 100 const testOffsetX = 100
const testOffsetY = 100 const testOffsetY = 100
editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) editor.setCamera({
x: editor.camera.x - testOffsetX,
y: editor.camera.y - testOffsetY,
z: editor.zoomLevel,
})
editor.paste() editor.paste()
const shapesAfter = editor.shapesOnCurrentPage const shapesAfter = editor.shapesOnCurrentPage
@ -116,7 +120,11 @@ describe('When copying and pasting', () => {
const testOffsetX = 1800 const testOffsetX = 1800
const testOffsetY = 0 const testOffsetY = 0
editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) editor.setCamera({
x: editor.camera.x - testOffsetX,
y: editor.camera.y - testOffsetY,
z: editor.zoomLevel,
})
editor.paste() editor.paste()
const shapesAfter = editor.shapesOnCurrentPage const shapesAfter = editor.shapesOnCurrentPage
@ -154,7 +162,11 @@ describe('When copying and pasting', () => {
const testOffsetY = 3000 const testOffsetY = 3000
const { w: screenWidth, h: screenHeight } = editor.viewportScreenBounds const { w: screenWidth, h: screenHeight } = editor.viewportScreenBounds
editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) editor.setCamera({
x: editor.camera.x - testOffsetX,
y: editor.camera.y - testOffsetY,
z: editor.zoomLevel,
})
editor.paste() editor.paste()
const shapesAfter = editor.shapesOnCurrentPage const shapesAfter = editor.shapesOnCurrentPage
@ -280,7 +292,11 @@ describe('When copying and pasting', () => {
const testOffsetX = 100 const testOffsetX = 100
const testOffsetY = 100 const testOffsetY = 100
editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) editor.setCamera({
x: editor.camera.x - testOffsetX,
y: editor.camera.y - testOffsetY,
z: editor.zoomLevel,
})
editor.paste() editor.paste()
const shapesAfter = editor.shapesOnCurrentPage const shapesAfter = editor.shapesOnCurrentPage
@ -306,7 +322,11 @@ describe('When copying and pasting', () => {
const testOffsetX = 1800 const testOffsetX = 1800
const testOffsetY = 0 const testOffsetY = 0
editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) editor.setCamera({
x: editor.camera.x - testOffsetX,
y: editor.camera.y - testOffsetY,
z: editor.zoomLevel,
})
editor.paste() editor.paste()
const shapesAfter = editor.shapesOnCurrentPage const shapesAfter = editor.shapesOnCurrentPage
@ -335,7 +355,11 @@ describe('When copying and pasting', () => {
const testOffsetY = 3000 const testOffsetY = 3000
const { w: screenWidth, h: screenHeight } = editor.viewportScreenBounds const { w: screenWidth, h: screenHeight } = editor.viewportScreenBounds
editor.setCamera(editor.camera.x - testOffsetX, editor.camera.y - testOffsetY, editor.zoomLevel) editor.setCamera({
x: editor.camera.x - testOffsetX,
y: editor.camera.y - testOffsetY,
z: editor.zoomLevel,
})
editor.paste() editor.paste()
const shapesAfter = editor.shapesOnCurrentPage const shapesAfter = editor.shapesOnCurrentPage

View file

@ -4,19 +4,22 @@ let editor: TestEditor
beforeEach(() => { beforeEach(() => {
editor = new TestEditor() editor = new TestEditor()
editor.setCamera({ x: 0, y: 0, z: 1 })
}) })
describe('viewport.pageToScreen', () => { describe('viewport.pageToScreen', () => {
it('converts correctly', () => { it('converts correctly', () => {
expect(editor.pageToScreen(0, 0)).toMatchObject({ x: 0, y: 0 }) expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen(200, 200)).toMatchObject({ expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({
x: 200, x: 200,
y: 200, y: 200,
}) })
editor.setCamera(100, 100) editor.setCamera({ x: 100, y: 100 })
expect(editor.pageToScreen(200, 200)).toMatchObject({ expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({
x: 300, x: 300,
y: 300, y: 300,
}) })
}) })
// see `screen to page` for paired tests
}) })

View file

@ -10,7 +10,7 @@ beforeEach(() => {
describe('When panning', () => { describe('When panning', () => {
it('Updates the camera', () => { it('Updates the camera', () => {
editor.pan(200, 200) editor.pan({ x: 200, y: 200 })
editor.expectCameraToBe(200, 200, 1) editor.expectCameraToBe(200, 200, 1)
}) })
@ -23,7 +23,7 @@ describe('When panning', () => {
screenBounds.h screenBounds.h
) )
const beforePageBounds = editor.viewportPageBounds.clone() const beforePageBounds = editor.viewportPageBounds.clone()
editor.pan(200, 200) editor.pan({ x: 200, y: 200 })
expect(editor.viewportScreenBounds).toMatchObject(beforeScreenBounds.toJson()) expect(editor.viewportScreenBounds).toMatchObject(beforeScreenBounds.toJson())
expect(editor.viewportPageBounds.toJson()).toMatchObject( expect(editor.viewportPageBounds.toJson()).toMatchObject(
beforePageBounds.translate(new Vec2d(-200, -200)).toJson() beforePageBounds.translate(new Vec2d(-200, -200)).toJson()

View file

@ -8,15 +8,293 @@ beforeEach(() => {
describe('viewport.screenToPage', () => { describe('viewport.screenToPage', () => {
it('converts correctly', () => { it('converts correctly', () => {
expect(editor.screenToPage(0, 0)).toMatchObject({ x: 0, y: 0 }) expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage(200, 200)).toMatchObject({ expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
x: 200,
y: 200, expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 100, y: 100 })
expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -100, y: -100 })
expect(editor.pageToScreen({ x: -100, y: -100 })).toMatchObject({ x: -100, y: -100 })
})
it('converts correctly when zoomed', () => {
editor.setCamera({ x: 0, y: 0, z: 0.5 })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 })
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 })
expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 })
})
it('converts correctly when panned', () => {
editor.setCamera({ x: 100, y: 100 })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: -100, y: -100 })
expect(editor.pageToScreen({ x: -100, y: -100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 })
expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 })
})
it('converts correctly when panned and zoomed', () => {
editor.setCamera({ x: 100, y: 100, z: 0.5 })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: -200, y: -200 })
expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -400, y: -400 })
expect(editor.pageToScreen({ x: -400, y: -400 })).toMatchObject({ x: -100, y: -100 })
})
it('converts correctly when offset', () => {
// move the editor's page bounds down and to the left by 100, 100
// 0,0 s
// +------------------------+
// | 100,100 s |
// | c-----------------+ |
// | | 0,0 p | |
// | | | |
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 100, y: 100 } })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: -100, y: -100 })
expect(editor.pageToScreen({ x: -100, y: -100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 })
expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 })
// 0,0 s
// c------------------------+
// | 100,100 s |
// | +-----------------+ |
// | | 100,100 p | |
// | | | |
editor.setCamera({ x: -100, y: -100 }) // -100, -100
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -100, y: -100 })
expect(editor.pageToScreen({ x: -100, y: -100 })).toMatchObject({ x: -100, y: -100 })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 100, y: 100 })
expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 100, y: 100 })
// 0,0 s no offset, zoom at 50%
// c------------------------+
// | 0,0 p |
// | |
// | |
// | |
editor.setCamera({ x: 0, y: 0, z: 0.5 })
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 })
expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 })
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 })
})
it('converts correctly when zoomed out', () => {
// camera at zero, screenbounds at zero, but zoom at .5
editor.setCamera({ x: 0, y: 0, z: 0.5 })
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -200, y: -200 })
expect(editor.pageToScreen({ x: -200, y: -200 })).toMatchObject({ x: -100, y: -100 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 })
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 })
})
it('converts correctly when zoomed in', () => {
editor.setCamera({ x: 0, y: 0, z: 2 })
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: -100, y: -100 })).toMatchObject({ x: -50, y: -50 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 50, y: 50 })
})
it('converts correctly when zoomed', () => {
// camera at zero, screenbounds at zero, but zoom at .5
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } })
editor.setCamera({ x: 0, y: 0, z: 0.5 })
// zero point, where page and screen are the same
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 50, y: 50 })
expect(editor.screenToPage({ x: 50, y: 50 })).toMatchObject({ x: 100, y: 100 })
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 })
})
it('converts correctly when zoomed and panned', () => {
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } })
editor.setCamera({ x: 100, y: 100, z: 0.5 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 150, y: 150 })
expect(editor.screenToPage({ x: 150, y: 150 })).toMatchObject({ x: 100, y: 100 })
// zero point, where page and screen are the same
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 })
expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 })
})
it('converts correctly when offset', () => {
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 100, y: 100 } })
editor.setCamera({ x: 0, y: 0, z: 0.5 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 })
expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 150, y: 150 })
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 })
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: -200, y: -200 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 })
expect(editor.screenToPage({ x: 300, y: 300 })).toMatchObject({ x: 400, y: 400 })
})
it('converts correctly when panned', () => {
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } })
editor.setCamera({ x: 100, y: 100, z: 1 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 })
expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 200, y: 200 })
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 300, y: 300 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: 300, y: 300 })).toMatchObject({ x: 200, y: 200 })
})
it('converts correctly when panned and zoomed', () => {
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0 } })
editor.setCamera({ x: 100, y: 100, z: 0.5 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 })
expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 150, y: 150 })
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 })
expect(editor.screenToPage({ x: 100, y: 100 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 150, y: 150 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 200, y: 200 })
})
it('converts correctly when panned and zoomed and offset', () => {
editor.updateInstanceState({ screenBounds: { ...editor.viewportScreenBounds, x: 100, y: 100 } })
editor.setCamera({ x: 100, y: 100, z: 0.5 })
expect(editor.pageToScreen({ x: 0, y: 0 })).toMatchObject({ x: 200, y: 200 })
expect(editor.pageToScreen({ x: 100, y: 100 })).toMatchObject({ x: 250, y: 250 })
expect(editor.pageToScreen({ x: 200, y: 200 })).toMatchObject({ x: 300, y: 300 })
expect(editor.screenToPage({ x: 200, y: 200 })).toMatchObject({ x: 0, y: 0 })
expect(editor.screenToPage({ x: 250, y: 250 })).toMatchObject({ x: 100, y: 100 })
expect(editor.screenToPage({ x: 300, y: 300 })).toMatchObject({ x: 200, y: 200 })
})
})
describe('viewportPageBounds', () => {
it('sets the page bounds', () => {
editor.updateInstanceState({
screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0, w: 1000, h: 1000 },
}) })
editor.setCamera(100, 100) editor.setCamera({ x: 0, y: 0, z: 1 })
expect(editor.screenToPage(200, 200)).toMatchObject({
x: 100, expect(editor.viewportPageBounds).toMatchObject({
y: 100, x: -0,
y: -0,
w: 1000,
h: 1000,
})
})
it('sets the page bounds when camera is zoomed', () => {
editor.updateInstanceState({
screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0, w: 1000, h: 1000 },
})
editor.setCamera({ x: 0, y: 0, z: 2 })
expect(editor.viewportPageBounds).toMatchObject({
x: -0,
y: -0,
w: 500,
h: 500,
})
editor.setCamera({ x: 0, y: 0, z: 0.5 })
expect(editor.viewportPageBounds).toMatchObject({
x: -0,
y: -0,
w: 2000,
h: 2000,
})
})
it('sets the page bounds when camera is panned', () => {
editor.updateInstanceState({
screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0, w: 1000, h: 1000 },
})
editor.setCamera({ x: 100, y: 100, z: 1 })
expect(editor.viewportPageBounds).toMatchObject({
x: -100,
y: -100,
w: 1000,
h: 1000,
maxX: 900,
maxY: 900,
})
})
it('sets the page bounds when camera is panned and zoomed', () => {
editor.updateInstanceState({
screenBounds: { ...editor.viewportScreenBounds, x: 0, y: 0, w: 1000, h: 1000 },
})
editor.setCamera({ x: 100, y: 100, z: 2 })
expect(editor.viewportPageBounds).toMatchObject({
x: -100,
y: -100,
w: 500,
h: 500,
maxX: 400,
maxY: 400,
})
})
it('sets the page bounds when viewport is offset', () => {
editor.updateInstanceState({
screenBounds: { ...editor.viewportScreenBounds, x: 100, y: 100, w: 1000, h: 1000 },
})
editor.setCamera({ x: 0, y: 0, z: 2 })
// changing the screen bounds should not affect the page bounds
expect(editor.viewportPageBounds).toMatchObject({
x: -0,
y: -0,
w: 500,
h: 500,
maxX: 500,
maxY: 500,
}) })
}) })
}) })

View file

@ -18,8 +18,8 @@ describe('When resizing', () => {
it('sets the viewport bounds with Editor.resize', () => { it('sets the viewport bounds with Editor.resize', () => {
editor.setScreenBounds({ x: 100, y: 200, w: 700, h: 600 }) editor.setScreenBounds({ x: 100, y: 200, w: 700, h: 600 })
expect(editor.viewportScreenBounds).toMatchObject({ expect(editor.viewportScreenBounds).toMatchObject({
x: 0, x: 100,
y: 0, y: 200,
w: 700, w: 700,
h: 600, h: 600,
}) })
@ -31,8 +31,8 @@ describe('When resizing', () => {
editor.undo() // this should have no effect editor.undo() // this should have no effect
expect(editor.viewportScreenBounds).toMatchObject({ expect(editor.viewportScreenBounds).toMatchObject({
x: 0, x: 100,
y: 0, y: 200,
w: 700, w: 700,
h: 600, h: 600,
}) })
@ -41,8 +41,8 @@ describe('When resizing', () => {
it('clamps bounds to minimim 0,0,1,1', () => { it('clamps bounds to minimim 0,0,1,1', () => {
editor.setScreenBounds({ x: -100, y: -200, w: -700, h: 0 }) editor.setScreenBounds({ x: -100, y: -200, w: -700, h: 0 })
expect(editor.viewportScreenBounds).toMatchObject({ expect(editor.viewportScreenBounds).toMatchObject({
x: 0, x: -100,
y: 0, y: -200,
w: 1, w: 1,
h: 1, h: 1,
}) })
@ -51,18 +51,49 @@ describe('When resizing', () => {
describe('When center is false', () => { describe('When center is false', () => {
it('keeps the same top left when resized', () => { it('keeps the same top left when resized', () => {
const a = editor.screenToPage(0, 0) const a = editor.screenToPage({ x: 0, y: 0 })
expect(a).toMatchObject({ x: 0, y: 0 })
editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, false) editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, false)
const b = editor.screenToPage(0, 0) expect(editor.viewportScreenBounds).toMatchObject({
expect(a).toMatchObject(b) x: 100,
y: 200,
w: 500,
h: 600,
})
const b = editor.screenToPage({ x: 0, y: 0 })
expect(b).toMatchObject({ x: -100, y: -200 })
})
it('keeps the same top left when resized while panned', () => {
editor.setCamera({ x: -100, y: -100, z: 1 })
const a = editor.screenToPage({ x: 0, y: 0 })
expect(a).toMatchObject({ x: 100, y: 100 })
editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, false)
expect(editor.viewportScreenBounds).toMatchObject({
x: 100,
y: 200,
w: 500,
h: 600,
})
const b = editor.screenToPage({ x: 0, y: 0 })
expect(b).toMatchObject({ x: 0, y: -100 })
}) })
it('keeps the same top left when resized while panned / zoomed', () => { it('keeps the same top left when resized while panned / zoomed', () => {
editor.setCamera(-100, -100, 1.2) editor.setCamera({ x: -100, y: -100, z: 1 })
const a = editor.screenToPage(0, 0) expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 100, y: 100 })
editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, false) editor.setCamera({ x: -100, y: -100, z: 2 })
const b = editor.screenToPage(0, 0) expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 50, y: 50 })
expect(a).toMatchObject(b)
editor.setScreenBounds({ x: 100, y: 100, w: 500, h: 600 }, false)
expect(editor.viewportScreenBounds).toMatchObject({
x: 100,
y: 100,
w: 500,
h: 600,
})
expect(editor.screenToPage({ x: 0, y: 0 })).toMatchObject({ x: 0, y: 0 })
}) })
}) })
@ -75,7 +106,7 @@ describe('When center is true', () => {
}) })
it('keep the same page center when resized while panned / zoomed', () => { it('keep the same page center when resized while panned / zoomed', () => {
editor.setCamera(-100, -100, 1.2) editor.setCamera({ x: -100, y: -100, z: 1.2 })
const a = editor.viewportPageCenter.toJson() const a = editor.viewportPageCenter.toJson()
editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, true) editor.setScreenBounds({ x: 100, y: 200, w: 500, h: 600 }, true)
const b = editor.viewportPageCenter.toJson() const b = editor.viewportPageCenter.toJson()

View file

@ -26,16 +26,16 @@ it('zooms by increments', () => {
}) })
it('zooms to from B to D when B >= (C - A)/2, else zooms from B to C', () => { it('zooms to from B to D when B >= (C - A)/2, else zooms from B to C', () => {
editor.setCamera(0, 0, (ZOOMS[2] + ZOOMS[3]) / 2) editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 })
editor.zoomIn() editor.zoomIn()
expect(editor.zoomLevel).toBe(ZOOMS[4]) expect(editor.zoomLevel).toBe(ZOOMS[4])
editor.setCamera(0, 0, (ZOOMS[2] + ZOOMS[3]) / 2 - 0.1) editor.setCamera({ x: 0, y: 0, z: (ZOOMS[2] + ZOOMS[3]) / 2 - 0.1 })
editor.zoomIn() editor.zoomIn()
expect(editor.zoomLevel).toBe(ZOOMS[3]) expect(editor.zoomLevel).toBe(ZOOMS[3])
}) })
it('does not zoom when camera is frozen', () => { it('does not zoom when camera is frozen', () => {
editor.setCamera(0, 0, 1) editor.setCamera({ x: 0, y: 0, z: 1 })
expect(editor.camera).toMatchObject({ x: 0, y: 0, z: 1 }) expect(editor.camera).toMatchObject({ x: 0, y: 0, z: 1 })
editor.updateInstanceState({ canMoveCamera: false }) editor.updateInstanceState({ canMoveCamera: false })
editor.zoomIn() editor.zoomIn()

View file

@ -23,7 +23,7 @@ it('zooms by increments', () => {
}) })
it('does not zoom out when camera is frozen', () => { it('does not zoom out when camera is frozen', () => {
editor.setCamera(0, 0, 1) editor.setCamera({ x: 0, y: 0, z: 1 })
expect(editor.camera).toMatchObject({ x: 0, y: 0, z: 1 }) expect(editor.camera).toMatchObject({ x: 0, y: 0, z: 1 })
editor.updateInstanceState({ canMoveCamera: false }) editor.updateInstanceState({ canMoveCamera: false })
editor.zoomOut() editor.zoomOut()

View file

@ -1,3 +1,4 @@
import { Box2d } from '@tldraw/editor'
import { TestEditor } from '../TestEditor' import { TestEditor } from '../TestEditor'
let editor: TestEditor let editor: TestEditor
@ -11,19 +12,32 @@ describe('When zooming to bounds', () => {
expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 }) expect(editor.viewportPageCenter).toMatchObject({ x: 540, y: 360 })
editor.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 }) editor.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 })
editor.setCamera(0, 0, 1)
editor.zoomToBounds(200, 300, 300, 300) expect(editor.viewportPageCenter).toMatchObject({ x: 500, y: 500 })
expect(editor.viewportPageCenter.toJson()).toCloselyMatchObject({ x: 350, y: 450 })
editor.setCamera({ x: 0, y: 0, z: 1 })
expect(editor.viewportPageBounds).toCloselyMatchObject({
x: -0,
y: -0,
w: 1000,
h: 1000,
})
editor.zoomToBounds(new Box2d(200, 300, 300, 300))
expect(editor.camera.z).toCloselyMatchObject((1000 - 256) / 300)
expect(editor.viewportPageBounds.width).toCloselyMatchObject(1000 / ((1000 - 256) / 300))
expect(editor.viewportPageBounds.height).toCloselyMatchObject(1000 / ((1000 - 256) / 300))
}) })
}) })
it('does not zoom past max', () => { it('does not zoom past max', () => {
editor.zoomToBounds(0, 0, 1, 1) editor.zoomToBounds(new Box2d(0, 0, 1, 1))
expect(editor.zoomLevel).toBe(8) expect(editor.zoomLevel).toBe(8)
}) })
it('does not zoom past min', () => { it('does not zoom past min', () => {
editor.zoomToBounds(0, 0, 1000000, 100000) editor.zoomToBounds(new Box2d(0, 0, 1000000, 100000))
expect(editor.zoomLevel).toBe(0.1) expect(editor.zoomLevel).toBe(0.1)
}) })
@ -31,6 +45,6 @@ it('does not zoom to bounds when camera is frozen', () => {
editor.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 }) editor.setScreenBounds({ x: 0, y: 0, w: 1000, h: 1000 })
expect(editor.viewportPageCenter.toJson()).toCloselyMatchObject({ x: 500, y: 500 }) expect(editor.viewportPageCenter.toJson()).toCloselyMatchObject({ x: 500, y: 500 })
editor.updateInstanceState({ canMoveCamera: false }) editor.updateInstanceState({ canMoveCamera: false })
editor.zoomToBounds(200, 300, 300, 300) editor.zoomToBounds(new Box2d(200, 300, 300, 300))
expect(editor.viewportPageCenter.toJson()).toCloselyMatchObject({ x: 500, y: 500 }) expect(editor.viewportPageCenter.toJson()).toCloselyMatchObject({ x: 500, y: 500 })
}) })

View file

@ -418,7 +418,7 @@ describe('When pasting into frames...', () => {
.select(ids.frame1) .select(ids.frame1)
.bringToFront(editor.selectedShapeIds) .bringToFront(editor.selectedShapeIds)
editor.setCamera(-2000, -2000, 1) editor.setCamera({ x: -2000, y: -2000, z: 1 })
editor.updateRenderingBounds() editor.updateRenderingBounds()
// Copy box 1 (should be out of viewport) // Copy box 1 (should be out of viewport)

File diff suppressed because it is too large Load diff

View file

@ -129,7 +129,7 @@ describe('When brushing arrows', () => {
const ids = editor const ids = editor
.selectAll() .selectAll()
.deleteShapes(editor.selectedShapeIds) .deleteShapes(editor.selectedShapeIds)
.setCamera(0, 0, 1) .setCamera({ x: 0, y: 0, z: 1 })
.createShapesFromJsx([ .createShapesFromJsx([
<TL.arrow <TL.arrow
ref="arrow1" ref="arrow1"
@ -151,7 +151,7 @@ describe('When brushing arrows', () => {
editor editor
.selectAll() .selectAll()
.deleteShapes(editor.selectedShapeIds) .deleteShapes(editor.selectedShapeIds)
.setCamera(0, 0, 1) .setCamera({ x: 0, y: 0, z: 1 })
.createShapesFromJsx([ .createShapesFromJsx([
<TL.arrow <TL.arrow
ref="arrow1" ref="arrow1"