feat: support scrollToContent opts.fitToViewport (#6581)
Co-authored-by: dwelle <luzar.david@gmail.com> Co-authored-by: Arnošt Pleskot <arnostpleskot@gmail.com>
This commit is contained in:
parent
b33fa6d6f6
commit
29a5e982c3
@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
|
|||||||
|
|
||||||
## scrollToContent
|
## scrollToContent
|
||||||
|
|
||||||
<pre>
|
```tsx
|
||||||
(<br />
|
(
|
||||||
{" "}
|
target?: ExcalidrawElement | ExcalidrawElement[],
|
||||||
target?:{" "}
|
opts?:
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
| {
|
||||||
ExcalidrawElement
|
fitToContent?: boolean;
|
||||||
</a>{" "}
|
animate?: boolean;
|
||||||
|{" "}
|
duration?: number;
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
}
|
||||||
ExcalidrawElement
|
| {
|
||||||
</a>
|
fitToViewport?: boolean;
|
||||||
[],
|
viewportZoomFactor?: number;
|
||||||
<br />
|
animate?: boolean;
|
||||||
{" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number
|
duration?: number;
|
||||||
}
|
}
|
||||||
<br />) => void
|
) => void
|
||||||
</pre>
|
```
|
||||||
|
|
||||||
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
|
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
|
||||||
|
|
||||||
| Attribute | type | default | Description |
|
| Attribute | type | default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| target | <code>ExcalidrawElement | ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
|
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
|
||||||
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
|
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
|
||||||
|
| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
|
||||||
|
| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
|
||||||
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
|
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
|
||||||
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
|
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
|
||||||
|
|
||||||
|
@ -20,7 +20,6 @@ import {
|
|||||||
isHandToolActive,
|
isHandToolActive,
|
||||||
} from "../appState";
|
} from "../appState";
|
||||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
|
||||||
import { Bounds } from "../element/bounds";
|
import { Bounds } from "../element/bounds";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
@ -226,52 +225,96 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zoomToFitElements = (
|
export const zoomToFit = ({
|
||||||
elements: readonly ExcalidrawElement[],
|
targetElements,
|
||||||
appState: Readonly<AppState>,
|
appState,
|
||||||
zoomToSelection: boolean,
|
fitToViewport = false,
|
||||||
) => {
|
viewportZoomFactor = 0.7,
|
||||||
const nonDeletedElements = getNonDeletedElements(elements);
|
}: {
|
||||||
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
targetElements: readonly ExcalidrawElement[];
|
||||||
|
appState: Readonly<AppState>;
|
||||||
const commonBounds =
|
/** whether to fit content to viewport (beyond >100%) */
|
||||||
zoomToSelection && selectedElements.length > 0
|
fitToViewport: boolean;
|
||||||
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
|
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||||
: getCommonBounds(
|
viewportZoomFactor?: number;
|
||||||
excludeElementsInFramesFromSelection(nonDeletedElements),
|
}) => {
|
||||||
);
|
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||||
|
|
||||||
const newZoom = {
|
|
||||||
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
const [x1, y1, x2, y2] = commonBounds;
|
||||||
const centerX = (x1 + x2) / 2;
|
const centerX = (x1 + x2) / 2;
|
||||||
const centerY = (y1 + y2) / 2;
|
const centerY = (y1 + y2) / 2;
|
||||||
return {
|
|
||||||
appState: {
|
let newZoomValue;
|
||||||
...appState,
|
let scrollX;
|
||||||
...centerScrollOn({
|
let scrollY;
|
||||||
|
|
||||||
|
if (fitToViewport) {
|
||||||
|
const commonBoundsWidth = x2 - x1;
|
||||||
|
const commonBoundsHeight = y2 - y1;
|
||||||
|
|
||||||
|
newZoomValue =
|
||||||
|
Math.min(
|
||||||
|
appState.width / commonBoundsWidth,
|
||||||
|
appState.height / commonBoundsHeight,
|
||||||
|
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
||||||
|
|
||||||
|
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||||
|
newZoomValue = Math.min(
|
||||||
|
Math.max(newZoomValue, 0.1),
|
||||||
|
30.0,
|
||||||
|
) as NormalizedZoomValue;
|
||||||
|
|
||||||
|
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||||
|
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||||
|
} else {
|
||||||
|
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
|
width: appState.width,
|
||||||
|
height: appState.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const centerScroll = centerScrollOn({
|
||||||
scenePoint: { x: centerX, y: centerY },
|
scenePoint: { x: centerX, y: centerY },
|
||||||
viewportDimensions: {
|
viewportDimensions: {
|
||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
},
|
},
|
||||||
zoom: newZoom,
|
zoom: { value: newZoomValue },
|
||||||
}),
|
});
|
||||||
zoom: newZoom,
|
|
||||||
|
scrollX = centerScroll.scrollX;
|
||||||
|
scrollY = centerScroll.scrollY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
scrollX,
|
||||||
|
scrollY,
|
||||||
|
zoom: { value: newZoomValue },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionZoomToSelected = register({
|
// Note, this action differs from actionZoomToFitSelection in that it doesn't
|
||||||
name: "zoomToSelection",
|
// zoom beyond 100%. In other words, if the content is smaller than viewport
|
||||||
|
// size, it won't be zoomed in.
|
||||||
|
export const actionZoomToFitSelectionInViewport = register({
|
||||||
|
name: "zoomToFitSelectionInViewport",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
return zoomToFit({
|
||||||
|
targetElements: selectedElements.length ? selectedElements : elements,
|
||||||
|
appState,
|
||||||
|
fitToViewport: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||||
|
// TBD on how proceed
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.TWO &&
|
event.code === CODES.TWO &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
@ -279,11 +322,34 @@ export const actionZoomToSelected = register({
|
|||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionZoomToFitSelection = register({
|
||||||
|
name: "zoomToFitSelection",
|
||||||
|
trackEvent: { category: "canvas" },
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
return zoomToFit({
|
||||||
|
targetElements: selectedElements.length ? selectedElements : elements,
|
||||||
|
appState,
|
||||||
|
fitToViewport: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// NOTE this action should use shift-2 per figma, alas
|
||||||
|
keyTest: (event) =>
|
||||||
|
event.code === CODES.THREE &&
|
||||||
|
event.shiftKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
});
|
||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
perform: (elements, appState) =>
|
||||||
|
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
|
@ -82,7 +82,8 @@ export type ActionName =
|
|||||||
| "zoomOut"
|
| "zoomOut"
|
||||||
| "resetZoom"
|
| "resetZoom"
|
||||||
| "zoomToFit"
|
| "zoomToFit"
|
||||||
| "zoomToSelection"
|
| "zoomToFitSelection"
|
||||||
|
| "zoomToFitSelectionInViewport"
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
| "changeVerticalAlign"
|
| "changeVerticalAlign"
|
||||||
|
@ -245,6 +245,7 @@ import {
|
|||||||
isTransparent,
|
isTransparent,
|
||||||
easeToValuesRAF,
|
easeToValuesRAF,
|
||||||
muteFSAbortError,
|
muteFSAbortError,
|
||||||
|
easeOut,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@ -320,10 +321,7 @@ import {
|
|||||||
actionRemoveAllElementsFromFrame,
|
actionRemoveAllElementsFromFrame,
|
||||||
actionSelectAllElementsInFrame,
|
actionSelectAllElementsInFrame,
|
||||||
} from "../actions/actionFrame";
|
} from "../actions/actionFrame";
|
||||||
import {
|
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
|
||||||
actionToggleHandTool,
|
|
||||||
zoomToFitElements,
|
|
||||||
} from "../actions/actionCanvas";
|
|
||||||
import { jotaiStore } from "../jotai";
|
import { jotaiStore } from "../jotai";
|
||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||||
@ -2239,27 +2237,51 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
target:
|
target:
|
||||||
| ExcalidrawElement
|
| ExcalidrawElement
|
||||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||||
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
|
opts?:
|
||||||
|
| {
|
||||||
|
fitToContent?: boolean;
|
||||||
|
fitToViewport?: never;
|
||||||
|
viewportZoomFactor?: never;
|
||||||
|
animate?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
fitToContent?: never;
|
||||||
|
fitToViewport?: boolean;
|
||||||
|
/** when fitToViewport=true, how much screen should the content cover,
|
||||||
|
* between 0.1 (10%) and 1 (100%)
|
||||||
|
*/
|
||||||
|
viewportZoomFactor?: number;
|
||||||
|
animate?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
this.cancelInProgresAnimation?.();
|
this.cancelInProgresAnimation?.();
|
||||||
|
|
||||||
// convert provided target into ExcalidrawElement[] if necessary
|
// convert provided target into ExcalidrawElement[] if necessary
|
||||||
const targets = Array.isArray(target) ? target : [target];
|
const targetElements = Array.isArray(target) ? target : [target];
|
||||||
|
|
||||||
let zoom = this.state.zoom;
|
let zoom = this.state.zoom;
|
||||||
let scrollX = this.state.scrollX;
|
let scrollX = this.state.scrollX;
|
||||||
let scrollY = this.state.scrollY;
|
let scrollY = this.state.scrollY;
|
||||||
|
|
||||||
if (opts?.fitToContent) {
|
if (opts?.fitToContent || opts?.fitToViewport) {
|
||||||
// compute an appropriate viewport location (scroll X, Y) and zoom level
|
const { appState } = zoomToFit({
|
||||||
// that fit the target elements on the scene
|
targetElements,
|
||||||
const { appState } = zoomToFitElements(targets, this.state, false);
|
appState: this.state,
|
||||||
|
fitToViewport: !!opts?.fitToViewport,
|
||||||
|
viewportZoomFactor: opts?.viewportZoomFactor,
|
||||||
|
});
|
||||||
zoom = appState.zoom;
|
zoom = appState.zoom;
|
||||||
scrollX = appState.scrollX;
|
scrollX = appState.scrollX;
|
||||||
scrollY = appState.scrollY;
|
scrollY = appState.scrollY;
|
||||||
} else {
|
} else {
|
||||||
// compute only the viewport location, without any zoom adjustment
|
// compute only the viewport location, without any zoom adjustment
|
||||||
const scroll = calculateScrollCenter(targets, this.state, this.canvas);
|
const scroll = calculateScrollCenter(
|
||||||
|
targetElements,
|
||||||
|
this.state,
|
||||||
|
this.canvas,
|
||||||
|
);
|
||||||
scrollX = scroll.scrollX;
|
scrollX = scroll.scrollX;
|
||||||
scrollY = scroll.scrollY;
|
scrollY = scroll.scrollY;
|
||||||
}
|
}
|
||||||
@ -2269,19 +2291,42 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (opts?.animate) {
|
if (opts?.animate) {
|
||||||
const origScrollX = this.state.scrollX;
|
const origScrollX = this.state.scrollX;
|
||||||
const origScrollY = this.state.scrollY;
|
const origScrollY = this.state.scrollY;
|
||||||
|
const origZoom = this.state.zoom.value;
|
||||||
|
|
||||||
// zoom animation could become problematic on scenes with large number
|
const cancel = easeToValuesRAF({
|
||||||
// of elements, setting it to its final value to improve user experience.
|
fromValues: {
|
||||||
//
|
scrollX: origScrollX,
|
||||||
// using zoomCanvas() to zoom on current viewport center
|
scrollY: origScrollY,
|
||||||
this.zoomCanvas(zoom.value);
|
zoom: origZoom,
|
||||||
|
},
|
||||||
|
toValues: { scrollX, scrollY, zoom: zoom.value },
|
||||||
|
interpolateValue: (from, to, progress, key) => {
|
||||||
|
// for zoom, use different easing
|
||||||
|
if (key === "zoom") {
|
||||||
|
return from * Math.pow(to / from, easeOut(progress));
|
||||||
|
}
|
||||||
|
// handle using default
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
onStep: ({ scrollX, scrollY, zoom }) => {
|
||||||
|
this.setState({
|
||||||
|
scrollX,
|
||||||
|
scrollY,
|
||||||
|
zoom: { value: zoom },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStart: () => {
|
||||||
|
this.setState({ shouldCacheIgnoreZoom: true });
|
||||||
|
},
|
||||||
|
onEnd: () => {
|
||||||
|
this.setState({ shouldCacheIgnoreZoom: false });
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this.setState({ shouldCacheIgnoreZoom: false });
|
||||||
|
},
|
||||||
|
duration: opts?.duration ?? 500,
|
||||||
|
});
|
||||||
|
|
||||||
const cancel = easeToValuesRAF(
|
|
||||||
[origScrollX, origScrollY],
|
|
||||||
[scrollX, scrollY],
|
|
||||||
(scrollX, scrollY) => this.setState({ scrollX, scrollY }),
|
|
||||||
{ duration: opts?.duration ?? 500 },
|
|
||||||
);
|
|
||||||
this.cancelInProgresAnimation = () => {
|
this.cancelInProgresAnimation = () => {
|
||||||
cancel();
|
cancel();
|
||||||
this.cancelInProgresAnimation = null;
|
this.cancelInProgresAnimation = null;
|
||||||
|
@ -10,6 +10,7 @@ export const CODES = {
|
|||||||
BRACKET_LEFT: "BracketLeft",
|
BRACKET_LEFT: "BracketLeft",
|
||||||
ONE: "Digit1",
|
ONE: "Digit1",
|
||||||
TWO: "Digit2",
|
TWO: "Digit2",
|
||||||
|
THREE: "Digit3",
|
||||||
NINE: "Digit9",
|
NINE: "Digit9",
|
||||||
QUOTE: "Quote",
|
QUOTE: "Quote",
|
||||||
ZERO: "Digit0",
|
ZERO: "Digit0",
|
||||||
|
@ -15,6 +15,7 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581).
|
||||||
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
|
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
|
||||||
- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
|
- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
|
||||||
- Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
|
- Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
|
||||||
@ -64,7 +65,7 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- [`ExcalidrawAPI.scrolToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319)
|
- [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319)
|
||||||
|
|
||||||
- Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `<Excalidraw>` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
|
- Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `<Excalidraw>` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
|
||||||
|
|
||||||
|
@ -784,7 +784,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
<div className="export export-blob">
|
<div className="export export-blob">
|
||||||
<img src={blobUrl} alt="" />
|
<img src={blobUrl} alt="" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!excalidrawAPI) {
|
if (!excalidrawAPI) {
|
||||||
@ -806,6 +805,78 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
>
|
>
|
||||||
Export to Canvas
|
Export to Canvas
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = await exportToCanvas({
|
||||||
|
elements: excalidrawAPI.getSceneElements(),
|
||||||
|
appState: {
|
||||||
|
...initialData.appState,
|
||||||
|
exportWithDarkMode,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI.getFiles(),
|
||||||
|
});
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.font = "30px Virgil";
|
||||||
|
ctx.strokeText("My custom text", 50, 60);
|
||||||
|
setCanvasUrl(canvas.toDataURL());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export to Canvas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = excalidrawAPI.getSceneElements();
|
||||||
|
excalidrawAPI.scrollToContent(elements[0], {
|
||||||
|
fitToViewport: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fit to viewport, first element
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = excalidrawAPI.getSceneElements();
|
||||||
|
excalidrawAPI.scrollToContent(elements[0], {
|
||||||
|
fitToContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
excalidrawAPI.scrollToContent(elements[0], {
|
||||||
|
fitToContent: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fit to content, first element
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = excalidrawAPI.getSceneElements();
|
||||||
|
excalidrawAPI.scrollToContent(elements[0], {
|
||||||
|
fitToContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
excalidrawAPI.scrollToContent(elements[0]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scroll to first element, no fitToContent, no fitToViewport
|
||||||
|
</button>
|
||||||
<div className="export export-canvas">
|
<div className="export export-canvas">
|
||||||
<img src={canvasUrl} alt="" />
|
<img src={canvasUrl} alt="" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -160,19 +160,6 @@ describe("fitToContent animated", () => {
|
|||||||
|
|
||||||
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
||||||
|
|
||||||
// Since this is an animation, we expect values to change through time.
|
|
||||||
// We'll verify that the zoom/scroll values change in each animation frame
|
|
||||||
|
|
||||||
// zoom is not animated, it should be set to its final value, which in our
|
|
||||||
// case zooms out to 50% so that th element is fully visible (it's 2x large
|
|
||||||
// as the canvas)
|
|
||||||
expect(h.state.zoom.value).toBeLessThanOrEqual(0.5);
|
|
||||||
|
|
||||||
// FIXME I think this should be [-100, -100] so we may have a bug in our zoom
|
|
||||||
// hadnling, alas
|
|
||||||
expect(h.state.scrollX).toBe(25);
|
|
||||||
expect(h.state.scrollY).toBe(25);
|
|
||||||
|
|
||||||
await waitForNextAnimationFrame();
|
await waitForNextAnimationFrame();
|
||||||
|
|
||||||
const prevScrollX = h.state.scrollX;
|
const prevScrollX = h.state.scrollX;
|
||||||
|
130
src/utils.ts
130
src/utils.ts
@ -197,68 +197,134 @@ export const throttleRAF = <T extends any[]>(
|
|||||||
* @param {number} k - The value to be tweened.
|
* @param {number} k - The value to be tweened.
|
||||||
* @returns {number} The tweened value.
|
* @returns {number} The tweened value.
|
||||||
*/
|
*/
|
||||||
function easeOut(k: number): number {
|
export const easeOut = (k: number) => {
|
||||||
return 1 - Math.pow(1 - k, 4);
|
return 1 - Math.pow(1 - k, 4);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const easeOutInterpolate = (from: number, to: number, progress: number) => {
|
||||||
|
return (to - from) * easeOut(progress) + from;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute new values based on the same ease function and trigger the
|
* Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
|
||||||
* callback through a requestAnimationFrame call
|
* Executes the `onStep` callback on each step with the interpolated values.
|
||||||
|
* Returns a function that can be called to cancel the animation.
|
||||||
*
|
*
|
||||||
* use `opts` to define a duration and/or an easeFn
|
* @example
|
||||||
|
* // Example usage:
|
||||||
|
* const fromValues = { x: 0, y: 0 };
|
||||||
|
* const toValues = { x: 100, y: 200 };
|
||||||
|
* const onStep = ({x, y}) => {
|
||||||
|
* setState(x, y)
|
||||||
|
* };
|
||||||
|
* const onCancel = () => {
|
||||||
|
* console.log("Animation canceled");
|
||||||
|
* };
|
||||||
*
|
*
|
||||||
* for example:
|
* const cancelAnimation = easeToValuesRAF({
|
||||||
* ```ts
|
* fromValues,
|
||||||
* easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c))
|
* toValues,
|
||||||
* ```
|
* onStep,
|
||||||
|
* onCancel,
|
||||||
|
* });
|
||||||
*
|
*
|
||||||
* @param fromValues The initial values, must be numeric
|
* // To cancel the animation:
|
||||||
* @param toValues The destination values, must also be numeric
|
* cancelAnimation();
|
||||||
* @param callback The callback receiving the values
|
|
||||||
* @param opts default to 250ms duration and the easeOut function
|
|
||||||
*/
|
*/
|
||||||
export const easeToValuesRAF = (
|
export const easeToValuesRAF = <
|
||||||
fromValues: number[],
|
T extends Record<keyof T, number>,
|
||||||
toValues: number[],
|
K extends keyof T,
|
||||||
callback: (...values: number[]) => void,
|
>({
|
||||||
opts?: { duration?: number; easeFn?: (value: number) => number },
|
fromValues,
|
||||||
) => {
|
toValues,
|
||||||
|
onStep,
|
||||||
|
duration = 250,
|
||||||
|
interpolateValue,
|
||||||
|
onStart,
|
||||||
|
onEnd,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
fromValues: T;
|
||||||
|
toValues: T;
|
||||||
|
/**
|
||||||
|
* Interpolate a single value.
|
||||||
|
* Return undefined to be handled by the default interpolator.
|
||||||
|
*/
|
||||||
|
interpolateValue?: (
|
||||||
|
fromValue: number,
|
||||||
|
toValue: number,
|
||||||
|
/** no easing applied */
|
||||||
|
progress: number,
|
||||||
|
key: K,
|
||||||
|
) => number | undefined;
|
||||||
|
onStep: (values: T) => void;
|
||||||
|
duration?: number;
|
||||||
|
onStart?: () => void;
|
||||||
|
onEnd?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}) => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
let startTime: number;
|
let startTime: number;
|
||||||
|
|
||||||
const duration = opts?.duration || 250; // default animation to 0.25 seconds
|
|
||||||
const easeFn = opts?.easeFn || easeOut; // default the easeFn to easeOut
|
|
||||||
|
|
||||||
function step(timestamp: number) {
|
function step(timestamp: number) {
|
||||||
if (canceled) {
|
if (canceled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (startTime === undefined) {
|
if (startTime === undefined) {
|
||||||
startTime = timestamp;
|
startTime = timestamp;
|
||||||
|
onStart?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = timestamp - startTime;
|
const elapsed = Math.min(timestamp - startTime, duration);
|
||||||
|
const factor = easeOut(elapsed / duration);
|
||||||
|
|
||||||
|
const newValues = {} as T;
|
||||||
|
|
||||||
|
Object.keys(fromValues).forEach((key) => {
|
||||||
|
const _key = key as keyof T;
|
||||||
|
const result = ((toValues[_key] - fromValues[_key]) * factor +
|
||||||
|
fromValues[_key]) as T[keyof T];
|
||||||
|
newValues[_key] = result;
|
||||||
|
});
|
||||||
|
|
||||||
|
onStep(newValues);
|
||||||
|
|
||||||
if (elapsed < duration) {
|
if (elapsed < duration) {
|
||||||
// console.log(elapsed, duration, elapsed / duration);
|
const progress = elapsed / duration;
|
||||||
const factor = easeFn(elapsed / duration);
|
|
||||||
const newValues = fromValues.map(
|
const newValues = {} as T;
|
||||||
(fromValue, index) =>
|
|
||||||
(toValues[index] - fromValue) * factor + fromValue,
|
Object.keys(fromValues).forEach((key) => {
|
||||||
);
|
const _key = key as K;
|
||||||
|
const startValue = fromValues[_key];
|
||||||
|
const endValue = toValues[_key];
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
result = interpolateValue
|
||||||
|
? interpolateValue(startValue, endValue, progress, _key)
|
||||||
|
: easeOutInterpolate(startValue, endValue, progress);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
result = easeOutInterpolate(startValue, endValue, progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
newValues[_key] = result as T[K];
|
||||||
|
});
|
||||||
|
onStep(newValues);
|
||||||
|
|
||||||
callback(...newValues);
|
|
||||||
frameId = window.requestAnimationFrame(step);
|
frameId = window.requestAnimationFrame(step);
|
||||||
} else {
|
} else {
|
||||||
// ensure final values are reached at the end of the transition
|
onStep(toValues);
|
||||||
callback(...toValues);
|
onEnd?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
frameId = window.requestAnimationFrame(step);
|
frameId = window.requestAnimationFrame(step);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
onCancel?.();
|
||||||
canceled = true;
|
canceled = true;
|
||||||
window.cancelAnimationFrame(frameId);
|
window.cancelAnimationFrame(frameId);
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user