Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b82a0749b1 | ||
|
|
a772362599 | ||
|
|
3f11ca0a44 | ||
|
|
808e4711f9 | ||
|
|
a26e8bade8 | ||
|
|
fc7135c5d1 | ||
|
|
f017a60101 | ||
|
|
17f9f64eda | ||
|
|
619e4061f5 | ||
|
|
028ad1ee81 | ||
|
|
09a05a4a1c | ||
|
|
c4738b31fb | ||
|
|
87e6638e9e | ||
|
|
0a6d41ecf9 | ||
|
|
11109fcc62 | ||
|
|
c7346e3a77 | ||
|
|
fd030de669 | ||
|
|
f77975cee5 | ||
|
|
f994e5d71d | ||
|
|
77028f4d08 | ||
|
|
fb29bb4816 | ||
|
|
23fcddb2a3 | ||
|
|
b314b939b2 | ||
|
|
bc687fea1b | ||
|
|
e15f313fe7 | ||
|
|
9f02922c91 | ||
|
|
2b6819eb2d | ||
|
|
c0e9b8d7bc | ||
|
|
c8c683c025 | ||
|
|
2117fbbc57 | ||
|
|
3b9953f57f | ||
|
|
7d1efb7f8b | ||
|
|
d2b8f4d2f8 | ||
|
|
f8e65bb77e | ||
|
|
3030e96d62 | ||
|
|
44453b725d | ||
|
|
25bb6738ea | ||
|
|
9e52c30ce8 |
@@ -23,6 +23,11 @@ REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
|||||||
|
|
||||||
FAST_REFRESH=false
|
FAST_REFRESH=false
|
||||||
|
|
||||||
|
# MATOMO
|
||||||
|
REACT_APP_MATOMO_URL=
|
||||||
|
REACT_APP_CDN_MATOMO_TRACKER_URL=
|
||||||
|
REACT_APP_MATOMO_SITE_ID=
|
||||||
|
|
||||||
#Debug flags
|
#Debug flags
|
||||||
|
|
||||||
# To enable bounding box for text containers
|
# To enable bounding box for text containers
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ REACT_APP_WS_SERVER_URL=
|
|||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
|
|
||||||
# production-only vars
|
# production-only vars
|
||||||
|
# GOOGLE ANALYTICS
|
||||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
# MATOMO
|
||||||
|
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
|
||||||
|
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
|
||||||
|
REACT_APP_MATOMO_SITE_ID=1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
# ref
|
# ref
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> | <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> | <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> | <br/>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">resolvablePromise</a> } }
|
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
|
||||||
|
createRef
|
||||||
|
</a>{" "}
|
||||||
|
|{" "}
|
||||||
|
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
|
||||||
|
|{" "}
|
||||||
|
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
|
||||||
|
callbackRef
|
||||||
|
</a>{" "}
|
||||||
|
| <br />
|
||||||
|
{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
|
||||||
|
resolvablePromise
|
||||||
|
</a> } }
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
|
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
|
||||||
@@ -139,7 +152,9 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: "500px" }}>
|
<div style={{ height: "500px" }}>
|
||||||
<p style={{ fontSize: "16px" }}> Click to update the scene</p>
|
<p style={{ fontSize: "16px" }}> Click to update the scene</p>
|
||||||
<button className="custom-button" onClick={updateScene}>Update Scene</button>
|
<button className="custom-button" onClick={updateScene}>
|
||||||
|
Update Scene
|
||||||
|
</button>
|
||||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -187,7 +202,8 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: "500px" }}>
|
<div style={{ height: "500px" }}>
|
||||||
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
|
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
|
||||||
<button className="custom-button"
|
<button
|
||||||
|
className="custom-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const libraryItems = [
|
const libraryItems = [
|
||||||
{
|
{
|
||||||
@@ -205,10 +221,8 @@ function App() {
|
|||||||
];
|
];
|
||||||
excalidrawAPI.updateLibrary({
|
excalidrawAPI.updateLibrary({
|
||||||
libraryItems,
|
libraryItems,
|
||||||
openLibraryMenu: true
|
openLibraryMenu: true,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Update Library
|
Update Library
|
||||||
@@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
|
|||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
() =>{" "}
|
() =>{" "}
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||||
ExcalidrawElement[]
|
ExcalidrawElement[]
|
||||||
</a>
|
</a>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
|
|||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
() => NonDeleted<
|
() => NonDeleted<
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||||
ExcalidrawElement
|
ExcalidrawElement
|
||||||
</a>
|
</a>
|
||||||
[]>
|
[]>
|
||||||
@@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
|
|||||||
## scrollToContent
|
## scrollToContent
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
(target?:{" "}
|
(<br />
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
{" "}
|
||||||
|
target?:{" "}
|
||||||
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||||
ExcalidrawElement
|
ExcalidrawElement
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
|{" "}
|
|{" "}
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||||
ExcalidrawElement
|
ExcalidrawElement
|
||||||
</a>
|
</a>
|
||||||
[]) => void
|
[],
|
||||||
|
<br />
|
||||||
|
{" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number
|
||||||
|
}
|
||||||
|
<br />) => void
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
Scroll the nearest element out of the elements supplied to the center. 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 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| target | <code>ExcalidrawElement | ExcalidrawElement[]</code> | 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.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`. |
|
||||||
|
|
||||||
## refresh
|
## refresh
|
||||||
|
|
||||||
@@ -323,7 +350,7 @@ For any other cases if the position of excalidraw is updated (example due to scr
|
|||||||
This API can be used to show the toast with custom message.
|
This API can be used to show the toast with custom message.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
({ message: string, closable?:boolean,duration?:number
|
({ message: string, closable?:boolean,duration?:number
|
||||||
} | null) => void
|
} | null) => void
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -358,15 +385,18 @@ This API can be used to get the files present in the scene. It may contain files
|
|||||||
|
|
||||||
This API has the below signature. It sets the `tool` passed in param as the active tool.
|
This API has the below signature. It sets the `tool` passed in param as the active tool.
|
||||||
|
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
(tool: <br/> { type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]| "eraser" } |<br/> { type: "custom"; customType: string }) => void
|
(tool: <br /> { type:{" "}
|
||||||
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
|
||||||
|
SHAPES
|
||||||
|
</a>
|
||||||
|
[number]["value"]| "eraser" } |
|
||||||
|
<br /> { type: "custom"; customType: string }) => void
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## setCursor
|
## setCursor
|
||||||
|
|
||||||
This API can be used to customise the mouse cursor on the canvas and has the below signature.
|
This API can be used to customise the mouse cursor on the canvas and has the below signature. It sets the mouse cursor to the cursor passed in param.
|
||||||
It sets the mouse cursor to the cursor passed in param.
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
(cursor: string) => void
|
(cursor: string) => void
|
||||||
|
|||||||
+31
-2
@@ -146,8 +146,10 @@
|
|||||||
// setting this so that libraries installation reuses this window tab.
|
// setting this so that libraries installation reuses this window tab.
|
||||||
window.name = "_excalidraw";
|
window.name = "_excalidraw";
|
||||||
</script>
|
</script>
|
||||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
|
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
|
||||||
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
|
||||||
|
<!-- LEGACY GOOGLE ANALYTICS -->
|
||||||
|
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||||
<script
|
<script
|
||||||
async
|
async
|
||||||
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||||
@@ -160,6 +162,33 @@
|
|||||||
gtag("js", new Date());
|
gtag("js", new Date());
|
||||||
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||||
</script>
|
</script>
|
||||||
|
<% } %>
|
||||||
|
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||||
|
|
||||||
|
<!-- Matomo -->
|
||||||
|
<% if (process.env.REACT_APP_MATOMO_URL &&
|
||||||
|
process.env.REACT_APP_MATOMO_SITE_ID &&
|
||||||
|
process.env.REACT_APP_CDN_MATOMO_TRACKER_URL) { %>
|
||||||
|
<script>
|
||||||
|
var _paq = (window._paq = window._paq || []);
|
||||||
|
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||||
|
_paq.push(["trackPageView"]);
|
||||||
|
_paq.push(["enableLinkTracking"]);
|
||||||
|
(function () {
|
||||||
|
var u = "%REACT_APP_MATOMO_URL%";
|
||||||
|
_paq.push(["setTrackerUrl", u + "matomo.php"]);
|
||||||
|
_paq.push(["setSiteId", "%REACT_APP_MATOMO_SITE_ID%"]);
|
||||||
|
var d = document,
|
||||||
|
g = d.createElement("script"),
|
||||||
|
s = d.getElementsByTagName("script")[0];
|
||||||
|
g.async = true;
|
||||||
|
g.src = "%REACT_APP_CDN_MATOMO_TRACKER_URL%";
|
||||||
|
s.parentNode.insertBefore(g, s);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
<!-- end Matomo analytics -->
|
||||||
|
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoomToFitElements = (
|
export const zoomToFitElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
zoomToSelection: boolean,
|
zoomToSelection: boolean,
|
||||||
|
|||||||
+30
-22
@@ -1,22 +1,30 @@
|
|||||||
export const trackEvent =
|
export const trackEvent = (
|
||||||
typeof process !== "undefined" &&
|
category: string,
|
||||||
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
action: string,
|
||||||
typeof window !== "undefined" &&
|
label?: string,
|
||||||
window.gtag
|
value?: number,
|
||||||
? (category: string, action: string, label?: string, value?: number) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
window.gtag("event", action, {
|
// Uncomment the next line to track locally
|
||||||
event_category: category,
|
// console.log("Track Event", { category, action, label, value });
|
||||||
event_label: label,
|
|
||||||
value,
|
if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
|
||||||
});
|
return;
|
||||||
} catch (error) {
|
}
|
||||||
console.error("error logging to ga", error);
|
|
||||||
}
|
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
|
||||||
}
|
window.gtag("event", action, {
|
||||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
event_category: category,
|
||||||
? (category: string, action: string, label?: string, value?: number) => {}
|
event_label: label,
|
||||||
: (category: string, action: string, label?: string, value?: number) => {
|
value,
|
||||||
// Uncomment the next line to track locally
|
});
|
||||||
// console.log("Track Event", { category, action, label, value });
|
}
|
||||||
};
|
|
||||||
|
// MATOMO event tracking _paq must be same as the one in index.html
|
||||||
|
if (window._paq) {
|
||||||
|
window._paq.push(["trackEvent", category, action, label, value]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("error during analytics", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
+106
-20
@@ -229,6 +229,7 @@ import {
|
|||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
|
easeToValuesRAF,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -284,7 +285,10 @@ import {
|
|||||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||||
import { Fonts } from "../scene/Fonts";
|
import { Fonts } from "../scene/Fonts";
|
||||||
import { actionPaste } from "../actions/actionClipboard";
|
import { actionPaste } from "../actions/actionClipboard";
|
||||||
import { actionToggleHandTool } from "../actions/actionCanvas";
|
import {
|
||||||
|
actionToggleHandTool,
|
||||||
|
zoomToFitElements,
|
||||||
|
} from "../actions/actionCanvas";
|
||||||
import { jotaiStore } from "../jotai";
|
import { jotaiStore } from "../jotai";
|
||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||||
@@ -1357,6 +1361,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
document.querySelector(".excalidraw")!,
|
document.querySelector(".excalidraw")!,
|
||||||
).getPropertyValue("--color-selection");
|
).getPropertyValue("--color-selection");
|
||||||
|
|
||||||
|
//const now = Date.now();
|
||||||
|
//if (!this.state.shouldCacheIgnoreZoom) {
|
||||||
|
// console.log(`renderScene`, now);
|
||||||
|
//}
|
||||||
renderScene(
|
renderScene(
|
||||||
{
|
{
|
||||||
elements: renderingElements,
|
elements: renderingElements,
|
||||||
@@ -1393,11 +1401,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (this.state.scrolledOutside !== scrolledOutside) {
|
if (this.state.scrolledOutside !== scrolledOutside) {
|
||||||
this.setState({ scrolledOutside });
|
this.setState({ scrolledOutside });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scheduleImageRefresh();
|
this.scheduleImageRefresh();
|
||||||
|
//if (!this.state.shouldCacheIgnoreZoom) {
|
||||||
|
// setTimeout(() => console.log(`after renderScene`, now));
|
||||||
|
//}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
THROTTLE_NEXT_RENDER && window.EXCALIDRAW_THROTTLE_RENDER === true,
|
true ||
|
||||||
|
(THROTTLE_NEXT_RENDER && window.EXCALIDRAW_THROTTLE_RENDER === true),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!THROTTLE_NEXT_RENDER) {
|
if (!THROTTLE_NEXT_RENDER) {
|
||||||
@@ -1843,18 +1854,89 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.actionManager.executeAction(actionToggleHandTool);
|
this.actionManager.executeAction(actionToggleHandTool);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zooms on canvas viewport center
|
||||||
|
*/
|
||||||
|
zoomCanvas = (
|
||||||
|
/** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
|
||||||
|
value: number,
|
||||||
|
) => {
|
||||||
|
this.setState({
|
||||||
|
...getStateForZoom(
|
||||||
|
{
|
||||||
|
viewportX: this.state.width / 2 + this.state.offsetLeft,
|
||||||
|
viewportY: this.state.height / 2 + this.state.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(value),
|
||||||
|
},
|
||||||
|
this.state,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private cancelInProgresAnimation: (() => void) | null = null;
|
||||||
|
|
||||||
scrollToContent = (
|
scrollToContent = (
|
||||||
target:
|
target:
|
||||||
| ExcalidrawElement
|
| ExcalidrawElement
|
||||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||||
|
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
|
||||||
) => {
|
) => {
|
||||||
this.setState({
|
this.cancelInProgresAnimation?.();
|
||||||
...calculateScrollCenter(
|
|
||||||
Array.isArray(target) ? target : [target],
|
// convert provided target into ExcalidrawElement[] if necessary
|
||||||
this.state,
|
const targets = Array.isArray(target) ? target : [target];
|
||||||
this.canvas,
|
|
||||||
),
|
let zoom = this.state.zoom;
|
||||||
});
|
let scrollX = this.state.scrollX;
|
||||||
|
let scrollY = this.state.scrollY;
|
||||||
|
|
||||||
|
if (opts?.fitToContent) {
|
||||||
|
// compute an appropriate viewport location (scroll X, Y) and zoom level
|
||||||
|
// that fit the target elements on the scene
|
||||||
|
const { appState } = zoomToFitElements(targets, this.state, false);
|
||||||
|
zoom = appState.zoom;
|
||||||
|
scrollX = appState.scrollX;
|
||||||
|
scrollY = appState.scrollY;
|
||||||
|
} else {
|
||||||
|
// compute only the viewport location, without any zoom adjustment
|
||||||
|
const scroll = calculateScrollCenter(targets, this.state, this.canvas);
|
||||||
|
scrollX = scroll.scrollX;
|
||||||
|
scrollY = scroll.scrollY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// when animating, we use RequestAnimationFrame to prevent the animation
|
||||||
|
// from slowing down other processes
|
||||||
|
if (opts?.animate) {
|
||||||
|
const origScrollX = this.state.scrollX;
|
||||||
|
const origScrollY = this.state.scrollY;
|
||||||
|
|
||||||
|
// zoom animation could become problematic on scenes with large number
|
||||||
|
// of elements, setting it to its final value to improve user experience.
|
||||||
|
//
|
||||||
|
// using zoomCanvas() to zoom on current viewport center
|
||||||
|
this.zoomCanvas(zoom.value);
|
||||||
|
|
||||||
|
const cancel = easeToValuesRAF(
|
||||||
|
[origScrollX, origScrollY],
|
||||||
|
[scrollX, scrollY],
|
||||||
|
(scrollX, scrollY) => this.setState({ scrollX, scrollY }),
|
||||||
|
{ duration: opts?.duration ?? 500 },
|
||||||
|
);
|
||||||
|
this.cancelInProgresAnimation = () => {
|
||||||
|
cancel();
|
||||||
|
this.cancelInProgresAnimation = null;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.setState({ scrollX, scrollY, zoom });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** use when changing scrollX/scrollY/zoom based on user interaction */
|
||||||
|
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
||||||
|
state,
|
||||||
|
) => {
|
||||||
|
this.cancelInProgresAnimation?.();
|
||||||
|
this.setState(state);
|
||||||
};
|
};
|
||||||
|
|
||||||
setToast = (
|
setToast = (
|
||||||
@@ -2055,9 +2137,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
offset = -offset;
|
offset = -offset;
|
||||||
}
|
}
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
this.setState((state) => ({ scrollX: state.scrollX + offset }));
|
this.translateCanvas((state) => ({
|
||||||
|
scrollX: state.scrollX + offset,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
this.setState((state) => ({ scrollY: state.scrollY + offset }));
|
this.translateCanvas((state) => ({
|
||||||
|
scrollY: state.scrollY + offset,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2938,12 +3024,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
this.translateCanvas({
|
||||||
zoom: zoomState.zoom,
|
zoom: zoomState.zoom,
|
||||||
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
||||||
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
||||||
shouldCacheIgnoreZoom: true,
|
shouldCacheIgnoreZoom: true,
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
this.resetShouldCacheIgnoreZoomDebounced();
|
this.resetShouldCacheIgnoreZoomDebounced();
|
||||||
} else {
|
} else {
|
||||||
@@ -3719,7 +3805,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
this.translateCanvas({
|
||||||
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
|
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
|
||||||
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
|
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
|
||||||
});
|
});
|
||||||
@@ -4865,7 +4951,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||||
const x = event.clientX;
|
const x = event.clientX;
|
||||||
const dx = x - pointerDownState.lastCoords.x;
|
const dx = x - pointerDownState.lastCoords.x;
|
||||||
this.setState({
|
this.translateCanvas({
|
||||||
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
||||||
});
|
});
|
||||||
pointerDownState.lastCoords.x = x;
|
pointerDownState.lastCoords.x = x;
|
||||||
@@ -4875,7 +4961,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (pointerDownState.scrollbars.isOverVertical) {
|
if (pointerDownState.scrollbars.isOverVertical) {
|
||||||
const y = event.clientY;
|
const y = event.clientY;
|
||||||
const dy = y - pointerDownState.lastCoords.y;
|
const dy = y - pointerDownState.lastCoords.y;
|
||||||
this.setState({
|
this.translateCanvas({
|
||||||
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
||||||
});
|
});
|
||||||
pointerDownState.lastCoords.y = y;
|
pointerDownState.lastCoords.y = y;
|
||||||
@@ -6304,7 +6390,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// reduced amplification for small deltas (small movements on a trackpad)
|
// reduced amplification for small deltas (small movements on a trackpad)
|
||||||
Math.min(1, absDelta / 20);
|
Math.min(1, absDelta / 20);
|
||||||
|
|
||||||
this.setState((state) => ({
|
this.translateCanvas((state) => ({
|
||||||
...getStateForZoom(
|
...getStateForZoom(
|
||||||
{
|
{
|
||||||
viewportX: cursorX,
|
viewportX: cursorX,
|
||||||
@@ -6321,14 +6407,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
// scroll horizontally when shift pressed
|
// scroll horizontally when shift pressed
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
this.setState(({ zoom, scrollX }) => ({
|
this.translateCanvas(({ zoom, scrollX }) => ({
|
||||||
// on Mac, shift+wheel tends to result in deltaX
|
// on Mac, shift+wheel tends to result in deltaX
|
||||||
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
|
||||||
scrollX: scrollX - deltaX / zoom.value,
|
scrollX: scrollX - deltaX / zoom.value,
|
||||||
scrollY: scrollY - deltaY / zoom.value,
|
scrollY: scrollY - deltaY / zoom.value,
|
||||||
}));
|
}));
|
||||||
|
|||||||
+3
-1
@@ -495,7 +495,9 @@ export const restoreAppState = (
|
|||||||
? {
|
? {
|
||||||
value: appState.zoom as NormalizedZoomValue,
|
value: appState.zoom as NormalizedZoomValue,
|
||||||
}
|
}
|
||||||
: appState.zoom || defaultAppState.zoom,
|
: appState.zoom?.value
|
||||||
|
? appState.zoom
|
||||||
|
: defaultAppState.zoom,
|
||||||
// when sidebar docked and user left it open in last session,
|
// when sidebar docked and user left it open in last session,
|
||||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||||
openSidebar:
|
openSidebar:
|
||||||
|
|||||||
@@ -332,6 +332,12 @@ describe("Test getDefaultLineHeight", () => {
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
expect(getDefaultLineHeight()).toBe(1.25);
|
expect(getDefaultLineHeight()).toBe(1.25);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return line height using default font family for unknown font", () => {
|
||||||
|
const UNKNOWN_FONT = 5;
|
||||||
|
expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||||
|
});
|
||||||
|
|
||||||
it("should return correct line height", () => {
|
it("should return correct line height", () => {
|
||||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -887,7 +887,7 @@ const DEFAULT_LINE_HEIGHT = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||||
if (fontFamily) {
|
if (fontFamily in DEFAULT_LINE_HEIGHT) {
|
||||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||||
}
|
}
|
||||||
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
||||||
|
|||||||
@@ -526,6 +526,44 @@ describe("textWysiwyg", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => {
|
||||||
|
const diamond = API.createElement({
|
||||||
|
type: "diamond",
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 90,
|
||||||
|
height: 75,
|
||||||
|
});
|
||||||
|
h.elements = [diamond];
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
|
expect(h.elements[0].id).toBe(diamond.id);
|
||||||
|
|
||||||
|
API.setSelectedElements([diamond]);
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
|
const editor = document.querySelector(
|
||||||
|
".excalidraw-textEditorContainer > textarea",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
const value = new Array(1000).fill("1").join("\n");
|
||||||
|
|
||||||
|
// Pasting large text to simulate height increase
|
||||||
|
expect(() =>
|
||||||
|
fireEvent.input(editor, { target: { value } }),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(diamond.height).toBe(50020);
|
||||||
|
|
||||||
|
// Clearing text to simulate height decrease
|
||||||
|
expect(() =>
|
||||||
|
fireEvent.input(editor, { target: { value: "" } }),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(diamond.height).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
it("should bind text to container when double clicked on center of transparent container", async () => {
|
it("should bind text to container when double clicked on center of transparent container", async () => {
|
||||||
const rectangle = API.createElement({
|
const rectangle = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
@@ -1181,9 +1219,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(
|
expect(
|
||||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||||
).toEqual(36);
|
).toEqual(36);
|
||||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
|
||||||
96.39999999999999,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update line height when font family updated", async () => {
|
it("should update line height when font family updated", async () => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
wrapText,
|
wrapText,
|
||||||
getMaxContainerHeight,
|
getMaxContainerHeight,
|
||||||
getMaxContainerWidth,
|
getMaxContainerWidth,
|
||||||
|
computeContainerDimensionForBoundText,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
actionDecreaseFontSize,
|
actionDecreaseFontSize,
|
||||||
@@ -208,11 +209,12 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
// autogrow container height if text exceeds
|
// autogrow container height if text exceeds
|
||||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||||
const diff = Math.min(
|
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||||
textElementHeight - maxHeight,
|
textElementHeight,
|
||||||
element.lineHeight,
|
container.type,
|
||||||
);
|
);
|
||||||
mutateElement(container, { height: containerDims.height + diff });
|
|
||||||
|
mutateElement(container, { height: targetContainerHeight });
|
||||||
return;
|
return;
|
||||||
} else if (
|
} else if (
|
||||||
// autoshrink container height until original container height
|
// autoshrink container height until original container height
|
||||||
@@ -221,11 +223,11 @@ export const textWysiwyg = ({
|
|||||||
containerDims.height > originalContainerData.height &&
|
containerDims.height > originalContainerData.height &&
|
||||||
textElementHeight < maxHeight
|
textElementHeight < maxHeight
|
||||||
) {
|
) {
|
||||||
const diff = Math.min(
|
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||||
maxHeight - textElementHeight,
|
textElementHeight,
|
||||||
element.lineHeight,
|
container.type,
|
||||||
);
|
);
|
||||||
mutateElement(container, { height: containerDims.height - diff });
|
mutateElement(container, { height: targetContainerHeight });
|
||||||
}
|
}
|
||||||
// Start pushing text upward until a diff of 30px (padding)
|
// Start pushing text upward until a diff of 30px (padding)
|
||||||
// is reached
|
// is reached
|
||||||
|
|||||||
Vendored
+2
@@ -18,6 +18,8 @@ interface Window {
|
|||||||
EXCALIDRAW_EXPORT_SOURCE: string;
|
EXCALIDRAW_EXPORT_SOURCE: string;
|
||||||
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
|
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
|
||||||
gtag: Function;
|
gtag: Function;
|
||||||
|
_paq: any[];
|
||||||
|
_mtm: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasRenderingContext2D {
|
interface CanvasRenderingContext2D {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ 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)
|
||||||
|
|
||||||
- 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)
|
||||||
|
|
||||||
- [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes
|
- [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { RenderConfig } from "../scene/types";
|
|||||||
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
||||||
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { AppState, BinaryFiles, Zoom } from "../types";
|
import { AppState, BinaryFiles, NormalizedZoomValue, Zoom } from "../types";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
@@ -93,6 +93,50 @@ export interface ExcalidrawElementWithCanvas {
|
|||||||
boundTextElementVersion: number | null;
|
boundTextElementVersion: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const cappedElementCanvasSize = (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
zoom: Zoom,
|
||||||
|
): {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
zoomValue: NormalizedZoomValue;
|
||||||
|
} => {
|
||||||
|
const sizelimit = 16777216; // 2^24
|
||||||
|
const padding = getCanvasPadding(element);
|
||||||
|
let zoomValue = zoom.value;
|
||||||
|
|
||||||
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
|
|
||||||
|
let width = distance(x1, x2) * window.devicePixelRatio + padding * 2;
|
||||||
|
let height = distance(y1, y2) * window.devicePixelRatio + padding * 2;
|
||||||
|
|
||||||
|
const size = width * height * zoomValue * zoomValue;
|
||||||
|
if (size > sizelimit) {
|
||||||
|
zoomValue = Math.sqrt(
|
||||||
|
sizelimit / (width * height),
|
||||||
|
) as NormalizedZoomValue;
|
||||||
|
width = distance(x1, x2) * window.devicePixelRatio + padding * 2;
|
||||||
|
height = distance(y1, y2) * window.devicePixelRatio + padding * 2;
|
||||||
|
}
|
||||||
|
width *= zoomValue;
|
||||||
|
height *= zoomValue;
|
||||||
|
return { width, height, zoomValue };
|
||||||
|
}
|
||||||
|
let width = element.width * window.devicePixelRatio + padding * 2;
|
||||||
|
let height = element.height * window.devicePixelRatio + padding * 2;
|
||||||
|
|
||||||
|
const size = width * height * zoomValue * zoomValue;
|
||||||
|
if (size > sizelimit) {
|
||||||
|
zoomValue = Math.sqrt(sizelimit / (width * height)) as NormalizedZoomValue;
|
||||||
|
width = element.width * window.devicePixelRatio + padding * 2;
|
||||||
|
height = element.height * window.devicePixelRatio + padding * 2;
|
||||||
|
}
|
||||||
|
width *= zoomValue;
|
||||||
|
height *= zoomValue;
|
||||||
|
return { width, height, zoomValue };
|
||||||
|
};
|
||||||
|
|
||||||
const generateElementCanvas = (
|
const generateElementCanvas = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
@@ -102,44 +146,35 @@ const generateElementCanvas = (
|
|||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
const padding = getCanvasPadding(element);
|
const padding = getCanvasPadding(element);
|
||||||
|
|
||||||
|
const { width, height, zoomValue } = cappedElementCanvasSize(element, zoom);
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
let canvasOffsetX = 0;
|
let canvasOffsetX = 0;
|
||||||
let canvasOffsetY = 0;
|
let canvasOffsetY = 0;
|
||||||
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1] = getElementAbsoluteCoords(element);
|
||||||
|
|
||||||
canvas.width =
|
|
||||||
distance(x1, x2) * window.devicePixelRatio * zoom.value +
|
|
||||||
padding * zoom.value * 2;
|
|
||||||
canvas.height =
|
|
||||||
distance(y1, y2) * window.devicePixelRatio * zoom.value +
|
|
||||||
padding * zoom.value * 2;
|
|
||||||
|
|
||||||
canvasOffsetX =
|
canvasOffsetX =
|
||||||
element.x > x1
|
element.x > x1
|
||||||
? distance(element.x, x1) * window.devicePixelRatio * zoom.value
|
? distance(element.x, x1) * window.devicePixelRatio * zoomValue
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
canvasOffsetY =
|
canvasOffsetY =
|
||||||
element.y > y1
|
element.y > y1
|
||||||
? distance(element.y, y1) * window.devicePixelRatio * zoom.value
|
? distance(element.y, y1) * window.devicePixelRatio * zoomValue
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
context.translate(canvasOffsetX, canvasOffsetY);
|
context.translate(canvasOffsetX, canvasOffsetY);
|
||||||
} else {
|
|
||||||
canvas.width =
|
|
||||||
element.width * window.devicePixelRatio * zoom.value +
|
|
||||||
padding * zoom.value * 2;
|
|
||||||
canvas.height =
|
|
||||||
element.height * window.devicePixelRatio * zoom.value +
|
|
||||||
padding * zoom.value * 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
context.save();
|
context.save();
|
||||||
context.translate(padding * zoom.value, padding * zoom.value);
|
context.translate(padding * zoomValue, padding * zoomValue);
|
||||||
context.scale(
|
context.scale(
|
||||||
window.devicePixelRatio * zoom.value,
|
window.devicePixelRatio * zoomValue,
|
||||||
window.devicePixelRatio * zoom.value,
|
window.devicePixelRatio * zoomValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rc = rough.canvas(canvas);
|
const rc = rough.canvas(canvas);
|
||||||
@@ -156,7 +191,7 @@ const generateElementCanvas = (
|
|||||||
element,
|
element,
|
||||||
canvas,
|
canvas,
|
||||||
theme: renderConfig.theme,
|
theme: renderConfig.theme,
|
||||||
canvasZoom: zoom.value,
|
canvasZoom: zoomValue,
|
||||||
canvasOffsetX,
|
canvasOffsetX,
|
||||||
canvasOffsetY,
|
canvasOffsetY,
|
||||||
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
||||||
@@ -422,6 +457,11 @@ const generateElementShape = (
|
|||||||
// `null` indicates no rc shape applicable for this element type
|
// `null` indicates no rc shape applicable for this element type
|
||||||
// (= do not generate anything)
|
// (= do not generate anything)
|
||||||
if (shape === undefined) {
|
if (shape === undefined) {
|
||||||
|
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||||
|
if (prevElementWithCanvas?.canvas) {
|
||||||
|
prevElementWithCanvas.canvas.width = 0;
|
||||||
|
prevElementWithCanvas.canvas.height = 0;
|
||||||
|
}
|
||||||
elementWithCanvasCache.delete(element);
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
@@ -685,7 +725,10 @@ const generateElementWithCanvas = (
|
|||||||
zoom,
|
zoom,
|
||||||
renderConfig,
|
renderConfig,
|
||||||
);
|
);
|
||||||
|
if (prevElementWithCanvas?.canvas) {
|
||||||
|
prevElementWithCanvas.canvas.width = 0;
|
||||||
|
prevElementWithCanvas.canvas.height = 0;
|
||||||
|
}
|
||||||
elementWithCanvasCache.set(element, elementWithCanvas);
|
elementWithCanvasCache.set(element, elementWithCanvas);
|
||||||
|
|
||||||
return elementWithCanvas;
|
return elementWithCanvas;
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ import {
|
|||||||
} from "../scene/scrollbars";
|
} from "../scene/scrollbars";
|
||||||
import { getSelectedElements } from "../scene/selection";
|
import { getSelectedElements } from "../scene/selection";
|
||||||
|
|
||||||
import { renderElement, renderElementToSvg } from "./renderElement";
|
import {
|
||||||
|
cappedElementCanvasSize,
|
||||||
|
renderElement,
|
||||||
|
renderElementToSvg,
|
||||||
|
} from "./renderElement";
|
||||||
import { getClientColors } from "../clients";
|
import { getClientColors } from "../clients";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
@@ -407,6 +411,21 @@ export const _renderScene = ({
|
|||||||
|
|
||||||
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
|
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
|
||||||
undefined;
|
undefined;
|
||||||
|
const start = Date.now();
|
||||||
|
const showDebug = false; //!appState.shouldCacheIgnoreZoom && (appState.zoom.value < 0.5);
|
||||||
|
if (showDebug) {
|
||||||
|
console.log("start: renderElements");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
visibleElements.length,
|
||||||
|
appState.zoom.value,
|
||||||
|
Math.round(
|
||||||
|
visibleElements.reduce((acc, el) => {
|
||||||
|
const { width, height } = cappedElementCanvasSize(el, appState.zoom);
|
||||||
|
return acc + width * height;
|
||||||
|
}, 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
visibleElements.forEach((element) => {
|
visibleElements.forEach((element) => {
|
||||||
try {
|
try {
|
||||||
renderElement(element, rc, context, renderConfig, appState);
|
renderElement(element, rc, context, renderConfig, appState);
|
||||||
@@ -426,7 +445,9 @@ export const _renderScene = ({
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (showDebug) {
|
||||||
|
console.log(`finish: renderElements ${Date.now() - start}`);
|
||||||
|
}
|
||||||
if (editingLinearElement) {
|
if (editingLinearElement) {
|
||||||
renderLinearPointHandles(
|
renderLinearPointHandles(
|
||||||
context,
|
context,
|
||||||
@@ -636,10 +657,8 @@ export const _renderScene = ({
|
|||||||
}
|
}
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset zoom
|
// Reset zoom
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|
||||||
// Paint remote pointers
|
// Paint remote pointers
|
||||||
for (const clientId in renderConfig.remotePointerViewportCoords) {
|
for (const clientId in renderConfig.remotePointerViewportCoords) {
|
||||||
let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
|
let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
|
||||||
@@ -787,7 +806,6 @@ export const _renderScene = ({
|
|||||||
});
|
});
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
|
return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { render } from "./test-utils";
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
|
||||||
|
import ExcalidrawApp from "../excalidraw-app";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe("fitToContent", () => {
|
||||||
|
it("should zoom to fit the selected element", async () => {
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
|
h.state.width = 10;
|
||||||
|
h.state.height = 10;
|
||||||
|
|
||||||
|
const rectElement = API.createElement({
|
||||||
|
width: 50,
|
||||||
|
height: 100,
|
||||||
|
x: 50,
|
||||||
|
y: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(h.state.zoom.value).toBe(1);
|
||||||
|
|
||||||
|
h.app.scrollToContent(rectElement, { fitToContent: true });
|
||||||
|
|
||||||
|
// element is 10x taller than the viewport size,
|
||||||
|
// zoom should be at least 1/10
|
||||||
|
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should zoom to fit multiple elements", async () => {
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
|
const topLeft = API.createElement({
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bottomRight = API.createElement({
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
x: 80,
|
||||||
|
y: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
h.state.width = 10;
|
||||||
|
h.state.height = 10;
|
||||||
|
|
||||||
|
expect(h.state.zoom.value).toBe(1);
|
||||||
|
|
||||||
|
h.app.scrollToContent([topLeft, bottomRight], {
|
||||||
|
fitToContent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// elements take 100x100, which is 10x bigger than the viewport size,
|
||||||
|
// zoom should be at least 1/10
|
||||||
|
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should scroll the viewport to the selected element", async () => {
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
|
h.state.width = 10;
|
||||||
|
h.state.height = 10;
|
||||||
|
|
||||||
|
const rectElement = API.createElement({
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(h.state.zoom.value).toBe(1);
|
||||||
|
expect(h.state.scrollX).toBe(0);
|
||||||
|
expect(h.state.scrollY).toBe(0);
|
||||||
|
|
||||||
|
h.app.scrollToContent(rectElement);
|
||||||
|
|
||||||
|
// zoom level should stay the same
|
||||||
|
expect(h.state.zoom.value).toBe(1);
|
||||||
|
|
||||||
|
// state should reflect some scrolling
|
||||||
|
expect(h.state.scrollX).not.toBe(0);
|
||||||
|
expect(h.state.scrollY).not.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const waitForNextAnimationFrame = () => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(resolve);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("fitToContent animated", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ease scroll the viewport to the selected element", async () => {
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
|
h.state.width = 10;
|
||||||
|
h.state.height = 10;
|
||||||
|
|
||||||
|
const rectElement = API.createElement({
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: -100,
|
||||||
|
y: -100,
|
||||||
|
});
|
||||||
|
|
||||||
|
h.app.scrollToContent(rectElement, { animate: true });
|
||||||
|
|
||||||
|
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Since this is an animation, we expect values to change through time.
|
||||||
|
// We'll verify that the scroll values change at 50ms and 100ms
|
||||||
|
expect(h.state.scrollX).toBe(0);
|
||||||
|
expect(h.state.scrollY).toBe(0);
|
||||||
|
|
||||||
|
await waitForNextAnimationFrame();
|
||||||
|
|
||||||
|
const prevScrollX = h.state.scrollX;
|
||||||
|
const prevScrollY = h.state.scrollY;
|
||||||
|
|
||||||
|
expect(h.state.scrollX).not.toBe(0);
|
||||||
|
expect(h.state.scrollY).not.toBe(0);
|
||||||
|
|
||||||
|
await waitForNextAnimationFrame();
|
||||||
|
|
||||||
|
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||||
|
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should animate the scroll but not the zoom", async () => {
|
||||||
|
await render(<ExcalidrawApp />);
|
||||||
|
|
||||||
|
h.state.width = 50;
|
||||||
|
h.state.height = 50;
|
||||||
|
|
||||||
|
const rectElement = API.createElement({
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(h.state.scrollX).toBe(0);
|
||||||
|
expect(h.state.scrollY).toBe(0);
|
||||||
|
|
||||||
|
h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const prevScrollX = h.state.scrollX;
|
||||||
|
const prevScrollY = h.state.scrollY;
|
||||||
|
|
||||||
|
expect(h.state.scrollX).not.toBe(0);
|
||||||
|
expect(h.state.scrollY).not.toBe(0);
|
||||||
|
|
||||||
|
await waitForNextAnimationFrame();
|
||||||
|
|
||||||
|
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||||
|
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||||
|
});
|
||||||
|
});
|
||||||
+95
-5
@@ -135,17 +135,24 @@ export const throttleRAF = <T extends any[]>(
|
|||||||
let timerId: number | null = null;
|
let timerId: number | null = null;
|
||||||
let lastArgs: T | null = null;
|
let lastArgs: T | null = null;
|
||||||
let lastArgsTrailing: T | null = null;
|
let lastArgsTrailing: T | null = null;
|
||||||
|
let watchdog: number | null = null;
|
||||||
|
|
||||||
const scheduleFunc = (args: T) => {
|
const scheduleFunc = (args: T) => {
|
||||||
timerId = window.requestAnimationFrame(() => {
|
timerId = window.requestAnimationFrame(() => {
|
||||||
timerId = null;
|
timerId = null;
|
||||||
|
//console.log("start render in animation frame");
|
||||||
fn(...args);
|
fn(...args);
|
||||||
|
//console.log("render done in animation frame");
|
||||||
lastArgs = null;
|
lastArgs = null;
|
||||||
if (lastArgsTrailing) {
|
if (lastArgsTrailing) {
|
||||||
|
//console.log("last args trailing", lastArgsTrailing);
|
||||||
lastArgs = lastArgsTrailing;
|
lastArgs = lastArgsTrailing;
|
||||||
lastArgsTrailing = null;
|
lastArgsTrailing = null;
|
||||||
scheduleFunc(lastArgs);
|
scheduleFunc(lastArgs);
|
||||||
}
|
}
|
||||||
|
if (watchdog) {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,6 +172,9 @@ export const throttleRAF = <T extends any[]>(
|
|||||||
if (timerId !== null) {
|
if (timerId !== null) {
|
||||||
cancelAnimationFrame(timerId);
|
cancelAnimationFrame(timerId);
|
||||||
timerId = null;
|
timerId = null;
|
||||||
|
if (watchdog) {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (lastArgs) {
|
if (lastArgs) {
|
||||||
fn(...(lastArgsTrailing || lastArgs));
|
fn(...(lastArgsTrailing || lastArgs));
|
||||||
@@ -176,11 +186,95 @@ export const throttleRAF = <T extends any[]>(
|
|||||||
if (timerId !== null) {
|
if (timerId !== null) {
|
||||||
cancelAnimationFrame(timerId);
|
cancelAnimationFrame(timerId);
|
||||||
timerId = null;
|
timerId = null;
|
||||||
|
if (watchdog) {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
watchdog = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
watchdog = window.setTimeout(() => {
|
||||||
|
console.log("watchdog", timerId);
|
||||||
|
if (timerId !== null) {
|
||||||
|
cancelAnimationFrame(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential ease-out method
|
||||||
|
*
|
||||||
|
* @param {number} k - The value to be tweened.
|
||||||
|
* @returns {number} The tweened value.
|
||||||
|
*/
|
||||||
|
function easeOut(k: number): number {
|
||||||
|
return 1 - Math.pow(1 - k, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute new values based on the same ease function and trigger the
|
||||||
|
* callback through a requestAnimationFrame call
|
||||||
|
*
|
||||||
|
* use `opts` to define a duration and/or an easeFn
|
||||||
|
*
|
||||||
|
* for example:
|
||||||
|
* ```ts
|
||||||
|
* easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c))
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param fromValues The initial values, must be numeric
|
||||||
|
* @param toValues The destination values, must also be numeric
|
||||||
|
* @param callback The callback receiving the values
|
||||||
|
* @param opts default to 250ms duration and the easeOut function
|
||||||
|
*/
|
||||||
|
export const easeToValuesRAF = (
|
||||||
|
fromValues: number[],
|
||||||
|
toValues: number[],
|
||||||
|
callback: (...values: number[]) => void,
|
||||||
|
opts?: { duration?: number; easeFn?: (value: number) => number },
|
||||||
|
) => {
|
||||||
|
let canceled = false;
|
||||||
|
let frameId = 0;
|
||||||
|
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) {
|
||||||
|
if (canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startTime === undefined) {
|
||||||
|
startTime = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = timestamp - startTime;
|
||||||
|
|
||||||
|
if (elapsed < duration) {
|
||||||
|
// console.log(elapsed, duration, elapsed / duration);
|
||||||
|
const factor = easeFn(elapsed / duration);
|
||||||
|
const newValues = fromValues.map(
|
||||||
|
(fromValue, index) =>
|
||||||
|
(toValues[index] - fromValue) * factor + fromValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
callback(...newValues);
|
||||||
|
frameId = window.requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
// ensure final values are reached at the end of the transition
|
||||||
|
callback(...toValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frameId = window.requestAnimationFrame(step);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
window.cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// https://github.com/lodash/lodash/blob/es/chunk.js
|
// https://github.com/lodash/lodash/blob/es/chunk.js
|
||||||
export const chunk = <T extends any>(
|
export const chunk = <T extends any>(
|
||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
@@ -616,11 +710,7 @@ export const arrayToMapWithIndex = <T extends { id: string }>(
|
|||||||
return acc;
|
return acc;
|
||||||
}, new Map<string, [element: T, index: number]>());
|
}, new Map<string, [element: T, index: number]>());
|
||||||
|
|
||||||
export const isTestEnv = () =>
|
export const isTestEnv = () => process.env.NODE_ENV === "test";
|
||||||
typeof process !== "undefined" && process.env?.NODE_ENV === "test";
|
|
||||||
|
|
||||||
export const isProdEnv = () =>
|
|
||||||
typeof process !== "undefined" && process.env?.NODE_ENV === "production";
|
|
||||||
|
|
||||||
export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => {
|
export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => {
|
||||||
return new CustomEvent(name, {
|
return new CustomEvent(name, {
|
||||||
|
|||||||
Reference in New Issue
Block a user