diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index b7382066..cd390d49 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Action } from "./types"; import { ColorPicker } from "../components/ColorPicker"; import { getDefaultAppState } from "../appState"; -import { trash } from "../components/icons"; +import { trash, zoomIn, zoomOut } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; 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 }) => ( + { + updateData(null); + }} + /> + ), +}; + +export const actionZoomOut: Action = { + name: "zoomOut", + perform: (elements, appState) => { + return { + appState: { + ...appState, + zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP), + }, + }; + }, + PanelComponent: ({ updateData }) => ( + { + updateData(null); + }} + /> + ), +}; diff --git a/src/actions/index.ts b/src/actions/index.ts index 79db254f..0a868e05 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -21,6 +21,8 @@ export { export { actionChangeViewBackgroundColor, actionClearCanvas, + actionZoomIn, + actionZoomOut, } from "./actionCanvas"; export { actionFinalize } from "./actionFinalize"; diff --git a/src/appState.ts b/src/appState.ts index f28e15dd..8a12f684 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -28,6 +28,7 @@ export function getDefaultAppState(): AppState { name: DEFAULT_PROJECT_NAME, isResizing: false, selectionElement: null, + zoom: 1, }; } diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 6bb3e123..73bf6ce3 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -76,3 +76,21 @@ export const exportFile = ( /> ); + +export const zoomIn = ( + +); + +export const zoomOut = ( + +); diff --git a/src/element/collision.ts b/src/element/collision.ts index b9a614eb..f82b18bd 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -1,6 +1,7 @@ import { distanceBetweenPointAndSegment } from "../math"; import { ExcalidrawElement } from "./types"; + import { getDiamondPoints, getElementAbsoluteCoords, @@ -17,10 +18,11 @@ export function hitTest( element: ExcalidrawElement, x: number, y: number, + zoom: number, ): boolean { // 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 - const lineThreshold = 10; + const lineThreshold = 10 / zoom; if (element.type === "ellipse") { // https://stackoverflow.com/a/46007540/232122 diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts index 342db675..c8cf723c 100644 --- a/src/element/handlerRectangles.ts +++ b/src/element/handlerRectangles.ts @@ -1,98 +1,83 @@ 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"; -export function handlerRectangles( - element: ExcalidrawElement, - { scrollX, scrollY }: SceneScroll, -) { - let elementX2 = 0; - let elementY2 = 0; - let elementX1 = Infinity; - let elementY1 = Infinity; - let marginX = -8; - let marginY = -8; +export function handlerRectangles(element: ExcalidrawElement, zoom: number) { + const handlerWidth = 8 / zoom; + const handlerHeight = 8 / zoom; - const minimumSize = 40; - if (element.type === "arrow" || element.type === "line") { - [ - elementX1, - elementY1, - elementX2, - elementY2, - ] = getLinearElementAbsoluteBounds(element); - } else { - elementX1 = element.x; - elementX2 = element.x + element.width; - elementY1 = element.y; - elementY2 = element.y + element.height; + const handlerMarginX = 8 / zoom; + const handlerMarginY = 8 / zoom; - marginX = element.width < 0 ? 8 : -8; - marginY = element.height < 0 ? 8 : -8; - } + const [elementX1, elementY1, elementX2, elementY2] = getElementAbsoluteCoords( + element, + ); - const margin = 4; - const handlers = {} as { [T in Sides]: number[] }; + const elementWidth = elementX2 - elementX1; + 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"] = [ - elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4, - elementY1 - margin + scrollY + marginY, - 8, - 8, + elementX1 + elementWidth / 2, + elementY1 - dashedLineMargin - handlerMarginY, + handlerWidth, + handlerHeight, ]; - handlers["s"] = [ - elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4, - elementY2 - margin + scrollY - marginY, - 8, - 8, + elementX1 + elementWidth / 2, + elementY2 + dashedLineMargin, + handlerWidth, + handlerHeight, ]; } - - if (Math.abs(elementY2 - elementY1) > minimumSize) { + if (Math.abs(elementHeight) > minimumSizeForEightHandlers) { handlers["w"] = [ - elementX1 - margin + scrollX + marginX, - elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4, - 8, - 8, + elementX1 - dashedLineMargin - handlerMarginX, + elementY1 + elementHeight / 2, + handlerWidth, + handlerHeight, ]; - handlers["e"] = [ - elementX2 - margin + scrollX - marginX, - elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4, - 8, - 8, + elementX2 + dashedLineMargin, + elementY1 + elementHeight / 2, + handlerWidth, + 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.points.length === 2) { // only check the last point because starting point is always (0,0) diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index 3d7ae807..92342f2b 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -1,7 +1,6 @@ import { ExcalidrawElement } from "./types"; import { handlerRectangles } from "./handlerRectangles"; -import { SceneScroll } from "../scene/types"; type HandlerRectanglesRet = keyof ReturnType; @@ -9,13 +8,13 @@ export function resizeTest( element: ExcalidrawElement, x: number, y: number, - { scrollX, scrollY }: SceneScroll, + zoom: number, ): HandlerRectanglesRet | false { if (!element.isSelected || element.type === "text") { return false; } - const handlers = handlerRectangles(element, { scrollX, scrollY }); + const handlers = handlerRectangles(element, zoom); const filter = Object.keys(handlers).filter(key => { const handler = handlers[key as HandlerRectanglesRet]!; @@ -24,10 +23,10 @@ export function resizeTest( } return ( - x + scrollX >= handler[0] && - x + scrollX <= handler[0] + handler[2] && - y + scrollY >= handler[1] && - y + scrollY <= handler[1] + handler[3] + x >= handler[0] && + x <= handler[0] + handler[2] && + y >= handler[1] && + y <= handler[1] + handler[3] ); }); @@ -41,16 +40,13 @@ export function resizeTest( export function getElementWithResizeHandler( elements: readonly ExcalidrawElement[], { x, y }: { x: number; y: number }, - { scrollX, scrollY }: SceneScroll, + zoom: number, ) { return elements.reduce((result, element) => { if (result) { return result; } - const resizeHandle = resizeTest(element, x, y, { - scrollX, - scrollY, - }); + const resizeHandle = resizeTest(element, x, y, zoom); return resizeHandle ? { element, resizeHandle } : null; }, null as { element: ExcalidrawElement; resizeHandle: ReturnType } | null); } diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index de2fe0c8..365f4500 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -8,6 +8,7 @@ type TextWysiwygParams = { strokeColor: string; font: string; opacity: number; + zoom: number; onSubmit: (text: string) => void; onCancel: () => void; }; @@ -25,6 +26,7 @@ export function textWysiwyg({ strokeColor, font, opacity, + zoom, onSubmit, onCancel, }: TextWysiwygParams) { @@ -43,7 +45,7 @@ export function textWysiwyg({ opacity: opacity / 100, top: `${y}px`, left: `${x}px`, - transform: "translate(-50%, -50%)", + transform: `translate(-50%, -50%) scale(${zoom})`, textAlign: "left", display: "inline-block", font: font, diff --git a/src/index.tsx b/src/index.tsx index c74ad1f7..f4d9d5a7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -34,6 +34,7 @@ import { loadScene, calculateScrollCenter, loadFromBlob, + getZoomOrigin, } from "./scene"; import { renderScene } from "./renderer"; @@ -77,6 +78,8 @@ import { actionChangeFontFamily, actionChangeViewBackgroundColor, actionClearCanvas, + actionZoomIn, + actionZoomOut, actionChangeProjectName, actionChangeExportBackground, actionLoadScene, @@ -127,17 +130,53 @@ const MOUSE_BUTTON = { SECONDARY: 2, }; -let lastCanvasWidth = -1; -let lastCanvasHeight = -1; - let lastMouseUp: ((e: any) => void) | null = null; export function viewportCoordsToSceneCoords( { 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 y = clientY - scrollY; + const zoomOrigin = getZoomOrigin(canvas); + 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 }; } @@ -320,6 +359,20 @@ const LayerUI = React.memo( ); } + function renderZoomActions() { + return ( + + + {actionManager.renderAction("zoomIn")} + {actionManager.renderAction("zoomOut")} +
+ {(appState.zoom * 100).toFixed(0)}% +
+
+
+ ); + } + return ( <> @@ -370,6 +423,16 @@ const LayerUI = React.memo(
+
+ +
+

+ {t("headings.canvasActions")} +

+ {renderZoomActions()} +
+
+