Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f32b7c1ef | ||
|
|
b885d87bc5 | ||
|
|
c170403b13 | ||
|
|
705ac9c1ab | ||
|
|
68692b9d4c | ||
|
|
d61b3cf83d | ||
|
|
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
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
An open source virtual hand-drawn style whiteboard. </br>
|
An open source virtual hand-drawn style whiteboard. </br>
|
||||||
Collaborative and end-to-end encrypted. </br>
|
Collaborative and end-to-end encrypted. </br>
|
||||||
<br />
|
<br />
|
||||||
</h3>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dwelle/tunnel-rat": "0.1.1",
|
"@dwelle/tunnel-rat": "0.1.1",
|
||||||
|
"@excalidraw/roughjs": "4.5.3",
|
||||||
"@sentry/browser": "6.2.5",
|
"@sentry/browser": "6.2.5",
|
||||||
"@sentry/integrations": "6.2.5",
|
"@sentry/integrations": "6.2.5",
|
||||||
"@testing-library/jest-dom": "5.16.2",
|
"@testing-library/jest-dom": "5.16.2",
|
||||||
|
|||||||
+32
-3
@@ -94,7 +94,7 @@
|
|||||||
// if its present redirect
|
// if its present redirect
|
||||||
document.cookie.includes("excplus-autoredirect=true")
|
document.cookie.includes("excplus-autoredirect=true")
|
||||||
) {
|
) {
|
||||||
window.location.href = "https://app.excalidraw.com";
|
// window.location.href = "https://app.excalidraw.com";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -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) -->
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { AppState } from "../types";
|
||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -185,107 +186,110 @@ export const actionCreateContainerFromText = register({
|
|||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState) => {
|
predicate: (elements, appState) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
return selectedElements.length === 1 && isTextElement(selectedElements[0]);
|
const areTextElements = selectedElements.every((el) => isTextElement(el));
|
||||||
|
return selectedElements.length > 0 && areTextElements;
|
||||||
},
|
},
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
const updatedElements = elements.slice();
|
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
const containerIds: AppState["selectedElementIds"] = {};
|
||||||
const textElement = selectedElements[0];
|
|
||||||
const container = newElement({
|
|
||||||
type: "rectangle",
|
|
||||||
backgroundColor: appState.currentItemBackgroundColor,
|
|
||||||
boundElements: [
|
|
||||||
...(textElement.boundElements || []),
|
|
||||||
{ id: textElement.id, type: "text" },
|
|
||||||
],
|
|
||||||
angle: textElement.angle,
|
|
||||||
fillStyle: appState.currentItemFillStyle,
|
|
||||||
strokeColor: appState.currentItemStrokeColor,
|
|
||||||
roughness: appState.currentItemRoughness,
|
|
||||||
strokeWidth: appState.currentItemStrokeWidth,
|
|
||||||
strokeStyle: appState.currentItemStrokeStyle,
|
|
||||||
roundness:
|
|
||||||
appState.currentItemRoundness === "round"
|
|
||||||
? {
|
|
||||||
type: isUsingAdaptiveRadius("rectangle")
|
|
||||||
? ROUNDNESS.ADAPTIVE_RADIUS
|
|
||||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
opacity: 100,
|
|
||||||
locked: false,
|
|
||||||
x: textElement.x - BOUND_TEXT_PADDING,
|
|
||||||
y: textElement.y - BOUND_TEXT_PADDING,
|
|
||||||
width: computeContainerDimensionForBoundText(
|
|
||||||
textElement.width,
|
|
||||||
"rectangle",
|
|
||||||
),
|
|
||||||
height: computeContainerDimensionForBoundText(
|
|
||||||
textElement.height,
|
|
||||||
"rectangle",
|
|
||||||
),
|
|
||||||
groupIds: textElement.groupIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
// update bindings
|
for (const textElement of selectedElements) {
|
||||||
if (textElement.boundElements?.length) {
|
if (isTextElement(textElement)) {
|
||||||
const linearElementIds = textElement.boundElements
|
const container = newElement({
|
||||||
.filter((ele) => ele.type === "arrow")
|
type: "rectangle",
|
||||||
.map((el) => el.id);
|
backgroundColor: appState.currentItemBackgroundColor,
|
||||||
const linearElements = updatedElements.filter((ele) =>
|
boundElements: [
|
||||||
linearElementIds.includes(ele.id),
|
...(textElement.boundElements || []),
|
||||||
) as ExcalidrawLinearElement[];
|
{ id: textElement.id, type: "text" },
|
||||||
linearElements.forEach((ele) => {
|
],
|
||||||
let startBinding = ele.startBinding;
|
angle: textElement.angle,
|
||||||
let endBinding = ele.endBinding;
|
fillStyle: appState.currentItemFillStyle,
|
||||||
|
strokeColor: appState.currentItemStrokeColor,
|
||||||
if (startBinding?.elementId === textElement.id) {
|
roughness: appState.currentItemRoughness,
|
||||||
startBinding = {
|
strokeWidth: appState.currentItemStrokeWidth,
|
||||||
...startBinding,
|
strokeStyle: appState.currentItemStrokeStyle,
|
||||||
elementId: container.id,
|
roundness:
|
||||||
};
|
appState.currentItemRoundness === "round"
|
||||||
}
|
? {
|
||||||
|
type: isUsingAdaptiveRadius("rectangle")
|
||||||
if (endBinding?.elementId === textElement.id) {
|
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||||
endBinding = { ...endBinding, elementId: container.id };
|
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
}
|
}
|
||||||
|
: null,
|
||||||
if (startBinding || endBinding) {
|
opacity: 100,
|
||||||
mutateElement(ele, { startBinding, endBinding });
|
locked: false,
|
||||||
}
|
x: textElement.x - BOUND_TEXT_PADDING,
|
||||||
|
y: textElement.y - BOUND_TEXT_PADDING,
|
||||||
|
width: computeContainerDimensionForBoundText(
|
||||||
|
textElement.width,
|
||||||
|
"rectangle",
|
||||||
|
),
|
||||||
|
height: computeContainerDimensionForBoundText(
|
||||||
|
textElement.height,
|
||||||
|
"rectangle",
|
||||||
|
),
|
||||||
|
groupIds: textElement.groupIds,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
mutateElement(textElement, {
|
// update bindings
|
||||||
containerId: container.id,
|
if (textElement.boundElements?.length) {
|
||||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
const linearElementIds = textElement.boundElements
|
||||||
boundElements: null,
|
.filter((ele) => ele.type === "arrow")
|
||||||
});
|
.map((el) => el.id);
|
||||||
redrawTextBoundingBox(textElement, container);
|
const linearElements = updatedElements.filter((ele) =>
|
||||||
|
linearElementIds.includes(ele.id),
|
||||||
|
) as ExcalidrawLinearElement[];
|
||||||
|
linearElements.forEach((ele) => {
|
||||||
|
let startBinding = ele.startBinding;
|
||||||
|
let endBinding = ele.endBinding;
|
||||||
|
|
||||||
return {
|
if (startBinding?.elementId === textElement.id) {
|
||||||
elements: pushContainerBelowText(
|
startBinding = {
|
||||||
[...elements, container],
|
...startBinding,
|
||||||
|
elementId: container.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endBinding?.elementId === textElement.id) {
|
||||||
|
endBinding = { ...endBinding, elementId: container.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startBinding || endBinding) {
|
||||||
|
mutateElement(ele, { startBinding, endBinding }, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mutateElement(
|
||||||
|
textElement,
|
||||||
|
{
|
||||||
|
containerId: container.id,
|
||||||
|
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||||
|
boundElements: null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
redrawTextBoundingBox(textElement, container);
|
||||||
|
|
||||||
|
updatedElements = pushContainerBelowText(
|
||||||
|
[...updatedElements, container],
|
||||||
container,
|
container,
|
||||||
textElement,
|
textElement,
|
||||||
),
|
);
|
||||||
appState: {
|
containerIds[container.id] = true;
|
||||||
...appState,
|
}
|
||||||
selectedElementIds: {
|
|
||||||
[container.id]: true,
|
|
||||||
[textElement.id]: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: updatedElements,
|
elements: updatedElements,
|
||||||
appState,
|
appState: {
|
||||||
|
...appState,
|
||||||
|
selectedElementIds: containerIds,
|
||||||
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
+99
-20
@@ -1,8 +1,8 @@
|
|||||||
import React, { useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "@excalidraw/roughjs/bin/canvas";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "@excalidraw/roughjs/bin/rough";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -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";
|
||||||
@@ -1843,18 +1847,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 +2130,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 +3017,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 +3798,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 +4944,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 +4954,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 +6383,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 +6400,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:
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { distance2d, rotate } from "../math";
|
import { distance2d, rotate } from "../math";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "@excalidraw/roughjs/bin/rough";
|
||||||
import { Drawable, Op } from "roughjs/bin/core";
|
import { Drawable, Op } from "@excalidraw/roughjs/bin/core";
|
||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
import {
|
import {
|
||||||
getShapeForElement,
|
getShapeForElement,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
|
|
||||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
import { Drawable } from "roughjs/bin/core";
|
import { Drawable } from "@excalidraw/roughjs/bin/core";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShapeForElement } from "../renderer/renderElement";
|
import { getShapeForElement } from "../renderer/renderElement";
|
||||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -295,6 +297,7 @@ export const textWysiwyg = ({
|
|||||||
filter: "var(--theme-filter)",
|
filter: "var(--theme-filter)",
|
||||||
maxHeight: `${editorMaxHeight}px`,
|
maxHeight: `${editorMaxHeight}px`,
|
||||||
});
|
});
|
||||||
|
editable.scrollTop = 0;
|
||||||
// For some reason updating font attribute doesn't set font family
|
// For some reason updating font attribute doesn't set font family
|
||||||
// hence updating font family explicitly for test environment
|
// hence updating font family explicitly for test environment
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
|
|||||||
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
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Random } from "roughjs/bin/math";
|
import { Random } from "@excalidraw/roughjs/bin/math";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { isTestEnv } from "./utils";
|
import { isTestEnv } from "./utils";
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ import {
|
|||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getArrowheadPoints,
|
getArrowheadPoints,
|
||||||
} from "../element/bounds";
|
} from "../element/bounds";
|
||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "@excalidraw/roughjs/bin/canvas";
|
||||||
import { Drawable, Options } from "roughjs/bin/core";
|
import { Drawable, Options } from "@excalidraw/roughjs/bin/core";
|
||||||
import { RoughSVG } from "roughjs/bin/svg";
|
import { RoughSVG } from "@excalidraw/roughjs/bin/svg";
|
||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
import { RoughGenerator } from "@excalidraw/roughjs/bin/generator";
|
||||||
|
|
||||||
import { RenderConfig } from "../scene/types";
|
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 "@excalidraw/roughjs/bin/rough";
|
||||||
import { AppState, BinaryFiles, Zoom } from "../types";
|
import { AppState, BinaryFiles, Zoom } from "../types";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import {
|
import {
|
||||||
@@ -87,12 +87,66 @@ export interface ExcalidrawElementWithCanvas {
|
|||||||
element: ExcalidrawElement | ExcalidrawTextElement;
|
element: ExcalidrawElement | ExcalidrawTextElement;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
theme: RenderConfig["theme"];
|
theme: RenderConfig["theme"];
|
||||||
canvasZoom: Zoom["value"];
|
scale: number;
|
||||||
canvasOffsetX: number;
|
canvasOffsetX: number;
|
||||||
canvasOffsetY: number;
|
canvasOffsetY: number;
|
||||||
boundTextElementVersion: number | null;
|
boundTextElementVersion: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cappedElementCanvasSize = (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
zoom: Zoom,
|
||||||
|
): {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
scale: number;
|
||||||
|
} => {
|
||||||
|
// these limits are ballpark, they depend on specific browsers and device.
|
||||||
|
// We've chosen lower limits to be safe. We might want to change these limits
|
||||||
|
// based on browser/device type, if we get reports of low quality rendering
|
||||||
|
// on zoom.
|
||||||
|
//
|
||||||
|
// ~ safari mobile canvas area limit
|
||||||
|
const AREA_LIMIT = 16777216;
|
||||||
|
// ~ safari width/height limit based on developer.mozilla.org.
|
||||||
|
const WIDTH_HEIGHT_LIMIT = 32767;
|
||||||
|
|
||||||
|
const padding = getCanvasPadding(element);
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
|
const elementWidth =
|
||||||
|
isLinearElement(element) || isFreeDrawElement(element)
|
||||||
|
? distance(x1, x2)
|
||||||
|
: element.width;
|
||||||
|
const elementHeight =
|
||||||
|
isLinearElement(element) || isFreeDrawElement(element)
|
||||||
|
? distance(y1, y2)
|
||||||
|
: element.height;
|
||||||
|
|
||||||
|
let width = elementWidth * window.devicePixelRatio + padding * 2;
|
||||||
|
let height = elementHeight * window.devicePixelRatio + padding * 2;
|
||||||
|
|
||||||
|
let scale: number = zoom.value;
|
||||||
|
|
||||||
|
// rescale to ensure width and height is within limits
|
||||||
|
if (
|
||||||
|
width * scale > WIDTH_HEIGHT_LIMIT ||
|
||||||
|
height * scale > WIDTH_HEIGHT_LIMIT
|
||||||
|
) {
|
||||||
|
scale = Math.min(WIDTH_HEIGHT_LIMIT / width, WIDTH_HEIGHT_LIMIT / height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rescale to ensure canvas area is within limits
|
||||||
|
if (width * height * scale * scale > AREA_LIMIT) {
|
||||||
|
scale = Math.sqrt(AREA_LIMIT / (width * height));
|
||||||
|
}
|
||||||
|
|
||||||
|
width = Math.floor(width * scale);
|
||||||
|
height = Math.floor(height * scale);
|
||||||
|
|
||||||
|
return { width, height, scale };
|
||||||
|
};
|
||||||
|
|
||||||
const generateElementCanvas = (
|
const generateElementCanvas = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
@@ -102,44 +156,35 @@ const generateElementCanvas = (
|
|||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
const padding = getCanvasPadding(element);
|
const padding = getCanvasPadding(element);
|
||||||
|
|
||||||
|
const { width, height, scale } = 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 * scale
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
canvasOffsetY =
|
canvasOffsetY =
|
||||||
element.y > y1
|
element.y > y1
|
||||||
? distance(element.y, y1) * window.devicePixelRatio * zoom.value
|
? distance(element.y, y1) * window.devicePixelRatio * scale
|
||||||
: 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 * scale, padding * scale);
|
||||||
context.scale(
|
context.scale(
|
||||||
window.devicePixelRatio * zoom.value,
|
window.devicePixelRatio * scale,
|
||||||
window.devicePixelRatio * zoom.value,
|
window.devicePixelRatio * scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rc = rough.canvas(canvas);
|
const rc = rough.canvas(canvas);
|
||||||
@@ -156,7 +201,7 @@ const generateElementCanvas = (
|
|||||||
element,
|
element,
|
||||||
canvas,
|
canvas,
|
||||||
theme: renderConfig.theme,
|
theme: renderConfig.theme,
|
||||||
canvasZoom: zoom.value,
|
scale,
|
||||||
canvasOffsetX,
|
canvasOffsetX,
|
||||||
canvasOffsetY,
|
canvasOffsetY,
|
||||||
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
||||||
@@ -344,6 +389,16 @@ export const setShapeForElement = <T extends ExcalidrawElement>(
|
|||||||
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
|
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
|
||||||
shapeCache.delete(element);
|
shapeCache.delete(element);
|
||||||
|
|
||||||
|
function adjustRoughness(size: number, roughness: number): number {
|
||||||
|
if (size >= 50) {
|
||||||
|
return roughness;
|
||||||
|
}
|
||||||
|
const factor = 2 + (50 - size) / 10;
|
||||||
|
|
||||||
|
// console.log({ ret: roughness / factor, roughness, factor, size });
|
||||||
|
return roughness / factor;
|
||||||
|
}
|
||||||
|
|
||||||
export const generateRoughOptions = (
|
export const generateRoughOptions = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
continuousPath = false,
|
continuousPath = false,
|
||||||
@@ -370,7 +425,10 @@ export const generateRoughOptions = (
|
|||||||
// calculate them (and we don't want the fills to be modified)
|
// calculate them (and we don't want the fills to be modified)
|
||||||
fillWeight: element.strokeWidth / 2,
|
fillWeight: element.strokeWidth / 2,
|
||||||
hachureGap: element.strokeWidth * 4,
|
hachureGap: element.strokeWidth * 4,
|
||||||
roughness: element.roughness,
|
roughness: adjustRoughness(
|
||||||
|
Math.min(element.width, element.height),
|
||||||
|
element.roughness,
|
||||||
|
),
|
||||||
stroke: element.strokeColor,
|
stroke: element.strokeColor,
|
||||||
preserveVertices: continuousPath,
|
preserveVertices: continuousPath,
|
||||||
};
|
};
|
||||||
@@ -670,7 +728,7 @@ const generateElementWithCanvas = (
|
|||||||
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||||
const shouldRegenerateBecauseZoom =
|
const shouldRegenerateBecauseZoom =
|
||||||
prevElementWithCanvas &&
|
prevElementWithCanvas &&
|
||||||
prevElementWithCanvas.canvasZoom !== zoom.value &&
|
prevElementWithCanvas.scale !== zoom.value &&
|
||||||
!renderConfig?.shouldCacheIgnoreZoom;
|
!renderConfig?.shouldCacheIgnoreZoom;
|
||||||
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
||||||
|
|
||||||
@@ -701,7 +759,7 @@ const drawElementFromCanvas = (
|
|||||||
) => {
|
) => {
|
||||||
const element = elementWithCanvas.element;
|
const element = elementWithCanvas.element;
|
||||||
const padding = getCanvasPadding(element);
|
const padding = getCanvasPadding(element);
|
||||||
const zoom = elementWithCanvas.canvasZoom;
|
const zoom = elementWithCanvas.scale;
|
||||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
|
|
||||||
// Free draw elements will otherwise "shuffle" as the min x and y change
|
// Free draw elements will otherwise "shuffle" as the min x and y change
|
||||||
@@ -728,10 +786,10 @@ const drawElementFromCanvas = (
|
|||||||
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
|
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
|
||||||
tempCanvas.width =
|
tempCanvas.width =
|
||||||
maxDim * window.devicePixelRatio * zoom +
|
maxDim * window.devicePixelRatio * zoom +
|
||||||
padding * elementWithCanvas.canvasZoom * 10;
|
padding * elementWithCanvas.scale * 10;
|
||||||
tempCanvas.height =
|
tempCanvas.height =
|
||||||
maxDim * window.devicePixelRatio * zoom +
|
maxDim * window.devicePixelRatio * zoom +
|
||||||
padding * elementWithCanvas.canvasZoom * 10;
|
padding * elementWithCanvas.scale * 10;
|
||||||
const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2;
|
const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2;
|
||||||
const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2;
|
const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2;
|
||||||
|
|
||||||
@@ -812,11 +870,11 @@ const drawElementFromCanvas = (
|
|||||||
context.drawImage(
|
context.drawImage(
|
||||||
elementWithCanvas.canvas!,
|
elementWithCanvas.canvas!,
|
||||||
(x1 + renderConfig.scrollX) * window.devicePixelRatio -
|
(x1 + renderConfig.scrollX) * window.devicePixelRatio -
|
||||||
(padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
|
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||||
(y1 + renderConfig.scrollY) * window.devicePixelRatio -
|
(y1 + renderConfig.scrollY) * window.devicePixelRatio -
|
||||||
(padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
|
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||||
elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
|
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
||||||
elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
|
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "@excalidraw/roughjs/bin/canvas";
|
||||||
import { RoughSVG } from "roughjs/bin/svg";
|
import { RoughSVG } from "@excalidraw/roughjs/bin/svg";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
|
|
||||||
import { AppState, BinaryFiles, Point, Zoom } from "../types";
|
import { AppState, BinaryFiles, Point, Zoom } from "../types";
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "@excalidraw/roughjs/bin/rough";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds } from "../element/bounds";
|
||||||
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -16,7 +16,7 @@ import {
|
|||||||
StrokeRoundness,
|
StrokeRoundness,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { SHAPES } from "./shapes";
|
import { SHAPES } from "./shapes";
|
||||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
import { Point as RoughPoint } from "@excalidraw/roughjs/bin/geometry";
|
||||||
import { LinearElementEditor } from "./element/linearElementEditor";
|
import { LinearElementEditor } from "./element/linearElementEditor";
|
||||||
import { SuggestedBinding } from "./element/binding";
|
import { SuggestedBinding } from "./element/binding";
|
||||||
import { ImportedDataState } from "./data/types";
|
import { ImportedDataState } from "./data/types";
|
||||||
|
|||||||
+74
-5
@@ -181,6 +181,79 @@ export const throttleRAF = <T extends any[]>(
|
|||||||
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 +689,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, {
|
||||||
|
|||||||
@@ -1484,6 +1484,15 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"
|
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"
|
||||||
integrity sha512-rFIq8+A8WvkEzBsF++Rw6gzxE+hU3ZNkdg8foI+Upz2y/rOC/gUpWJaggPbCkoH3nlREVU59axQjZ1+F6ePRGg==
|
integrity sha512-rFIq8+A8WvkEzBsF++Rw6gzxE+hU3ZNkdg8foI+Upz2y/rOC/gUpWJaggPbCkoH3nlREVU59axQjZ1+F6ePRGg==
|
||||||
|
|
||||||
|
"@excalidraw/roughjs@4.5.3":
|
||||||
|
version "4.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@excalidraw/roughjs/-/roughjs-4.5.3.tgz#7d91897bfd1aaeb19a48d5272bd4776cb19f015b"
|
||||||
|
integrity sha512-X9HPfNjPLNHt+gN7t1LN7nN4h4o8M2EWuN/Y4YgG0ODsDZn51gRBeIPuhSo2hsDgLeTQHnkrmpPTm4XeeQVtoA==
|
||||||
|
dependencies:
|
||||||
|
path-data-parser "^0.1.0"
|
||||||
|
points-on-curve "^0.2.0"
|
||||||
|
points-on-path "^0.2.1"
|
||||||
|
|
||||||
"@firebase/analytics-types@0.4.0":
|
"@firebase/analytics-types@0.4.0":
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.4.0.tgz#d6716f9fa36a6e340bc0ecfe68af325aa6f60508"
|
resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.4.0.tgz#d6716f9fa36a6e340bc0ecfe68af325aa6f60508"
|
||||||
|
|||||||
Reference in New Issue
Block a user