Canvas zooming (#716)

* Zoom icons.

* Actions.

* Min zoom of 0 does not make sense.

* Zoom logic.

* Modify how zoom affects selection rendering.

* More precise scrollbar dimensions.

* Adjust elements visibility and scrollbars.

* Normalized canvas width and height.

* Apply zoom to resize test.

* [WIP] Zoom using canvas center as an origin.

* Undo zoom on `getScrollBars`.

* WIP: center zoom origin via scroll

* This was wrong for sure.

* Finish scaling using center as origin.

* Almost there.

* Scroll offset should be not part of zoom transforms.

* Better naming.

* Wheel movement should be the same no matter the zoom level.

* Panning movement should be the same no matter the zoom level.

* Fix elements pasting.

* Fix text WYSIWGT.

* Fix scrollbars and visibility.
This commit is contained in:
Enzo Ferey 2020-02-15 21:03:32 +01:00 committed by GitHub
parent dd2d7e1a88
commit c7ff4c2ed6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 612 additions and 272 deletions

View File

@ -2,7 +2,7 @@ import React from "react";
import { Action } from "./types"; import { Action } from "./types";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { trash } from "../components/icons"; import { trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
@ -53,3 +53,57 @@ export const actionClearCanvas: Action = {
/> />
), ),
}; };
const ZOOM_STEP = 0.1;
function getNormalizedZoom(zoom: number): number {
const normalizedZoom = parseFloat(zoom.toFixed(2));
const clampedZoom = Math.max(0.1, Math.min(normalizedZoom, 2));
return clampedZoom;
}
export const actionZoomIn: Action = {
name: "zoomIn",
perform: (elements, appState) => {
return {
appState: {
...appState,
zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
},
};
},
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={zoomIn}
title={t("buttons.zoomIn")}
aria-label={t("buttons.zoomIn")}
onClick={() => {
updateData(null);
}}
/>
),
};
export const actionZoomOut: Action = {
name: "zoomOut",
perform: (elements, appState) => {
return {
appState: {
...appState,
zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
},
};
},
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={zoomOut}
title={t("buttons.zoomOut")}
aria-label={t("buttons.zoomOut")}
onClick={() => {
updateData(null);
}}
/>
),
};

View File

@ -21,6 +21,8 @@ export {
export { export {
actionChangeViewBackgroundColor, actionChangeViewBackgroundColor,
actionClearCanvas, actionClearCanvas,
actionZoomIn,
actionZoomOut,
} from "./actionCanvas"; } from "./actionCanvas";
export { actionFinalize } from "./actionFinalize"; export { actionFinalize } from "./actionFinalize";

View File

@ -28,6 +28,7 @@ export function getDefaultAppState(): AppState {
name: DEFAULT_PROJECT_NAME, name: DEFAULT_PROJECT_NAME,
isResizing: false, isResizing: false,
selectionElement: null, selectionElement: null,
zoom: 1,
}; };
} }

View File

@ -76,3 +76,21 @@ export const exportFile = (
/> />
</svg> </svg>
); );
export const zoomIn = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
<path
fill="currentColor"
d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
/>
</svg>
);
export const zoomOut = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
<path
fill="currentColor"
d="M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
/>
</svg>
);

View File

@ -1,6 +1,7 @@
import { distanceBetweenPointAndSegment } from "../math"; import { distanceBetweenPointAndSegment } from "../math";
import { ExcalidrawElement } from "./types"; import { ExcalidrawElement } from "./types";
import { import {
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
@ -17,10 +18,11 @@ export function hitTest(
element: ExcalidrawElement, element: ExcalidrawElement,
x: number, x: number,
y: number, y: number,
zoom: number,
): boolean { ): boolean {
// For shapes that are composed of lines, we only enable point-selection when the distance // For shapes that are composed of lines, we only enable point-selection when the distance
// of the click is less than x pixels of any of the lines that the shape is composed of // of the click is less than x pixels of any of the lines that the shape is composed of
const lineThreshold = 10; const lineThreshold = 10 / zoom;
if (element.type === "ellipse") { if (element.type === "ellipse") {
// https://stackoverflow.com/a/46007540/232122 // https://stackoverflow.com/a/46007540/232122

View File

@ -1,98 +1,83 @@
import { ExcalidrawElement } from "./types"; import { ExcalidrawElement } from "./types";
import { SceneScroll } from "../scene/types";
import { getLinearElementAbsoluteBounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se"; type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
export function handlerRectangles( export function handlerRectangles(element: ExcalidrawElement, zoom: number) {
element: ExcalidrawElement, const handlerWidth = 8 / zoom;
{ scrollX, scrollY }: SceneScroll, const handlerHeight = 8 / zoom;
) {
let elementX2 = 0;
let elementY2 = 0;
let elementX1 = Infinity;
let elementY1 = Infinity;
let marginX = -8;
let marginY = -8;
const minimumSize = 40; const handlerMarginX = 8 / zoom;
if (element.type === "arrow" || element.type === "line") { const handlerMarginY = 8 / zoom;
[
elementX1,
elementY1,
elementX2,
elementY2,
] = getLinearElementAbsoluteBounds(element);
} else {
elementX1 = element.x;
elementX2 = element.x + element.width;
elementY1 = element.y;
elementY2 = element.y + element.height;
marginX = element.width < 0 ? 8 : -8; const [elementX1, elementY1, elementX2, elementY2] = getElementAbsoluteCoords(
marginY = element.height < 0 ? 8 : -8; element,
} );
const margin = 4; const elementWidth = elementX2 - elementX1;
const handlers = {} as { [T in Sides]: number[] }; const elementHeight = elementY2 - elementY1;
if (Math.abs(elementX2 - elementX1) > minimumSize) { const dashedLineMargin = 4 / zoom;
const handlers = {
nw: [
elementX1 - dashedLineMargin - handlerMarginX,
elementY1 - dashedLineMargin - handlerMarginY,
handlerWidth,
handlerHeight,
],
ne: [
elementX2 + dashedLineMargin,
elementY1 - dashedLineMargin - handlerMarginY,
handlerWidth,
handlerHeight,
],
sw: [
elementX1 - dashedLineMargin - handlerMarginX,
elementY2 + dashedLineMargin,
handlerWidth,
handlerHeight,
],
se: [
elementX2 + dashedLineMargin,
elementY2 + dashedLineMargin,
handlerWidth,
handlerHeight,
],
} as { [T in Sides]: number[] };
// We only want to show height handlers (all cardinal directions) above a certain size
const minimumSizeForEightHandlers = 40 / zoom;
if (Math.abs(elementWidth) > minimumSizeForEightHandlers) {
handlers["n"] = [ handlers["n"] = [
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4, elementX1 + elementWidth / 2,
elementY1 - margin + scrollY + marginY, elementY1 - dashedLineMargin - handlerMarginY,
8, handlerWidth,
8, handlerHeight,
]; ];
handlers["s"] = [ handlers["s"] = [
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4, elementX1 + elementWidth / 2,
elementY2 - margin + scrollY - marginY, elementY2 + dashedLineMargin,
8, handlerWidth,
8, handlerHeight,
]; ];
} }
if (Math.abs(elementHeight) > minimumSizeForEightHandlers) {
if (Math.abs(elementY2 - elementY1) > minimumSize) {
handlers["w"] = [ handlers["w"] = [
elementX1 - margin + scrollX + marginX, elementX1 - dashedLineMargin - handlerMarginX,
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4, elementY1 + elementHeight / 2,
8, handlerWidth,
8, handlerHeight,
]; ];
handlers["e"] = [ handlers["e"] = [
elementX2 - margin + scrollX - marginX, elementX2 + dashedLineMargin,
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4, elementY1 + elementHeight / 2,
8, handlerWidth,
8, handlerHeight,
]; ];
} }
handlers["nw"] = [
elementX1 - margin + scrollX + marginX,
elementY1 - margin + scrollY + marginY,
8,
8,
]; // nw
handlers["ne"] = [
elementX2 - margin + scrollX - marginX,
elementY1 - margin + scrollY + marginY,
8,
8,
]; // ne
handlers["sw"] = [
elementX1 - margin + scrollX + marginX,
elementY2 - margin + scrollY - marginY,
8,
8,
]; // sw
handlers["se"] = [
elementX2 - margin + scrollX - marginX,
elementY2 - margin + scrollY - marginY,
8,
8,
]; // se
if (element.type === "arrow" || element.type === "line") { if (element.type === "arrow" || element.type === "line") {
if (element.points.length === 2) { if (element.points.length === 2) {
// only check the last point because starting point is always (0,0) // only check the last point because starting point is always (0,0)

View File

@ -1,7 +1,6 @@
import { ExcalidrawElement } from "./types"; import { ExcalidrawElement } from "./types";
import { handlerRectangles } from "./handlerRectangles"; import { handlerRectangles } from "./handlerRectangles";
import { SceneScroll } from "../scene/types";
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>; type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
@ -9,13 +8,13 @@ export function resizeTest(
element: ExcalidrawElement, element: ExcalidrawElement,
x: number, x: number,
y: number, y: number,
{ scrollX, scrollY }: SceneScroll, zoom: number,
): HandlerRectanglesRet | false { ): HandlerRectanglesRet | false {
if (!element.isSelected || element.type === "text") { if (!element.isSelected || element.type === "text") {
return false; return false;
} }
const handlers = handlerRectangles(element, { scrollX, scrollY }); const handlers = handlerRectangles(element, zoom);
const filter = Object.keys(handlers).filter(key => { const filter = Object.keys(handlers).filter(key => {
const handler = handlers[key as HandlerRectanglesRet]!; const handler = handlers[key as HandlerRectanglesRet]!;
@ -24,10 +23,10 @@ export function resizeTest(
} }
return ( return (
x + scrollX >= handler[0] && x >= handler[0] &&
x + scrollX <= handler[0] + handler[2] && x <= handler[0] + handler[2] &&
y + scrollY >= handler[1] && y >= handler[1] &&
y + scrollY <= handler[1] + handler[3] y <= handler[1] + handler[3]
); );
}); });
@ -41,16 +40,13 @@ export function resizeTest(
export function getElementWithResizeHandler( export function getElementWithResizeHandler(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
{ x, y }: { x: number; y: number }, { x, y }: { x: number; y: number },
{ scrollX, scrollY }: SceneScroll, zoom: number,
) { ) {
return elements.reduce((result, element) => { return elements.reduce((result, element) => {
if (result) { if (result) {
return result; return result;
} }
const resizeHandle = resizeTest(element, x, y, { const resizeHandle = resizeTest(element, x, y, zoom);
scrollX,
scrollY,
});
return resizeHandle ? { element, resizeHandle } : null; return resizeHandle ? { element, resizeHandle } : null;
}, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null); }, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
} }

View File

@ -8,6 +8,7 @@ type TextWysiwygParams = {
strokeColor: string; strokeColor: string;
font: string; font: string;
opacity: number; opacity: number;
zoom: number;
onSubmit: (text: string) => void; onSubmit: (text: string) => void;
onCancel: () => void; onCancel: () => void;
}; };
@ -25,6 +26,7 @@ export function textWysiwyg({
strokeColor, strokeColor,
font, font,
opacity, opacity,
zoom,
onSubmit, onSubmit,
onCancel, onCancel,
}: TextWysiwygParams) { }: TextWysiwygParams) {
@ -43,7 +45,7 @@ export function textWysiwyg({
opacity: opacity / 100, opacity: opacity / 100,
top: `${y}px`, top: `${y}px`,
left: `${x}px`, left: `${x}px`,
transform: "translate(-50%, -50%)", transform: `translate(-50%, -50%) scale(${zoom})`,
textAlign: "left", textAlign: "left",
display: "inline-block", display: "inline-block",
font: font, font: font,

View File

@ -34,6 +34,7 @@ import {
loadScene, loadScene,
calculateScrollCenter, calculateScrollCenter,
loadFromBlob, loadFromBlob,
getZoomOrigin,
} from "./scene"; } from "./scene";
import { renderScene } from "./renderer"; import { renderScene } from "./renderer";
@ -77,6 +78,8 @@ import {
actionChangeFontFamily, actionChangeFontFamily,
actionChangeViewBackgroundColor, actionChangeViewBackgroundColor,
actionClearCanvas, actionClearCanvas,
actionZoomIn,
actionZoomOut,
actionChangeProjectName, actionChangeProjectName,
actionChangeExportBackground, actionChangeExportBackground,
actionLoadScene, actionLoadScene,
@ -127,17 +130,53 @@ const MOUSE_BUTTON = {
SECONDARY: 2, SECONDARY: 2,
}; };
let lastCanvasWidth = -1;
let lastCanvasHeight = -1;
let lastMouseUp: ((e: any) => void) | null = null; let lastMouseUp: ((e: any) => void) | null = null;
export function viewportCoordsToSceneCoords( export function viewportCoordsToSceneCoords(
{ clientX, clientY }: { clientX: number; clientY: number }, { clientX, clientY }: { clientX: number; clientY: number },
{ scrollX, scrollY }: { scrollX: number; scrollY: number }, {
scrollX,
scrollY,
zoom,
}: {
scrollX: number;
scrollY: number;
zoom: number;
},
canvas: HTMLCanvasElement | null,
) { ) {
const x = clientX - scrollX; const zoomOrigin = getZoomOrigin(canvas);
const y = clientY - scrollY; const clientXWithZoom = zoomOrigin.x + (clientX - zoomOrigin.x) / zoom;
const clientYWithZoom = zoomOrigin.y + (clientY - zoomOrigin.y) / zoom;
const x = clientXWithZoom - scrollX;
const y = clientYWithZoom - scrollY;
return { x, y };
}
export function sceneCoordsToViewportCoords(
{ sceneX, sceneY }: { sceneX: number; sceneY: number },
{
scrollX,
scrollY,
zoom,
}: {
scrollX: number;
scrollY: number;
zoom: number;
},
canvas: HTMLCanvasElement | null,
) {
const zoomOrigin = getZoomOrigin(canvas);
const sceneXWithZoomAndScroll =
zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
const sceneYWithZoomAndScroll =
zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
const x = sceneXWithZoomAndScroll;
const y = sceneYWithZoomAndScroll;
return { x, y }; return { x, y };
} }
@ -320,6 +359,20 @@ const LayerUI = React.memo(
); );
} }
function renderZoomActions() {
return (
<Stack.Col gap={1}>
<Stack.Row gap={1} align="center">
{actionManager.renderAction("zoomIn")}
{actionManager.renderAction("zoomOut")}
<div style={{ marginLeft: 4 }}>
{(appState.zoom * 100).toFixed(0)}%
</div>
</Stack.Row>
</Stack.Col>
);
}
return ( return (
<> <>
<FixedSideContainer side="top"> <FixedSideContainer side="top">
@ -370,6 +423,16 @@ const LayerUI = React.memo(
</section> </section>
<div /> <div />
</div> </div>
<div className="App-menu App-menu_bottom">
<Stack.Col gap={2}>
<section aria-labelledby="canvas-zoom-actions-title">
<h2 className="visually-hidden" id="canvas-zoom-actions-title">
{t("headings.canvasActions")}
</h2>
<Island padding={1}>{renderZoomActions()}</Island>
</section>
</Stack.Col>
</div>
</FixedSideContainer> </FixedSideContainer>
<footer role="contentinfo"> <footer role="contentinfo">
<HintViewer <HintViewer
@ -459,6 +522,8 @@ export class App extends React.Component<any, AppState> {
this.actionManager.registerAction(actionChangeViewBackgroundColor); this.actionManager.registerAction(actionChangeViewBackgroundColor);
this.actionManager.registerAction(actionClearCanvas); this.actionManager.registerAction(actionClearCanvas);
this.actionManager.registerAction(actionZoomIn);
this.actionManager.registerAction(actionZoomOut);
this.actionManager.registerAction(actionChangeProjectName); this.actionManager.registerAction(actionChangeProjectName);
this.actionManager.registerAction(actionChangeExportBackground); this.actionManager.registerAction(actionChangeExportBackground);
@ -684,8 +749,6 @@ export class App extends React.Component<any, AppState> {
} }
}; };
private removeWheelEventListener: (() => void) | undefined;
private copyToAppClipboard = () => { private copyToAppClipboard = () => {
copyToAppClipboard(elements); copyToAppClipboard(elements);
}; };
@ -708,6 +771,7 @@ export class App extends React.Component<any, AppState> {
const { x, y } = viewportCoordsToSceneCoords( const { x, y } = viewportCoordsToSceneCoords(
{ clientX: cursorX, clientY: cursorY }, { clientX: cursorX, clientY: cursorY },
this.state, this.state,
this.canvas,
); );
const element = newTextElement( const element = newTextElement(
@ -759,8 +823,13 @@ export class App extends React.Component<any, AppState> {
}; };
public render() { public render() {
const canvasWidth = window.innerWidth; const canvasDOMWidth = window.innerWidth;
const canvasHeight = window.innerHeight; const canvasDOMHeight = window.innerHeight;
const canvasScale = window.devicePixelRatio;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
return ( return (
<div className="container"> <div className="container">
@ -777,46 +846,43 @@ export class App extends React.Component<any, AppState> {
<canvas <canvas
id="canvas" id="canvas"
style={{ style={{
width: canvasWidth, width: canvasDOMWidth,
height: canvasHeight, height: canvasDOMHeight,
}} }}
width={canvasWidth * window.devicePixelRatio} width={canvasWidth}
height={canvasHeight * window.devicePixelRatio} height={canvasHeight}
ref={canvas => { ref={canvas => {
if (this.canvas === null) { // canvas is null when unmounting
if (canvas !== null) {
this.canvas = canvas; this.canvas = canvas;
this.rc = rough.canvas(this.canvas!); this.rc = rough.canvas(this.canvas);
}
if (this.removeWheelEventListener) { this.canvas.addEventListener("wheel", this.handleWheel, {
this.removeWheelEventListener();
this.removeWheelEventListener = undefined;
}
if (canvas) {
canvas.addEventListener("wheel", this.handleWheel, {
passive: false, passive: false,
}); });
this.removeWheelEventListener = () =>
canvas.removeEventListener("wheel", this.handleWheel); this.canvas
// Whenever React sets the width/height of the canvas element, .getContext("2d")
// the context loses the scale transform. We need to re-apply it ?.setTransform(canvasScale, 0, 0, canvasScale, 0, 0);
if ( } else {
canvasWidth !== lastCanvasWidth || this.canvas?.removeEventListener("wheel", this.handleWheel);
canvasHeight !== lastCanvasHeight
) {
lastCanvasWidth = canvasWidth;
lastCanvasHeight = canvasHeight;
canvas
.getContext("2d")!
.scale(window.devicePixelRatio, window.devicePixelRatio);
}
} }
}} }}
onContextMenu={e => { onContextMenu={e => {
e.preventDefault(); e.preventDefault();
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
const element = getElementAtPosition(elements, x, y); const element = getElementAtPosition(
elements,
x,
y,
this.state.zoom,
);
if (!element) { if (!element) {
ContextMenu.push({ ContextMenu.push({
options: [ options: [
@ -887,8 +953,8 @@ export class App extends React.Component<any, AppState> {
lastY = e.clientY; lastY = e.clientY;
this.setState({ this.setState({
scrollX: this.state.scrollX - deltaX, scrollX: this.state.scrollX - deltaX / this.state.zoom,
scrollY: this.state.scrollY - deltaY, scrollY: this.state.scrollY - deltaY / this.state.zoom,
}); });
}; };
const teardown = (lastMouseUp = () => { const teardown = (lastMouseUp = () => {
@ -933,11 +999,14 @@ export class App extends React.Component<any, AppState> {
e.clientY / window.devicePixelRatio, e.clientY / window.devicePixelRatio,
canvasWidth / window.devicePixelRatio, canvasWidth / window.devicePixelRatio,
canvasHeight / window.devicePixelRatio, canvasHeight / window.devicePixelRatio,
this.state.scrollX, this.state,
this.state.scrollY,
); );
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
const originX = x; const originX = x;
const originY = y; const originY = y;
@ -972,7 +1041,7 @@ export class App extends React.Component<any, AppState> {
const resizeElement = getElementWithResizeHandler( const resizeElement = getElementWithResizeHandler(
elements, elements,
{ x, y }, { x, y },
this.state, this.state.zoom,
); );
this.setState({ this.setState({
resizingElement: resizeElement ? resizeElement.element : null, resizingElement: resizeElement ? resizeElement.element : null,
@ -985,7 +1054,12 @@ export class App extends React.Component<any, AppState> {
); );
isResizingElements = true; isResizingElements = true;
} else { } else {
hitElement = getElementAtPosition(elements, x, y); hitElement = getElementAtPosition(
elements,
x,
y,
this.state.zoom,
);
// clear selection if shift is not clicked // clear selection if shift is not clicked
if (!hitElement?.isSelected && !e.shiftKey) { if (!hitElement?.isSelected && !e.shiftKey) {
elements = clearSelection(elements); elements = clearSelection(elements);
@ -1061,6 +1135,7 @@ export class App extends React.Component<any, AppState> {
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
font: this.state.currentItemFont, font: this.state.currentItemFont,
zoom: this.state.zoom,
onSubmit: text => { onSubmit: text => {
if (text) { if (text) {
elements = [ elements = [
@ -1211,7 +1286,9 @@ export class App extends React.Component<any, AppState> {
if (isOverHorizontalScrollBar) { if (isOverHorizontalScrollBar) {
const x = e.clientX; const x = e.clientX;
const dx = x - lastX; const dx = x - lastX;
this.setState({ scrollX: this.state.scrollX - dx }); this.setState({
scrollX: this.state.scrollX - dx / this.state.zoom,
});
lastX = x; lastX = x;
return; return;
} }
@ -1219,7 +1296,9 @@ export class App extends React.Component<any, AppState> {
if (isOverVerticalScrollBar) { if (isOverVerticalScrollBar) {
const y = e.clientY; const y = e.clientY;
const dy = y - lastY; const dy = y - lastY;
this.setState({ scrollY: this.state.scrollY - dy }); this.setState({
scrollY: this.state.scrollY - dy / this.state.zoom,
});
lastY = y; lastY = y;
return; return;
} }
@ -1233,7 +1312,11 @@ export class App extends React.Component<any, AppState> {
(this.state.elementType === "arrow" || (this.state.elementType === "arrow" ||
this.state.elementType === "line") this.state.elementType === "line")
) { ) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) { if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
return; return;
} }
@ -1244,7 +1327,11 @@ export class App extends React.Component<any, AppState> {
const el = this.state.resizingElement; const el = this.state.resizingElement;
const selectedElements = elements.filter(el => el.isSelected); const selectedElements = elements.filter(el => el.isSelected);
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
const deltaX = x - lastX; const deltaX = x - lastX;
const deltaY = y - lastY; const deltaY = y - lastY;
const element = selectedElements[0]; const element = selectedElements[0];
@ -1467,7 +1554,11 @@ export class App extends React.Component<any, AppState> {
draggingOccurred = true; draggingOccurred = true;
const selectedElements = elements.filter(el => el.isSelected); const selectedElements = elements.filter(el => el.isSelected);
if (selectedElements.length) { if (selectedElements.length) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
selectedElements.forEach(element => { selectedElements.forEach(element => {
element.x += x - lastX; element.x += x - lastX;
@ -1487,7 +1578,11 @@ export class App extends React.Component<any, AppState> {
return; return;
} }
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
let width = distance(originX, x); let width = distance(originX, x);
let height = distance(originY, y); let height = distance(originY, y);
@ -1581,7 +1676,11 @@ export class App extends React.Component<any, AppState> {
this.setState({}); this.setState({});
} }
if (!draggingOccurred && draggingElement && !multiElement) { if (!draggingOccurred && draggingElement && !multiElement) {
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
draggingElement.points.push([ draggingElement.points.push([
x - draggingElement.x, x - draggingElement.x,
y - draggingElement.y, y - draggingElement.y,
@ -1696,9 +1795,18 @@ export class App extends React.Component<any, AppState> {
onDoubleClick={e => { onDoubleClick={e => {
resetCursor(); resetCursor();
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
const elementAtPosition = getElementAtPosition(elements, x, y); const elementAtPosition = getElementAtPosition(
elements,
x,
y,
this.state.zoom,
);
const element = const element =
elementAtPosition && isTextElement(elementAtPosition) elementAtPosition && isTextElement(elementAtPosition)
@ -1730,18 +1838,26 @@ export class App extends React.Component<any, AppState> {
); );
this.setState({}); this.setState({});
textX = const centerElementX =
this.state.scrollX + elementAtPosition.x + elementAtPosition.width / 2;
elementAtPosition.x + const centerElementY =
elementAtPosition.width / 2; elementAtPosition.y + elementAtPosition.height / 2;
textY =
this.state.scrollY + const {
elementAtPosition.y + x: centerElementXInViewport,
elementAtPosition.height / 2; y: centerElementYInViewport,
} = sceneCoordsToViewportCoords(
{ sceneX: centerElementX, sceneY: centerElementY },
this.state,
this.canvas,
);
textX = centerElementXInViewport;
textY = centerElementYInViewport;
// x and y will change after calling newTextElement function // x and y will change after calling newTextElement function
element.x = elementAtPosition.x + elementAtPosition.width / 2; element.x = centerElementX;
element.y = elementAtPosition.y + elementAtPosition.height / 2; element.y = centerElementY;
} else if (!e.altKey) { } else if (!e.altKey) {
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition( const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
x, x,
@ -1770,6 +1886,7 @@ export class App extends React.Component<any, AppState> {
strokeColor: element.strokeColor, strokeColor: element.strokeColor,
font: element.font, font: element.font,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
zoom: this.state.zoom,
onSubmit: text => { onSubmit: text => {
if (text) { if (text) {
elements = [ elements = [
@ -1796,7 +1913,11 @@ export class App extends React.Component<any, AppState> {
} }
const hasDeselectedButton = Boolean(e.buttons); const hasDeselectedButton = Boolean(e.buttons);
const { x, y } = viewportCoordsToSceneCoords(e, this.state); const { x, y } = viewportCoordsToSceneCoords(
e,
this.state,
this.canvas,
);
if (this.state.multiElement) { if (this.state.multiElement) {
const { multiElement } = this.state; const { multiElement } = this.state;
const originX = multiElement.x; const originX = multiElement.x;
@ -1823,7 +1944,7 @@ export class App extends React.Component<any, AppState> {
const resizeElement = getElementWithResizeHandler( const resizeElement = getElementWithResizeHandler(
elements, elements,
{ x, y }, { x, y },
this.state, this.state.zoom,
); );
if (resizeElement && resizeElement.resizeHandle) { if (resizeElement && resizeElement.resizeHandle) {
document.documentElement.style.cursor = getCursorForResizingElement( document.documentElement.style.cursor = getCursorForResizingElement(
@ -1832,7 +1953,12 @@ export class App extends React.Component<any, AppState> {
return; return;
} }
} }
const hitElement = getElementAtPosition(elements, x, y); const hitElement = getElementAtPosition(
elements,
x,
y,
this.state.zoom,
);
document.documentElement.style.cursor = hitElement ? "move" : ""; document.documentElement.style.cursor = hitElement ? "move" : "";
}} }}
onDrop={e => { onDrop={e => {
@ -1858,8 +1984,8 @@ export class App extends React.Component<any, AppState> {
const { deltaX, deltaY } = e; const { deltaX, deltaY } = e;
this.setState({ this.setState({
scrollX: this.state.scrollX - deltaX, scrollX: this.state.scrollX - deltaX / this.state.zoom,
scrollY: this.state.scrollY - deltaY, scrollY: this.state.scrollY - deltaY / this.state.zoom,
}); });
}; };
@ -1873,8 +1999,14 @@ export class App extends React.Component<any, AppState> {
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
const elementsCenterY = distance(minY, maxY) / 2; const elementsCenterY = distance(minY, maxY) / 2;
const dx = cursorX - this.state.scrollX - elementsCenterX; const { x, y } = viewportCoordsToSceneCoords(
const dy = cursorY - this.state.scrollY - elementsCenterY; { clientX: cursorX, clientY: cursorY },
this.state,
this.canvas,
);
const dx = x - elementsCenterX;
const dy = y - elementsCenterY;
elements = [ elements = [
...elements, ...elements,
@ -1933,6 +2065,7 @@ export class App extends React.Component<any, AppState> {
scrollX: this.state.scrollX, scrollX: this.state.scrollX,
scrollY: this.state.scrollY, scrollY: this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor, viewBackgroundColor: this.state.viewBackgroundColor,
zoom: this.state.zoom,
}, },
); );
const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0; const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;

View File

@ -53,7 +53,9 @@
"getShareableLink": "Get shareable link", "getShareableLink": "Get shareable link",
"close": "Close", "close": "Close",
"selectLanguage": "Select Language", "selectLanguage": "Select Language",
"scrollBackToContent": "Scroll back to content" "scrollBackToContent": "Scroll back to content",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out"
}, },
"alerts": { "alerts": {
"clearReset": "This will clear the whole canvas. Are you sure?", "clearReset": "This will clear the whole canvas. Are you sure?",

View File

@ -11,6 +11,7 @@ import {
SCROLLBAR_COLOR, SCROLLBAR_COLOR,
SCROLLBAR_WIDTH, SCROLLBAR_WIDTH,
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { getZoomTranslation } from "../scene/zoom";
import { renderElement, renderElementToSvg } from "./renderElement"; import { renderElement, renderElementToSvg } from "./renderElement";
@ -32,116 +33,164 @@ export function renderScene(
renderScrollbars?: boolean; renderScrollbars?: boolean;
renderSelection?: boolean; renderSelection?: boolean;
} = {}, } = {},
) { ): boolean {
if (!canvas) { if (!canvas) {
return false; return false;
} }
const context = canvas.getContext("2d")!;
const fillStyle = context.fillStyle;
if (typeof sceneState.viewBackgroundColor === "string") {
const hasTransparence =
sceneState.viewBackgroundColor === "transparent" ||
sceneState.viewBackgroundColor.length === 5 ||
sceneState.viewBackgroundColor.length === 9;
if (hasTransparence) {
context.clearRect(0, 0, canvas.width, canvas.height);
}
context.fillStyle = sceneState.viewBackgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);
} else {
context.clearRect(0, 0, canvas.width, canvas.height);
}
context.fillStyle = fillStyle;
// Use offsets insteads of scrolls if available
sceneState = { sceneState = {
...sceneState, ...sceneState,
scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX, scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX,
scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY, scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY,
}; };
let atLeastOneVisibleElement = false; const context = canvas.getContext("2d")!;
elements.forEach(element => {
if ( // Get initial scale transform as reference for later usage
!isVisibleElement( const initialContextTransform = context.getTransform();
// When doing calculations based on canvas width we should used normalized one
const normalizedCanvasWidth =
canvas.width / getContextTransformScaleX(initialContextTransform);
const normalizedCanvasHeight =
canvas.height / getContextTransformScaleY(initialContextTransform);
// Handle zoom scaling
function scaleContextToZoom() {
context.setTransform(
getContextTransformScaleX(initialContextTransform) * sceneState.zoom,
0,
0,
getContextTransformScaleY(initialContextTransform) * sceneState.zoom,
getContextTransformTranslateX(context.getTransform()),
getContextTransformTranslateY(context.getTransform()),
);
}
// Handle zoom translation
const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom);
function translateContextToZoom() {
context.setTransform(
getContextTransformScaleX(context.getTransform()),
0,
0,
getContextTransformScaleY(context.getTransform()),
getContextTransformTranslateX(initialContextTransform) -
zoomTranslation.x,
getContextTransformTranslateY(initialContextTransform) -
zoomTranslation.y,
);
}
// Paint background
context.save();
if (typeof sceneState.viewBackgroundColor === "string") {
const hasTransparence =
sceneState.viewBackgroundColor === "transparent" ||
sceneState.viewBackgroundColor.length === 5 ||
sceneState.viewBackgroundColor.length === 9;
if (hasTransparence) {
context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
}
context.fillStyle = sceneState.viewBackgroundColor;
context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
} else {
context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
}
context.restore();
// Paint visible elements
const visibleElements = elements.filter(element =>
isVisibleElement(
element, element,
sceneState.scrollX, normalizedCanvasWidth,
sceneState.scrollY, normalizedCanvasHeight,
// If canvas is scaled for high pixelDeviceRatio width and height sceneState,
// setted in the `style` attribute ),
parseInt(canvas.style.width) || canvas.width,
parseInt(canvas.style.height) || canvas.height,
)
) {
return;
}
atLeastOneVisibleElement = true;
context.translate(
element.x + sceneState.scrollX,
element.y + sceneState.scrollY,
); );
context.save();
scaleContextToZoom();
translateContextToZoom();
context.translate(sceneState.scrollX, sceneState.scrollY);
visibleElements.forEach(element => {
context.save();
context.translate(element.x, element.y);
renderElement(element, rc, context); renderElement(element, rc, context);
context.translate( context.restore();
-element.x - sceneState.scrollX,
-element.y - sceneState.scrollY,
);
}); });
context.restore();
// Pain selection element
if (selectionElement) { if (selectionElement) {
context.translate( context.save();
selectionElement.x + sceneState.scrollX, scaleContextToZoom();
selectionElement.y + sceneState.scrollY, translateContextToZoom();
); context.translate(sceneState.scrollX, sceneState.scrollY);
context.translate(selectionElement.x, selectionElement.y);
renderElement(selectionElement, rc, context); renderElement(selectionElement, rc, context);
context.translate( context.restore();
-selectionElement.x - sceneState.scrollX,
-selectionElement.y - sceneState.scrollY,
);
} }
// Pain selected elements
if (renderSelection) { if (renderSelection) {
const selectedElements = elements.filter(el => el.isSelected); const selectedElements = elements.filter(element => element.isSelected);
const dashledLinePadding = 4 / sceneState.zoom;
context.save();
scaleContextToZoom();
translateContextToZoom();
context.translate(sceneState.scrollX, sceneState.scrollY);
selectedElements.forEach(element => { selectedElements.forEach(element => {
const margin = 4;
const [ const [
elementX1, elementX1,
elementY1, elementY1,
elementX2, elementX2,
elementY2, elementY2,
] = getElementAbsoluteCoords(element); ] = getElementAbsoluteCoords(element);
const lineDash = context.getLineDash();
context.setLineDash([8, 4]);
context.strokeRect(
elementX1 - margin + sceneState.scrollX,
elementY1 - margin + sceneState.scrollY,
elementX2 - elementX1 + margin * 2,
elementY2 - elementY1 + margin * 2,
);
context.setLineDash(lineDash);
});
const elementWidth = elementX2 - elementX1;
const elementHeight = elementY2 - elementY1;
const initialLineDash = context.getLineDash();
context.setLineDash([8 / sceneState.zoom, 4 / sceneState.zoom]);
context.strokeRect(
elementX1 - dashledLinePadding,
elementY1 - dashledLinePadding,
elementWidth + dashledLinePadding * 2,
elementHeight + dashledLinePadding * 2,
);
context.setLineDash(initialLineDash);
});
context.restore();
// Paint resize handlers
if (selectedElements.length === 1 && selectedElements[0].type !== "text") { if (selectedElements.length === 1 && selectedElements[0].type !== "text") {
const handlers = handlerRectangles(selectedElements[0], sceneState); context.save();
scaleContextToZoom();
translateContextToZoom();
context.translate(sceneState.scrollX, sceneState.scrollY);
const handlers = handlerRectangles(selectedElements[0], sceneState.zoom);
Object.values(handlers) Object.values(handlers)
.filter(handler => handler !== undefined) .filter(handler => handler !== undefined)
.forEach(handler => { .forEach(handler => {
context.strokeRect(handler[0], handler[1], handler[2], handler[3]); context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
}); });
context.restore();
} }
} }
// Paint scrollbars
if (renderScrollbars) { if (renderScrollbars) {
const scrollBars = getScrollBars( const scrollBars = getScrollBars(
elements, elements,
context.canvas.width / window.devicePixelRatio, normalizedCanvasWidth,
context.canvas.height / window.devicePixelRatio, normalizedCanvasHeight,
sceneState.scrollX, sceneState,
sceneState.scrollY,
); );
const strokeStyle = context.strokeStyle; context.save();
context.fillStyle = SCROLLBAR_COLOR; context.fillStyle = SCROLLBAR_COLOR;
context.strokeStyle = "rgba(255,255,255,0.8)"; context.strokeStyle = "rgba(255,255,255,0.8)";
[scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => { [scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => {
@ -156,33 +205,40 @@ export function renderScene(
); );
} }
}); });
context.strokeStyle = strokeStyle; context.restore();
context.fillStyle = fillStyle;
} }
return atLeastOneVisibleElement; return visibleElements.length > 0;
} }
function isVisibleElement( function isVisibleElement(
element: ExcalidrawElement, element: ExcalidrawElement,
scrollX: number, viewportWidth: number,
scrollY: number, viewportHeight: number,
canvasWidth: number, {
canvasHeight: number, scrollX,
scrollY,
zoom,
}: {
scrollX: number;
scrollY: number;
zoom: number;
},
) { ) {
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
if (element.type !== "arrow") {
x1 += scrollX; // Apply zoom
y1 += scrollY; const viewportWidthWithZoom = viewportWidth / zoom;
x2 += scrollX; const viewportHeightWithZoom = viewportHeight / zoom;
y2 += scrollY;
return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight; const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
} const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
return ( return (
x2 + scrollX >= 0 && x2 + scrollX - viewportWidthDiff / 2 >= 0 &&
x1 + scrollX <= canvasWidth && x1 + scrollX - viewportWidthDiff / 2 <= viewportWidthWithZoom &&
y2 + scrollY >= 0 && y2 + scrollY - viewportHeightDiff / 2 >= 0 &&
y1 + scrollY <= canvasHeight y1 + scrollY - viewportHeightDiff / 2 <= viewportHeightWithZoom
); );
} }
@ -213,3 +269,16 @@ export function renderSceneToSvg(
); );
}); });
} }
function getContextTransformScaleX(transform: DOMMatrix): number {
return transform.a;
}
function getContextTransformScaleY(transform: DOMMatrix): number {
return transform.d;
}
function getContextTransformTranslateX(transform: DOMMatrix): number {
return transform.e;
}
function getContextTransformTranslateY(transform: DOMMatrix): number {
return transform.f;
}

View File

@ -1,6 +1,6 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { hitTest } from "../element/collision";
import { getElementAbsoluteCoords } from "../element"; import { getElementAbsoluteCoords, hitTest } from "../element";
export const hasBackground = (type: string) => export const hasBackground = (type: string) =>
type === "rectangle" || type === "ellipse" || type === "diamond"; type === "rectangle" || type === "ellipse" || type === "diamond";
@ -18,11 +18,12 @@ export function getElementAtPosition(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
x: number, x: number,
y: number, y: number,
zoom: number,
) { ) {
let hitElement = null; let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array) // We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let i = elements.length - 1; i >= 0; --i) { for (let i = elements.length - 1; i >= 0; --i) {
if (hitTest(elements[i], x, y)) { if (hitTest(elements[i], x, y, zoom)) {
hitElement = elements[i]; hitElement = elements[i];
break; break;
} }

View File

@ -44,6 +44,7 @@ export function exportToCanvas(
viewBackgroundColor: exportBackground ? viewBackgroundColor : null, viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
scrollX: 0, scrollX: 0,
scrollY: 0, scrollY: 0,
zoom: 1,
}, },
{ {
offsetX: -minX + exportPadding, offsetX: -minX + exportPadding,

View File

@ -27,3 +27,4 @@ export {
hasText, hasText,
} from "./comparisons"; } from "./comparisons";
export { createScene } from "./createScene"; export { createScene } from "./createScene";
export { getZoomOrigin, getZoomTranslation } from "./zoom";

View File

@ -9,8 +9,15 @@ export function getScrollBars(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
viewportWidth: number, viewportWidth: number,
viewportHeight: number, viewportHeight: number,
scrollX: number, {
scrollY: number, scrollX,
scrollY,
zoom,
}: {
scrollX: number;
scrollY: number;
zoom: number;
},
) { ) {
// This is the bounding box of all the elements // This is the bounding box of all the elements
const [ const [
@ -20,11 +27,18 @@ export function getScrollBars(
elementsMaxY, elementsMaxY,
] = getCommonBounds(elements); ] = getCommonBounds(elements);
// Apply zoom
const viewportWidthWithZoom = viewportWidth / zoom;
const viewportHeightWithZoom = viewportHeight / zoom;
const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
// The viewport is the rectangle currently visible for the user // The viewport is the rectangle currently visible for the user
const viewportMinX = -scrollX; const viewportMinX = -scrollX + viewportWidthDiff / 2;
const viewportMinY = -scrollY; const viewportMinY = -scrollY + viewportHeightDiff / 2;
const viewportMaxX = -scrollX + viewportWidth; const viewportMaxX = viewportMinX + viewportWidthWithZoom;
const viewportMaxY = -scrollY + viewportHeight; const viewportMaxY = viewportMinY + viewportHeightWithZoom;
// The scene is the bounding box of both the elements and viewport // The scene is the bounding box of both the elements and viewport
const sceneMinX = Math.min(elementsMinX, viewportMinX); const sceneMinX = Math.min(elementsMinX, viewportMinX);
@ -74,16 +88,21 @@ export function isOverScrollBars(
y: number, y: number,
viewportWidth: number, viewportWidth: number,
viewportHeight: number, viewportHeight: number,
scrollX: number, {
scrollY: number,
) {
const scrollBars = getScrollBars(
elements,
viewportWidth,
viewportHeight,
scrollX, scrollX,
scrollY, scrollY,
); zoom,
}: {
scrollX: number;
scrollY: number;
zoom: number;
},
) {
const scrollBars = getScrollBars(elements, viewportWidth, viewportHeight, {
scrollX,
scrollY,
zoom,
});
const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [ const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [
scrollBars.horizontal, scrollBars.horizontal,

View File

@ -5,6 +5,7 @@ export type SceneState = {
scrollY: number; scrollY: number;
// null indicates transparent bg // null indicates transparent bg
viewBackgroundColor: string | null; viewBackgroundColor: string | null;
zoom: number;
}; };
export type SceneScroll = { export type SceneScroll = {

30
src/scene/zoom.ts Normal file
View File

@ -0,0 +1,30 @@
export function getZoomOrigin(canvas: HTMLCanvasElement | null) {
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 / context.getTransform().a;
const normalizedCanvasHeight = canvas.height / context.getTransform().d;
return {
x: normalizedCanvasWidth / 2,
y: normalizedCanvasHeight / 2,
};
}
export function getZoomTranslation(canvas: HTMLCanvasElement, zoom: number) {
const diffMiddleOfTheCanvas = {
x: (canvas.width / 2) * (zoom - 1),
y: (canvas.height / 2) * (zoom - 1),
};
// Due to JavaScript float precision, we fix to fix decimals count to have symmetric zoom
return {
x: parseFloat(diffMiddleOfTheCanvas.x.toFixed(8)),
y: parseFloat(diffMiddleOfTheCanvas.y.toFixed(8)),
};
}

View File

@ -142,6 +142,28 @@ button,
justify-self: flex-end; justify-self: flex-end;
} }
.App-menu_bottom {
position: fixed;
bottom: 0;
grid-template-columns: 1fr auto 1fr;
align-items: flex-start;
cursor: default;
pointer-events: none !important;
}
.App-menu_bottom > * {
pointer-events: all;
}
.App-menu_bottom > *:first-child {
justify-self: flex-start;
}
.App-menu_bottom > *:last-child {
justify-self: flex-end;
}
.App-menu_left { .App-menu_left {
grid-template-rows: 1fr auto 1fr; grid-template-rows: 1fr auto 1fr;
height: 100%; height: 100%;

View File

@ -28,4 +28,5 @@ export type AppState = {
name: string; name: string;
selectedId?: string; selectedId?: string;
isResizing: boolean; isResizing: boolean;
zoom: number;
}; };