1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-10 11:35:52 +01:00

feat: improve zoom-to-content when creating flowchart (#8368)

This commit is contained in:
David Luzar 2024-08-12 20:42:08 +02:00 committed by GitHub
parent 8420e1aa13
commit 4320a3cf41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 107 additions and 30 deletions

@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import type { AppState, NormalizedZoomValue } from "../types"; import type { AppState } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
@ -38,6 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds"; import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { clamp } from "../math";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@ -244,6 +245,7 @@ export const actionResetZoom = register({
const zoomValueToFitBoundsOnViewport = ( const zoomValueToFitBoundsOnViewport = (
bounds: SceneBounds, bounds: SceneBounds,
viewportDimensions: { width: number; height: number }, viewportDimensions: { width: number; height: number },
viewportZoomFactor: number = 1, // default to 1 if not provided
) => { ) => {
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
const commonBoundsWidth = x2 - x1; const commonBoundsWidth = x2 - x1;
@ -251,20 +253,21 @@ const zoomValueToFitBoundsOnViewport = (
const commonBoundsHeight = y2 - y1; const commonBoundsHeight = y2 - y1;
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight; const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight); const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps = const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, MIN_ZOOM), return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
1,
);
return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
export const zoomToFitBounds = ({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
fitToViewport = false, fitToViewport = false,
viewportZoomFactor = 0.7, viewportZoomFactor = 1,
}: { }: {
bounds: SceneBounds; bounds: SceneBounds;
appState: Readonly<AppState>; appState: Readonly<AppState>;
@ -289,13 +292,10 @@ export const zoomToFitBounds = ({
Math.min( Math.min(
appState.width / commonBoundsWidth, appState.width / commonBoundsWidth,
appState.height / commonBoundsHeight, 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% // Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min( newZoomValue = getNormalizedZoom(newZoomValue);
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width; let appStateWidth = appState.width;
@ -314,10 +314,14 @@ export const zoomToFitBounds = ({
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX; scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else { } else {
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, { newZoomValue = zoomValueToFitBoundsOnViewport(
bounds,
{
width: appState.width, width: appState.width,
height: appState.height, height: appState.height,
}); },
viewportZoomFactor,
);
const centerScroll = centerScrollOn({ const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY }, scenePoint: { x: centerX, y: centerY },
@ -408,6 +412,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: true, fitToViewport: true,
viewportZoomFactor: 0.7,
}); });
}, },
// NOTE this action should use shift-2 per figma, alas // NOTE this action should use shift-2 per figma, alas

@ -3595,7 +3595,7 @@ class App extends React.Component<AppProps, AppState> {
| { | {
fitToContent?: boolean; fitToContent?: boolean;
fitToViewport?: never; fitToViewport?: never;
viewportZoomFactor?: never; viewportZoomFactor?: number;
animate?: boolean; animate?: boolean;
duration?: number; duration?: number;
} }
@ -3860,6 +3860,43 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
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 // Input handling
private onKeyDown = withBatchedUpdates( private onKeyDown = withBatchedUpdates(
(event: React.KeyboardEvent | KeyboardEvent) => { (event: React.KeyboardEvent | KeyboardEvent) => {
@ -3920,6 +3957,31 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
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; return;
} }
@ -3955,7 +4017,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
nextNode && nextNode &&
!isElementCompletelyInViewport( !isElementCompletelyInViewport(
nextNode, [nextNode],
this.canvas.width / window.devicePixelRatio, this.canvas.width / window.devicePixelRatio,
this.canvas.height / window.devicePixelRatio, this.canvas.height / window.devicePixelRatio,
{ {
@ -3966,6 +4028,7 @@ class App extends React.Component<AppProps, AppState> {
zoom: this.state.zoom, zoom: this.state.zoom,
}, },
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.getEditorUIOffsets(),
) )
) { ) {
this.scrollToContent(nextNode, { this.scrollToContent(nextNode, {
@ -4373,7 +4436,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
!isElementCompletelyInViewport( !isElementCompletelyInViewport(
firstNode, [firstNode],
this.canvas.width / window.devicePixelRatio, this.canvas.width / window.devicePixelRatio,
this.canvas.height / window.devicePixelRatio, this.canvas.height / window.devicePixelRatio,
{ {
@ -4384,6 +4447,7 @@ class App extends React.Component<AppProps, AppState> {
zoom: this.state.zoom, zoom: this.state.zoom,
}, },
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
this.getEditorUIOffsets(),
) )
) { ) {
this.scrollToContent(firstNode, { this.scrollToContent(firstNode, {

@ -738,6 +738,7 @@ export const getElementBounds = (
export const getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
@ -748,10 +749,11 @@ export const getCommonBounds = (
let minY = Infinity; let minY = Infinity;
let maxY = -Infinity; let maxY = -Infinity;
const elementsMap = arrayToMap(elements);
elements.forEach((element) => { 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); minX = Math.min(minX, x1);
minY = Math.min(minY, y1); minY = Math.min(minY, y1);
maxX = Math.max(maxX, x2); maxX = Math.max(maxX, x2);

@ -3,7 +3,7 @@ import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import type { AppState, Zoom } from "../types"; import type { AppState, Zoom } from "../types";
import { getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils"; import { viewportCoordsToSceneCoords } from "../utils";
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted // 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 = ( export const isElementCompletelyInViewport = (
element: ExcalidrawElement, elements: ExcalidrawElement[],
width: number, width: number,
height: number, height: number,
viewTransformations: { viewTransformations: {
@ -67,19 +67,25 @@ export const isElementCompletelyInViewport = (
scrollY: number; scrollY: number;
}, },
elementsMap: ElementsMap, 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( const topLeftSceneCoords = viewportCoordsToSceneCoords(
{ {
clientX: viewTransformations.offsetLeft, clientX: viewTransformations.offsetLeft + (padding?.left || 0),
clientY: viewTransformations.offsetTop, clientY: viewTransformations.offsetTop + (padding?.top || 0),
}, },
viewTransformations, viewTransformations,
); );
const bottomRightSceneCoords = viewportCoordsToSceneCoords( const bottomRightSceneCoords = viewportCoordsToSceneCoords(
{ {
clientX: viewTransformations.offsetLeft + width, clientX: viewTransformations.offsetLeft + width - (padding?.right || 0),
clientY: viewTransformations.offsetTop + height, clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0),
}, },
viewTransformations, viewTransformations,
); );