Zoom on cursor | Issue #940 (#2319)

This commit is contained in:
João Forja 2020-11-04 17:49:15 +00:00 committed by GitHub
parent facde7ace0
commit 566e6a5ede
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 912 additions and 357 deletions

View File

@ -4,14 +4,16 @@ import { getDefaultAppState } from "../appState";
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons"; import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { getNormalizedZoom, normalizeScroll } from "../scene"; import { getNormalizedZoom } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getShortcutKey } from "../utils"; import { getShortcutKey } from "../utils";
import useIsMobile from "../is-mobile"; import useIsMobile from "../is-mobile";
import { register } from "./register"; import { register } from "./register";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { AppState, FlooredNumber } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { getCommonBounds } from "../element"; import { getCommonBounds } from "../element";
import { getNewZoom } from "../scene/zoom";
import { centerScrollOn } from "../scene/scroll";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@ -84,7 +86,11 @@ export const actionZoomIn = register({
return { return {
appState: { appState: {
...appState, ...appState,
zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP), zoom: getNewZoom(
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
appState.zoom,
{ x: appState.width / 2, y: appState.height / 2 },
),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@ -111,7 +117,11 @@ export const actionZoomOut = register({
return { return {
appState: { appState: {
...appState, ...appState,
zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP), zoom: getNewZoom(
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
appState.zoom,
{ x: appState.width / 2, y: appState.height / 2 },
),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@ -138,7 +148,10 @@ export const actionResetZoom = register({
return { return {
appState: { appState: {
...appState, ...appState,
zoom: 1, zoom: getNewZoom(1 as NormalizedZoomValue, appState.zoom, {
x: appState.width / 2,
y: appState.height / 2,
}),
}, },
commitToHistory: false, commitToHistory: false,
}; };
@ -159,40 +172,23 @@ export const actionResetZoom = register({
(event[KEYS.CTRL_OR_CMD] || event.shiftKey), (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
}); });
const calculateZoom = ( const zoomValueToFitBoundsOnViewport = (
commonBounds: number[], bounds: [number, number, number, number],
currentZoom: number, viewportDimensions: { width: number; height: number },
{ ) => {
scrollX, const [x1, y1, x2, y2] = bounds;
scrollY, const commonBoundsWidth = x2 - x1;
}: { const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
scrollX: FlooredNumber; const commonBoundsHeight = y2 - y1;
scrollY: FlooredNumber; const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
}, const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
): number => { const zoomAdjustedToSteps =
const { innerWidth, innerHeight } = window; Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
const [x, y] = commonBounds; const clampedZoomValueToFitElements = Math.min(
const zoomX = -innerWidth / (2 * scrollX + 2 * x - innerWidth); Math.max(zoomAdjustedToSteps, ZOOM_STEP),
const zoomY = -innerHeight / (2 * scrollY + 2 * y - innerHeight); 1,
const margin = 0.01; );
let newZoom; return clampedZoomValueToFitElements as NormalizedZoomValue;
if (zoomX < zoomY) {
newZoom = zoomX - margin;
} else if (zoomY <= zoomX) {
newZoom = zoomY - margin;
} else {
newZoom = currentZoom;
}
if (newZoom <= 0.1) {
return 0.1;
}
if (newZoom >= 1) {
return 1;
}
return newZoom;
}; };
export const actionZoomToFit = register({ export const actionZoomToFit = register({
@ -200,22 +196,29 @@ export const actionZoomToFit = register({
perform: (elements, appState) => { perform: (elements, appState) => {
const nonDeletedElements = elements.filter((element) => !element.isDeleted); const nonDeletedElements = elements.filter((element) => !element.isDeleted);
const commonBounds = getCommonBounds(nonDeletedElements); const commonBounds = getCommonBounds(nonDeletedElements);
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const newZoom = getNewZoom(zoomValue, appState.zoom);
const [x1, y1, x2, y2] = commonBounds; const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2; const centerY = (y1 + y2) / 2;
const scrollX = normalizeScroll(appState.width / 2 - centerX);
const scrollY = normalizeScroll(appState.height / 2 - centerY);
const zoom = calculateZoom(commonBounds, appState.zoom, {
scrollX,
scrollY,
});
return { return {
appState: { appState: {
...appState, ...appState,
scrollX, ...centerScrollOn({
scrollY, scenePoint: { x: centerX, y: centerY },
zoom, viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
}, },
commitToHistory: false, commitToHistory: false,
}; };

View File

@ -3,7 +3,7 @@ import { Avatar } from "../components/Avatar";
import { register } from "./register"; import { register } from "./register";
import { getClientColors, getClientInitials } from "../clients"; import { getClientColors, getClientInitials } from "../clients";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
import { normalizeScroll } from "../scene"; import { centerScrollOn } from "../scene/scroll";
export const actionGoToCollaborator = register({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
@ -16,8 +16,14 @@ export const actionGoToCollaborator = register({
return { return {
appState: { appState: {
...appState, ...appState,
scrollX: normalizeScroll(appState.width / 2 - point.x), ...centerScrollOn({
scrollY: normalizeScroll(appState.height / 2 - point.y), scenePoint: point,
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: appState.zoom,
}),
// Close mobile menu // Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
}, },

View File

@ -1,5 +1,5 @@
import oc from "open-color"; import oc from "open-color";
import { AppState, FlooredNumber } from "./types"; import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils"; import { getDateTime } from "./utils";
import { t } from "./i18n"; import { t } from "./i18n";
import { import {
@ -53,7 +53,10 @@ export const getDefaultAppState = (): Omit<
isResizing: false, isResizing: false,
isRotating: false, isRotating: false,
selectionElement: null, selectionElement: null,
zoom: 1, zoom: {
value: 1 as NormalizedZoomValue,
translation: { x: 0, y: 0 },
},
openMenu: null, openMenu: null,
lastPointerDownWith: "mouse", lastPointerDownWith: "mouse",
selectedElementIds: {}, selectedElementIds: {},

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { AppState } from "../types"; import { AppState, Zoom } from "../types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { import {
@ -183,14 +183,16 @@ export const ZoomActions = ({
zoom, zoom,
}: { }: {
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
zoom: number; zoom: Zoom;
}) => ( }) => (
<Stack.Col gap={1}> <Stack.Col gap={1}>
<Stack.Row gap={1} align="center"> <Stack.Row gap={1} align="center">
{renderAction("zoomIn")} {renderAction("zoomIn")}
{renderAction("zoomOut")} {renderAction("zoomOut")}
{renderAction("resetZoom")} {renderAction("resetZoom")}
<div style={{ marginInlineStart: 4 }}>{(zoom * 100).toFixed(0)}%</div> <div style={{ marginInlineStart: 4 }}>
{(zoom.value * 100).toFixed(0)}%
</div>
</Stack.Row> </Stack.Row>
</Stack.Col> </Stack.Col>
); );

View File

@ -180,6 +180,7 @@ import {
saveToFirebase, saveToFirebase,
isSavedToFirebase, isSavedToFirebase,
} from "../data/firebase"; } from "../data/firebase";
import { getNewZoom } from "../scene/zoom";
/** /**
* @param func handler taking at most single parameter (event). * @param func handler taking at most single parameter (event).
@ -935,8 +936,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
sceneY: user.pointer.y, sceneY: user.pointer.y,
}, },
this.state, this.state,
this.canvas,
window.devicePixelRatio,
); );
cursorButton[socketId] = user.button; cursorButton[socketId] = user.button;
}); });
@ -1146,8 +1145,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { x, y } = viewportCoordsToSceneCoords( const { x, y } = viewportCoordsToSceneCoords(
{ clientX, clientY }, { clientX, clientY },
this.state, this.state,
this.canvas,
window.devicePixelRatio,
); );
const dx = x - elementsCenterX; const dx = x - elementsCenterX;
@ -1205,8 +1202,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { x, y } = viewportCoordsToSceneCoords( const { x, y } = viewportCoordsToSceneCoords(
{ clientX: cursorX, clientY: cursorY }, { clientX: cursorX, clientY: cursorY },
this.state, this.state,
this.canvas,
window.devicePixelRatio,
); );
const element = newTextElement({ const element = newTextElement({
@ -1719,15 +1714,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.setState({ this.setState({
selectedElementIds: {}, selectedElementIds: {},
}); });
gesture.initialScale = this.state.zoom; gesture.initialScale = this.state.zoom.value;
}); });
private onGestureChange = withBatchedUpdates((event: GestureEvent) => { private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
event.preventDefault(); event.preventDefault();
const gestureCenter = getCenter(gesture.pointers);
this.setState({ this.setState(({ zoom }) => ({
zoom: getNormalizedZoom(gesture.initialScale! * event.scale), zoom: getNewZoom(
}); getNormalizedZoom(gesture.initialScale! * event.scale),
zoom,
gestureCenter,
),
}));
}); });
private onGestureEnd = withBatchedUpdates((event: GestureEvent) => { private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
@ -1771,8 +1770,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
sceneY: y, sceneY: y,
}, },
this.state, this.state,
this.canvas,
window.devicePixelRatio,
); );
return [viewportX, viewportY]; return [viewportX, viewportY];
}, },
@ -1990,8 +1987,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event, event,
this.state, this.state,
this.canvas,
window.devicePixelRatio,
); );
const selectedGroupIds = getSelectedGroupIds(this.state); const selectedGroupIds = getSelectedGroupIds(this.state);
@ -2051,12 +2046,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const distance = getDistance(Array.from(gesture.pointers.values())); const distance = getDistance(Array.from(gesture.pointers.values()));
const scaleFactor = distance / gesture.initialDistance!; const scaleFactor = distance / gesture.initialDistance!;
this.setState({ this.setState(({ zoom, scrollX, scrollY }) => ({
scrollX: normalizeScroll(this.state.scrollX + deltaX / this.state.zoom), scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
scrollY: normalizeScroll(this.state.scrollY + deltaY / this.state.zoom), scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
zoom: getNormalizedZoom(gesture.initialScale! * scaleFactor), zoom: getNewZoom(
getNormalizedZoom(gesture.initialScale! * scaleFactor),
zoom,
center,
),
shouldCacheIgnoreZoom: true, shouldCacheIgnoreZoom: true,
}); }));
this.resetShouldCacheIgnoreZoomDebounced(); this.resetShouldCacheIgnoreZoomDebounced();
} else { } else {
gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null; gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null;
@ -2079,12 +2078,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
} }
const scenePointer = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(event, this.state);
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
const { x: scenePointerX, y: scenePointerY } = scenePointer; const { x: scenePointerX, y: scenePointerY } = scenePointer;
if ( if (
@ -2453,8 +2447,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
this.setState({ this.setState({
scrollX: normalizeScroll(this.state.scrollX - deltaX / this.state.zoom), scrollX: normalizeScroll(
scrollY: normalizeScroll(this.state.scrollY - deltaY / this.state.zoom), this.state.scrollX - deltaX / this.state.zoom.value,
),
scrollY: normalizeScroll(
this.state.scrollY - deltaY / this.state.zoom.value,
),
}); });
}); });
const teardown = withBatchedUpdates( const teardown = withBatchedUpdates(
@ -2491,7 +2489,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (gesture.pointers.size === 2) { if (gesture.pointers.size === 2) {
gesture.lastCenter = getCenter(gesture.pointers); gesture.lastCenter = getCenter(gesture.pointers);
gesture.initialScale = this.state.zoom; gesture.initialScale = this.state.zoom.value;
gesture.initialDistance = getDistance( gesture.initialDistance = getDistance(
Array.from(gesture.pointers.values()), Array.from(gesture.pointers.values()),
); );
@ -2501,12 +2499,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
private initialPointerDownState( private initialPointerDownState(
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
): PointerDownState { ): PointerDownState {
const origin = viewportCoordsToSceneCoords( const origin = viewportCoordsToSceneCoords(event, this.state);
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
this.scene.getElements(), this.scene.getElements(),
this.state, this.state,
@ -2790,7 +2783,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
// How many pixels off the shape boundary we still consider a hit // How many pixels off the shape boundary we still consider a hit
const threshold = 10 / this.state.zoom; const threshold = 10 / this.state.zoom.value;
const [x1, y1, x2, y2] = getCommonBounds(selectedElements); const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return ( return (
point.x > x1 - threshold && point.x > x1 - threshold &&
@ -2985,12 +2978,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return; return;
} }
const pointerCoords = viewportCoordsToSceneCoords( const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
event,
this.state,
this.canvas,
window.devicePixelRatio,
);
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
@ -3212,7 +3200,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
mutateElement(draggingElement, { mutateElement(draggingElement, {
points: simplify( points: simplify(
[...(points as Point[]), [dx, dy]], [...(points as Point[]), [dx, dy]],
0.7 / this.state.zoom, 0.7 / this.state.zoom.value,
), ),
}); });
} else { } else {
@ -3300,7 +3288,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const x = event.clientX; const x = event.clientX;
const dx = x - pointerDownState.lastCoords.x; const dx = x - pointerDownState.lastCoords.x;
this.setState({ this.setState({
scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom), scrollX: normalizeScroll(
this.state.scrollX - dx / this.state.zoom.value,
),
}); });
pointerDownState.lastCoords.x = x; pointerDownState.lastCoords.x = x;
return true; return true;
@ -3310,7 +3300,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const y = event.clientY; const y = event.clientY;
const dy = y - pointerDownState.lastCoords.y; const dy = y - pointerDownState.lastCoords.y;
this.setState({ this.setState({
scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), scrollY: normalizeScroll(
this.state.scrollY - dy / this.state.zoom.value,
),
}); });
pointerDownState.lastCoords.y = y; pointerDownState.lastCoords.y = y;
return true; return true;
@ -3387,8 +3379,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const pointerCoords = viewportCoordsToSceneCoords( const pointerCoords = viewportCoordsToSceneCoords(
childEvent, childEvent,
this.state, this.state,
this.canvas,
window.devicePixelRatio,
); );
if ( if (
@ -3808,8 +3798,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { x, y } = viewportCoordsToSceneCoords( const { x, y } = viewportCoordsToSceneCoords(
{ clientX, clientY }, { clientX, clientY },
this.state, this.state,
this.canvas,
window.devicePixelRatio,
); );
const elements = this.scene.getElements(); const elements = this.scene.getElements();
@ -3885,7 +3873,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { deltaX, deltaY } = event; const { deltaX, deltaY } = event;
const { selectedElementIds, previousSelectedElementIds } = this.state; const { selectedElementIds, previousSelectedElementIds } = this.state;
// note that event.ctrlKey is necessary to handle pinch zooming // note that event.ctrlKey is necessary to handle pinch zooming
if (event.metaKey || event.ctrlKey) { if (event.metaKey || event.ctrlKey) {
const sign = Math.sign(deltaY); const sign = Math.sign(deltaY);
@ -3903,8 +3890,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}); });
}, 1000); }, 1000);
} }
this.setState(({ zoom }) => ({ this.setState(({ zoom }) => ({
zoom: getNormalizedZoom(zoom - delta / 100), zoom: getNewZoom(getNormalizedZoom(zoom.value - delta / 100), zoom, {
x: cursorX,
y: cursorY,
}),
selectedElementIds: {}, selectedElementIds: {},
previousSelectedElementIds: previousSelectedElementIds:
Object.keys(selectedElementIds).length !== 0 Object.keys(selectedElementIds).length !== 0
@ -3920,14 +3911,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (event.shiftKey) { if (event.shiftKey) {
this.setState(({ zoom, scrollX }) => ({ this.setState(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX // on Mac, shift+wheel tends to result in deltaX
scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom), scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value),
})); }));
return; return;
} }
this.setState(({ zoom, scrollX, scrollY }) => ({ this.setState(({ zoom, scrollX, scrollY }) => ({
scrollX: normalizeScroll(scrollX - deltaX / zoom), scrollX: normalizeScroll(scrollX - deltaX / zoom.value),
scrollY: normalizeScroll(scrollY - deltaY / zoom), scrollY: normalizeScroll(scrollY - deltaY / zoom.value),
})); }));
}); });
@ -3960,8 +3951,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: elementCenterX, sceneY: elementCenterY }, { sceneX: elementCenterX, sceneY: elementCenterY },
appState, appState,
canvas,
scale,
); );
return { viewportX, viewportY, elementCenterX, elementCenterY }; return { viewportX, viewportY, elementCenterX, elementCenterY };
} }
@ -3975,8 +3964,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const pointer = viewportCoordsToSceneCoords( const pointer = viewportCoordsToSceneCoords(
{ clientX: x, clientY: y }, { clientX: x, clientY: y },
this.state, this.state,
this.canvas,
window.devicePixelRatio,
); );
if (isNaN(pointer.x) || isNaN(pointer.y)) { if (isNaN(pointer.x) || isNaN(pointer.y)) {

View File

@ -3,7 +3,7 @@ import {
FontFamily, FontFamily,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
} from "../element/types"; } from "../element/types";
import { AppState } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { DataState, ImportedDataState } from "./types"; import { DataState, ImportedDataState } from "./types";
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element"; import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
import { isLinearElementType } from "../element/typeChecks"; import { isLinearElementType } from "../element/typeChecks";
@ -161,6 +161,14 @@ const restoreAppState = (
...nextAppState, ...nextAppState,
offsetLeft: appState.offsetLeft || 0, offsetLeft: appState.offsetLeft || 0,
offsetTop: appState.offsetTop || 0, offsetTop: appState.offsetTop || 0,
/* Migrates from previous version where appState.zoom was a number */
zoom:
typeof appState.zoom === "number"
? {
value: appState.zoom as NormalizedZoomValue,
translation: defaultAppState.zoom.translation,
}
: appState.zoom || defaultAppState.zoom,
}; };
}; };

View File

@ -44,7 +44,7 @@ export const hitTest = (
y: number, y: number,
): boolean => { ): boolean => {
// How many pixels off the shape boundary we still consider a hit // How many pixels off the shape boundary we still consider a hit
const threshold = 10 / appState.zoom; const threshold = 10 / appState.zoom.value;
const point: Point = [x, y]; const point: Point = [x, y];
if (isElementSelected(appState, element)) { if (isElementSelected(appState, element)) {
@ -60,7 +60,7 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
x: number, x: number,
y: number, y: number,
): boolean => { ): boolean => {
const threshold = 10 / appState.zoom; const threshold = 10 / appState.zoom.value;
return ( return (
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) && !isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
@ -73,7 +73,7 @@ const isHittingElementNotConsideringBoundingBox = (
appState: AppState, appState: AppState,
point: Point, point: Point,
): boolean => { ): boolean => {
const threshold = 10 / appState.zoom; const threshold = 10 / appState.zoom.value;
const check = const check =
element.type === "text" element.type === "text"

View File

@ -384,7 +384,7 @@ export class LinearElementEditor {
while (--idx > -1) { while (--idx > -1) {
const point = pointHandles[idx]; const point = pointHandles[idx];
if ( if (
distance2d(x, y, point[0], point[1]) * zoom < distance2d(x, y, point[0], point[1]) * zoom.value <
// +1px to account for outline stroke // +1px to account for outline stroke
this.POINT_HANDLE_SIZE / 2 + 1 this.POINT_HANDLE_SIZE / 2 + 1
) { ) {

View File

@ -148,7 +148,6 @@ const getAdjustedDimensions = (
height: nextHeight, height: nextHeight,
baseline: nextBaseline, baseline: nextBaseline,
} = measureText(nextText, getFontString(element)); } = measureText(nextText, getFontString(element));
const { textAlign, verticalAlign } = element; const { textAlign, verticalAlign } = element;
let x, y; let x, y;

View File

@ -12,7 +12,7 @@ import {
TransformHandle, TransformHandle,
MaybeTransformHandleType, MaybeTransformHandleType,
} from "./transformHandles"; } from "./transformHandles";
import { AppState } from "../types"; import { AppState, Zoom } from "../types";
const isInsideTransformHandle = ( const isInsideTransformHandle = (
transformHandle: TransformHandle, transformHandle: TransformHandle,
@ -29,7 +29,7 @@ export const resizeTest = (
appState: AppState, appState: AppState,
x: number, x: number,
y: number, y: number,
zoom: number, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
): MaybeTransformHandleType => { ): MaybeTransformHandleType => {
if (!appState.selectedElementIds[element.id]) { if (!appState.selectedElementIds[element.id]) {
@ -70,7 +70,7 @@ export const getElementWithTransformHandleType = (
appState: AppState, appState: AppState,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
zoom: number, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
) => { ) => {
return elements.reduce((result, element) => { return elements.reduce((result, element) => {
@ -93,7 +93,7 @@ export const getTransformHandleTypeFromCoords = (
[x1, y1, x2, y2]: readonly [number, number, number, number], [x1, y1, x2, y2]: readonly [number, number, number, number],
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
zoom: number, zoom: Zoom,
pointerType: PointerType, pointerType: PointerType,
): MaybeTransformHandleType => { ): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords( const transformHandles = getTransformHandlesFromCoords(

View File

@ -26,9 +26,9 @@ const getTransform = (
const degree = (180 * angle) / Math.PI; const degree = (180 * angle) / Math.PI;
// offsets must be multiplied by 2 to account for the division by 2 of // offsets must be multiplied by 2 to account for the division by 2 of
// the whole expression afterwards // the whole expression afterwards
return `translate(${((width - offsetLeft * 2) * (zoom - 1)) / 2}px, ${ return `translate(${((width - offsetLeft * 2) * (zoom.value - 1)) / 2}px, ${
((height - offsetTop * 2) * (zoom - 1)) / 2 ((height - offsetTop * 2) * (zoom.value - 1)) / 2
}px) scale(${zoom}) rotate(${degree}deg)`; }px) scale(${zoom.value}) rotate(${degree}deg)`;
}; };
export const textWysiwyg = ({ export const textWysiwyg = ({

View File

@ -2,6 +2,7 @@ import { ExcalidrawElement, PointerType } from "./types";
import { getElementAbsoluteCoords, Bounds } from "./bounds"; import { getElementAbsoluteCoords, Bounds } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { Zoom } from "../types";
export type TransformHandleType = export type TransformHandleType =
| "n" | "n"
@ -76,25 +77,25 @@ const generateTransformHandle = (
export const getTransformHandlesFromCoords = ( export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
angle: number, angle: number,
zoom: number, zoom: Zoom,
pointerType: PointerType = "mouse", pointerType: PointerType = "mouse",
omitSides: { [T in TransformHandleType]?: boolean } = {}, omitSides: { [T in TransformHandleType]?: boolean } = {},
): TransformHandles => { ): TransformHandles => {
const size = transformHandleSizes[pointerType]; const size = transformHandleSizes[pointerType];
const handleWidth = size / zoom; const handleWidth = size / zoom.value;
const handleHeight = size / zoom; const handleHeight = size / zoom.value;
const handleMarginX = size / zoom; const handleMarginX = size / zoom.value;
const handleMarginY = size / zoom; const handleMarginY = size / zoom.value;
const width = x2 - x1; const width = x2 - x1;
const height = y2 - y1; const height = y2 - y1;
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const dashedLineMargin = 4 / zoom; const dashedLineMargin = 4 / zoom.value;
const centeringOffset = (size - 8) / (2 * zoom); const centeringOffset = (size - 8) / (2 * zoom.value);
const transformHandles: TransformHandles = { const transformHandles: TransformHandles = {
nw: omitSides["nw"] nw: omitSides["nw"]
@ -149,7 +150,7 @@ export const getTransformHandlesFromCoords = (
dashedLineMargin - dashedLineMargin -
handleMarginY + handleMarginY +
centeringOffset - centeringOffset -
ROTATION_RESIZE_HANDLE_GAP / zoom, ROTATION_RESIZE_HANDLE_GAP / zoom.value,
handleWidth, handleWidth,
handleHeight, handleHeight,
cx, cx,
@ -159,7 +160,7 @@ export const getTransformHandlesFromCoords = (
}; };
// We only want to show height handles (all cardinal directions) above a certain size // We only want to show height handles (all cardinal directions) above a certain size
const minimumSizeForEightHandles = (5 * size) / zoom; const minimumSizeForEightHandles = (5 * size) / zoom.value;
if (Math.abs(width) > minimumSizeForEightHandles) { if (Math.abs(width) > minimumSizeForEightHandles) {
if (!omitSides["n"]) { if (!omitSides["n"]) {
transformHandles["n"] = generateTransformHandle( transformHandles["n"] = generateTransformHandle(
@ -214,7 +215,7 @@ export const getTransformHandlesFromCoords = (
export const getTransformHandles = ( export const getTransformHandles = (
element: ExcalidrawElement, element: ExcalidrawElement,
zoom: number, zoom: Zoom,
pointerType: PointerType = "mouse", pointerType: PointerType = "mouse",
): TransformHandles => { ): TransformHandles => {
let omitSides: { [T in TransformHandleType]?: boolean } = {}; let omitSides: { [T in TransformHandleType]?: boolean } = {};

View File

@ -23,6 +23,10 @@ import {
} from "../utils"; } from "../utils";
import { isPathALoop } from "../math"; import { isPathALoop } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { Zoom } from "../types";
import { getDefaultAppState } from "../appState";
const defaultAppState = getDefaultAppState();
const CANVAS_PADDING = 20; const CANVAS_PADDING = 20;
@ -32,14 +36,14 @@ const DASHARRAY_DOTTED = [3, 6];
export interface ExcalidrawElementWithCanvas { export interface ExcalidrawElementWithCanvas {
element: ExcalidrawElement | ExcalidrawTextElement; element: ExcalidrawElement | ExcalidrawTextElement;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
canvasZoom: number; canvasZoom: Zoom["value"];
canvasOffsetX: number; canvasOffsetX: number;
canvasOffsetY: number; canvasOffsetY: number;
} }
const generateElementCanvas = ( const generateElementCanvas = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
zoom: number, zoom: Zoom,
): ExcalidrawElementWithCanvas => { ): ExcalidrawElementWithCanvas => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
@ -50,9 +54,11 @@ const generateElementCanvas = (
if (isLinearElement(element)) { if (isLinearElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
canvas.width = canvas.width =
distance(x1, x2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; distance(x1, x2) * window.devicePixelRatio * zoom.value +
CANVAS_PADDING * 2;
canvas.height = canvas.height =
distance(y1, y2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; distance(y1, y2) * window.devicePixelRatio * zoom.value +
CANVAS_PADDING * 2;
canvasOffsetX = canvasOffsetX =
element.x > x1 element.x > x1
@ -62,25 +68,35 @@ const generateElementCanvas = (
element.y > y1 element.y > y1
? Math.floor(distance(element.y, y1)) * window.devicePixelRatio ? Math.floor(distance(element.y, y1)) * window.devicePixelRatio
: 0; : 0;
context.translate(canvasOffsetX * zoom, canvasOffsetY * zoom); context.translate(canvasOffsetX * zoom.value, canvasOffsetY * zoom.value);
} else { } else {
canvas.width = canvas.width =
element.width * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; element.width * window.devicePixelRatio * zoom.value + CANVAS_PADDING * 2;
canvas.height = canvas.height =
element.height * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; element.height * window.devicePixelRatio * zoom.value +
CANVAS_PADDING * 2;
} }
context.translate(CANVAS_PADDING, CANVAS_PADDING); context.translate(CANVAS_PADDING, CANVAS_PADDING);
context.scale(window.devicePixelRatio * zoom, window.devicePixelRatio * zoom); context.scale(
window.devicePixelRatio * zoom.value,
window.devicePixelRatio * zoom.value,
);
const rc = rough.canvas(canvas); const rc = rough.canvas(canvas);
drawElementOnCanvas(element, rc, context); drawElementOnCanvas(element, rc, context);
context.translate(-CANVAS_PADDING, -CANVAS_PADDING); context.translate(-CANVAS_PADDING, -CANVAS_PADDING);
context.scale( context.scale(
1 / (window.devicePixelRatio * zoom), 1 / (window.devicePixelRatio * zoom.value),
1 / (window.devicePixelRatio * zoom), 1 / (window.devicePixelRatio * zoom.value),
); );
return { element, canvas, canvasZoom: zoom, canvasOffsetX, canvasOffsetY }; return {
element,
canvas,
canvasZoom: zoom.value,
canvasOffsetX,
canvasOffsetY,
};
}; };
const drawElementOnCanvas = ( const drawElementOnCanvas = (
@ -352,11 +368,11 @@ const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
sceneState?: SceneState, sceneState?: SceneState,
) => { ) => {
const zoom = sceneState ? sceneState.zoom : 1; const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
const prevElementWithCanvas = elementWithCanvasCache.get(element); const prevElementWithCanvas = elementWithCanvasCache.get(element);
const shouldRegenerateBecauseZoom = const shouldRegenerateBecauseZoom =
prevElementWithCanvas && prevElementWithCanvas &&
prevElementWithCanvas.canvasZoom !== zoom && prevElementWithCanvas.canvasZoom !== zoom.value &&
!sceneState?.shouldCacheIgnoreZoom; !sceneState?.shouldCacheIgnoreZoom;
if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) { if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) {
const elementWithCanvas = generateElementCanvas(element, zoom); const elementWithCanvas = generateElementCanvas(element, zoom);

View File

@ -2,7 +2,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
import { RoughSVG } from "roughjs/bin/svg"; import { RoughSVG } from "roughjs/bin/svg";
import oc from "open-color"; import oc from "open-color";
import { FlooredNumber, AppState } from "../types"; import { AppState, Zoom } from "../types";
import { import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@ -47,6 +47,7 @@ import {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { viewportCoordsToSceneCoords } from "../utils";
const strokeRectWithRotation = ( const strokeRectWithRotation = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@ -147,7 +148,7 @@ const renderLinearPointHandles = (
context.translate(sceneState.scrollX, sceneState.scrollY); context.translate(sceneState.scrollX, sceneState.scrollY);
const origStrokeStyle = context.strokeStyle; const origStrokeStyle = context.strokeStyle;
const lineWidth = context.lineWidth; const lineWidth = context.lineWidth;
context.lineWidth = 1 / sceneState.zoom; context.lineWidth = 1 / sceneState.zoom.value;
LinearElementEditor.getPointsGlobalCoordinates(element).forEach( LinearElementEditor.getPointsGlobalCoordinates(element).forEach(
(point, idx) => { (point, idx) => {
@ -162,7 +163,7 @@ const renderLinearPointHandles = (
context, context,
point[0], point[0],
point[1], point[1],
POINT_HANDLE_SIZE / 2 / sceneState.zoom, POINT_HANDLE_SIZE / 2 / sceneState.zoom.value,
); );
}, },
); );
@ -226,36 +227,36 @@ export const renderScene = (
} }
// Apply zoom // Apply zoom
const zoomTranslationX = (-normalizedCanvasWidth * (sceneState.zoom - 1)) / 2; const zoomTranslationX = sceneState.zoom.translation.x;
const zoomTranslationY = const zoomTranslationY = sceneState.zoom.translation.y;
(-normalizedCanvasHeight * (sceneState.zoom - 1)) / 2;
context.translate(zoomTranslationX, zoomTranslationY); context.translate(zoomTranslationX, zoomTranslationY);
context.scale(sceneState.zoom, sceneState.zoom); context.scale(sceneState.zoom.value, sceneState.zoom.value);
// Grid // Grid
if (renderGrid && appState.gridSize) { if (renderGrid && appState.gridSize) {
strokeGrid( strokeGrid(
context, context,
appState.gridSize, appState.gridSize,
-Math.ceil(zoomTranslationX / sceneState.zoom / appState.gridSize) * -Math.ceil(zoomTranslationX / sceneState.zoom.value / appState.gridSize) *
appState.gridSize + appState.gridSize +
(sceneState.scrollX % appState.gridSize), (sceneState.scrollX % appState.gridSize),
-Math.ceil(zoomTranslationY / sceneState.zoom / appState.gridSize) * -Math.ceil(zoomTranslationY / sceneState.zoom.value / appState.gridSize) *
appState.gridSize + appState.gridSize +
(sceneState.scrollY % appState.gridSize), (sceneState.scrollY % appState.gridSize),
normalizedCanvasWidth / sceneState.zoom, normalizedCanvasWidth / sceneState.zoom.value,
normalizedCanvasHeight / sceneState.zoom, normalizedCanvasHeight / sceneState.zoom.value,
); );
} }
// Paint visible elements // Paint visible elements
const visibleElements = elements.filter((element) => const visibleElements = elements.filter((element) =>
isVisibleElement( isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, {
element, zoom: sceneState.zoom,
normalizedCanvasWidth, offsetLeft: appState.offsetLeft,
normalizedCanvasHeight, offsetTop: appState.offsetTop,
sceneState, scrollX: sceneState.scrollX,
), scrollY: sceneState.scrollY,
}),
); );
visibleElements.forEach((element) => { visibleElements.forEach((element) => {
@ -378,13 +379,13 @@ export const renderScene = (
locallySelectedElements[0].angle, locallySelectedElements[0].angle,
); );
} else if (locallySelectedElements.length > 1 && !appState.isRotating) { } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
const dashedLinePadding = 4 / sceneState.zoom; const dashedLinePadding = 4 / sceneState.zoom.value;
context.fillStyle = oc.white; context.fillStyle = oc.white;
const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements); const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
const initialLineDash = context.getLineDash(); const initialLineDash = context.getLineDash();
context.setLineDash([2 / sceneState.zoom]); context.setLineDash([2 / sceneState.zoom.value]);
const lineWidth = context.lineWidth; const lineWidth = context.lineWidth;
context.lineWidth = 1 / sceneState.zoom; context.lineWidth = 1 / sceneState.zoom.value;
strokeRectWithRotation( strokeRectWithRotation(
context, context,
x1 - dashedLinePadding, x1 - dashedLinePadding,
@ -410,7 +411,7 @@ export const renderScene = (
} }
// Reset zoom // Reset zoom
context.scale(1 / sceneState.zoom, 1 / sceneState.zoom); context.scale(1 / sceneState.zoom.value, 1 / sceneState.zoom.value);
context.translate(-zoomTranslationX, -zoomTranslationY); context.translate(-zoomTranslationX, -zoomTranslationY);
// Paint remote pointers // Paint remote pointers
@ -556,7 +557,7 @@ const renderTransformHandles = (
const transformHandle = transformHandles[key as TransformHandleType]; const transformHandle = transformHandles[key as TransformHandleType];
if (transformHandle !== undefined) { if (transformHandle !== undefined) {
const lineWidth = context.lineWidth; const lineWidth = context.lineWidth;
context.lineWidth = 1 / sceneState.zoom; context.lineWidth = 1 / sceneState.zoom.value;
if (key === "rotation") { if (key === "rotation") {
fillCircle( fillCircle(
context, context,
@ -610,11 +611,11 @@ const renderSelectionBorder = (
const lineDashOffset = context.lineDashOffset; const lineDashOffset = context.lineDashOffset;
const strokeStyle = context.strokeStyle; const strokeStyle = context.strokeStyle;
const dashedLinePadding = 4 / sceneState.zoom; const dashedLinePadding = 4 / sceneState.zoom.value;
const dashWidth = 8 / sceneState.zoom; const dashWidth = 8 / sceneState.zoom.value;
const spaceWidth = 4 / sceneState.zoom; const spaceWidth = 4 / sceneState.zoom.value;
context.lineWidth = 1 / sceneState.zoom; context.lineWidth = 1 / sceneState.zoom.value;
context.translate(sceneState.scrollX, sceneState.scrollY); context.translate(sceneState.scrollX, sceneState.scrollY);
@ -749,32 +750,30 @@ const renderBindingHighlightForSuggestedPointBinding = (
const isVisibleElement = ( const isVisibleElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
viewportWidth: number, canvasWidth: number,
viewportHeight: number, canvasHeight: number,
{ viewTransformations: {
scrollX, zoom: Zoom;
scrollY, offsetLeft: number;
zoom, offsetTop: number;
}: { scrollX: number;
scrollX: FlooredNumber; scrollY: number;
scrollY: FlooredNumber;
zoom: number;
}, },
) => { ) => {
const [x1, y1, x2, y2] = getElementBounds(element); const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords(
// Apply zoom { clientX: 0, clientY: 0 },
const viewportWidthWithZoom = viewportWidth / zoom; viewTransformations,
const viewportHeightWithZoom = viewportHeight / zoom; );
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
const viewportWidthDiff = viewportWidth - viewportWidthWithZoom; { clientX: canvasWidth, clientY: canvasHeight },
const viewportHeightDiff = viewportHeight - viewportHeightWithZoom; viewTransformations,
);
return ( return (
x2 + scrollX - viewportWidthDiff / 2 >= 0 && topLeftSceneCoords.x <= x2 &&
x1 + scrollX - viewportWidthDiff / 2 <= viewportWidthWithZoom && topLeftSceneCoords.y <= y2 &&
y2 + scrollY - viewportHeightDiff / 2 >= 0 && bottomRightSceneCoords.x >= x1 &&
y1 + scrollY - viewportHeightDiff / 2 <= viewportHeightWithZoom bottomRightSceneCoords.y >= y1
); );
}; };

View File

@ -9,6 +9,7 @@ import { normalizeScroll } from "./scroll";
import { AppState } from "../types"; import { AppState } from "../types";
import { t } from "../i18n"; import { t } from "../i18n";
import { DEFAULT_FONT_FAMILY, DEFAULT_VERTICAL_ALIGN } from "../constants"; import { DEFAULT_FONT_FAMILY, DEFAULT_VERTICAL_ALIGN } from "../constants";
import { getDefaultAppState } from "../appState";
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
const WATERMARK_HEIGHT = 16; const WATERMARK_HEIGHT = 16;
@ -60,7 +61,7 @@ export const exportToCanvas = (
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: normalizeScroll(-minX + exportPadding), scrollX: normalizeScroll(-minX + exportPadding),
scrollY: normalizeScroll(-minY + exportPadding), scrollY: normalizeScroll(-minY + exportPadding),
zoom: 1, zoom: getDefaultAppState().zoom,
remotePointerViewportCoords: {}, remotePointerViewportCoords: {},
remoteSelectedElementIds: {}, remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,

View File

@ -16,4 +16,4 @@ export {
hasText, hasText,
getElementsAtPosition, getElementsAtPosition,
} from "./comparisons"; } from "./comparisons";
export { getZoomOrigin, getNormalizedZoom } from "./zoom"; export { normalizeZoomValue as getNormalizedZoom, getNewZoom } from "./zoom";

View File

@ -1,4 +1,4 @@
import { AppState, FlooredNumber } from "../types"; import { AppState, FlooredNumber, PointerCoords, Zoom } from "../types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { getCommonBounds, getClosestElementBounds } from "../element"; import { getCommonBounds, getClosestElementBounds } from "../element";
@ -19,14 +19,10 @@ function isOutsideViewPort(
const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords( const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords(
{ sceneX: x1, sceneY: y1 }, { sceneX: x1, sceneY: y1 },
appState, appState,
canvas,
window.devicePixelRatio,
); );
const { x: viewportX2, y: viewportY2 } = sceneCoordsToViewportCoords( const { x: viewportX2, y: viewportY2 } = sceneCoordsToViewportCoords(
{ sceneX: x2, sceneY: y2 }, { sceneX: x2, sceneY: y2 },
appState, appState,
canvas,
window.devicePixelRatio,
); );
return ( return (
viewportX2 - viewportX1 > appState.width || viewportX2 - viewportX1 > appState.width ||
@ -34,6 +30,29 @@ function isOutsideViewPort(
); );
} }
export const centerScrollOn = ({
scenePoint,
viewportDimensions,
zoom,
}: {
scenePoint: PointerCoords;
viewportDimensions: { height: number; width: number };
zoom: Zoom;
}) => {
return {
scrollX: normalizeScroll(
(viewportDimensions.width / 2) * (1 / zoom.value) -
scenePoint.x -
zoom.translation.x * (1 / zoom.value),
),
scrollY: normalizeScroll(
(viewportDimensions.height / 2) * (1 / zoom.value) -
scenePoint.y -
zoom.translation.y * (1 / zoom.value),
),
};
};
export const calculateScrollCenter = ( export const calculateScrollCenter = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
@ -45,7 +64,6 @@ export const calculateScrollCenter = (
scrollY: normalizeScroll(0), scrollY: normalizeScroll(0),
}; };
} }
const scale = window.devicePixelRatio;
let [x1, y1, x2, y2] = getCommonBounds(elements); let [x1, y1, x2, y2] = getCommonBounds(elements);
if (isOutsideViewPort(appState, canvas, [x1, y1, x2, y2])) { if (isOutsideViewPort(appState, canvas, [x1, y1, x2, y2])) {
@ -54,8 +72,6 @@ export const calculateScrollCenter = (
viewportCoordsToSceneCoords( viewportCoordsToSceneCoords(
{ clientX: appState.scrollX, clientY: appState.scrollY }, { clientX: appState.scrollX, clientY: appState.scrollY },
appState, appState,
canvas,
scale,
), ),
); );
} }
@ -63,8 +79,9 @@ export const calculateScrollCenter = (
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2; const centerY = (y1 + y2) / 2;
return { return centerScrollOn({
scrollX: normalizeScroll(appState.width / 2 - centerX), scenePoint: { x: centerX, y: centerY },
scrollY: normalizeScroll(appState.height / 2 - centerY), viewportDimensions: { width: appState.width, height: appState.height },
}; zoom: appState.zoom,
});
}; };

View File

@ -1,6 +1,6 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { getCommonBounds } from "../element"; import { getCommonBounds } from "../element";
import { FlooredNumber } from "../types"; import { FlooredNumber, Zoom } from "../types";
import { ScrollBars } from "./types"; import { ScrollBars } from "./types";
import { getGlobalCSSVariable } from "../utils"; import { getGlobalCSSVariable } from "../utils";
import { getLanguage } from "../i18n"; import { getLanguage } from "../i18n";
@ -20,7 +20,7 @@ export const getScrollBars = (
}: { }: {
scrollX: FlooredNumber; scrollX: FlooredNumber;
scrollY: FlooredNumber; scrollY: FlooredNumber;
zoom: number; zoom: Zoom;
}, },
): ScrollBars => { ): ScrollBars => {
// This is the bounding box of all the elements // This is the bounding box of all the elements
@ -32,8 +32,8 @@ export const getScrollBars = (
] = getCommonBounds(elements); ] = getCommonBounds(elements);
// Apply zoom // Apply zoom
const viewportWidthWithZoom = viewportWidth / zoom; const viewportWidthWithZoom = viewportWidth / zoom.value;
const viewportHeightWithZoom = viewportHeight / zoom; const viewportHeightWithZoom = viewportHeight / zoom.value;
const viewportWidthDiff = viewportWidth - viewportWidthWithZoom; const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
const viewportHeightDiff = viewportHeight - viewportHeightWithZoom; const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;

View File

@ -1,12 +1,12 @@
import { ExcalidrawTextElement } from "../element/types"; import { ExcalidrawTextElement } from "../element/types";
import { FlooredNumber } from "../types"; import { FlooredNumber, Zoom } from "../types";
export type SceneState = { export type SceneState = {
scrollX: FlooredNumber; scrollX: FlooredNumber;
scrollY: FlooredNumber; scrollY: FlooredNumber;
// null indicates transparent bg // null indicates transparent bg
viewBackgroundColor: string | null; viewBackgroundColor: string | null;
zoom: number; zoom: Zoom;
shouldCacheIgnoreZoom: boolean; shouldCacheIgnoreZoom: boolean;
remotePointerViewportCoords: { [id: string]: { x: number; y: number } }; remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
remotePointerButton?: { [id: string]: string | undefined }; remotePointerButton?: { [id: string]: string | undefined };

View File

@ -1,26 +1,27 @@
export const getZoomOrigin = ( import { NormalizedZoomValue, PointerCoords, Zoom } from "../types";
canvas: HTMLCanvasElement | null,
scale: number,
) => {
if (canvas === null) {
return { x: 0, y: 0 };
}
const context = canvas.getContext("2d");
if (context === null) {
return { x: 0, y: 0 };
}
const normalizedCanvasWidth = canvas.width / scale;
const normalizedCanvasHeight = canvas.height / scale;
export const getNewZoom = (
newZoomValue: NormalizedZoomValue,
prevZoom: Zoom,
zoomOnViewportPoint: PointerCoords = { x: 0, y: 0 },
): Zoom => {
return { return {
x: normalizedCanvasWidth / 2, value: newZoomValue,
y: normalizedCanvasHeight / 2, translation: {
x:
zoomOnViewportPoint.x -
(zoomOnViewportPoint.x - prevZoom.translation.x) *
(newZoomValue / prevZoom.value),
y:
zoomOnViewportPoint.y -
(zoomOnViewportPoint.y - prevZoom.translation.y) *
(newZoomValue / prevZoom.value),
},
}; };
}; };
export const getNormalizedZoom = (zoom: number): number => { export const normalizeZoomValue = (zoom: number): NormalizedZoomValue => {
const normalizedZoom = parseFloat(zoom.toFixed(2)); const normalizedZoom = parseFloat(zoom.toFixed(2));
const clampedZoom = Math.max(0.1, Math.min(normalizedZoom, 2)); const clampedZoom = Math.max(0.1, Math.min(normalizedZoom, 2));
return clampedZoom; return clampedZoom as NormalizedZoomValue;
}; };

File diff suppressed because it is too large Load Diff

View File

@ -364,15 +364,15 @@ describe("regression tests", () => {
}); });
it("pinch-to-zoom works", () => { it("pinch-to-zoom works", () => {
expect(h.state.zoom).toBe(1); expect(h.state.zoom.value).toBe(1);
finger1.down(50, 50); finger1.down(50, 50);
finger2.down(60, 50); finger2.down(60, 50);
finger1.move(-10, 0); finger1.move(-10, 0);
expect(h.state.zoom).toBeGreaterThan(1); expect(h.state.zoom.value).toBeGreaterThan(1);
const zoomed = h.state.zoom; const zoomed = h.state.zoom.value;
finger1.move(5, 0); finger1.move(5, 0);
finger2.move(-5, 0); finger2.move(-5, 0);
expect(h.state.zoom).toBeLessThan(zoomed); expect(h.state.zoom.value).toBeLessThan(zoomed);
}); });
it("two-finger scroll works", () => { it("two-finger scroll works", () => {
@ -500,13 +500,13 @@ describe("regression tests", () => {
}); });
it("zoom hotkeys", () => { it("zoom hotkeys", () => {
expect(h.state.zoom).toBe(1); expect(h.state.zoom.value).toBe(1);
fireEvent.keyDown(document, { code: "Equal", ctrlKey: true }); fireEvent.keyDown(document, { code: "Equal", ctrlKey: true });
fireEvent.keyUp(document, { code: "Equal", ctrlKey: true }); fireEvent.keyUp(document, { code: "Equal", ctrlKey: true });
expect(h.state.zoom).toBeGreaterThan(1); expect(h.state.zoom.value).toBeGreaterThan(1);
fireEvent.keyDown(document, { code: "Minus", ctrlKey: true }); fireEvent.keyDown(document, { code: "Minus", ctrlKey: true });
fireEvent.keyUp(document, { code: "Minus", ctrlKey: true }); fireEvent.keyUp(document, { code: "Minus", ctrlKey: true });
expect(h.state.zoom).toBe(1); expect(h.state.zoom.value).toBe(1);
}); });
it("rerenders UI on language change", async () => { it("rerenders UI on language change", async () => {

View File

@ -73,7 +73,7 @@ export type AppState = {
isCollaborating: boolean; isCollaborating: boolean;
isResizing: boolean; isResizing: boolean;
isRotating: boolean; isRotating: boolean;
zoom: number; zoom: Zoom;
openMenu: "canvas" | "shape" | null; openMenu: "canvas" | "shape" | null;
lastPointerDownWith: PointerType; lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean }; selectedElementIds: { [id: string]: boolean };
@ -99,6 +99,16 @@ export type AppState = {
fileHandle: import("browser-nativefs").FileSystemHandle | null; fileHandle: import("browser-nativefs").FileSystemHandle | null;
}; };
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
export type Zoom = Readonly<{
value: NormalizedZoomValue;
translation: Readonly<{
x: number;
y: number;
}>;
}>;
export type PointerCoords = Readonly<{ export type PointerCoords = Readonly<{
x: number; x: number;
y: number; y: number;

View File

@ -1,5 +1,4 @@
import { AppState } from "./types"; import { Zoom } from "./types";
import { getZoomOrigin } from "./scene";
import { import {
CURSOR_TYPE, CURSOR_TYPE,
FONT_FAMILY, FONT_FAMILY,
@ -183,42 +182,47 @@ export const getShortcutKey = (shortcut: string): string => {
} }
return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`; return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`;
}; };
export const viewportCoordsToSceneCoords = ( export const viewportCoordsToSceneCoords = (
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
appState: AppState, {
canvas: HTMLCanvasElement | null, zoom,
scale: number, offsetLeft,
offsetTop,
scrollX,
scrollY,
}: {
zoom: Zoom;
offsetLeft: number;
offsetTop: number;
scrollX: number;
scrollY: number;
},
) => { ) => {
const zoomOrigin = getZoomOrigin(canvas, scale); const invScale = 1 / zoom.value;
const clientXWithZoom = const x = (clientX - zoom.translation.x - offsetLeft) * invScale - scrollX;
zoomOrigin.x + const y = (clientY - zoom.translation.y - offsetTop) * invScale - scrollY;
(clientX - zoomOrigin.x - appState.offsetLeft) / appState.zoom;
const clientYWithZoom =
zoomOrigin.y +
(clientY - zoomOrigin.y - appState.offsetTop) / appState.zoom;
const x = clientXWithZoom - appState.scrollX;
const y = clientYWithZoom - appState.scrollY;
return { x, y }; return { x, y };
}; };
export const sceneCoordsToViewportCoords = ( export const sceneCoordsToViewportCoords = (
{ sceneX, sceneY }: { sceneX: number; sceneY: number }, { sceneX, sceneY }: { sceneX: number; sceneY: number },
appState: AppState, {
canvas: HTMLCanvasElement | null, zoom,
scale: number, offsetLeft,
offsetTop,
scrollX,
scrollY,
}: {
zoom: Zoom;
offsetLeft: number;
offsetTop: number;
scrollX: number;
scrollY: number;
},
) => { ) => {
const zoomOrigin = getZoomOrigin(canvas, scale); const x = (sceneX + scrollX + offsetLeft) * zoom.value + zoom.translation.x;
const x = const y = (sceneY + scrollY + offsetTop) * zoom.value + zoom.translation.y;
zoomOrigin.x -
(zoomOrigin.x - sceneX - appState.scrollX - appState.offsetLeft) *
appState.zoom;
const y =
zoomOrigin.y -
(zoomOrigin.y - sceneY - appState.scrollY - appState.offsetTop) *
appState.zoom;
return { x, y }; return { x, y };
}; };