diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 8da5acd6c..7d0599112 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys"; import { getNormalizedZoom } from "../scene"; import { centerScrollOn } from "../scene/scroll"; import { getStateForZoom } from "../scene/zoom"; -import type { AppState, NormalizedZoomValue } from "../types"; +import type { AppState } from "../types"; import { getShortcutKey, updateActiveTool } from "../utils"; import { register } from "./register"; import { Tooltip } from "../components/Tooltip"; @@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import type { SceneBounds } from "../element/bounds"; import { setCursor } from "../cursor"; import { StoreAction } from "../store"; +import { clamp } from "../math"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", @@ -244,6 +245,7 @@ export const actionResetZoom = register({ const zoomValueToFitBoundsOnViewport = ( bounds: SceneBounds, viewportDimensions: { width: number; height: number }, + viewportZoomFactor: number = 1, // default to 1 if not provided ) => { const [x1, y1, x2, y2] = bounds; const commonBoundsWidth = x2 - x1; @@ -251,20 +253,21 @@ const zoomValueToFitBoundsOnViewport = ( const commonBoundsHeight = y2 - y1; const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight; const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight); + + const adjustedZoomValue = + smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1); + const zoomAdjustedToSteps = - Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; - const clampedZoomValueToFitElements = Math.min( - Math.max(zoomAdjustedToSteps, MIN_ZOOM), - 1, - ); - return clampedZoomValueToFitElements as NormalizedZoomValue; + Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP; + + return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1)); }; export const zoomToFitBounds = ({ bounds, appState, fitToViewport = false, - viewportZoomFactor = 0.7, + viewportZoomFactor = 1, }: { bounds: SceneBounds; appState: Readonly; @@ -289,13 +292,10 @@ export const zoomToFitBounds = ({ Math.min( appState.width / commonBoundsWidth, appState.height / commonBoundsHeight, - ) * Math.min(1, Math.max(viewportZoomFactor, 0.1)); + ) * clamp(viewportZoomFactor, 0.1, 1); // Apply clamping to newZoomValue to be between 10% and 3000% - newZoomValue = Math.min( - Math.max(newZoomValue, MIN_ZOOM), - MAX_ZOOM, - ) as NormalizedZoomValue; + newZoomValue = getNormalizedZoom(newZoomValue); let appStateWidth = appState.width; @@ -314,10 +314,14 @@ export const zoomToFitBounds = ({ scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX; scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; } else { - newZoomValue = zoomValueToFitBoundsOnViewport(bounds, { - width: appState.width, - height: appState.height, - }); + newZoomValue = zoomValueToFitBoundsOnViewport( + bounds, + { + width: appState.width, + height: appState.height, + }, + viewportZoomFactor, + ); const centerScroll = centerScrollOn({ scenePoint: { x: centerX, y: centerY }, @@ -408,6 +412,7 @@ export const actionZoomToFitSelection = register({ userToFollow: null, }, fitToViewport: true, + viewportZoomFactor: 0.7, }); }, // NOTE this action should use shift-2 per figma, alas diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index bd23ea70f..032f5b5c1 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3595,7 +3595,7 @@ class App extends React.Component { | { fitToContent?: boolean; fitToViewport?: never; - viewportZoomFactor?: never; + viewportZoomFactor?: number; animate?: boolean; duration?: number; } @@ -3860,6 +3860,43 @@ class App extends React.Component { }, ); + private getEditorUIOffsets = (): { + top: number; + right: number; + bottom: number; + left: number; + } => { + const toolbarBottom = + this.excalidrawContainerRef?.current + ?.querySelector(".App-toolbar") + ?.getBoundingClientRect()?.bottom ?? 0; + const sidebarWidth = Math.max( + this.excalidrawContainerRef?.current + ?.querySelector(".default-sidebar") + ?.getBoundingClientRect()?.width ?? 0, + ); + const propertiesPanelWidth = Math.max( + this.excalidrawContainerRef?.current + ?.querySelector(".App-menu__left") + ?.getBoundingClientRect()?.width ?? 0, + 0, + ); + + return getLanguage().rtl + ? { + top: toolbarBottom, + right: propertiesPanelWidth, + bottom: 0, + left: sidebarWidth, + } + : { + top: toolbarBottom, + right: sidebarWidth, + bottom: 0, + left: propertiesPanelWidth, + }; + }; + // Input handling private onKeyDown = withBatchedUpdates( (event: React.KeyboardEvent | KeyboardEvent) => { @@ -3920,6 +3957,31 @@ class App extends React.Component { ); } + if ( + this.flowChartCreator.pendingNodes?.length && + !isElementCompletelyInViewport( + this.flowChartCreator.pendingNodes, + this.canvas.width / window.devicePixelRatio, + this.canvas.height / window.devicePixelRatio, + { + offsetLeft: this.state.offsetLeft, + offsetTop: this.state.offsetTop, + scrollX: this.state.scrollX, + scrollY: this.state.scrollY, + zoom: this.state.zoom, + }, + this.scene.getNonDeletedElementsMap(), + this.getEditorUIOffsets(), + ) + ) { + this.scrollToContent(this.flowChartCreator.pendingNodes, { + animate: true, + duration: 300, + fitToContent: true, + viewportZoomFactor: 0.8, + }); + } + return; } @@ -3955,7 +4017,7 @@ class App extends React.Component { if ( nextNode && !isElementCompletelyInViewport( - nextNode, + [nextNode], this.canvas.width / window.devicePixelRatio, this.canvas.height / window.devicePixelRatio, { @@ -3966,6 +4028,7 @@ class App extends React.Component { zoom: this.state.zoom, }, this.scene.getNonDeletedElementsMap(), + this.getEditorUIOffsets(), ) ) { this.scrollToContent(nextNode, { @@ -4373,7 +4436,7 @@ class App extends React.Component { if ( !isElementCompletelyInViewport( - firstNode, + [firstNode], this.canvas.width / window.devicePixelRatio, this.canvas.height / window.devicePixelRatio, { @@ -4384,6 +4447,7 @@ class App extends React.Component { zoom: this.state.zoom, }, this.scene.getNonDeletedElementsMap(), + this.getEditorUIOffsets(), ) ) { this.scrollToContent(firstNode, { diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index bd633680d..d494e59b7 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -738,6 +738,7 @@ export const getElementBounds = ( export const getCommonBounds = ( elements: readonly ExcalidrawElement[], + elementsMap?: ElementsMap, ): Bounds => { if (!elements.length) { return [0, 0, 0, 0]; @@ -748,10 +749,11 @@ export const getCommonBounds = ( let minY = Infinity; let maxY = -Infinity; - const elementsMap = arrayToMap(elements); - elements.forEach((element) => { - const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); + const [x1, y1, x2, y2] = getElementBounds( + element, + elementsMap || arrayToMap(elements), + ); minX = Math.min(minX, x1); minY = Math.min(minY, y1); maxX = Math.max(maxX, x2); diff --git a/packages/excalidraw/element/sizeHelpers.ts b/packages/excalidraw/element/sizeHelpers.ts index 9ffc682f6..b10f31f32 100644 --- a/packages/excalidraw/element/sizeHelpers.ts +++ b/packages/excalidraw/element/sizeHelpers.ts @@ -3,7 +3,7 @@ import { mutateElement } from "./mutateElement"; import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { SHIFT_LOCKING_ANGLE } from "../constants"; import type { AppState, Zoom } from "../types"; -import { getElementBounds } from "./bounds"; +import { getCommonBounds, getElementBounds } from "./bounds"; import { viewportCoordsToSceneCoords } from "../utils"; // TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted @@ -56,7 +56,7 @@ export const isElementInViewport = ( }; export const isElementCompletelyInViewport = ( - element: ExcalidrawElement, + elements: ExcalidrawElement[], width: number, height: number, viewTransformations: { @@ -67,19 +67,25 @@ export const isElementCompletelyInViewport = ( scrollY: number; }, elementsMap: ElementsMap, + padding?: Partial<{ + top: number; + right: number; + bottom: number; + left: number; + }>, ) => { - const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates + const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates const topLeftSceneCoords = viewportCoordsToSceneCoords( { - clientX: viewTransformations.offsetLeft, - clientY: viewTransformations.offsetTop, + clientX: viewTransformations.offsetLeft + (padding?.left || 0), + clientY: viewTransformations.offsetTop + (padding?.top || 0), }, viewTransformations, ); const bottomRightSceneCoords = viewportCoordsToSceneCoords( { - clientX: viewTransformations.offsetLeft + width, - clientY: viewTransformations.offsetTop + height, + clientX: viewTransformations.offsetLeft + width - (padding?.right || 0), + clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0), }, viewTransformations, );