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:
parent
8420e1aa13
commit
4320a3cf41
@ -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(
|
||||||
width: appState.width,
|
bounds,
|
||||||
height: appState.height,
|
{
|
||||||
});
|
width: appState.width,
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user