fix: improve canvas search scroll behavior further (#8491)

pull/8498/head
David Luzar 5 months ago committed by GitHub
parent b46ca0192b
commit fd39712ba6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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 } from "../types"; import type { AppState, Offsets } 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,7 +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"; import { clamp, roundToStep } from "../../math";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@ -259,70 +259,69 @@ const zoomValueToFitBoundsOnViewport = (
const adjustedZoomValue = const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1); smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps = return Math.min(adjustedZoomValue, 1);
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
}; };
export const zoomToFitBounds = ({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
canvasOffsets,
fitToViewport = false, fitToViewport = false,
viewportZoomFactor = 1, viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
}: { }: {
bounds: SceneBounds; bounds: SceneBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>; appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */ /** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean; fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */ /** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => { }) => {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2; const centerY = (y1 + y2) / 2;
let newZoomValue; const canvasOffsetLeft = canvasOffsets?.left ?? 0;
let scrollX; const canvasOffsetTop = canvasOffsets?.top ?? 0;
let scrollY; const canvasOffsetRight = canvasOffsets?.right ?? 0;
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
const effectiveCanvasWidth =
appState.width - canvasOffsetLeft - canvasOffsetRight;
const effectiveCanvasHeight =
appState.height - canvasOffsetTop - canvasOffsetBottom;
let adjustedZoomValue;
if (fitToViewport) { if (fitToViewport) {
const commonBoundsWidth = x2 - x1; const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1; const commonBoundsHeight = y2 - y1;
newZoomValue = adjustedZoomValue =
Math.min( Math.min(
appState.width / commonBoundsWidth, effectiveCanvasWidth / commonBoundsWidth,
appState.height / commonBoundsHeight, effectiveCanvasHeight / commonBoundsHeight,
) * clamp(viewportZoomFactor, 0.1, 1); ) * viewportZoomFactor;
newZoomValue = getNormalizedZoom(newZoomValue);
let appStateWidth = appState.width;
if (appState.openSidebar) {
const sidebarDOMElem = document.querySelector(
".sidebar",
) as HTMLElement | null;
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
appStateWidth = !isRTL
? appState.width - sidebarWidth
: appState.width + sidebarWidth;
}
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else { } else {
newZoomValue = zoomValueToFitBoundsOnViewport( adjustedZoomValue = zoomValueToFitBoundsOnViewport(
bounds, bounds,
{ {
width: appState.width, width: effectiveCanvasWidth,
height: appState.height, height: effectiveCanvasHeight,
}, },
viewportZoomFactor, viewportZoomFactor,
); );
}
const newZoomValue = getNormalizedZoom(
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
);
const centerScroll = centerScrollOn({ const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY }, scenePoint: { x: centerX, y: centerY },
@ -330,18 +329,15 @@ export const zoomToFitBounds = ({
width: appState.width, width: appState.width,
height: appState.height, height: appState.height,
}, },
offsets: canvasOffsets,
zoom: { value: newZoomValue }, zoom: { value: newZoomValue },
}); });
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
}
return { return {
appState: { appState: {
...appState, ...appState,
scrollX, scrollX: centerScroll.scrollX,
scrollY, scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue }, zoom: { value: newZoomValue },
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
}; };
export const zoomToFit = ({ export const zoomToFit = ({
canvasOffsets,
targetElements, targetElements,
appState, appState,
fitToViewport, fitToViewport,
viewportZoomFactor, viewportZoomFactor,
minZoom,
maxZoom,
}: { }: {
canvasOffsets?: Offsets;
targetElements: readonly ExcalidrawElement[]; targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>; appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */ /** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean; fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */ /** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => { }) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
return zoomToFitBounds({ return zoomToFitBounds({
canvasOffsets,
bounds: commonBounds, bounds: commonBounds,
appState, appState,
fitToViewport, fitToViewport,
viewportZoomFactor, viewportZoomFactor,
minZoom,
maxZoom,
}); });
}; };
@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
// NOTE shift-2 should have been assigned actionZoomToFitSelection. // NOTE shift-2 should have been assigned actionZoomToFitSelection.
@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: true, fitToViewport: true,
viewportZoomFactor: 0.7, canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
// NOTE this action should use shift-2 per figma, alas // NOTE this action should use shift-2 per figma, alas
@ -430,7 +436,7 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon, icon: zoomAreaIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => perform: (elements, appState, _, app) =>
zoomToFit({ zoomToFit({
targetElements: elements, targetElements: elements,
appState: { appState: {
@ -438,6 +444,7 @@ export const actionZoomToFit = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}), }),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&

@ -259,6 +259,7 @@ import type {
ElementsPendingErasure, ElementsPendingErasure,
GenerateDiagramToCode, GenerateDiagramToCode,
NullableGridSize, NullableGridSize,
Offsets,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@ -3232,6 +3233,7 @@ class App extends React.Component<AppProps, AppState> {
if (opts.fitToContent) { if (opts.fitToContent) {
this.scrollToContent(newElements, { this.scrollToContent(newElements, {
fitToContent: true, fitToContent: true,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
}; };
@ -3544,7 +3546,7 @@ class App extends React.Component<AppProps, AppState> {
target: target:
| ExcalidrawElement | ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: opts?: (
| { | {
fitToContent?: boolean; fitToContent?: boolean;
fitToViewport?: never; fitToViewport?: never;
@ -3561,6 +3563,11 @@ class App extends React.Component<AppProps, AppState> {
viewportZoomFactor?: number; viewportZoomFactor?: number;
animate?: boolean; animate?: boolean;
duration?: number; duration?: number;
}
) & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
}, },
) => { ) => {
this.cancelInProgressAnimation?.(); this.cancelInProgressAnimation?.();
@ -3574,10 +3581,13 @@ class App extends React.Component<AppProps, AppState> {
if (opts?.fitToContent || opts?.fitToViewport) { if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({ const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements, targetElements,
appState: this.state, appState: this.state,
fitToViewport: !!opts?.fitToViewport, fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor, viewportZoomFactor: opts?.viewportZoomFactor,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
}); });
zoom = appState.zoom; zoom = appState.zoom;
scrollX = appState.scrollX; scrollX = appState.scrollX;
@ -3805,40 +3815,42 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
public getEditorUIOffsets = (): { public getEditorUIOffsets = (): Offsets => {
top: number;
right: number;
bottom: number;
left: number;
} => {
const toolbarBottom = const toolbarBottom =
this.excalidrawContainerRef?.current this.excalidrawContainerRef?.current
?.querySelector(".App-toolbar") ?.querySelector(".App-toolbar")
?.getBoundingClientRect()?.bottom ?? 0; ?.getBoundingClientRect()?.bottom ?? 0;
const sidebarWidth = Math.max( const sidebarRect = this.excalidrawContainerRef?.current
this.excalidrawContainerRef?.current ?.querySelector(".sidebar")
?.querySelector(".default-sidebar") ?.getBoundingClientRect();
?.getBoundingClientRect()?.width ?? 0, const propertiesPanelRect = this.excalidrawContainerRef?.current
);
const propertiesPanelWidth = Math.max(
this.excalidrawContainerRef?.current
?.querySelector(".App-menu__left") ?.querySelector(".App-menu__left")
?.getBoundingClientRect()?.width ?? 0, ?.getBoundingClientRect();
0,
); const PADDING = 16;
return getLanguage().rtl return getLanguage().rtl
? { ? {
top: toolbarBottom, top: toolbarBottom + PADDING,
right: propertiesPanelWidth, right:
bottom: 0, Math.max(
left: sidebarWidth, this.state.width -
(propertiesPanelRect?.left ?? this.state.width),
0,
) + PADDING,
bottom: PADDING,
left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
} }
: { : {
top: toolbarBottom, top: toolbarBottom + PADDING,
right: sidebarWidth, right: Math.max(
bottom: 0, this.state.width -
left: propertiesPanelWidth, (sidebarRect?.left ?? this.state.width) +
PADDING,
0,
),
bottom: PADDING,
left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
}; };
}; };
@ -3923,7 +3935,7 @@ class App extends React.Component<AppProps, AppState> {
animate: true, animate: true,
duration: 300, duration: 300,
fitToContent: true, fitToContent: true,
viewportZoomFactor: 0.8, canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
@ -3979,6 +3991,7 @@ class App extends React.Component<AppProps, AppState> {
this.scrollToContent(nextNode, { this.scrollToContent(nextNode, {
animate: true, animate: true,
duration: 300, duration: 300,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
} }
@ -4411,6 +4424,7 @@ class App extends React.Component<AppProps, AppState> {
this.scrollToContent(firstNode, { this.scrollToContent(firstNode, {
animate: true, animate: true,
duration: 300, duration: 300,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
} }

@ -20,6 +20,7 @@ import { CLASSES, EVENT } from "../constants";
import { useStable } from "../hooks/useStable"; import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss"; import "./SearchMenu.scss";
import { round } from "../../math";
const searchQueryAtom = atom<string>(""); const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null); export const searchItemInFocusAtom = atom<number | null>(null);
@ -154,16 +155,23 @@ export const SearchMenu = () => {
const match = searchMatches.items[focusIndex]; const match = searchMatches.items[focusIndex];
if (match) { if (match) {
const zoomValue = app.state.zoom.value;
const matchAsElement = newTextElement({ const matchAsElement = newTextElement({
text: match.searchQuery, text: match.searchQuery,
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0), x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0), y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
width: match.matchedLines[0]?.width, width: match.matchedLines[0]?.width,
height: match.matchedLines[0]?.height, height: match.matchedLines[0]?.height,
fontSize: match.textElement.fontSize,
fontFamily: match.textElement.fontFamily,
}); });
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
const fontSize = match.textElement.fontSize;
const isTextTiny = const isTextTiny =
match.textElement.fontSize * app.state.zoom.value < 12; fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
if ( if (
!isElementCompletelyInViewport( !isElementCompletelyInViewport(
@ -184,9 +192,17 @@ export const SearchMenu = () => {
) { ) {
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1]; let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
if (isTextTiny && app.state.zoom.value >= 1) { if (isTextTiny) {
zoomOptions = { fitToViewport: true }; if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
} else if (isTextTiny || app.state.zoom.value > 1) { zoomOptions = { fitToContent: true };
} else {
zoomOptions = {
fitToViewport: true,
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
};
}
} else {
zoomOptions = { fitToContent: true }; zoomOptions = { fitToContent: true };
} }
@ -194,6 +210,7 @@ export const SearchMenu = () => {
animate: true, animate: true,
duration: 300, duration: 300,
...zoomOptions, ...zoomOptions,
canvasOffsets: app.getEditorUIOffsets(),
}); });
} }
} }

@ -223,7 +223,6 @@ export const newTextElement = (
verticalAlign?: VerticalAlign; verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null; containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
autoResize?: ExcalidrawTextElement["autoResize"]; autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {

@ -2,7 +2,7 @@ import type { ElementsMap, ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement"; 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, Offsets, Zoom } from "../types";
import { getCommonBounds, getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils"; import { viewportCoordsToSceneCoords } from "../utils";
@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
scrollY: number; scrollY: number;
}, },
elementsMap: ElementsMap, elementsMap: ElementsMap,
padding?: Partial<{ padding?: Offsets,
top: number;
right: number;
bottom: number;
left: number;
}>,
) => { ) => {
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords( const topLeftSceneCoords = viewportCoordsToSceneCoords(

@ -1,4 +1,4 @@
import type { AppState, PointerCoords, Zoom } from "../types"; import type { AppState, Offsets, PointerCoords, Zoom } from "../types";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { import {
getCommonBounds, getCommonBounds,
@ -31,14 +31,28 @@ export const centerScrollOn = ({
scenePoint, scenePoint,
viewportDimensions, viewportDimensions,
zoom, zoom,
offsets,
}: { }: {
scenePoint: PointerCoords; scenePoint: PointerCoords;
viewportDimensions: { height: number; width: number }; viewportDimensions: { height: number; width: number };
zoom: Zoom; zoom: Zoom;
offsets?: Offsets;
}) => { }) => {
let scrollX =
(viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
scenePoint.x;
scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
let scrollY =
(viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
scenePoint.y;
scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
return { return {
scrollX: viewportDimensions.width / 2 / zoom.value - scenePoint.x, scrollX,
scrollY: viewportDimensions.height / 2 / zoom.value - scenePoint.y, scrollY,
}; };
}; };

@ -851,3 +851,10 @@ export type GenerateDiagramToCode = (props: {
frame: ExcalidrawMagicFrameElement; frame: ExcalidrawMagicFrameElement;
children: readonly ExcalidrawElement[]; children: readonly ExcalidrawElement[];
}) => MaybePromise<{ html: string }>; }) => MaybePromise<{ html: string }>;
export type Offsets = Partial<{
top: number;
right: number;
bottom: number;
left: number;
}>;

@ -1,14 +1,27 @@
export const PRECISION = 10e-5; export const PRECISION = 10e-5;
export function clamp(value: number, min: number, max: number) { export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} };
export function round(value: number, precision: number) { export const round = (
value: number,
precision: number,
func: "round" | "floor" | "ceil" = "round",
) => {
const multiplier = Math.pow(10, precision); const multiplier = Math.pow(10, precision);
return Math.round((value + Number.EPSILON) * multiplier) / multiplier; return Math[func]((value + Number.EPSILON) * multiplier) / multiplier;
} };
export const roundToStep = (
value: number,
step: number,
func: "round" | "floor" | "ceil" = "round",
): number => {
const factor = 1 / step;
return Math[func](value * factor) / factor;
};
export const average = (a: number, b: number) => (a + b) / 2; export const average = (a: number, b: number) => (a + b) / 2;

Loading…
Cancel
Save