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
|
||||
|
||||
# MATOMO
|
||||
REACT_APP_MATOMO_URL=
|
||||
REACT_APP_CDN_MATOMO_TRACKER_URL=
|
||||
REACT_APP_MATOMO_SITE_ID=
|
||||
|
||||
#Debug flags
|
||||
|
||||
# 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"}'
|
||||
|
||||
# production-only vars
|
||||
# GOOGLE ANALYTICS
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
An open source virtual hand-drawn style whiteboard. </br>
|
||||
Collaborative and end-to-end encrypted. </br>
|
||||
<br />
|
||||
</h3>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
# ref
|
||||
|
||||
<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>
|
||||
|
||||
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 (
|
||||
<div style={{ height: "500px" }}>
|
||||
<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)} />
|
||||
</div>
|
||||
);
|
||||
@@ -187,7 +202,8 @@ function App() {
|
||||
return (
|
||||
<div style={{ height: "500px" }}>
|
||||
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
|
||||
<button className="custom-button"
|
||||
<button
|
||||
className="custom-button"
|
||||
onClick={() => {
|
||||
const libraryItems = [
|
||||
{
|
||||
@@ -205,10 +221,8 @@ function App() {
|
||||
];
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems,
|
||||
openLibraryMenu: true
|
||||
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
|
||||
}}
|
||||
>
|
||||
Update Library
|
||||
@@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
|
||||
|
||||
<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[]
|
||||
</a>
|
||||
</pre>
|
||||
@@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
|
||||
|
||||
<pre>
|
||||
() => 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
|
||||
</a>
|
||||
[]>
|
||||
@@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
|
||||
## scrollToContent
|
||||
|
||||
<pre>
|
||||
(target?:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
(<br />
|
||||
{" "}
|
||||
target?:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</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
|
||||
</a>
|
||||
[]) => void
|
||||
[],
|
||||
<br />
|
||||
{" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number
|
||||
}
|
||||
<br />) => void
|
||||
</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
|
||||
|
||||
@@ -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.
|
||||
|
||||
```tsx
|
||||
({ message: string, closable?:boolean,duration?:number
|
||||
({ message: string, closable?:boolean,duration?:number
|
||||
} | 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.
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
## setCursor
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```tsx
|
||||
(cursor: string) => void
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dwelle/tunnel-rat": "0.1.1",
|
||||
"@excalidraw/roughjs": "4.5.3",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
|
||||
+32
-3
@@ -94,7 +94,7 @@
|
||||
// if its present redirect
|
||||
document.cookie.includes("excplus-autoredirect=true")
|
||||
) {
|
||||
window.location.href = "https://app.excalidraw.com";
|
||||
// window.location.href = "https://app.excalidraw.com";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -146,8 +146,10 @@
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
|
||||
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
|
||||
|
||||
<!-- LEGACY GOOGLE ANALYTICS -->
|
||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||
@@ -160,6 +162,33 @@
|
||||
gtag("js", new Date());
|
||||
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||
</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) -->
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
@@ -185,107 +186,110 @@ export const actionCreateContainerFromText = register({
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (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) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const updatedElements = elements.slice();
|
||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||
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,
|
||||
});
|
||||
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||
const containerIds: AppState["selectedElementIds"] = {};
|
||||
|
||||
// update bindings
|
||||
if (textElement.boundElements?.length) {
|
||||
const linearElementIds = textElement.boundElements
|
||||
.filter((ele) => ele.type === "arrow")
|
||||
.map((el) => el.id);
|
||||
const linearElements = updatedElements.filter((ele) =>
|
||||
linearElementIds.includes(ele.id),
|
||||
) as ExcalidrawLinearElement[];
|
||||
linearElements.forEach((ele) => {
|
||||
let startBinding = ele.startBinding;
|
||||
let endBinding = ele.endBinding;
|
||||
|
||||
if (startBinding?.elementId === textElement.id) {
|
||||
startBinding = {
|
||||
...startBinding,
|
||||
elementId: container.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (endBinding?.elementId === textElement.id) {
|
||||
endBinding = { ...endBinding, elementId: container.id };
|
||||
}
|
||||
|
||||
if (startBinding || endBinding) {
|
||||
mutateElement(ele, { startBinding, endBinding });
|
||||
}
|
||||
for (const textElement of selectedElements) {
|
||||
if (isTextElement(textElement)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
});
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
// update bindings
|
||||
if (textElement.boundElements?.length) {
|
||||
const linearElementIds = textElement.boundElements
|
||||
.filter((ele) => ele.type === "arrow")
|
||||
.map((el) => el.id);
|
||||
const linearElements = updatedElements.filter((ele) =>
|
||||
linearElementIds.includes(ele.id),
|
||||
) as ExcalidrawLinearElement[];
|
||||
linearElements.forEach((ele) => {
|
||||
let startBinding = ele.startBinding;
|
||||
let endBinding = ele.endBinding;
|
||||
|
||||
return {
|
||||
elements: pushContainerBelowText(
|
||||
[...elements, container],
|
||||
if (startBinding?.elementId === textElement.id) {
|
||||
startBinding = {
|
||||
...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,
|
||||
textElement,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {
|
||||
[container.id]: true,
|
||||
[textElement.id]: false,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
);
|
||||
containerIds[container.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
};
|
||||
|
||||
const zoomToFitElements = (
|
||||
export const zoomToFitElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
zoomToSelection: boolean,
|
||||
|
||||
+30
-22
@@ -1,22 +1,30 @@
|
||||
export const trackEvent =
|
||||
typeof process !== "undefined" &&
|
||||
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
||||
typeof window !== "undefined" &&
|
||||
window.gtag
|
||||
? (category: string, action: string, label?: string, value?: number) => {
|
||||
try {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("error logging to ga", error);
|
||||
}
|
||||
}
|
||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||
? (category: string, action: string, label?: string, value?: number) => {}
|
||||
: (category: string, action: string, label?: string, value?: number) => {
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
};
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
action: string,
|
||||
label?: string,
|
||||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
|
||||
if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: 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 { flushSync } from "react-dom";
|
||||
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { RoughCanvas } from "@excalidraw/roughjs/bin/canvas";
|
||||
import rough from "@excalidraw/roughjs/bin/rough";
|
||||
import clsx from "clsx";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -229,6 +229,7 @@ import {
|
||||
updateActiveTool,
|
||||
getShortcutKey,
|
||||
isTransparent,
|
||||
easeToValuesRAF,
|
||||
} from "../utils";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -284,7 +285,10 @@ import {
|
||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import { actionPaste } from "../actions/actionClipboard";
|
||||
import { actionToggleHandTool } from "../actions/actionCanvas";
|
||||
import {
|
||||
actionToggleHandTool,
|
||||
zoomToFitElements,
|
||||
} from "../actions/actionCanvas";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||
@@ -1843,18 +1847,89 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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 = (
|
||||
target:
|
||||
| ExcalidrawElement
|
||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
|
||||
) => {
|
||||
this.setState({
|
||||
...calculateScrollCenter(
|
||||
Array.isArray(target) ? target : [target],
|
||||
this.state,
|
||||
this.canvas,
|
||||
),
|
||||
});
|
||||
this.cancelInProgresAnimation?.();
|
||||
|
||||
// convert provided target into ExcalidrawElement[] if necessary
|
||||
const targets = Array.isArray(target) ? target : [target];
|
||||
|
||||
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 = (
|
||||
@@ -2055,9 +2130,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
offset = -offset;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
this.setState((state) => ({ scrollX: state.scrollX + offset }));
|
||||
this.translateCanvas((state) => ({
|
||||
scrollX: state.scrollX + offset,
|
||||
}));
|
||||
} 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,
|
||||
);
|
||||
|
||||
return {
|
||||
this.translateCanvas({
|
||||
zoom: zoomState.zoom,
|
||||
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
||||
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
||||
shouldCacheIgnoreZoom: true,
|
||||
};
|
||||
});
|
||||
});
|
||||
this.resetShouldCacheIgnoreZoomDebounced();
|
||||
} else {
|
||||
@@ -3719,7 +3798,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollX: this.state.scrollX - deltaX / 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) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
@@ -4875,7 +4954,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
||||
});
|
||||
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)
|
||||
Math.min(1, absDelta / 20);
|
||||
|
||||
this.setState((state) => ({
|
||||
this.translateCanvas((state) => ({
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: cursorX,
|
||||
@@ -6321,14 +6400,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// scroll horizontally when shift pressed
|
||||
if (event.shiftKey) {
|
||||
this.setState(({ zoom, scrollX }) => ({
|
||||
this.translateCanvas(({ zoom, scrollX }) => ({
|
||||
// on Mac, shift+wheel tends to result in deltaX
|
||||
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
||||
this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
|
||||
scrollX: scrollX - deltaX / zoom.value,
|
||||
scrollY: scrollY - deltaY / zoom.value,
|
||||
}));
|
||||
|
||||
+3
-1
@@ -495,7 +495,9 @@ export const restoreAppState = (
|
||||
? {
|
||||
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,
|
||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||
openSidebar:
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
import rough from "@excalidraw/roughjs/bin/rough";
|
||||
import { Drawable, Op } from "@excalidraw/roughjs/bin/core";
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
getShapeForElement,
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
import { Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { Drawable } from "@excalidraw/roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
|
||||
@@ -332,6 +332,12 @@ describe("Test getDefaultLineHeight", () => {
|
||||
//@ts-ignore
|
||||
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", () => {
|
||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
|
||||
@@ -887,7 +887,7 @@ const DEFAULT_LINE_HEIGHT = {
|
||||
};
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily) {
|
||||
if (fontFamily in DEFAULT_LINE_HEIGHT) {
|
||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||
}
|
||||
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 () => {
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
@@ -1181,9 +1219,7 @@ describe("textWysiwyg", () => {
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||
).toEqual(36);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
|
||||
96.39999999999999,
|
||||
);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
|
||||
});
|
||||
|
||||
it("should update line height when font family updated", async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
wrapText,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
@@ -208,11 +209,12 @@ export const textWysiwyg = ({
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
const diff = Math.min(
|
||||
textElementHeight - maxHeight,
|
||||
element.lineHeight,
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
textElementHeight,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
return;
|
||||
} else if (
|
||||
// autoshrink container height until original container height
|
||||
@@ -221,11 +223,11 @@ export const textWysiwyg = ({
|
||||
containerDims.height > originalContainerData.height &&
|
||||
textElementHeight < maxHeight
|
||||
) {
|
||||
const diff = Math.min(
|
||||
maxHeight - textElementHeight,
|
||||
element.lineHeight,
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
textElementHeight,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height - diff });
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
@@ -295,6 +297,7 @@ export const textWysiwyg = ({
|
||||
filter: "var(--theme-filter)",
|
||||
maxHeight: `${editorMaxHeight}px`,
|
||||
});
|
||||
editable.scrollTop = 0;
|
||||
// For some reason updating font attribute doesn't set font family
|
||||
// hence updating font family explicitly for test environment
|
||||
if (isTestEnv()) {
|
||||
|
||||
Vendored
+2
@@ -18,6 +18,8 @@ interface Window {
|
||||
EXCALIDRAW_EXPORT_SOURCE: string;
|
||||
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
|
||||
gtag: Function;
|
||||
_paq: any[];
|
||||
_mtm: any[];
|
||||
}
|
||||
|
||||
interface CanvasRenderingContext2D {
|
||||
|
||||
@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### 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)
|
||||
|
||||
- [`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 { isTestEnv } from "./utils";
|
||||
|
||||
|
||||
@@ -21,15 +21,15 @@ import {
|
||||
getElementAbsoluteCoords,
|
||||
getArrowheadPoints,
|
||||
} from "../element/bounds";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { Drawable, Options } from "roughjs/bin/core";
|
||||
import { RoughSVG } from "roughjs/bin/svg";
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
import { RoughCanvas } from "@excalidraw/roughjs/bin/canvas";
|
||||
import { Drawable, Options } from "@excalidraw/roughjs/bin/core";
|
||||
import { RoughSVG } from "@excalidraw/roughjs/bin/svg";
|
||||
import { RoughGenerator } from "@excalidraw/roughjs/bin/generator";
|
||||
|
||||
import { RenderConfig } from "../scene/types";
|
||||
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
||||
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 { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
@@ -87,12 +87,66 @@ export interface ExcalidrawElementWithCanvas {
|
||||
element: ExcalidrawElement | ExcalidrawTextElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
theme: RenderConfig["theme"];
|
||||
canvasZoom: Zoom["value"];
|
||||
scale: number;
|
||||
canvasOffsetX: number;
|
||||
canvasOffsetY: number;
|
||||
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 = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
zoom: Zoom,
|
||||
@@ -102,44 +156,35 @@ const generateElementCanvas = (
|
||||
const context = canvas.getContext("2d")!;
|
||||
const padding = getCanvasPadding(element);
|
||||
|
||||
const { width, height, scale } = cappedElementCanvasSize(element, zoom);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
let canvasOffsetX = 0;
|
||||
let canvasOffsetY = 0;
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const [x1, y1, x2, y2] = 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;
|
||||
const [x1, y1] = getElementAbsoluteCoords(element);
|
||||
|
||||
canvasOffsetX =
|
||||
element.x > x1
|
||||
? distance(element.x, x1) * window.devicePixelRatio * zoom.value
|
||||
? distance(element.x, x1) * window.devicePixelRatio * scale
|
||||
: 0;
|
||||
|
||||
canvasOffsetY =
|
||||
element.y > y1
|
||||
? distance(element.y, y1) * window.devicePixelRatio * zoom.value
|
||||
? distance(element.y, y1) * window.devicePixelRatio * scale
|
||||
: 0;
|
||||
|
||||
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.translate(padding * zoom.value, padding * zoom.value);
|
||||
context.translate(padding * scale, padding * scale);
|
||||
context.scale(
|
||||
window.devicePixelRatio * zoom.value,
|
||||
window.devicePixelRatio * zoom.value,
|
||||
window.devicePixelRatio * scale,
|
||||
window.devicePixelRatio * scale,
|
||||
);
|
||||
|
||||
const rc = rough.canvas(canvas);
|
||||
@@ -156,7 +201,7 @@ const generateElementCanvas = (
|
||||
element,
|
||||
canvas,
|
||||
theme: renderConfig.theme,
|
||||
canvasZoom: zoom.value,
|
||||
scale,
|
||||
canvasOffsetX,
|
||||
canvasOffsetY,
|
||||
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
||||
@@ -344,6 +389,16 @@ export const setShapeForElement = <T extends ExcalidrawElement>(
|
||||
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
|
||||
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 = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
@@ -370,7 +425,10 @@ export const generateRoughOptions = (
|
||||
// calculate them (and we don't want the fills to be modified)
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: element.roughness,
|
||||
roughness: adjustRoughness(
|
||||
Math.min(element.width, element.height),
|
||||
element.roughness,
|
||||
),
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices: continuousPath,
|
||||
};
|
||||
@@ -670,7 +728,7 @@ const generateElementWithCanvas = (
|
||||
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||
const shouldRegenerateBecauseZoom =
|
||||
prevElementWithCanvas &&
|
||||
prevElementWithCanvas.canvasZoom !== zoom.value &&
|
||||
prevElementWithCanvas.scale !== zoom.value &&
|
||||
!renderConfig?.shouldCacheIgnoreZoom;
|
||||
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
||||
|
||||
@@ -701,7 +759,7 @@ const drawElementFromCanvas = (
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
const padding = getCanvasPadding(element);
|
||||
const zoom = elementWithCanvas.canvasZoom;
|
||||
const zoom = elementWithCanvas.scale;
|
||||
let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
|
||||
// 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));
|
||||
tempCanvas.width =
|
||||
maxDim * window.devicePixelRatio * zoom +
|
||||
padding * elementWithCanvas.canvasZoom * 10;
|
||||
padding * elementWithCanvas.scale * 10;
|
||||
tempCanvas.height =
|
||||
maxDim * window.devicePixelRatio * zoom +
|
||||
padding * elementWithCanvas.canvasZoom * 10;
|
||||
padding * elementWithCanvas.scale * 10;
|
||||
const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2;
|
||||
const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2;
|
||||
|
||||
@@ -812,11 +870,11 @@ const drawElementFromCanvas = (
|
||||
context.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
(x1 + renderConfig.scrollX) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
(y1 + renderConfig.scrollY) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { RoughSVG } from "roughjs/bin/svg";
|
||||
import { RoughCanvas } from "@excalidraw/roughjs/bin/canvas";
|
||||
import { RoughSVG } from "@excalidraw/roughjs/bin/svg";
|
||||
import oc from "open-color";
|
||||
|
||||
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 { getCommonBounds } from "../element/bounds";
|
||||
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,
|
||||
} from "./element/types";
|
||||
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 { SuggestedBinding } from "./element/binding";
|
||||
import { ImportedDataState } from "./data/types";
|
||||
|
||||
+74
-5
@@ -181,6 +181,79 @@ export const throttleRAF = <T extends any[]>(
|
||||
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
|
||||
export const chunk = <T extends any>(
|
||||
array: readonly T[],
|
||||
@@ -616,11 +689,7 @@ export const arrayToMapWithIndex = <T extends { id: string }>(
|
||||
return acc;
|
||||
}, new Map<string, [element: T, index: number]>());
|
||||
|
||||
export const isTestEnv = () =>
|
||||
typeof process !== "undefined" && process.env?.NODE_ENV === "test";
|
||||
|
||||
export const isProdEnv = () =>
|
||||
typeof process !== "undefined" && process.env?.NODE_ENV === "production";
|
||||
export const isTestEnv = () => process.env.NODE_ENV === "test";
|
||||
|
||||
export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => {
|
||||
return new CustomEvent(name, {
|
||||
|
||||
@@ -1484,6 +1484,15 @@
|
||||
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==
|
||||
|
||||
"@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":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.4.0.tgz#d6716f9fa36a6e340bc0ecfe68af325aa6f60508"
|
||||
|
||||
Reference in New Issue
Block a user