Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-04-08 09:52:03 -05:00
commit ef347cc685
40 changed files with 1265 additions and 426 deletions

View File

@ -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

View File

@ -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

View File

@ -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 />

View File

@ -1,6 +1,19 @@
# ref # ref
<pre> <pre>
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> &#124; <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> &#124; <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> &#124; <br/>&#123; current: &#123; 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>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
callbackRef
</a>{" "}
&#124; <br />
&#123; current: &#123; 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&#60; () => NonDeleted&#60;
<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>
[]&#62; []&#62;
@ -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>{" "}
&#124;{" "} &#124;{" "}
<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?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<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 &#124; 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
@ -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/> &#123; type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]&#124; "eraser" &#125; &#124;<br/> &#123; type: "custom"; customType: string &#125;) => void (tool: <br /> &#123; type:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
SHAPES
</a>
[number]["value"]&#124; "eraser" &#125; &#124;
<br /> &#123; type: "custom"; customType: string &#125;) => 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

View File

@ -7542,9 +7542,9 @@ webpack-sources@^3.2.2, webpack-sources@^3.2.3:
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5.73.0: webpack@^5.73.0:
version "5.74.0" version "5.76.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.74.0.tgz#02a5dac19a17e0bb47093f2be67c695102a55980" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.1.tgz#7773de017e988bccb0f13c7d75ec245f377d295c"
integrity sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA== integrity sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==
dependencies: dependencies:
"@types/eslint-scope" "^3.7.3" "@types/eslint-scope" "^3.7.3"
"@types/estree" "^0.0.51" "@types/estree" "^0.0.51"

View File

@ -147,8 +147,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%"
@ -161,6 +163,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) -->

View File

@ -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 { register } from "./register"; import { register } from "./register";
export const actionUnbindText = register({ export const actionUnbindText = register({
@ -182,99 +183,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 = null; angle: textElement.angle,
let endBinding = null; fillStyle: appState.currentItemFillStyle,
if (ele.startBinding) { strokeColor: appState.currentItemStrokeColor,
startBinding = { ...ele.startBinding, elementId: container.id }; roughness: appState.currentItemRoughness,
} strokeWidth: appState.currentItemStrokeWidth,
if (ele.endBinding) { strokeStyle: appState.currentItemStrokeStyle,
endBinding = { ...ele.endBinding, elementId: container.id }; roundness:
} appState.currentItemRoundness === "round"
mutateElement(ele, { startBinding, endBinding }); ? {
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, { // 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,
}; };
}, },

View File

@ -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,

View File

@ -54,6 +54,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
getDefaultLineHeight,
} from "../element/textElement"; } from "../element/textElement";
import { import {
isBoundToContainer, isBoundToContainer,
@ -637,6 +638,7 @@ export const actionChangeFontFamily = register({
oldElement, oldElement,
{ {
fontFamily: value, fontFamily: value,
lineHeight: getDefaultLineHeight(value),
}, },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(newElement, getContainerElement(oldElement));

View File

@ -12,7 +12,10 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
} from "../constants"; } from "../constants";
import { getBoundTextElement } from "../element/textElement"; import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
@ -92,12 +95,18 @@ export const actionPasteStyles = register({
}); });
if (isTextElement(newElement)) { if (isTextElement(newElement)) {
const fontSize =
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
const fontFamily =
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
newElement = newElementWith(newElement, { newElement = newElementWith(newElement, {
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE, fontSize,
fontFamily: fontFamily,
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
textAlign: textAlign:
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN, elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
lineHeight:
elementStylesToCopyFrom.lineHeight ||
getDefaultLineHeight(fontFamily),
}); });
let container = null; let container = null;
if (newElement.containerId) { if (newElement.containerId) {

View File

@ -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);
}
};

View File

@ -230,6 +230,7 @@ import {
updateActiveTool, updateActiveTool,
getShortcutKey, getShortcutKey,
isTransparent, isTransparent,
easeToValuesRAF,
} from "../utils"; } from "../utils";
import { import {
ContextMenu, ContextMenu,
@ -269,13 +270,14 @@ import throttle from "lodash.throttle";
import { fileOpen, FileSystemHandle } from "../data/filesystem"; import { fileOpen, FileSystemHandle } from "../data/filesystem";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getApproxLineHeight,
getApproxMinLineHeight, getApproxMinLineHeight,
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
getDefaultLineHeight,
getLineHeightInPx,
getTextBindableContainerAtPosition, getTextBindableContainerAtPosition,
isMeasureTextSupported, isMeasureTextSupported,
isValidTextContainer, isValidTextContainer,
@ -292,7 +294,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";
@ -1793,12 +1798,14 @@ class App extends React.Component<AppProps, AppState> {
(acc: ExcalidrawTextElement[], line, idx) => { (acc: ExcalidrawTextElement[], line, idx) => {
const text = line.trim(); const text = line.trim();
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) { if (text.length) {
const element = newTextElement({ const element = newTextElement({
...textElementProps, ...textElementProps,
x, x,
y: currentY, y: currentY,
text, text,
lineHeight,
}); });
acc.push(element); acc.push(element);
currentY += element.height + LINE_GAP; currentY += element.height + LINE_GAP;
@ -1807,14 +1814,9 @@ class App extends React.Component<AppProps, AppState> {
// add paragraph only if previous line was not empty, IOW don't add // add paragraph only if previous line was not empty, IOW don't add
// more than one empty line // more than one empty line
if (prevLine) { if (prevLine) {
const defaultLineHeight = getApproxLineHeight( currentY +=
getFontString({ getLineHeightInPx(textElementProps.fontSize, lineHeight) +
fontSize: textElementProps.fontSize, LINE_GAP;
fontFamily: textElementProps.fontFamily,
}),
);
currentY += defaultLineHeight + LINE_GAP;
} }
} }
@ -1907,18 +1909,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 = (
@ -2119,9 +2192,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,
}));
} }
} }
@ -2669,6 +2746,13 @@ class App extends React.Component<AppProps, AppState> {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
} }
const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
const lineHeight =
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
const fontSize = this.state.currentItemFontSize;
if ( if (
!existingTextElement && !existingTextElement &&
shouldBindToContainer && shouldBindToContainer &&
@ -2676,11 +2760,14 @@ class App extends React.Component<AppProps, AppState> {
!isArrowElement(container) !isArrowElement(container)
) { ) {
const fontString = { const fontString = {
fontSize: this.state.currentItemFontSize, fontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily,
}; };
const minWidth = getApproxMinLineWidth(getFontString(fontString)); const minWidth = getApproxMinLineWidth(
const minHeight = getApproxMinLineHeight(getFontString(fontString)); getFontString(fontString),
lineHeight,
);
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const containerDims = getContainerDims(container); const containerDims = getContainerDims(container);
const newHeight = Math.max(containerDims.height, minHeight); const newHeight = Math.max(containerDims.height, minHeight);
const newWidth = Math.max(containerDims.width, minWidth); const newWidth = Math.max(containerDims.width, minWidth);
@ -2714,8 +2801,8 @@ class App extends React.Component<AppProps, AppState> {
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
roundness: null, roundness: null,
text: "", text: "",
fontSize: this.state.currentItemFontSize, fontSize,
fontFamily: this.state.currentItemFontFamily, fontFamily,
textAlign: parentCenterPosition textAlign: parentCenterPosition
? "center" ? "center"
: this.state.currentItemTextAlign, : this.state.currentItemTextAlign,
@ -2726,6 +2813,7 @@ class App extends React.Component<AppProps, AppState> {
containerId: shouldBindToContainer ? container?.id : undefined, containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [], groupIds: container?.groupIds ?? [],
locked: false, locked: false,
lineHeight,
}); });
if (!existingTextElement && shouldBindToContainer && container) { if (!existingTextElement && shouldBindToContainer && container) {
@ -2992,12 +3080,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 {
@ -3773,7 +3861,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,
}); });
@ -4922,7 +5010,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;
@ -4932,7 +5020,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;
@ -6384,7 +6472,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,
@ -6401,14 +6489,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,
})); }));

View File

@ -89,7 +89,9 @@ export const exportCanvas = async (
return await fileSave(blob, { return await fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, name,
extension: appState.exportEmbedScene ? "excalidraw.png" : "png", // FIXME reintroduce `excalidraw.png` when most people upgrade away
// from 111.0.5563.64 (arm64), see #6349
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {

View File

@ -36,6 +36,7 @@ import { arrayToMap } from "../utils";
import { isValidSubtype } from "../subtypes"; import { isValidSubtype } from "../subtypes";
import oc from "open-color"; import oc from "open-color";
import { MarkOptional, Mutable } from "../utility-types"; import { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getDefaultLineHeight } from "../element/textElement";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@ -170,17 +171,32 @@ const restoreElement = (
const [fontPx, _fontFamily]: [string, string] = ( const [fontPx, _fontFamily]: [string, string] = (
element as any element as any
).font.split(" "); ).font.split(" ");
fontSize = parseInt(fontPx, 10); fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily); fontFamily = getFontFamilyByName(_fontFamily);
} }
const text = element.text ?? "";
element = restoreElementWithProperties(element, { element = restoreElementWithProperties(element, {
fontSize, fontSize,
fontFamily, fontFamily,
text: element.text ?? "", text,
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN, textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN, verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
containerId: element.containerId ?? null, containerId: element.containerId ?? null,
originalText: element.originalText || element.text, originalText: element.originalText || text,
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
// For the latter we want to detect the original line height which
// will likely differ from our per-font fixed line height we now use,
// to maintain backward compatibility.
lineHeight:
element.lineHeight ||
(element.height
? // detect line-height from current element height and font-size
detectLineHeight(element)
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily)),
}); });
if (refreshDimensions) { if (refreshDimensions) {
@ -490,7 +506,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:

View File

@ -786,7 +786,12 @@ export const findFocusPointForEllipse = (
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
squares; squares;
const n = (-m * px - 1) / py; let n = (-m * px - 1) / py;
if (n === 0) {
// if zero {-0, 0}, fall back to a same-sign value in the similar range
n = (Object.is(n, -0) ? -1 : 1) * 0.01;
}
const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
return GA.point(x, (-m * x - 1) / n); return GA.point(x, (-m * x - 1) / n);

View File

@ -29,6 +29,7 @@ import {
normalizeText, normalizeText,
wrapTextElement, wrapTextElement,
getMaxContainerWidth, getMaxContainerWidth,
getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
import { VERTICAL_ALIGN } from "../constants"; import { VERTICAL_ALIGN } from "../constants";
import { isArrowElement } from "./typeChecks"; import { isArrowElement } from "./typeChecks";
@ -168,15 +169,20 @@ export const newTextElement = (
textAlign: TextAlign; textAlign: TextAlign;
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"]; containerId?: ExcalidrawTextContainer["id"];
lineHeight?: ExcalidrawTextElement["lineHeight"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const map = getSubtypeMethods(opts?.subtype); const map = getSubtypeMethods(opts?.subtype);
map?.clean && map.clean(opts); map?.clean && map.clean(opts);
const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
const text = normalizeText(opts.text); const text = normalizeText(opts.text);
const metrics = measureTextElement(opts, { const metrics = measureTextElement(
text, { ...opts, lineHeight },
customData: opts.customData, {
}); text,
customData: opts.customData,
},
);
const offsets = getTextElementPositionOffsets(opts, metrics); const offsets = getTextElementPositionOffsets(opts, metrics);
const textElement = newElementWith( const textElement = newElementWith(
{ {
@ -192,6 +198,7 @@ export const newTextElement = (
height: metrics.height, height: metrics.height,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
lineHeight,
}, },
{}, {},
); );
@ -220,9 +227,7 @@ const getAdjustedDimensions = (
verticalAlign === VERTICAL_ALIGN.MIDDLE && verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId !element.containerId
) { ) {
const prevMetrics = measureTextElement(element, { const prevMetrics = measureTextElement(element);
fontSize: element.fontSize,
});
const offsets = getTextElementPositionOffsets(element, { const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width, width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height, height: nextHeight - prevMetrics.height,

View File

@ -39,13 +39,13 @@ import {
import { Point, PointerDownState } from "../types"; import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { import {
getApproxMinLineHeight,
getApproxMinLineWidth, getApproxMinLineWidth,
getBoundTextElement, getBoundTextElement,
getBoundTextElementId, getBoundTextElementId,
getContainerElement, getContainerElement,
handleBindTextResize, handleBindTextResize,
getMaxContainerWidth, getMaxContainerWidth,
getApproxMinLineHeight,
} from "./textElement"; } from "./textElement";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
@ -360,7 +360,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight; let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFont: { fontSize?: number } = {}; let boundTextFontSize: number | null = null;
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (transformHandleDirection.includes("e")) { if (transformHandleDirection.includes("e")) {
@ -410,9 +410,7 @@ export const resizeSingleElement = (
boundTextElement.id, boundTextElement.id,
) as typeof boundTextElement | undefined; ) as typeof boundTextElement | undefined;
if (stateOfBoundTextElementAtResize) { if (stateOfBoundTextElementAtResize) {
boundTextFont = { boundTextFontSize = stateOfBoundTextElementAtResize.fontSize;
fontSize: stateOfBoundTextElementAtResize.fontSize,
};
} }
if (shouldMaintainAspectRatio) { if (shouldMaintainAspectRatio) {
const updatedElement = { const updatedElement = {
@ -428,12 +426,16 @@ export const resizeSingleElement = (
if (nextFontSize === null) { if (nextFontSize === null) {
return; return;
} }
boundTextFont = { boundTextFontSize = nextFontSize;
fontSize: nextFontSize,
};
} else { } else {
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement)); const minWidth = getApproxMinLineWidth(
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement)); getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth)); eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight)); eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
} }
@ -566,8 +568,10 @@ export const resizeSingleElement = (
}); });
mutateElement(element, resizedElement); mutateElement(element, resizedElement);
if (boundTextElement && boundTextFont) { if (boundTextElement && boundTextFontSize != null) {
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize }); mutateElement(boundTextElement, {
fontSize: boundTextFontSize,
});
} }
handleBindTextResize(element, transformHandleDirection); handleBindTextResize(element, transformHandleDirection);
} }

View File

@ -1,4 +1,4 @@
import { BOUND_TEXT_PADDING } from "../constants"; import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { import {
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
@ -6,6 +6,9 @@ import {
getMaxContainerWidth, getMaxContainerWidth,
getMaxContainerHeight, getMaxContainerHeight,
wrapText, wrapText,
detectLineHeight,
getLineHeightInPx,
getDefaultLineHeight,
} from "./textElement"; } from "./textElement";
import { FontString } from "./types"; import { FontString } from "./types";
@ -40,9 +43,7 @@ describe("Test wrapText", () => {
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 80, width: 80,
res: `Hello res: `Hello \nwhats \nup`,
whats
up`,
}, },
{ {
desc: "break all characters when width of each character is less than container width", desc: "break all characters when width of each character is less than container width",
@ -64,8 +65,7 @@ p`,
desc: "break words as per the width", desc: "break words as per the width",
width: 140, width: 140,
res: `Hello whats res: `Hello whats \nup`,
up`,
}, },
{ {
desc: "fit the container", desc: "fit the container",
@ -95,9 +95,7 @@ whats up`;
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 80, width: 80,
res: `Hello res: `Hello\nwhats \nup`,
whats
up`,
}, },
{ {
desc: "break all characters when width of each character is less than container width", desc: "break all characters when width of each character is less than container width",
@ -143,11 +141,7 @@ whats up`,
{ {
desc: "fit characters of long string as per container width", desc: "fit characters of long string as per container width",
width: 170, width: 170,
res: `hellolongtextth res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
isiswhatsupwith
youIamtypingggg
gandtypinggg
break it now`,
}, },
{ {
@ -166,8 +160,7 @@ now`,
desc: "fit the long text when container width is greater than text length and move the rest to next line", desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600, width: 600,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
break it now`,
}, },
].forEach((data) => { ].forEach((data) => {
it(`should ${data.desc}`, () => { it(`should ${data.desc}`, () => {
@ -181,8 +174,14 @@ break it now`,
const text = "Hello Excalidraw"; const text = "Hello Excalidraw";
// Length of "Excalidraw" is 100 and exacty equal to max width // Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, 100); const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello expect(res).toEqual(`Hello \nExcalidraw`);
Excalidraw`); });
it("should return the text as is if max width is invalid", () => {
const text = "Hello Excalidraw";
expect(wrapText(text, font, NaN)).toEqual(text);
expect(wrapText(text, font, -1)).toEqual(text);
expect(wrapText(text, font, Infinity)).toEqual(text);
}); });
}); });
@ -305,3 +304,41 @@ describe("Test measureText", () => {
}); });
}); });
}); });
const textElement = API.createElement({
type: "text",
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
fontSize: 20,
fontFamily: 1,
height: 175,
});
describe("Test detectLineHeight", () => {
it("should return correct line height", () => {
expect(detectLineHeight(textElement)).toBe(1.25);
});
});
describe("Test getLineHeightInPx", () => {
it("should return correct line height", () => {
expect(
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
).toBe(25);
});
});
describe("Test getDefaultLineHeight", () => {
it("should return line height using default font family when not passed", () => {
//@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);
});
});

View File

@ -5,6 +5,7 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontFamilyValues,
FontString, FontString,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@ -13,6 +14,7 @@ import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY,
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
@ -41,7 +43,7 @@ export const measureTextElement = function (element, next) {
const fontSize = next?.fontSize ?? element.fontSize; const fontSize = next?.fontSize ?? element.fontSize;
const font = getFontString({ fontSize, fontFamily: element.fontFamily }); const font = getFontString({ fontSize, fontFamily: element.fontFamily });
const text = next?.text ?? element.text; const text = next?.text ?? element.text;
return measureText(text, font); return measureText(text, font, element.lineHeight);
} as SubtypeMethods["measureText"]; } as SubtypeMethods["measureText"];
export const wrapTextElement = function (element, containerWidth, next) { export const wrapTextElement = function (element, containerWidth, next) {
@ -66,12 +68,15 @@ export const normalizeText = (text: string) => {
); );
}; };
export const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
) => { ) => {
let maxWidth = undefined; let maxWidth = undefined;
const boundTextUpdates = { const boundTextUpdates = {
x: textElement.x, x: textElement.x,
y: textElement.y, y: textElement.y,
@ -285,32 +290,52 @@ const computeBoundTextPosition = (
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (text: string, font: FontString) => { export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
text = text text = text
.split("\n") .split("\n")
// replace empty lines with single space because leading/trailing empty // replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation // lines would be stripped from computation
.map((x) => x || " ") .map((x) => x || " ")
.join("\n"); .join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(text, font); const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font); const width = getTextWidth(text, font);
return { width, height }; return { width, height };
}; };
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase(); /**
const cacheApproxLineHeight: { [key: FontString]: number } = {}; * To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
export const getApproxLineHeight = (font: FontString) => { /**
if (cacheApproxLineHeight[font]) { * We calculate the line height from the font size and the unitless line height,
return cacheApproxLineHeight[font]; * aligning with the W3C spec.
} */
const fontSize = parseInt(font); export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// Calculate line height relative to font size // FIXME rename to getApproxMinContainerHeight
cacheApproxLineHeight[font] = fontSize * 1.2; export const getApproxMinLineHeight = (
return cacheApproxLineHeight[font]; fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
}; };
let canvas: HTMLCanvasElement | undefined; let canvas: HTMLCanvasElement | undefined;
@ -333,7 +358,7 @@ const getLineWidth = (text: string, font: FontString) => {
}; };
export const getTextWidth = (text: string, font: FontString) => { export const getTextWidth = (text: string, font: FontString) => {
const lines = text.replace(/\r\n?/g, "\n").split("\n"); const lines = splitIntoLines(text);
let width = 0; let width = 0;
lines.forEach((line) => { lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font)); width = Math.max(width, getLineWidth(line, font));
@ -341,13 +366,23 @@ export const getTextWidth = (text: string, font: FontString) => {
return width; return width;
}; };
export const getTextHeight = (text: string, font: FontString) => { export const getTextHeight = (
const lines = text.replace(/\r\n?/g, "\n").split("\n"); text: string,
const lineHeight = getApproxLineHeight(font); fontSize: number,
return lineHeight * lines.length; lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
}; };
export const wrapText = (text: string, font: FontString, maxWidth: number) => { export const wrapText = (text: string, font: FontString, maxWidth: number) => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
}
const lines: Array<string> = []; const lines: Array<string> = [];
const originalLines = text.split("\n"); const originalLines = text.split("\n");
const spaceWidth = getLineWidth(" ", font); const spaceWidth = getLineWidth(" ", font);
@ -485,21 +520,23 @@ export const charWidth = (() => {
}; };
})(); })();
export const getApproxMinLineWidth = (font: FontString) => { const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font); const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) { if (maxCharWidth === 0) {
return ( return (
measureText(DUMMY_TEXT.split("").join("\n"), font).width + measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2 BOUND_TEXT_PADDING * 2
); );
} }
return maxCharWidth + BOUND_TEXT_PADDING * 2; return maxCharWidth + BOUND_TEXT_PADDING * 2;
}; };
export const getApproxMinLineHeight = (font: FontString) => {
return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2;
};
export const getMinCharWidth = (font: FontString) => { export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font); const cache = charWidth.getCache(font);
if (!cache) { if (!cache) {
@ -845,3 +882,32 @@ export const isMeasureTextSupported = () => {
); );
return width > 0; return width > 0;
}; };
/**
* Unitless line height
*
* In previous versions we used `normal` line height, which browsers interpret
* differently, and based on font-family and font-size.
*
* To make line heights consistent across browsers we hardcode the values for
* each of our fonts based on most common average line-heights.
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
* where the values come from.
*/
const DEFAULT_LINE_HEIGHT = {
// ~1.25 is the average for Virgil in WebKit and Blink.
// Gecko (FF) uses ~1.28.
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
// ~1.15 is the average for Virgil in WebKit and Blink.
// Gecko if all over the place.
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
};
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
if (fontFamily in DEFAULT_LINE_HEIGHT) {
return DEFAULT_LINE_HEIGHT[fontFamily];
}
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
};

View File

@ -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",
@ -783,7 +821,7 @@ describe("textWysiwyg", () => {
rectangle.y + h.elements[0].height / 2 - text.height / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
); );
expect(text.x).toBe(25); expect(text.x).toBe(25);
expect(text.height).toBe(48); expect(text.height).toBe(50);
expect(text.width).toBe(60); expect(text.width).toBe(60);
// Edit and text by removing second line and it should // Edit and text by removing second line and it should
@ -810,7 +848,7 @@ describe("textWysiwyg", () => {
expect(text.text).toBe("Hello"); expect(text.text).toBe("Hello");
expect(text.originalText).toBe("Hello"); expect(text.originalText).toBe("Hello");
expect(text.height).toBe(24); expect(text.height).toBe(25);
expect(text.width).toBe(50); expect(text.width).toBe(50);
expect(text.y).toBe( expect(text.y).toBe(
rectangle.y + h.elements[0].height / 2 - text.height / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
@ -903,7 +941,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
85, 85,
5, 4.5,
] ]
`); `);
@ -929,7 +967,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
66, 65,
] ]
`); `);
@ -1067,9 +1105,9 @@ describe("textWysiwyg", () => {
mouse.moveTo(rectangle.x + 100, rectangle.y + 50); mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
mouse.up(rectangle.x + 100, rectangle.y + 50); mouse.up(rectangle.x + 100, rectangle.y + 50);
expect(rectangle.x).toBe(80); expect(rectangle.x).toBe(80);
expect(rectangle.y).toBe(-35); expect(rectangle.y).toBe(-40);
expect(text.x).toBe(85); expect(text.x).toBe(85);
expect(text.y).toBe(-30); expect(text.y).toBe(-35);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z);
@ -1112,7 +1150,7 @@ describe("textWysiwyg", () => {
target: { value: "Online whiteboard collaboration made easy" }, target: { value: "Online whiteboard collaboration made easy" },
}); });
editor.blur(); editor.blur();
expect(rectangle.height).toBe(178); expect(rectangle.height).toBe(185);
mouse.select(rectangle); mouse.select(rectangle);
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2, button: 2,
@ -1181,9 +1219,42 @@ 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 () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.25);
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
fireEvent.click(screen.getByTitle(/code/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Cascadia);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.2);
fireEvent.click(screen.getByTitle(/normal/i));
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
).toEqual(FONT_FAMILY.Helvetica);
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.15);
}); });
describe("should align correctly", () => { describe("should align correctly", () => {
@ -1245,7 +1316,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
45.5, 45,
] ]
`); `);
}); });
@ -1257,7 +1328,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
30, 30,
45.5, 45,
] ]
`); `);
}); });
@ -1269,7 +1340,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
45, 45,
45.5, 45,
] ]
`); `);
}); });
@ -1281,7 +1352,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
15, 15,
66, 65,
] ]
`); `);
}); });
@ -1292,7 +1363,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
30, 30,
66, 65,
] ]
`); `);
}); });
@ -1303,7 +1374,7 @@ describe("textWysiwyg", () => {
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
Array [ Array [
45, 45,
66, 65,
] ]
`); `);
}); });
@ -1333,7 +1404,7 @@ describe("textWysiwyg", () => {
const textElement = h.elements[1] as ExcalidrawTextElement; const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.width).toBe(600); expect(textElement.width).toBe(600);
expect(textElement.height).toBe(24); expect(textElement.height).toBe(25);
expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT); expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
expect((textElement as ExcalidrawTextElement).text).toBe( expect((textElement as ExcalidrawTextElement).text).toBe(
"Excalidraw is an opensource virtual collaborative whiteboard", "Excalidraw is an opensource virtual collaborative whiteboard",
@ -1365,7 +1436,7 @@ describe("textWysiwyg", () => {
], ],
fillStyle: "hachure", fillStyle: "hachure",
groupIds: [], groupIds: [],
height: 34, height: 35,
isDeleted: false, isDeleted: false,
link: null, link: null,
locked: false, locked: false,

View File

@ -22,7 +22,6 @@ import {
import { AppState } from "../types"; import { AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
getApproxLineHeight,
getBoundTextElementId, getBoundTextElementId,
getContainerCoords, getContainerCoords,
getContainerDims, getContainerDims,
@ -35,6 +34,7 @@ import {
wrapText, wrapText,
getMaxContainerHeight, getMaxContainerHeight,
getMaxContainerWidth, getMaxContainerWidth,
computeContainerDimensionForBoundText,
} from "./textElement"; } from "./textElement";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
@ -160,9 +160,7 @@ export const textWysiwyg = ({
return; return;
} }
const { textAlign, verticalAlign } = updatedTextElement; const { textAlign, verticalAlign } = updatedTextElement;
const approxLineHeight = getApproxLineHeight(
getFontString(updatedTextElement),
);
if (updatedTextElement && isTextElement(updatedTextElement)) { if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x; let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y; let coordY = updatedTextElement.y;
@ -179,6 +177,7 @@ export const textWysiwyg = ({
) )
: updatedTextElement.originalText, : updatedTextElement.originalText,
getFontString(updatedTextElement), getFontString(updatedTextElement),
updatedTextElement.lineHeight,
); );
let maxHeight = eMetrics.height; let maxHeight = eMetrics.height;
@ -217,7 +216,7 @@ export const textWysiwyg = ({
// update height of the editor after properties updated // update height of the editor after properties updated
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
textElementHeight = textElementHeight =
getApproxLineHeight(font) * updatedTextElement.lineHeight *
wrapText( wrapText(
updatedTextElement.originalText, updatedTextElement.originalText,
font, font,
@ -250,11 +249,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,
approxLineHeight, 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
@ -263,11 +263,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,
approxLineHeight, 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
@ -305,10 +305,6 @@ export const textWysiwyg = ({
editable.selectionEnd = editable.value.length - diff; editable.selectionEnd = editable.value.length - diff;
} }
const lines = updatedTextElement.originalText.split("\n");
const lineHeight = updatedTextElement.containerId
? approxLineHeight
: eMetrics.height / lines.length;
let transformWidth = updatedTextElement.width; let transformWidth = updatedTextElement.width;
if (!container) { if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
@ -338,7 +334,7 @@ export const textWysiwyg = ({
Object.assign(editable.style, { Object.assign(editable.style, {
font: getFontString(updatedTextElement), font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯ // must be defined *after* font ¯\_(ツ)_/¯
lineHeight: `${lineHeight}px`, lineHeight: element.lineHeight,
width: `${Math.min(textElementWidth, maxWidth)}px`, width: `${Math.min(textElementWidth, maxWidth)}px`,
height: `${textElementHeight}px`, height: `${textElementHeight}px`,
left: `${viewportX}px`, left: `${viewportX}px`,
@ -360,6 +356,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()) {
@ -447,7 +444,11 @@ export const textWysiwyg = ({
font, font,
getMaxContainerWidth(container!), getMaxContainerWidth(container!),
); );
const { width, height } = measureText(wrappedText, font); const { width, height } = measureText(
wrappedText,
font,
updatedTextElement.lineHeight,
);
editable.style.width = `${width}px`; editable.style.width = `${width}px`;
editable.style.height = `${height}px`; editable.style.height = `${height}px`;
} }

View File

@ -137,6 +137,11 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
verticalAlign: VerticalAlign; verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null; containerId: ExcalidrawGenericElement["id"] | null;
originalText: string; originalText: string;
/**
* Unitless line height (aligned to W3C). To get line height in px, multiply
* with font size (using `getLineHeightInPx` helper).
*/
lineHeight: number & { _brand: "unitlessLineHeight" };
}>; }>;
export type ExcalidrawBindableElement = export type ExcalidrawBindableElement =

2
src/global.d.ts vendored
View File

@ -19,6 +19,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 {

View File

@ -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

View File

@ -64,7 +64,7 @@
"terser-webpack-plugin": "5.3.3", "terser-webpack-plugin": "5.3.3",
"ts-loader": "9.3.1", "ts-loader": "9.3.1",
"typescript": "4.7.4", "typescript": "4.7.4",
"webpack": "5.73.0", "webpack": "5.76.0",
"webpack-bundle-analyzer": "4.5.0", "webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.10.0", "webpack-cli": "4.10.0",
"webpack-dev-server": "4.9.3", "webpack-dev-server": "4.9.3",

View File

@ -1393,10 +1393,10 @@ acorn-walk@^8.0.0:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3"
integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A== integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==
acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1:
version "8.7.1" version "8.8.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
ajv-formats@^2.1.1: ajv-formats@^2.1.1:
version "2.1.1" version "2.1.1"
@ -2068,10 +2068,10 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3: enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0:
version "5.10.0" version "5.12.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==
dependencies: dependencies:
graceful-fs "^4.2.4" graceful-fs "^4.2.4"
tapable "^2.2.0" tapable "^2.2.0"
@ -3751,10 +3751,10 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
watchpack@^2.3.1: watchpack@^2.4.0:
version "2.3.1" version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
dependencies: dependencies:
glob-to-regexp "^0.4.1" glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2" graceful-fs "^4.1.2"
@ -3858,21 +3858,21 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@5.73.0: webpack@5.76.0:
version "5.73.0" version "5.76.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c"
integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA== integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==
dependencies: dependencies:
"@types/eslint-scope" "^3.7.3" "@types/eslint-scope" "^3.7.3"
"@types/estree" "^0.0.51" "@types/estree" "^0.0.51"
"@webassemblyjs/ast" "1.11.1" "@webassemblyjs/ast" "1.11.1"
"@webassemblyjs/wasm-edit" "1.11.1" "@webassemblyjs/wasm-edit" "1.11.1"
"@webassemblyjs/wasm-parser" "1.11.1" "@webassemblyjs/wasm-parser" "1.11.1"
acorn "^8.4.1" acorn "^8.7.1"
acorn-import-assertions "^1.7.6" acorn-import-assertions "^1.7.6"
browserslist "^4.14.5" browserslist "^4.14.5"
chrome-trace-event "^1.0.2" chrome-trace-event "^1.0.2"
enhanced-resolve "^5.9.3" enhanced-resolve "^5.10.0"
es-module-lexer "^0.9.0" es-module-lexer "^0.9.0"
eslint-scope "5.1.1" eslint-scope "5.1.1"
events "^3.2.0" events "^3.2.0"
@ -3885,7 +3885,7 @@ webpack@5.73.0:
schema-utils "^3.1.0" schema-utils "^3.1.0"
tapable "^2.1.1" tapable "^2.1.1"
terser-webpack-plugin "^5.1.3" terser-webpack-plugin "^5.1.3"
watchpack "^2.3.1" watchpack "^2.4.0"
webpack-sources "^3.2.3" webpack-sources "^3.2.3"
websocket-driver@>=0.5.1, websocket-driver@^0.7.4: websocket-driver@>=0.5.1, websocket-driver@^0.7.4:

View File

@ -3,7 +3,6 @@ import fallbackMathJaxLangData from "./locales/en.json";
import { FONT_FAMILY, SVG_NS } from "../../../../constants"; import { FONT_FAMILY, SVG_NS } from "../../../../constants";
import { getFontString, getFontFamilyString, isRTL } from "../../../../utils"; import { getFontString, getFontFamilyString, isRTL } from "../../../../utils";
import { import {
getApproxLineHeight,
getBoundTextElement, getBoundTextElement,
getContainerElement, getContainerElement,
getMaxContainerWidth, getMaxContainerWidth,
@ -555,6 +554,7 @@ const getCacheKey = (
const measureMarkup = ( const measureMarkup = (
markup: Array<string | Element>, markup: Array<string | Element>,
fontSize: number, fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
mathProps: MathProps, mathProps: MathProps,
isMathJaxLoaded: boolean, isMathJaxLoaded: boolean,
maxWidth?: number | null, maxWidth?: number | null,
@ -567,11 +567,10 @@ const measureMarkup = (
container.style.minHeight = "1em"; container.style.minHeight = "1em";
if (maxWidth) { if (maxWidth) {
const lineHeight = getApproxLineHeight(font);
container.style.maxWidth = `${String(maxWidth)}px`; container.style.maxWidth = `${String(maxWidth)}px`;
container.style.overflow = "hidden"; container.style.overflow = "hidden";
container.style.wordBreak = "break-word"; container.style.wordBreak = "break-word";
container.style.lineHeight = `${String(lineHeight)}px`; container.style.lineHeight = `${String(lineHeight)}`;
container.style.whiteSpace = "pre-wrap"; container.style.whiteSpace = "pre-wrap";
} }
document.body.appendChild(container); document.body.appendChild(container);
@ -635,7 +634,7 @@ const measureMarkup = (
const constrainedText = maxWidth const constrainedText = maxWidth
? wrapText(text, font, maxWidth) ? wrapText(text, font, maxWidth)
: text; : text;
const textMetrics = measureText(constrainedText, font); const textMetrics = measureText(constrainedText, font, lineHeight);
childMetrics.push({ childMetrics.push({
x: nextX, x: nextX,
y: baseline, y: baseline,
@ -656,6 +655,7 @@ const measureMarkup = (
const getMetrics = ( const getMetrics = (
markup: string[][], markup: string[][],
fontSize: number, fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
mathProps: MathProps, mathProps: MathProps,
isMathJaxLoaded: boolean, isMathJaxLoaded: boolean,
maxWidth?: number | null, maxWidth?: number | null,
@ -699,6 +699,7 @@ const getMetrics = (
const { width, height, baseline, childMetrics } = measureMarkup( const { width, height, baseline, childMetrics } = measureMarkup(
lineMarkup, lineMarkup,
fontSize, fontSize,
lineHeight,
mathProps, mathProps,
isMathJaxLoaded, isMathJaxLoaded,
maxWidth, maxWidth,
@ -725,6 +726,7 @@ const getMetrics = (
const renderMath = ( const renderMath = (
text: string, text: string,
fontSize: number, fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
textAlign: string, textAlign: string,
mathProps: MathProps, mathProps: MathProps,
isMathJaxLoaded: boolean, isMathJaxLoaded: boolean,
@ -743,7 +745,13 @@ const renderMath = (
isMathJaxLoaded ? getMathNewline(mathProps) : "\n", isMathJaxLoaded ? getMathNewline(mathProps) : "\n",
); );
const { markup, aria } = markupText(text, mathProps, isMathJaxLoaded); const { markup, aria } = markupText(text, mathProps, isMathJaxLoaded);
const metrics = getMetrics(markup, fontSize, mathProps, isMathJaxLoaded); const metrics = getMetrics(
markup,
fontSize,
lineHeight,
mathProps,
isMathJaxLoaded,
);
const width = parentWidth ?? metrics.imageMetrics.width; const width = parentWidth ?? metrics.imageMetrics.width;
let y = 0; let y = 0;
@ -799,13 +807,20 @@ const renderMath = (
const getImageMetrics = ( const getImageMetrics = (
text: string, text: string,
fontSize: number, fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
mathProps: MathProps, mathProps: MathProps,
isMathJaxLoaded: boolean, isMathJaxLoaded: boolean,
maxWidth?: number | null, maxWidth?: number | null,
) => { ) => {
const markup = markupText(text, mathProps, isMathJaxLoaded).markup; const markup = markupText(text, mathProps, isMathJaxLoaded).markup;
return getMetrics(markup, fontSize, mathProps, isMathJaxLoaded, maxWidth) return getMetrics(
.imageMetrics; markup,
fontSize,
lineHeight,
mathProps,
isMathJaxLoaded,
maxWidth,
).imageMetrics;
}; };
const getSelectedMathElements = ( const getSelectedMathElements = (
@ -877,10 +892,17 @@ const measureMathElement = function (element, next) {
return { width, height }; return { width, height };
} }
const fontSize = next?.fontSize ?? element.fontSize; const fontSize = next?.fontSize ?? element.fontSize;
const lineHeight = element.lineHeight;
const text = next?.text ?? element.text; const text = next?.text ?? element.text;
const customData = next?.customData ?? element.customData; const customData = next?.customData ?? element.customData;
const mathProps = getMathProps.ensureMathProps(customData); const mathProps = getMathProps.ensureMathProps(customData);
const metrics = getImageMetrics(text, fontSize, mathProps, isMathJaxLoaded); const metrics = getImageMetrics(
text,
fontSize,
lineHeight,
mathProps,
isMathJaxLoaded,
);
const { width, height } = metrics; const { width, height } = metrics;
return { width, height }; return { width, height };
} as SubtypeMethods["measureText"]; } as SubtypeMethods["measureText"];
@ -891,6 +913,7 @@ const renderMathElement = function (element, context, renderCb) {
const _element = element as NonDeleted<ExcalidrawMathElement>; const _element = element as NonDeleted<ExcalidrawMathElement>;
const text = _element.text; const text = _element.text;
const fontSize = _element.fontSize; const fontSize = _element.fontSize;
const lineHeight = _element.lineHeight;
const strokeColor = _element.strokeColor; const strokeColor = _element.strokeColor;
const textAlign = _element.textAlign; const textAlign = _element.textAlign;
const opacity = _element.opacity / 100; const opacity = _element.opacity / 100;
@ -1005,6 +1028,7 @@ const renderMathElement = function (element, context, renderCb) {
element.customData!.ariaLabel = renderMath( element.customData!.ariaLabel = renderMath(
text, text,
fontSize, fontSize,
lineHeight,
textAlign, textAlign,
mathProps, mathProps,
isMathJaxLoaded, isMathJaxLoaded,
@ -1023,6 +1047,7 @@ const renderSvgMathElement = function (svgRoot, root, element, opt) {
const mathProps = getMathProps.ensureMathProps(_element.customData); const mathProps = getMathProps.ensureMathProps(_element.customData);
const text = _element.text; const text = _element.text;
const fontSize = _element.fontSize; const fontSize = _element.fontSize;
const lineHeight = _element.lineHeight;
const strokeColor = _element.strokeColor; const strokeColor = _element.strokeColor;
const textAlign = _element.textAlign; const textAlign = _element.textAlign;
const opacity = _element.opacity / 100; const opacity = _element.opacity / 100;
@ -1078,6 +1103,7 @@ const renderSvgMathElement = function (svgRoot, root, element, opt) {
const { width, height } = getImageMetrics( const { width, height } = getImageMetrics(
text, text,
fontSize, fontSize,
lineHeight,
mathProps, mathProps,
isMathJaxLoaded, isMathJaxLoaded,
parentWidth, parentWidth,
@ -1140,6 +1166,7 @@ const renderSvgMathElement = function (svgRoot, root, element, opt) {
element.customData!.ariaLabel = renderMath( element.customData!.ariaLabel = renderMath(
text, text,
fontSize, fontSize,
lineHeight,
textAlign, textAlign,
mathProps, mathProps,
isMathJaxLoaded, isMathJaxLoaded,
@ -1164,6 +1191,7 @@ const wrapMathElement = function (element, containerWidth, next) {
const isMathJaxLoaded = mathJaxLoaded; const isMathJaxLoaded = mathJaxLoaded;
const fontSize = const fontSize =
next?.fontSize !== undefined ? next.fontSize : element.fontSize; next?.fontSize !== undefined ? next.fontSize : element.fontSize;
const lineHeight = element.lineHeight;
const text = next?.text !== undefined ? next.text : element.originalText; const text = next?.text !== undefined ? next.text : element.originalText;
const customData = next?.customData ?? element.customData; const customData = next?.customData ?? element.customData;
const mathProps = getMathProps.ensureMathProps(customData); const mathProps = getMathProps.ensureMathProps(customData);
@ -1178,7 +1206,13 @@ const wrapMathElement = function (element, containerWidth, next) {
const maxWidth = containerWidth; const maxWidth = containerWidth;
const markup = markupText(text, mathProps, isMathJaxLoaded).markup; const markup = markupText(text, mathProps, isMathJaxLoaded).markup;
const metrics = getMetrics(markup, fontSize, mathProps, isMathJaxLoaded); const metrics = getMetrics(
markup,
fontSize,
lineHeight,
mathProps,
isMathJaxLoaded,
);
const lines = consumeMathNewlines(text, mathProps, isMathJaxLoaded).split( const lines = consumeMathNewlines(text, mathProps, isMathJaxLoaded).split(
isMathJaxLoaded ? getMathNewline(mathProps) : "\n", isMathJaxLoaded ? getMathNewline(mathProps) : "\n",

View File

@ -48,7 +48,7 @@
"file-loader": "6.2.0", "file-loader": "6.2.0",
"sass-loader": "13.0.2", "sass-loader": "13.0.2",
"ts-loader": "9.3.1", "ts-loader": "9.3.1",
"webpack": "5.73.0", "webpack": "5.76.0",
"webpack-bundle-analyzer": "4.5.0", "webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.10.0" "webpack-cli": "4.10.0"
}, },

View File

@ -1187,10 +1187,10 @@ acorn-walk@^8.0.0:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3"
integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A== integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==
acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1:
version "8.7.1" version "8.8.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
ajv-keywords@^3.5.2: ajv-keywords@^3.5.2:
version "3.5.2" version "3.5.2"
@ -1383,18 +1383,7 @@ braces@^3.0.1:
dependencies: dependencies:
fill-range "^7.0.1" fill-range "^7.0.1"
browserslist@^4.14.5: browserslist@^4.14.5, browserslist@^4.20.2, browserslist@^4.21.2:
version "4.19.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.19.3.tgz#29b7caad327ecf2859485f696f9604214bedd383"
integrity sha512-XK3X4xtKJ+Txj8G5c30B4gsm71s69lqXlkYui4s6EkKxuv49qjYlY6oVd+IFJ73d4YymtM3+djvvt/R/iJwwDg==
dependencies:
caniuse-lite "^1.0.30001312"
electron-to-chromium "^1.4.71"
escalade "^3.1.1"
node-releases "^2.0.2"
picocolors "^1.0.0"
browserslist@^4.20.2, browserslist@^4.21.2:
version "4.21.2" version "4.21.2"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.2.tgz#59a400757465535954946a400b841ed37e2b4ecf" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.2.tgz#59a400757465535954946a400b841ed37e2b4ecf"
integrity sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA== integrity sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA==
@ -1417,11 +1406,6 @@ call-bind@^1.0.0:
function-bind "^1.1.1" function-bind "^1.1.1"
get-intrinsic "^1.0.2" get-intrinsic "^1.0.2"
caniuse-lite@^1.0.30001312:
version "1.0.30001312"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001312.tgz#e11eba4b87e24d22697dae05455d5aea28550d5f"
integrity sha512-Wiz1Psk2MEK0pX3rUzWaunLTZzqS2JYZFzNKqAiJGiuxIjRPLgV6+VDPOg6lQOUxmDwhTlh198JsTTi8Hzw6aQ==
caniuse-lite@^1.0.30001366: caniuse-lite@^1.0.30001366:
version "1.0.30001367" version "1.0.30001367"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001367.tgz#2b97fe472e8fa29c78c5970615d7cd2ee414108a" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001367.tgz#2b97fe472e8fa29c78c5970615d7cd2ee414108a"
@ -1601,20 +1585,15 @@ electron-to-chromium@^1.4.188:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.195.tgz#139b2d95a42a3f17df217589723a1deac71d1473" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.195.tgz#139b2d95a42a3f17df217589723a1deac71d1473"
integrity sha512-vefjEh0sk871xNmR5whJf9TEngX+KTKS3hOHpjoMpauKkwlGwtMz1H8IaIjAT/GNnX0TbGwAdmVoXCAzXf+PPg== integrity sha512-vefjEh0sk871xNmR5whJf9TEngX+KTKS3hOHpjoMpauKkwlGwtMz1H8IaIjAT/GNnX0TbGwAdmVoXCAzXf+PPg==
electron-to-chromium@^1.4.71:
version "1.4.75"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.75.tgz#d1ad9bb46f2f1bf432118c2be21d27ffeae82fdd"
integrity sha512-LxgUNeu3BVU7sXaKjUDD9xivocQLxFtq6wgERrutdY/yIOps3ODOZExK1jg8DTEg4U8TUCb5MLGeWFOYuxjF3Q==
emojis-list@^3.0.0: emojis-list@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3: enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0:
version "5.9.3" version "5.12.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz#44a342c012cbc473254af5cc6ae20ebd0aae5d88" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
integrity sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow== integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==
dependencies: dependencies:
graceful-fs "^4.2.4" graceful-fs "^4.2.4"
tapable "^2.2.0" tapable "^2.2.0"
@ -2011,11 +1990,6 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
node-releases@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01"
integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==
node-releases@^2.0.6: node-releases@^2.0.6:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
@ -2494,10 +2468,10 @@ util-deprecate@^1.0.2:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
watchpack@^2.3.1: watchpack@^2.4.0:
version "2.3.1" version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA== integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
dependencies: dependencies:
glob-to-regexp "^0.4.1" glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2" graceful-fs "^4.1.2"
@ -2548,21 +2522,21 @@ webpack-sources@^3.2.3:
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@5.73.0: webpack@5.76.0:
version "5.73.0" version "5.76.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c"
integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA== integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==
dependencies: dependencies:
"@types/eslint-scope" "^3.7.3" "@types/eslint-scope" "^3.7.3"
"@types/estree" "^0.0.51" "@types/estree" "^0.0.51"
"@webassemblyjs/ast" "1.11.1" "@webassemblyjs/ast" "1.11.1"
"@webassemblyjs/wasm-edit" "1.11.1" "@webassemblyjs/wasm-edit" "1.11.1"
"@webassemblyjs/wasm-parser" "1.11.1" "@webassemblyjs/wasm-parser" "1.11.1"
acorn "^8.4.1" acorn "^8.7.1"
acorn-import-assertions "^1.7.6" acorn-import-assertions "^1.7.6"
browserslist "^4.14.5" browserslist "^4.14.5"
chrome-trace-event "^1.0.2" chrome-trace-event "^1.0.2"
enhanced-resolve "^5.9.3" enhanced-resolve "^5.10.0"
es-module-lexer "^0.9.0" es-module-lexer "^0.9.0"
eslint-scope "5.1.1" eslint-scope "5.1.1"
events "^3.2.0" events "^3.2.0"
@ -2575,7 +2549,7 @@ webpack@5.73.0:
schema-utils "^3.1.0" schema-utils "^3.1.0"
tapable "^2.1.1" tapable "^2.1.1"
terser-webpack-plugin "^5.1.3" terser-webpack-plugin "^5.1.3"
watchpack "^2.3.1" watchpack "^2.4.0"
webpack-sources "^3.2.3" webpack-sources "^3.2.3"
which@^2.0.1: which@^2.0.1:

View File

@ -41,10 +41,10 @@ import {
} from "../constants"; } from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand"; import { getStroke, StrokeOptions } from "perfect-freehand";
import { import {
getApproxLineHeight,
getBoundTextElement, getBoundTextElement,
getContainerCoords, getContainerCoords,
getContainerElement, getContainerElement,
getLineHeightInPx,
getMaxContainerHeight, getMaxContainerHeight,
getMaxContainerWidth, getMaxContainerWidth,
} from "../element/textElement"; } from "../element/textElement";
@ -88,12 +88,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,
@ -103,44 +157,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);
@ -157,7 +202,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,
@ -286,9 +331,7 @@ const drawElementOnCanvas = (
// Canvas does not support multiline text by default // Canvas does not support multiline text by default
const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = element.containerId
? getApproxLineHeight(getFontString(element))
: element.height / lines.length;
const horizontalOffset = const horizontalOffset =
element.textAlign === "center" element.textAlign === "center"
? element.width / 2 ? element.width / 2
@ -297,11 +340,16 @@ const drawElementOnCanvas = (
: 0; : 0;
context.textBaseline = "bottom"; context.textBaseline = "bottom";
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
for (let index = 0; index < lines.length; index++) { for (let index = 0; index < lines.length; index++) {
context.fillText( context.fillText(
lines[index], lines[index],
horizontalOffset, horizontalOffset,
(index + 1) * lineHeight, (index + 1) * lineHeightPx,
); );
} }
context.restore(); context.restore();
@ -674,7 +722,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;
@ -705,7 +753,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
@ -732,10 +780,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;
@ -816,11 +864,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 (
@ -1325,7 +1373,10 @@ export const renderElementToSvg = (
}) rotate(${degree} ${cx} ${cy})`, }) rotate(${degree} ${cx} ${cy})`,
); );
const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeight = element.height / lines.length; const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const horizontalOffset = const horizontalOffset =
element.textAlign === "center" element.textAlign === "center"
? element.width / 2 ? element.width / 2
@ -1343,7 +1394,7 @@ export const renderElementToSvg = (
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i]; text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`); text.setAttribute("x", `${horizontalOffset}`);
text.setAttribute("y", `${i * lineHeight}`); text.setAttribute("y", `${i * lineHeightPx}`);
text.setAttribute("font-family", getFontFamilyString(element)); text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor); text.setAttribute("fill", element.strokeColor);

View File

@ -225,7 +225,12 @@ export type SubtypeMethods = {
measureText: ( measureText: (
element: Pick< element: Pick<
ExcalidrawTextElement, ExcalidrawTextElement,
"subtype" | "customData" | "fontSize" | "fontFamily" | "text" | "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "text"
| "lineHeight"
>, >,
next?: { next?: {
fontSize?: number; fontSize?: number;
@ -247,7 +252,12 @@ export type SubtypeMethods = {
wrapText: ( wrapText: (
element: Pick< element: Pick<
ExcalidrawTextElement, ExcalidrawTextElement,
"subtype" | "customData" | "fontSize" | "fontFamily" | "originalText" | "subtype"
| "customData"
| "fontSize"
| "fontFamily"
| "originalText"
| "lineHeight"
>, >,
containerWidth: number, containerWidth: number,
next?: { next?: {

View File

@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 24px; left: 35px; top: 8px; transform-origin: 5px 12px; transform: translate(0px, 0px) scale(1) rotate(0deg) translate(0px, 0px); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -8px; font: Emoji 20px 20px; line-height: 24px; font-family: Virgil, Segoe UI Emoji;" style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform-origin: 5px 12.5px; transform: translate(0px, 0px) scale(1) rotate(0deg) translate(0px, 0px); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />

View File

@ -4,6 +4,7 @@ import { UI, Pointer, Keyboard } from "./helpers/ui";
import { getTransformHandles } from "../element/transformHandles"; import { getTransformHandles } from "../element/transformHandles";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { actionCreateContainerFromText } from "../actions/actionBoundText";
const { h } = window; const { h } = window;
@ -209,4 +210,103 @@ describe("element binding", () => {
).toBe(null); ).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id); expect(arrow.endBinding?.elementId).toBe(text.id);
}); });
it("should update binding when text containerized", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
width: 100,
height: 100,
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
],
});
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
points: [
[0, 0],
[0, -87.45777932247563],
],
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "text1",
focus: 0.2,
gap: 7,
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
points: [
[0, 0],
[0, -87.45777932247563],
],
startBinding: {
elementId: "text1",
focus: 0.2,
gap: 7,
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
},
});
const text1 = API.createElement({
type: "text",
id: "text1",
text: "ola",
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
],
});
h.elements = [rectangle1, arrow1, arrow2, text1];
API.setSelectedElements([text1]);
expect(h.state.selectedElementIds[text1.id]).toBe(true);
h.app.actionManager.executeAction(actionCreateContainerFromText);
// new text container will be placed before the text element
const container = h.elements.at(-2)!;
expect(container.type).toBe("rectangle");
expect(container.id).not.toBe(rectangle1.id);
expect(container).toEqual(
expect.objectContaining({
boundElements: expect.arrayContaining([
{
type: "text",
id: text1.id,
},
{
type: "arrow",
id: arrow1.id,
},
{
type: "arrow",
id: arrow2.id,
},
]),
}),
);
expect(arrow1.startBinding?.elementId).toBe(rectangle1.id);
expect(arrow1.endBinding?.elementId).toBe(container.id);
expect(arrow2.startBinding?.elementId).toBe(container.id);
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
});
}); });

View File

@ -3,8 +3,10 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui"; import { Pointer, Keyboard } from "./helpers/ui";
import ExcalidrawApp from "../excalidraw-app"; import ExcalidrawApp from "../excalidraw-app";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getApproxLineHeight } from "../element/textElement"; import {
import { getFontString } from "../utils"; getDefaultLineHeight,
getLineHeightInPx,
} from "../element/textElement";
import { getElementBounds } from "../element"; import { getElementBounds } from "../element";
import { NormalizedZoomValue } from "../types"; import { NormalizedZoomValue } from "../types";
@ -118,12 +120,10 @@ describe("paste text as single lines", () => {
it("should space items correctly", async () => { it("should space items correctly", async () => {
const text = "hkhkjhki\njgkjhffjh\njgkjhffjh"; const text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
const lineHeight = const lineHeightPx =
getApproxLineHeight( getLineHeightInPx(
getFontString({ h.app.state.currentItemFontSize,
fontSize: h.app.state.currentItemFontSize, getDefaultLineHeight(h.state.currentItemFontFamily),
fontFamily: h.app.state.currentItemFontFamily,
}),
) + ) +
10 / h.app.state.zoom.value; 10 / h.app.state.zoom.value;
mouse.moveTo(100, 100); mouse.moveTo(100, 100);
@ -135,19 +135,17 @@ describe("paste text as single lines", () => {
for (let i = 1; i < h.elements.length; i++) { for (let i = 1; i < h.elements.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [fx, elY] = getElementBounds(h.elements[i]); const [fx, elY] = getElementBounds(h.elements[i]);
expect(elY).toEqual(firstElY + lineHeight * i); expect(elY).toEqual(firstElY + lineHeightPx * i);
} }
}); });
}); });
it("should leave a space for blank new lines", async () => { it("should leave a space for blank new lines", async () => {
const text = "hkhkjhki\n\njgkjhffjh"; const text = "hkhkjhki\n\njgkjhffjh";
const lineHeight = const lineHeightPx =
getApproxLineHeight( getLineHeightInPx(
getFontString({ h.app.state.currentItemFontSize,
fontSize: h.app.state.currentItemFontSize, getDefaultLineHeight(h.state.currentItemFontFamily),
fontFamily: h.app.state.currentItemFontFamily,
}),
) + ) +
10 / h.app.state.zoom.value; 10 / h.app.state.zoom.value;
mouse.moveTo(100, 100); mouse.moveTo(100, 100);
@ -158,7 +156,7 @@ describe("paste text as single lines", () => {
const [fx, firstElY] = getElementBounds(h.elements[0]); const [fx, firstElY] = getElementBounds(h.elements[0]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [lx, lastElY] = getElementBounds(h.elements[1]); const [lx, lastElY] = getElementBounds(h.elements[1]);
expect(lastElY).toEqual(firstElY + lineHeight * 2); expect(lastElY).toEqual(firstElY + lineHeightPx * 2);
}); });
}); });
}); });
@ -224,7 +222,7 @@ describe("Paste bound text container", () => {
await sleep(1); await sleep(1);
expect(h.elements.length).toEqual(2); expect(h.elements.length).toEqual(2);
const container = h.elements[0]; const container = h.elements[0];
expect(container.height).toBe(354); expect(container.height).toBe(368);
expect(container.width).toBe(166); expect(container.width).toBe(166);
}); });
}); });
@ -247,7 +245,7 @@ describe("Paste bound text container", () => {
await sleep(1); await sleep(1);
expect(h.elements.length).toEqual(2); expect(h.elements.length).toEqual(2);
const container = h.elements[0]; const container = h.elements[0];
expect(container.height).toBe(740); expect(container.height).toBe(770);
expect(container.width).toBe(166); expect(container.width).toBe(166);
}); });
}); });

View File

@ -291,6 +291,7 @@ Object {
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",
"isDeleted": false, "isDeleted": false,
"lineHeight": 1.25,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -312,7 +313,7 @@ Object {
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 100, "width": 100,
"x": -20, "x": -20,
"y": -8.4, "y": -8.75,
} }
`; `;
@ -329,6 +330,7 @@ Object {
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",
"isDeleted": false, "isDeleted": false,
"lineHeight": 1.25,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,

View File

@ -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);
});
});

View File

@ -139,6 +139,9 @@ export class API {
fileId?: T extends "image" ? string : never; fileId?: T extends "image" ? string : never;
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
status?: T extends "image" ? ExcalidrawImageElement["status"] : never; status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
startBinding?: T extends "arrow"
? ExcalidrawLinearElement["startBinding"]
: never;
endBinding?: T extends "arrow" endBinding?: T extends "arrow"
? ExcalidrawLinearElement["endBinding"] ? ExcalidrawLinearElement["endBinding"]
: never; : never;
@ -215,11 +218,13 @@ export class API {
}); });
break; break;
case "text": case "text":
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
element = newTextElement({ element = newTextElement({
...base, ...base,
text: rest.text || "test", text: rest.text || "test",
fontSize: rest.fontSize ?? appState.currentItemFontSize, fontSize,
fontFamily: rest.fontFamily ?? appState.currentItemFontFamily, fontFamily,
textAlign: rest.textAlign ?? appState.currentItemTextAlign, textAlign: rest.textAlign ?? appState.currentItemTextAlign,
verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN, verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
containerId: rest.containerId ?? undefined, containerId: rest.containerId ?? undefined,
@ -258,6 +263,10 @@ export class API {
}); });
break; break;
} }
if (element.type === "arrow") {
element.startBinding = rest.startBinding ?? null;
element.endBinding = rest.endBinding ?? null;
}
if (id) { if (id) {
element.id = id; element.id = id;
} }

View File

@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height }) expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"height": 128, "height": 130,
"width": 367, "width": 367,
} }
`); `);
@ -1040,7 +1040,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"x": 272, "x": 272,
"y": 46, "y": 45,
} }
`); `);
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text) expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Array [ Array [
20, 20,
36, 35,
502, 502,
94, 95,
205.9061448421403, 205.9061448421403,
53, 52.5,
] ]
`); `);
}); });
@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => {
expect({ width: container.width, height: container.height }) expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"height": 128, "height": 130,
"width": 340, "width": 340,
} }
`); `);
@ -1099,7 +1099,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
Object { Object {
"x": 75, "x": 75,
"y": -4, "y": -5,
} }
`); `);
expect(textElement.text).toMatchInlineSnapshot(` expect(textElement.text).toMatchInlineSnapshot(`

View File

@ -163,7 +163,8 @@ const measureTest2: SubtypeMethods["measureText"] = function (element, next) {
: next?.fontSize ?? element.fontSize; : next?.fontSize ?? element.fontSize;
const fontFamily = element.fontFamily; const fontFamily = element.fontFamily;
const fontString = getFontString({ fontSize, fontFamily }); const fontString = getFontString({ fontSize, fontFamily });
const metrics = textElementUtils.measureText(text, fontString); const lineHeight = element.lineHeight;
const metrics = textElementUtils.measureText(text, fontString, lineHeight);
const width = Math.max(metrics.width - 10, 0); const width = Math.max(metrics.width - 10, 0);
const height = Math.max(metrics.height - 5, 0); const height = Math.max(metrics.height - 5, 0);
return { width, height, baseline: 1 }; return { width, height, baseline: 1 };
@ -410,11 +411,7 @@ describe("subtypes", () => {
}), }),
]; ];
await render(<ExcalidrawApp />, { localStorageData: { elements } }); await render(<ExcalidrawApp />, { localStorageData: { elements } });
const mockMeasureText = ( const mockMeasureText = (text: string, font: FontString) => {
text: string,
font: FontString,
maxWidth?: number | null,
) => {
if (text === testString) { if (text === testString) {
let multiplier = 1; let multiplier = 1;
if (font.includes(`${DBFONTSIZE}`)) { if (font.includes(`${DBFONTSIZE}`)) {
@ -423,9 +420,7 @@ describe("subtypes", () => {
if (font.includes(`${TRFONTSIZE}`)) { if (font.includes(`${TRFONTSIZE}`)) {
multiplier = 3; multiplier = 3;
} }
const width = maxWidth const width = multiplier * TWIDTH;
? Math.min(multiplier * TWIDTH, maxWidth)
: multiplier * TWIDTH;
const height = multiplier * THEIGHT; const height = multiplier * THEIGHT;
const baseline = multiplier * TBASELINE; const baseline = multiplier * TBASELINE;
return { width, height, baseline }; return { width, height, baseline };

View File

@ -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, {