mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-31 22:27:34 +02:00
Compare commits
34 Commits
aakansha-d
...
aakansha-r
Author | SHA1 | Date | |
---|---|---|---|
![]() |
993294ac08 | ||
![]() |
f584416c9a | ||
![]() |
87b0c7a679 | ||
![]() |
ee8fff8e8b | ||
![]() |
b799490ece | ||
![]() |
83383977f5 | ||
![]() |
ac4c8b3ca7 | ||
![]() |
5c8941467d | ||
![]() |
0726911fa6 | ||
![]() |
fd18896293 | ||
![]() |
e900cb0b64 | ||
![]() |
7e330c8ee1 | ||
![]() |
7d21747644 | ||
![]() |
e718136aea | ||
![]() |
54bf3d9092 | ||
![]() |
15f19835fe | ||
![]() |
96c4cff805 | ||
![]() |
fe83e2922d | ||
![]() |
20edddcd4e | ||
![]() |
f6e8be399e | ||
![]() |
ab49cad6b1 | ||
![]() |
6aeb18b784 | ||
![]() |
023313e92f | ||
![]() |
1eee488dab | ||
![]() |
1ac580136d | ||
![]() |
dd4c333925 | ||
![]() |
8c89fdfa51 | ||
![]() |
0e54994187 | ||
![]() |
91f6e87317 | ||
![]() |
a05db6864e | ||
![]() |
eacee9a158 | ||
![]() |
7722de4ef2 | ||
![]() |
0a295e523b | ||
![]() |
60deddb0e2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ src/packages/excalidraw/types
|
||||
src/packages/excalidraw/example/public/bundle.js
|
||||
src/packages/excalidraw/example/public/excalidraw-assets-dev
|
||||
src/packages/excalidraw/example/public/excalidraw.development.js
|
||||
coverage
|
||||
|
@@ -4,6 +4,34 @@
|
||||
|
||||
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
|
||||
|
||||
### Turning off Aggressive Anti-Fingerprinting in Brave browser
|
||||
|
||||
When *Aggressive Anti-Fingerprinting* is turned on, the `measureText` API breaks which in turn breaks the Text Elements in your drawings. Here is more [info](https://github.com/excalidraw/excalidraw/pull/6336) on the same.
|
||||
|
||||
We strongly recommend turning it off. You can follow the steps below on how to do so.
|
||||
|
||||
|
||||
1. Open [excalidraw.com](https://excalidraw.com) in Brave and click on the **Shield** button
|
||||

|
||||
|
||||
<div style={{width:'30rem'}}>
|
||||
|
||||
2. Once opened, look for **Aggressively Block Fingerprinting**
|
||||
|
||||

|
||||
|
||||
3. Switch to **Block Fingerprinting**
|
||||
|
||||

|
||||
|
||||
4. Thats all. All text elements should be fixed now 🎉
|
||||
|
||||
</div>
|
||||
|
||||
If disabling this setting doesn't fix the display of text elements, please consider opening an [issue](https://github.com/excalidraw/excalidraw/issues/new) on our GitHub, or message us on [Discord](https://discord.gg/UexuTaE).
|
||||
|
||||
|
||||
|
||||
## Need help?
|
||||
|
||||
Check out the existing [Q&A](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw). If you have any queries or need help, ask us [here](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aexcalidraw).
|
||||
|
BIN
dev-docs/docs/assets/aggressive-block-fingerprint.png
Normal file
BIN
dev-docs/docs/assets/aggressive-block-fingerprint.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 214 KiB |
BIN
dev-docs/docs/assets/block-fingerprint.png
Normal file
BIN
dev-docs/docs/assets/block-fingerprint.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 266 KiB |
BIN
dev-docs/docs/assets/brave-shield.png
Normal file
BIN
dev-docs/docs/assets/brave-shield.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
@@ -7542,9 +7542,9 @@ webpack-sources@^3.2.2, webpack-sources@^3.2.3:
|
||||
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
|
||||
|
||||
webpack@^5.73.0:
|
||||
version "5.74.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.74.0.tgz#02a5dac19a17e0bb47093f2be67c695102a55980"
|
||||
integrity sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==
|
||||
version "5.76.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.1.tgz#7773de017e988bccb0f13c7d75ec245f377d295c"
|
||||
integrity sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.3"
|
||||
"@types/estree" "^0.0.51"
|
||||
|
14
package.json
14
package.json
@@ -25,11 +25,6 @@
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@tldraw/vec": "1.7.1",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"clsx": "1.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
@@ -57,7 +52,6 @@
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "2.3.1",
|
||||
"tunnel-rat": "0.1.0",
|
||||
"typescript": "4.9.4",
|
||||
"workbox-background-sync": "^6.5.4",
|
||||
"workbox-broadcast-update": "^6.5.4",
|
||||
"workbox-cacheable-response": "^6.5.4",
|
||||
@@ -75,9 +69,14 @@
|
||||
"@excalidraw/eslint-config": "1.0.0",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/pako": "1.0.3",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/resize-observer-browser": "0.1.7",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "16.0.1",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
@@ -88,7 +87,8 @@
|
||||
"lint-staged": "12.3.7",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0"
|
||||
"rewire": "6.0.0",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
|
@@ -2,6 +2,9 @@ const fs = require("fs");
|
||||
|
||||
const THRESSHOLD = 85;
|
||||
|
||||
// we're using BCP 47 language tags as keys
|
||||
// e.g. https://gist.github.com/typpo/b2b828a35e683b9bf8db91b5404f1bd1
|
||||
|
||||
const crowdinMap = {
|
||||
"ar-SA": "en-ar",
|
||||
"bg-BG": "en-bg",
|
||||
@@ -52,6 +55,7 @@ const crowdinMap = {
|
||||
"kk-KZ": "en-kk",
|
||||
"vi-VN": "en-vi",
|
||||
"mr-IN": "en-mr",
|
||||
"th-TH": "en-th",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
@@ -104,6 +108,7 @@ const flags = {
|
||||
"eu-ES": "🇪🇦",
|
||||
"vi-VN": "🇻🇳",
|
||||
"mr-IN": "🇮🇳",
|
||||
"th-TH": "🇹🇭",
|
||||
};
|
||||
|
||||
const languages = {
|
||||
@@ -156,6 +161,7 @@ const languages = {
|
||||
"zh-TW": "繁體中文",
|
||||
"vi-VN": "Tiếng Việt",
|
||||
"mr-IN": "मराठी",
|
||||
"th-TH": "ภาษาไทย",
|
||||
};
|
||||
|
||||
const percentages = fs.readFileSync(
|
||||
|
@@ -4,9 +4,9 @@ import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import { measureText } from "../element/textMeasurements";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
@@ -45,6 +45,7 @@ export const actionUnbindText = register({
|
||||
const { width, height } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||
element.id,
|
||||
@@ -239,15 +240,23 @@ export const actionCreateContainerFromText = register({
|
||||
linearElementIds.includes(ele.id),
|
||||
) as ExcalidrawLinearElement[];
|
||||
linearElements.forEach((ele) => {
|
||||
let startBinding = null;
|
||||
let endBinding = null;
|
||||
if (ele.startBinding) {
|
||||
startBinding = { ...ele.startBinding, elementId: container.id };
|
||||
let startBinding = ele.startBinding;
|
||||
let endBinding = ele.endBinding;
|
||||
|
||||
if (startBinding?.elementId === textElement.id) {
|
||||
startBinding = {
|
||||
...startBinding,
|
||||
elementId: container.id,
|
||||
};
|
||||
}
|
||||
if (ele.endBinding) {
|
||||
endBinding = { ...ele.endBinding, elementId: container.id };
|
||||
|
||||
if (endBinding?.elementId === textElement.id) {
|
||||
endBinding = { ...endBinding, elementId: container.id };
|
||||
}
|
||||
|
||||
if (startBinding || endBinding) {
|
||||
mutateElement(ele, { startBinding, endBinding });
|
||||
}
|
||||
mutateElement(ele, { startBinding, endBinding });
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -55,6 +55,7 @@ import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "../element/textElement";
|
||||
import { getDefaultLineHeight } from "../element/textMeasurements";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
@@ -637,6 +638,7 @@ export const actionChangeFontFamily = register({
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: value,
|
||||
lineHeight: getDefaultLineHeight(value),
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
@@ -745,16 +747,19 @@ export const actionChangeTextAlign = register({
|
||||
value: "left",
|
||||
text: t("labels.left"),
|
||||
icon: TextAlignLeftIcon,
|
||||
testId: "align-left",
|
||||
},
|
||||
{
|
||||
value: "center",
|
||||
text: t("labels.center"),
|
||||
icon: TextAlignCenterIcon,
|
||||
testId: "align-horizontal-center",
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
text: t("labels.right"),
|
||||
icon: TextAlignRightIcon,
|
||||
testId: "align-right",
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
|
@@ -19,6 +19,7 @@ import {
|
||||
getDefaultRoundnessTypeForElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getDefaultLineHeight } from "../element/textMeasurements";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@@ -92,12 +93,18 @@ export const actionPasteStyles = register({
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily:
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
elementStylesToCopyFrom.lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
|
@@ -30,7 +30,10 @@ import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import "./Actions.scss";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { shouldAllowVerticalAlign } from "../element/textElement";
|
||||
import {
|
||||
shouldAllowVerticalAlign,
|
||||
suppportsHorizontalAlign,
|
||||
} from "../element/textElement";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@@ -122,7 +125,8 @@ export const SelectedShapeActions = ({
|
||||
|
||||
{renderAction("changeFontFamily")}
|
||||
|
||||
{renderAction("changeTextAlign")}
|
||||
{suppportsHorizontalAlign(targetElements) &&
|
||||
renderAction("changeTextAlign")}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
45
src/components/App.test.tsx
Normal file
45
src/components/App.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import * as Renderer from "../renderer/renderScene";
|
||||
import { reseed } from "../random";
|
||||
import { render, queryByTestId } from "../tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
|
||||
const renderScene = jest.spyOn(Renderer, "renderScene");
|
||||
|
||||
describe("Test <App/>", () => {
|
||||
beforeEach(async () => {
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
localStorage.clear();
|
||||
renderScene.mockClear();
|
||||
reseed(7);
|
||||
});
|
||||
|
||||
it("should show error modal when using brave and measureText API is not working", async () => {
|
||||
(global.navigator as any).brave = {
|
||||
isBrave: {
|
||||
name: "isBrave",
|
||||
},
|
||||
};
|
||||
|
||||
const originalContext = global.HTMLCanvasElement.prototype.getContext("2d");
|
||||
//@ts-ignore
|
||||
global.HTMLCanvasElement.prototype.getContext = (contextId) => {
|
||||
return {
|
||||
...originalContext,
|
||||
measureText: () => ({
|
||||
width: 0,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
await render(<ExcalidrawApp />);
|
||||
expect(
|
||||
queryByTestId(
|
||||
document.querySelector(".excalidraw-modal-container")!,
|
||||
"brave-measure-text-error",
|
||||
),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
@@ -62,6 +62,7 @@ import {
|
||||
GRID_SIZE,
|
||||
IMAGE_RENDER_TIMEOUT,
|
||||
isAndroid,
|
||||
isBrave,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
MAX_ALLOWED_FILE_BYTES,
|
||||
MIME_TYPES,
|
||||
@@ -259,9 +260,6 @@ import throttle from "lodash.throttle";
|
||||
import { fileOpen, FileSystemHandle } from "../data/filesystem";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getApproxLineHeight,
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
@@ -269,6 +267,13 @@ import {
|
||||
getTextBindableContainerAtPosition,
|
||||
isValidTextContainer,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
getApproxMinContainerHeight,
|
||||
getApproxMinContainerWidth,
|
||||
isMeasureTextSupported,
|
||||
getLineHeightInPx,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textMeasurements";
|
||||
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
|
||||
import {
|
||||
normalizeLink,
|
||||
@@ -285,6 +290,7 @@ import { actionToggleHandTool } from "../actions/actionCanvas";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
isSmScreen: false,
|
||||
@@ -429,7 +435,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
this.id = nanoid();
|
||||
|
||||
this.library = new Library(this);
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
@@ -711,6 +716,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const theme =
|
||||
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
|
||||
let name = actionResult?.appState?.name ?? this.state.name;
|
||||
const errorMessage =
|
||||
actionResult?.appState?.errorMessage ?? this.state.errorMessage;
|
||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||
viewModeEnabled = this.props.viewModeEnabled;
|
||||
}
|
||||
@@ -726,7 +733,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (typeof this.props.name !== "undefined") {
|
||||
name = this.props.name;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
(state) => {
|
||||
// using Object.assign instead of spread to fool TS 4.2.2+ into
|
||||
@@ -744,6 +750,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
gridSize,
|
||||
theme,
|
||||
name,
|
||||
errorMessage,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
@@ -872,7 +879,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// FontFaceSet loadingdone event we listen on may not always fire
|
||||
// (looking at you Safari), so on init we manually load fonts for current
|
||||
// text elements on canvas, and rerender them once done. This also
|
||||
@@ -1000,6 +1006,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} else {
|
||||
this.updateDOMRect(this.initializeScene);
|
||||
}
|
||||
|
||||
// note that this check seems to always pass in localhost
|
||||
if (isBrave() && !isMeasureTextSupported()) {
|
||||
this.setState({
|
||||
errorMessage: <BraveMeasureTextError />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
@@ -1721,12 +1734,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(acc: ExcalidrawTextElement[], line, idx) => {
|
||||
const text = line.trim();
|
||||
|
||||
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
|
||||
if (text.length) {
|
||||
const element = newTextElement({
|
||||
...textElementProps,
|
||||
x,
|
||||
y: currentY,
|
||||
text,
|
||||
lineHeight,
|
||||
});
|
||||
acc.push(element);
|
||||
currentY += element.height + LINE_GAP;
|
||||
@@ -1735,14 +1750,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// add paragraph only if previous line was not empty, IOW don't add
|
||||
// more than one empty line
|
||||
if (prevLine) {
|
||||
const defaultLineHeight = getApproxLineHeight(
|
||||
getFontString({
|
||||
fontSize: textElementProps.fontSize,
|
||||
fontFamily: textElementProps.fontFamily,
|
||||
}),
|
||||
);
|
||||
|
||||
currentY += defaultLineHeight + LINE_GAP;
|
||||
currentY +=
|
||||
getLineHeightInPx(textElementProps.fontSize, lineHeight) +
|
||||
LINE_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2597,6 +2607,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
||||
}
|
||||
|
||||
const fontFamily =
|
||||
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
|
||||
|
||||
const lineHeight =
|
||||
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const fontSize = this.state.currentItemFontSize;
|
||||
|
||||
if (
|
||||
!existingTextElement &&
|
||||
shouldBindToContainer &&
|
||||
@@ -2604,11 +2621,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isArrowElement(container)
|
||||
) {
|
||||
const fontString = {
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
};
|
||||
const minWidth = getApproxMinLineWidth(getFontString(fontString));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(fontString));
|
||||
const minWidth = getApproxMinContainerWidth(
|
||||
getFontString(fontString),
|
||||
lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinContainerHeight(fontSize, lineHeight);
|
||||
const containerDims = getContainerDims(container);
|
||||
const newHeight = Math.max(containerDims.height, minHeight);
|
||||
const newWidth = Math.max(containerDims.width, minWidth);
|
||||
@@ -2642,8 +2662,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness: null,
|
||||
text: "",
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign: parentCenterPosition
|
||||
? "center"
|
||||
: this.state.currentItemTextAlign,
|
||||
@@ -2653,6 +2673,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
locked: false,
|
||||
lineHeight,
|
||||
});
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
|
42
src/components/BraveMeasureTextError.tsx
Normal file
42
src/components/BraveMeasureTextError.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { t } from "../i18n";
|
||||
const BraveMeasureTextError = () => {
|
||||
return (
|
||||
<div data-testid="brave-measure-text-error">
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.start")}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{t("errors.brave_measure_text_error.aggressive_block_fingerprint")}
|
||||
</span>{" "}
|
||||
{t("errors.brave_measure_text_error.setting_enabled")}.
|
||||
<br />
|
||||
<br />
|
||||
{t("errors.brave_measure_text_error.break")}{" "}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{t("errors.brave_measure_text_error.text_elements")}
|
||||
</span>{" "}
|
||||
{t("errors.brave_measure_text_error.in_your_drawings")}.
|
||||
</p>
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.strongly_recommend")}{" "}
|
||||
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
|
||||
{" "}
|
||||
{t("errors.brave_measure_text_error.steps")}
|
||||
</a>{" "}
|
||||
{t("errors.brave_measure_text_error.how")}.
|
||||
</p>
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.disable_setting")}{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/issues/new">
|
||||
{t("errors.brave_measure_text_error.issue")}
|
||||
</a>{" "}
|
||||
{t("errors.brave_measure_text_error.write")}{" "}
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
{t("errors.brave_measure_text_error.discord")}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BraveMeasureTextError;
|
@@ -5,13 +5,13 @@ import { Dialog } from "./Dialog";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
export const ErrorDialog = ({
|
||||
message,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||
const [modalIsShown, setModalIsShown] = useState(!!children);
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
@@ -32,7 +32,7 @@ export const ErrorDialog = ({
|
||||
onCloseRequest={handleClose}
|
||||
title={t("errorDialog.title")}
|
||||
>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{message}</div>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{children}</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
|
@@ -9,6 +9,10 @@
|
||||
text-align: center;
|
||||
padding: var(--preview-padding);
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ExportDialog__preview canvas {
|
||||
|
@@ -364,10 +364,9 @@ const LayerUI = ({
|
||||
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
message={appState.errorMessage}
|
||||
onClose={() => setAppState({ errorMessage: null })}
|
||||
/>
|
||||
<ErrorDialog onClose={() => setAppState({ errorMessage: null })}>
|
||||
{appState.errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
{appState.openDialog === "help" && (
|
||||
<HelpDialog
|
||||
|
63
src/components/__snapshots__/App.test.tsx.snap
Normal file
63
src/components/__snapshots__/App.test.tsx.snap
Normal file
@@ -0,0 +1,63 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Test <App/> should show error modal when using brave and measureText API is not working 1`] = `
|
||||
<div
|
||||
data-testid="brave-measure-text-error"
|
||||
>
|
||||
<p>
|
||||
Looks like you are using Brave browser with the
|
||||
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Aggressively Block Fingerprinting
|
||||
</span>
|
||||
|
||||
setting enabled
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
This could result in breaking the
|
||||
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Text Elements
|
||||
</span>
|
||||
|
||||
in your drawings
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
We strongly recommend disabling this setting. You can follow
|
||||
|
||||
<a
|
||||
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
|
||||
>
|
||||
|
||||
these steps
|
||||
</a>
|
||||
|
||||
on how to do so
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
If disabling this setting doesn't fix the display of text elements, please open an
|
||||
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw/issues/new"
|
||||
>
|
||||
issue
|
||||
</a>
|
||||
|
||||
on our GitHub, or write us on
|
||||
|
||||
<a
|
||||
href="https://discord.gg/UexuTaE"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
@@ -12,6 +12,9 @@ export const isFirefox =
|
||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
|
@@ -157,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{ repairBindings: true },
|
||||
{ repairBindings: true, refreshDimensions: true },
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
|
@@ -89,7 +89,9 @@ export const exportCanvas = async (
|
||||
return await fileSave(blob, {
|
||||
description: "Export to PNG",
|
||||
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,
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
|
@@ -35,6 +35,10 @@ import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import oc from "open-color";
|
||||
import { MarkOptional, Mutable } from "../utility-types";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textMeasurements";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -165,17 +169,32 @@ const restoreElement = (
|
||||
const [fontPx, _fontFamily]: [string, string] = (
|
||||
element as any
|
||||
).font.split(" ");
|
||||
fontSize = parseInt(fontPx, 10);
|
||||
fontSize = parseFloat(fontPx);
|
||||
fontFamily = getFontFamilyByName(_fontFamily);
|
||||
}
|
||||
const text = element.text ?? "";
|
||||
|
||||
element = restoreElementWithProperties(element, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
text: element.text ?? "",
|
||||
text,
|
||||
textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||
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) {
|
||||
|
@@ -786,7 +786,12 @@ export const findFocusPointForEllipse = (
|
||||
orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
|
||||
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);
|
||||
return GA.point(x, (-m * x - 1) / n);
|
||||
|
@@ -25,14 +25,17 @@ import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import {
|
||||
measureText,
|
||||
wrapText,
|
||||
getDefaultLineHeight,
|
||||
} from "./textMeasurements";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@@ -137,10 +140,12 @@ export const newTextElement = (
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureText(text, getFontString(opts));
|
||||
const metrics = measureText(text, getFontString(opts), lineHeight);
|
||||
const offsets = getTextElementPositionOffsets(opts, metrics);
|
||||
const textElement = newElementWith(
|
||||
{
|
||||
@@ -156,6 +161,7 @@ export const newTextElement = (
|
||||
height: metrics.height,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -176,6 +182,7 @@ const getAdjustedDimensions = (
|
||||
const { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
@@ -185,7 +192,11 @@ const getAdjustedDimensions = (
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureText(element.text, getFontString(element));
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
@@ -261,7 +272,7 @@ export const refreshTextDimensions = (
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
getBoundTextMaxWidth(container),
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
|
@@ -39,15 +39,16 @@ import {
|
||||
import { Point, PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
handleBindTextResize,
|
||||
getMaxContainerWidth,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
|
||||
import {
|
||||
getApproxMinContainerHeight,
|
||||
getApproxMinContainerWidth,
|
||||
} from "./textMeasurements";
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle >= 2 * Math.PI) {
|
||||
return angle - 2 * Math.PI;
|
||||
@@ -201,7 +202,7 @@ const measureFontSizeFromWidth = (
|
||||
if (hasContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
width = getMaxContainerWidth(container);
|
||||
width = getBoundTextMaxWidth(container);
|
||||
}
|
||||
}
|
||||
const nextFontSize = element.fontSize * (nextWidth / width);
|
||||
@@ -360,7 +361,7 @@ export const resizeSingleElement = (
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
let boundTextFontSize: number | null = null;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
@@ -410,15 +411,35 @@ export const resizeSingleElement = (
|
||||
boundTextElement.id,
|
||||
) as typeof boundTextElement | undefined;
|
||||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
};
|
||||
boundTextFontSize = stateOfBoundTextElementAtResize.fontSize;
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const updatedElement = {
|
||||
...element,
|
||||
width: eleNewWidth,
|
||||
height: eleNewHeight,
|
||||
};
|
||||
|
||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
const nextFontSize = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
getBoundTextMaxWidth(updatedElement),
|
||||
);
|
||||
if (nextFontSize === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFontSize = nextFontSize;
|
||||
} else {
|
||||
const minWidth = getApproxMinContainerWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinContainerHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
}
|
||||
}
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
@@ -548,14 +569,12 @@ export const resizeSingleElement = (
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
|
||||
if (boundTextElement && boundTextFontSize != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(
|
||||
element,
|
||||
transformHandleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -680,7 +699,7 @@ const resizeMultipleElements = (
|
||||
const fontSize = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getMaxContainerWidth(updatedElement)
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
);
|
||||
|
||||
|
@@ -1,327 +1,173 @@
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
computeBoundTextElementCoords,
|
||||
getMaxContainerWidth,
|
||||
getMaxContainerHeight,
|
||||
wrapText,
|
||||
computeContainerCoords,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
} from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
import { ExcalidrawTextElementWithContainer } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
describe("Test measureText", () => {
|
||||
describe("Test getContainerCoords", () => {
|
||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello whats up ");
|
||||
});
|
||||
it("should compute coords correctly when ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 44.2893218813452455,
|
||||
y: 39.64466094067262,
|
||||
});
|
||||
});
|
||||
|
||||
it("should work with emojis", () => {
|
||||
const text = "😀";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀");
|
||||
});
|
||||
it("should compute coords correctly when rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 15,
|
||||
y: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the text correctly when min width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 140,
|
||||
res: `Hello whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "should push the word if its equal to max width",
|
||||
width: 60,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
it("should compute coords correctly when diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 65,
|
||||
y: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello
|
||||
whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
describe("Test computeContainerDimensionForBoundText", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
width: 150,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
it("should compute container height correctly for rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
160,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 170,
|
||||
res: `hellolongtextth
|
||||
isiswhatsupwith
|
||||
youIamtypingggg
|
||||
gandtypinggg
|
||||
break it now`,
|
||||
},
|
||||
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `hellolongte
|
||||
xtthisiswha
|
||||
tsupwithyou
|
||||
Iamtypinggg
|
||||
ggandtyping
|
||||
gg break it
|
||||
now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 600,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg
|
||||
break it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
it("should compute container height correctly for ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
226,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
320,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(79);
|
||||
});
|
||||
|
||||
it("should return max width when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxWidth(container)).toBe(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
id: "container-id",
|
||||
};
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: params.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
height: 70,
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
});
|
||||
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||
boundTextElement.height,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeBoundTextElementCoords", () => {
|
||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||
|
||||
it("should compute coords correctly when ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(computeBoundTextElementCoords(element)).toEqual({
|
||||
x: 44.2893218813452455,
|
||||
y: 39.64466094067262,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(computeBoundTextElementCoords(element)).toEqual({
|
||||
x: 15,
|
||||
y: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(computeBoundTextElementCoords(element)).toEqual({
|
||||
x: 65,
|
||||
y: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeContainerCoords", () => {
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
width: 200,
|
||||
height: 100,
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
it("should compute coords correctly when ellipse", () => {
|
||||
expect(computeContainerCoords(boundTextElement, "ellipse")).toEqual({
|
||||
x: -24.289321881345245,
|
||||
y: 0.3553390593273775,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when rectangle", () => {
|
||||
expect(computeContainerCoords(boundTextElement, "rectangle")).toEqual({
|
||||
x: 5,
|
||||
y: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when diamond", () => {
|
||||
expect(computeContainerCoords(boundTextElement, "diamond")).toEqual({
|
||||
x: -45,
|
||||
y: -10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeContainerDimensionForBoundText", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should compute container height correctly for rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
160,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
226,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
320,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getMaxContainerWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getMaxContainerWidth(container)).toBe(79);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getMaxContainerHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getMaxContainerHeight(container)).toBe(87);
|
||||
});
|
||||
});
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
||||
import { getFontString, arrayToMap } from "../utils";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
updateOriginalContainerCache,
|
||||
} from "./textWysiwyg";
|
||||
import { ExtractSetType } from "../utility-types";
|
||||
import { measureText, wrapText } from "./textMeasurements";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
@@ -40,7 +40,6 @@ export const redrawTextBoundingBox = (
|
||||
container: ExcalidrawElement | null,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
@@ -49,54 +48,48 @@ export const redrawTextBoundingBox = (
|
||||
height: textElement.height,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
boundTextUpdates.height = metrics.height;
|
||||
|
||||
if (container) {
|
||||
if (isArrowElement(container)) {
|
||||
const centerX = textElement.x + textElement.width / 2;
|
||||
const centerY = textElement.y + textElement.height / 2;
|
||||
const diffWidth = metrics.width - textElement.width;
|
||||
const diffHeight = metrics.height - textElement.height;
|
||||
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
|
||||
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
|
||||
} else {
|
||||
const containerDims = getContainerDims(container);
|
||||
let maxContainerHeight = getMaxContainerHeight(container);
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let nextHeight = containerDims.height;
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
maxContainerHeight = getMaxContainerHeight(container);
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
}
|
||||
|
||||
mutateElement(textElement, boundTextUpdates);
|
||||
@@ -147,7 +140,6 @@ export const bindTextToShapeAfterDuplication = (
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
@@ -169,8 +161,11 @@ export const handleBindTextResize = (
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getMaxContainerWidth(container);
|
||||
const maxHeight = getMaxContainerHeight(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let containerHeight = containerDims.height;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
@@ -180,12 +175,16 @@ export const handleBindTextResize = (
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
const dimensions = measureText(text, getFontString(textElement));
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (!shouldMaintainAspectRatio && nextHeight > maxHeight) {
|
||||
if (nextHeight > maxHeight) {
|
||||
containerHeight = computeContainerDimensionForBoundText(
|
||||
nextHeight,
|
||||
container.type,
|
||||
@@ -206,53 +205,6 @@ export const handleBindTextResize = (
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
shouldMaintainAspectRatio &&
|
||||
(nextHeight > maxHeight || nextWidth > maxWidth)
|
||||
) {
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
let x = container.x;
|
||||
let y = container.y;
|
||||
|
||||
if (nextHeight > maxHeight) {
|
||||
height = computeContainerDimensionForBoundText(
|
||||
nextHeight,
|
||||
container.type,
|
||||
);
|
||||
}
|
||||
if (nextWidth > maxWidth) {
|
||||
width = computeContainerDimensionForBoundText(
|
||||
nextWidth,
|
||||
container.type,
|
||||
);
|
||||
}
|
||||
const diffX = width - containerDims.width;
|
||||
const diffY = height - containerDims.height;
|
||||
|
||||
if (transformHandleType === "n") {
|
||||
y = container.y - diffY;
|
||||
} else if (
|
||||
transformHandleType === "e" ||
|
||||
transformHandleType === "w" ||
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw"
|
||||
) {
|
||||
y = container.y - diffY / 2;
|
||||
}
|
||||
|
||||
if (transformHandleType === "s" || transformHandleType === "n") {
|
||||
x = container.x - diffX / 2;
|
||||
}
|
||||
|
||||
mutateElement(container, {
|
||||
height,
|
||||
width,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
text,
|
||||
width: nextWidth,
|
||||
@@ -271,16 +223,22 @@ export const handleBindTextResize = (
|
||||
}
|
||||
};
|
||||
|
||||
const computeBoundTextPosition = (
|
||||
export const computeBoundTextPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const containerCoords = computeBoundTextElementCoords(container);
|
||||
const maxContainerHeight = getMaxContainerHeight(container);
|
||||
const maxContainerWidth = getMaxContainerWidth(container);
|
||||
const containerCoords = getContainerCoords(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
let x;
|
||||
let y;
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
@@ -301,266 +259,6 @@ const computeBoundTextPosition = (
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
|
||||
export const measureText = (text: string, font: FontString) => {
|
||||
text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
|
||||
const height = getTextHeight(text, font);
|
||||
const width = getTextWidth(text, font);
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
const cacheApproxLineHeight: { [key: FontString]: number } = {};
|
||||
|
||||
export const getApproxLineHeight = (font: FontString) => {
|
||||
if (cacheApproxLineHeight[font]) {
|
||||
return cacheApproxLineHeight[font];
|
||||
}
|
||||
const fontSize = parseInt(font);
|
||||
|
||||
// Calculate line height relative to font size
|
||||
cacheApproxLineHeight[font] = fontSize * 1.2;
|
||||
return cacheApproxLineHeight[font];
|
||||
};
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const width = canvas2dContext.measureText(text).width;
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return width * 10;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (text: string, font: FontString) => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = getApproxLineHeight(font);
|
||||
return lineHeight * lines.length;
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
const push = (str: string) => {
|
||||
if (str.trim()) {
|
||||
lines.push(str);
|
||||
}
|
||||
};
|
||||
originalLines.forEach((originalLine) => {
|
||||
const words = originalLine.split(" ");
|
||||
// This means its newline so push it
|
||||
if (words.length === 1 && words[0] === "") {
|
||||
lines.push(words[0]);
|
||||
return; // continue
|
||||
}
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
push(words[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
push(currentLine);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
}
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
} else {
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
currentLineWidthTillNow = 0;
|
||||
currentLine = "";
|
||||
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
currentLine += `${word} `;
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
const word = currentLine.slice(0, -1);
|
||||
push(word);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentLineWidthTillNow === maxWidth) {
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine) {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
}
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const ascii = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][ascii];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getApproxMinLineWidth = (font: FontString) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||
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) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
|
||||
return Math.min(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
const batchLength = 6;
|
||||
let index = 0;
|
||||
let widthTillNow = 0;
|
||||
let str = "";
|
||||
while (widthTillNow <= width) {
|
||||
const batch = dummyText.substr(index, index + batchLength);
|
||||
str += batch;
|
||||
widthTillNow += getLineWidth(str, font);
|
||||
if (index === dummyText.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
index = index + batchLength;
|
||||
}
|
||||
|
||||
while (widthTillNow > width) {
|
||||
str = str.substr(0, str.length - 1);
|
||||
widthTillNow = getLineWidth(str, font);
|
||||
}
|
||||
return str.length;
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
|
||||
@@ -644,9 +342,7 @@ export const getContainerCenter = (
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
};
|
||||
|
||||
export const computeBoundTextElementCoords = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
) => {
|
||||
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
|
||||
@@ -666,27 +362,6 @@ export const computeBoundTextElementCoords = (
|
||||
};
|
||||
};
|
||||
|
||||
export const computeContainerCoords = (
|
||||
boundTextElement: ExcalidrawTextElement,
|
||||
containerType: string,
|
||||
) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
|
||||
if (containerType === "ellipse") {
|
||||
offsetX += (boundTextElement.width / 2) * (1 - Math.sqrt(2) / 2);
|
||||
offsetY += (boundTextElement.height / 2) * (1 - Math.sqrt(2) / 2);
|
||||
}
|
||||
if (containerType === "diamond") {
|
||||
offsetX += boundTextElement.width / 4;
|
||||
offsetY += boundTextElement.height / 4;
|
||||
}
|
||||
return {
|
||||
x: boundTextElement.x - offsetX,
|
||||
y: boundTextElement.y - offsetY,
|
||||
};
|
||||
};
|
||||
|
||||
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
|
||||
const container = getContainerElement(textElement);
|
||||
if (!container || isArrowElement(container)) {
|
||||
@@ -709,18 +384,6 @@ export const getBoundTextElementOffset = (
|
||||
return BOUND_TEXT_PADDING;
|
||||
};
|
||||
|
||||
export const getBoundTextElementPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldAllowVerticalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
@@ -733,14 +396,24 @@ export const shouldAllowVerticalAlign = (
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
if (isArrowElement(element)) {
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const suppportsHorizontalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
const hasBoundContainer = isBoundToContainer(element);
|
||||
if (hasBoundContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (isTextElement(element) && isArrowElement(container)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
return isTextElement(element);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -811,18 +484,10 @@ export const computeContainerDimensionForBoundText = (
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerWidth <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.width;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return containerWidth;
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
@@ -839,16 +504,15 @@ export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.height;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
return boundTextElement.height;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
213
src/element/textMeasurements.test.ts
Normal file
213
src/element/textMeasurements.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
wrapText,
|
||||
} from "./textMeasurements";
|
||||
import { FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(text);
|
||||
});
|
||||
|
||||
it("should work with emojis", () => {
|
||||
const text = "😀";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀");
|
||||
});
|
||||
|
||||
it("should show the text correctly when max width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello \nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 140,
|
||||
res: `Hello whats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "should push the word if its equal to max width",
|
||||
width: 60,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello
|
||||
whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello\nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 150,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 170,
|
||||
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
|
||||
},
|
||||
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `hellolongte
|
||||
xtthisiswha
|
||||
tsupwithyou
|
||||
Iamtypinggg
|
||||
ggandtyping
|
||||
gg break it
|
||||
now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 600,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should wrap the text correctly when word length is exactly equal to max width", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||
const res = wrapText(text, font, 100);
|
||||
expect(res).toEqual(`Hello \nExcalidraw`);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
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 correct line height", () => {
|
||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
339
src/element/textMeasurements.ts
Normal file
339
src/element/textMeasurements.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { getFontString, isTestEnv } from "../utils";
|
||||
import { normalizeText } from "./textElement";
|
||||
import { ExcalidrawTextElement, FontFamilyValues, FontString } from "./types";
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letter is 10px
|
||||
const DUMMY_CHAR_WIDTH = 10;
|
||||
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const width = canvas2dContext.measureText(text).width;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (isTestEnv()) {
|
||||
return width * DUMMY_CHAR_WIDTH;
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
export const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(text, fontSize, lineHeight);
|
||||
const width = getTextWidth(text, font);
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const getApproxMinContainerWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getApproxMinContainerHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const ascii = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][ascii];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
/** this is not used currently but might be useful
|
||||
* in future hence keeping it
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
const batchLength = 6;
|
||||
let index = 0;
|
||||
let widthTillNow = 0;
|
||||
let str = "";
|
||||
while (widthTillNow <= width) {
|
||||
const batch = dummyText.substr(index, index + batchLength);
|
||||
str += batch;
|
||||
widthTillNow += getLineWidth(str, font);
|
||||
if (index === dummyText.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
index = index + batchLength;
|
||||
}
|
||||
|
||||
while (widthTillNow > width) {
|
||||
str = str.substr(0, str.length - 1);
|
||||
widthTillNow = getLineWidth(str, font);
|
||||
}
|
||||
return str.length;
|
||||
};
|
||||
|
||||
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 originalLines = text.split("\n");
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
const push = (str: string) => {
|
||||
if (str.trim()) {
|
||||
lines.push(str);
|
||||
}
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
|
||||
originalLines.forEach((originalLine) => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
//Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
}
|
||||
const words = originalLine.split(" ");
|
||||
|
||||
resetParams();
|
||||
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
push(words[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
} else {
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
currentLine += `${word} `;
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
const word = currentLine.slice(0, -1);
|
||||
push(word);
|
||||
resetParams();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* We calculate the line height from the font size and the unitless line height,
|
||||
* aligning with the W3C spec.
|
||||
*/
|
||||
export const getLineHeightInPx = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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"];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 Helvetica in WebKit and Blink.
|
||||
// Gecko if all over the place.
|
||||
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
|
||||
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
||||
};
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily) {
|
||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||
}
|
||||
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
||||
};
|
@@ -783,7 +783,7 @@ describe("textWysiwyg", () => {
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
expect(text.x).toBe(25);
|
||||
expect(text.height).toBe(48);
|
||||
expect(text.height).toBe(50);
|
||||
expect(text.width).toBe(60);
|
||||
|
||||
// Edit and text by removing second line and it should
|
||||
@@ -810,7 +810,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect(text.text).toBe("Hello");
|
||||
expect(text.originalText).toBe("Hello");
|
||||
expect(text.height).toBe(24);
|
||||
expect(text.height).toBe(25);
|
||||
expect(text.width).toBe(50);
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
@@ -903,7 +903,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
85,
|
||||
5,
|
||||
4.5,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -929,7 +929,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -989,136 +989,26 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
describe.only("when using shift resize", () => {
|
||||
it("should wrap text correctly when resizing using shift from 'ne' handle", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, {
|
||||
target: { value: "Excalidraw is an opensource virtual whiteboard" },
|
||||
});
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
expect(rectangle.height).toBe(202);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
expect(textElement.text).toBe(
|
||||
`Excalid
|
||||
raw is
|
||||
an
|
||||
opensou
|
||||
rce
|
||||
virtual
|
||||
whitebo
|
||||
ard`,
|
||||
);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
expect(rectangle.height).toBe(75);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
|
||||
shift: true,
|
||||
});
|
||||
expect(rectangle.width).toBe(200);
|
||||
expect(rectangle.height).toBe(449);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
expect(textElement.text).toBe(
|
||||
`Excalidraw is an
|
||||
opensource virtual
|
||||
whiteboard`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should wrap text correctly when resizing using shift vertically using 'n' handle", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, {
|
||||
target: { value: "Excalidraw is an opensource virtual whiteboard" },
|
||||
});
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
expect(rectangle.height).toBe(202);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
expect(textElement.text).toBe(
|
||||
`Excalid
|
||||
raw is
|
||||
an
|
||||
opensou
|
||||
rce
|
||||
virtual
|
||||
whitebo
|
||||
ard`,
|
||||
);
|
||||
|
||||
resize(rectangle, "n", [rectangle.x + 30, rectangle.y - 50], {
|
||||
shift: true,
|
||||
});
|
||||
expect(rectangle.width).toBe(104);
|
||||
expect(rectangle.height).toBe(232);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
expect(textElement.text).toBe(
|
||||
`Excalid
|
||||
raw is
|
||||
an
|
||||
opensou
|
||||
rce
|
||||
virtual
|
||||
whitebo
|
||||
ard`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should wrap text correctly when resizing using shift horizontally and text overflows", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, {
|
||||
target: { value: "Excalidraw is an opensource virtual whiteboard" },
|
||||
});
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
expect(rectangle.height).toBe(202);
|
||||
expect(rectangle.y).toBe(20);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
expect(textElement.text).toBe(
|
||||
`Excalid
|
||||
raw is
|
||||
an
|
||||
opensou
|
||||
rce
|
||||
virtual
|
||||
whitebo
|
||||
ard`,
|
||||
);
|
||||
|
||||
resize(rectangle, "e", [rectangle.x - 30, rectangle.y + 30], {
|
||||
shift: true,
|
||||
});
|
||||
expect(rectangle.width).toBe(70);
|
||||
expect(rectangle.height).toBe(226);
|
||||
expect(rectangle.y).toBe(8);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
expect(textElement.text).toBe(
|
||||
`Excal
|
||||
idraw
|
||||
is an
|
||||
opens
|
||||
ource
|
||||
virtu
|
||||
al
|
||||
white
|
||||
board`,
|
||||
);
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
|
||||
shift: true,
|
||||
});
|
||||
expect(rectangle.width).toBe(200);
|
||||
expect(rectangle.height).toBe(166.66666666666669);
|
||||
expect(textElement.fontSize).toBe(47.5);
|
||||
});
|
||||
|
||||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
@@ -1177,9 +1067,9 @@ board`,
|
||||
mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
|
||||
mouse.up(rectangle.x + 100, rectangle.y + 50);
|
||||
expect(rectangle.x).toBe(80);
|
||||
expect(rectangle.y).toBe(-35);
|
||||
expect(rectangle.y).toBe(-40);
|
||||
expect(text.x).toBe(85);
|
||||
expect(text.y).toBe(-30);
|
||||
expect(text.y).toBe(-35);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.Z);
|
||||
@@ -1222,7 +1112,7 @@ board`,
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(178);
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
@@ -1296,6 +1186,41 @@ board`,
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
let editor: HTMLTextAreaElement;
|
||||
|
||||
@@ -1355,7 +1280,7 @@ board`,
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1367,7 +1292,7 @@ board`,
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
30,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1379,7 +1304,7 @@ board`,
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
45,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1391,7 +1316,7 @@ board`,
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1402,7 +1327,7 @@ board`,
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
30,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1413,7 +1338,7 @@ board`,
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
45,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1443,7 +1368,7 @@ board`,
|
||||
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(textElement.width).toBe(600);
|
||||
expect(textElement.height).toBe(24);
|
||||
expect(textElement.height).toBe(25);
|
||||
expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
|
||||
expect((textElement as ExcalidrawTextElement).text).toBe(
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
@@ -1475,7 +1400,7 @@ board`,
|
||||
],
|
||||
fillStyle: "hachure",
|
||||
groupIds: [],
|
||||
height: 34,
|
||||
height: 35,
|
||||
isDeleted: false,
|
||||
link: null,
|
||||
locked: false,
|
||||
|
@@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isFirefox, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -22,19 +22,15 @@ import {
|
||||
import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
computeBoundTextElementCoords,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
@@ -44,6 +40,12 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||
import App from "../components/App";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
import {
|
||||
getTextWidth,
|
||||
measureText,
|
||||
wrapText,
|
||||
getTextHeight,
|
||||
} from "./textMeasurements";
|
||||
|
||||
const getTransform = (
|
||||
width: number,
|
||||
@@ -150,9 +152,7 @@ export const textWysiwyg = ({
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedTextElement;
|
||||
const approxLineHeight = getApproxLineHeight(
|
||||
getFontString(updatedTextElement),
|
||||
);
|
||||
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
let coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
@@ -180,15 +180,12 @@ export const textWysiwyg = ({
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
textElementHeight = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
textElementHeight = updatedTextElement.height;
|
||||
}
|
||||
|
||||
textElementHeight = getTextHeight(
|
||||
updatedTextElement.text,
|
||||
updatedTextElement.fontSize,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
@@ -206,14 +203,17 @@ export const textWysiwyg = ({
|
||||
}
|
||||
}
|
||||
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxHeight = getMaxContainerHeight(container);
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
const diff = Math.min(
|
||||
textElementHeight - maxHeight,
|
||||
approxLineHeight,
|
||||
element.lineHeight,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
return;
|
||||
@@ -226,25 +226,15 @@ export const textWysiwyg = ({
|
||||
) {
|
||||
const diff = Math.min(
|
||||
maxHeight - textElementHeight,
|
||||
approxLineHeight,
|
||||
element.lineHeight,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height - diff });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = computeBoundTextElementCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
}
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
@@ -266,15 +256,10 @@ export const textWysiwyg = ({
|
||||
editable.selectionEnd = editable.value.length - diff;
|
||||
}
|
||||
|
||||
const lines = updatedTextElement.originalText.split("\n");
|
||||
const lineHeight = updatedTextElement.containerId
|
||||
? approxLineHeight
|
||||
: updatedTextElement.height / lines.length;
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||
} else if (isFirefox || isSafari) {
|
||||
// As firefox, Safari needs little higher dimensions on DOM
|
||||
} else {
|
||||
textElementWidth += 0.5;
|
||||
}
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
@@ -283,7 +268,7 @@ export const textWysiwyg = ({
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
lineHeight: element.lineHeight,
|
||||
width: `${textElementWidth}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
@@ -370,7 +355,7 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
`${editable.value}${data}`,
|
||||
font,
|
||||
getMaxContainerWidth(container),
|
||||
getBoundTextMaxWidth(container),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
@@ -387,9 +372,13 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
const { width, height } = measureText(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
|
@@ -135,6 +135,11 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
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 =
|
||||
|
@@ -838,10 +838,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
/>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<ErrorDialog
|
||||
message={errorMessage}
|
||||
onClose={() => this.setState({ errorMessage: "" })}
|
||||
/>
|
||||
<ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@@ -263,7 +263,7 @@ export const loadScene = async (
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true },
|
||||
{ repairBindings: true, refreshDimensions: true },
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
|
@@ -673,10 +673,9 @@ const ExcalidrawWrapper = () => {
|
||||
</Excalidraw>
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
<ErrorDialog
|
||||
message={errorMessage}
|
||||
onClose={() => setErrorMessage("")}
|
||||
/>
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@@ -120,7 +120,6 @@
|
||||
"edit": "Edit line",
|
||||
"exit": "Exit line editor"
|
||||
},
|
||||
|
||||
"elementLock": {
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
@@ -206,7 +205,22 @@
|
||||
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
|
||||
"importLibraryError": "Couldn't load library",
|
||||
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
|
||||
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work."
|
||||
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
|
||||
"brave_measure_text_error": {
|
||||
"start": "Looks like you are using Brave browser with the",
|
||||
"aggressive_block_fingerprint": "Aggressively Block Fingerprinting",
|
||||
"setting_enabled": "setting enabled",
|
||||
"break": "This could result in breaking the",
|
||||
"text_elements": "Text Elements",
|
||||
"in_your_drawings": "in your drawings",
|
||||
"strongly_recommend": "We strongly recommend disabling this setting. You can follow",
|
||||
"steps": "these steps",
|
||||
"how": "on how to do so",
|
||||
"disable_setting": " If disabling this setting doesn't fix the display of text elements, please open an",
|
||||
"issue": "issue",
|
||||
"write": "on our GitHub, or write us on",
|
||||
"discord": "Discord"
|
||||
}
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selection",
|
||||
|
@@ -64,7 +64,7 @@
|
||||
"terser-webpack-plugin": "5.3.3",
|
||||
"ts-loader": "9.3.1",
|
||||
"typescript": "4.7.4",
|
||||
"webpack": "5.73.0",
|
||||
"webpack": "5.76.0",
|
||||
"webpack-bundle-analyzer": "4.5.0",
|
||||
"webpack-cli": "4.10.0",
|
||||
"webpack-dev-server": "4.9.3",
|
||||
|
@@ -1393,10 +1393,10 @@ acorn-walk@^8.0.0:
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3"
|
||||
integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==
|
||||
|
||||
acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0:
|
||||
version "8.7.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
|
||||
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
|
||||
acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1:
|
||||
version "8.8.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
|
||||
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
|
||||
|
||||
ajv-formats@^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"
|
||||
integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
|
||||
|
||||
enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3:
|
||||
version "5.10.0"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6"
|
||||
integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==
|
||||
enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0:
|
||||
version "5.12.0"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
|
||||
integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.2.0"
|
||||
@@ -3751,10 +3751,10 @@ vary@~1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
|
||||
|
||||
watchpack@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"
|
||||
integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==
|
||||
watchpack@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
|
||||
integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
|
||||
dependencies:
|
||||
glob-to-regexp "^0.4.1"
|
||||
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"
|
||||
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
|
||||
|
||||
webpack@5.73.0:
|
||||
version "5.73.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38"
|
||||
integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA==
|
||||
webpack@5.76.0:
|
||||
version "5.76.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c"
|
||||
integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.3"
|
||||
"@types/estree" "^0.0.51"
|
||||
"@webassemblyjs/ast" "1.11.1"
|
||||
"@webassemblyjs/wasm-edit" "1.11.1"
|
||||
"@webassemblyjs/wasm-parser" "1.11.1"
|
||||
acorn "^8.4.1"
|
||||
acorn "^8.7.1"
|
||||
acorn-import-assertions "^1.7.6"
|
||||
browserslist "^4.14.5"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.9.3"
|
||||
enhanced-resolve "^5.10.0"
|
||||
es-module-lexer "^0.9.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
@@ -3885,7 +3885,7 @@ webpack@5.73.0:
|
||||
schema-utils "^3.1.0"
|
||||
tapable "^2.1.1"
|
||||
terser-webpack-plugin "^5.1.3"
|
||||
watchpack "^2.3.1"
|
||||
watchpack "^2.4.0"
|
||||
webpack-sources "^3.2.3"
|
||||
|
||||
websocket-driver@>=0.5.1, websocket-driver@^0.7.4:
|
||||
|
@@ -48,7 +48,7 @@
|
||||
"file-loader": "6.2.0",
|
||||
"sass-loader": "13.0.2",
|
||||
"ts-loader": "9.3.1",
|
||||
"webpack": "5.73.0",
|
||||
"webpack": "5.76.0",
|
||||
"webpack-bundle-analyzer": "4.5.0",
|
||||
"webpack-cli": "4.10.0"
|
||||
},
|
||||
|
@@ -1187,10 +1187,10 @@ acorn-walk@^8.0.0:
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3"
|
||||
integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==
|
||||
|
||||
acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0:
|
||||
version "8.7.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
|
||||
integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
|
||||
acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1:
|
||||
version "8.8.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
|
||||
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
|
||||
|
||||
ajv-keywords@^3.5.2:
|
||||
version "3.5.2"
|
||||
@@ -1383,18 +1383,7 @@ braces@^3.0.1:
|
||||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
|
||||
browserslist@^4.14.5:
|
||||
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:
|
||||
browserslist@^4.14.5, browserslist@^4.20.2, browserslist@^4.21.2:
|
||||
version "4.21.2"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.2.tgz#59a400757465535954946a400b841ed37e2b4ecf"
|
||||
integrity sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA==
|
||||
@@ -1417,11 +1406,6 @@ call-bind@^1.0.0:
|
||||
function-bind "^1.1.1"
|
||||
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:
|
||||
version "1.0.30001367"
|
||||
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"
|
||||
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:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
|
||||
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
|
||||
|
||||
enhanced-resolve@^5.0.0, enhanced-resolve@^5.9.3:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz#44a342c012cbc473254af5cc6ae20ebd0aae5d88"
|
||||
integrity sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==
|
||||
enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0:
|
||||
version "5.12.0"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
|
||||
integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
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"
|
||||
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:
|
||||
version "2.0.6"
|
||||
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"
|
||||
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
|
||||
|
||||
watchpack@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"
|
||||
integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==
|
||||
watchpack@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
|
||||
integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
|
||||
dependencies:
|
||||
glob-to-regexp "^0.4.1"
|
||||
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"
|
||||
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
|
||||
|
||||
webpack@5.73.0:
|
||||
version "5.73.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.73.0.tgz#bbd17738f8a53ee5760ea2f59dce7f3431d35d38"
|
||||
integrity sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA==
|
||||
webpack@5.76.0:
|
||||
version "5.76.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c"
|
||||
integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.3"
|
||||
"@types/estree" "^0.0.51"
|
||||
"@webassemblyjs/ast" "1.11.1"
|
||||
"@webassemblyjs/wasm-edit" "1.11.1"
|
||||
"@webassemblyjs/wasm-parser" "1.11.1"
|
||||
acorn "^8.4.1"
|
||||
acorn "^8.7.1"
|
||||
acorn-import-assertions "^1.7.6"
|
||||
browserslist "^4.14.5"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.9.3"
|
||||
enhanced-resolve "^5.10.0"
|
||||
es-module-lexer "^0.9.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
@@ -2575,7 +2549,7 @@ webpack@5.73.0:
|
||||
schema-utils "^3.1.0"
|
||||
tapable "^2.1.1"
|
||||
terser-webpack-plugin "^5.1.3"
|
||||
watchpack "^2.3.1"
|
||||
watchpack "^2.4.0"
|
||||
webpack-sources "^3.2.3"
|
||||
|
||||
which@^2.0.1:
|
||||
|
@@ -40,14 +40,14 @@ import {
|
||||
} from "../constants";
|
||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElement,
|
||||
computeBoundTextElementCoords,
|
||||
getContainerCoords,
|
||||
getContainerElement,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
} from "../element/textElement";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { getLineHeightInPx } from "../element/textMeasurements";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
@@ -279,9 +279,6 @@ const drawElementOnCanvas = (
|
||||
|
||||
// Canvas does not support multiline text by default
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.containerId
|
||||
? getApproxLineHeight(getFontString(element))
|
||||
: element.height / lines.length;
|
||||
const horizontalOffset =
|
||||
element.textAlign === "center"
|
||||
? element.width / 2
|
||||
@@ -290,11 +287,16 @@ const drawElementOnCanvas = (
|
||||
: 0;
|
||||
context.textBaseline = "bottom";
|
||||
|
||||
const lineHeightPx = getLineHeightInPx(
|
||||
element.fontSize,
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
context.fillText(
|
||||
lines[index],
|
||||
horizontalOffset,
|
||||
(index + 1) * lineHeight,
|
||||
(index + 1) * lineHeightPx,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
@@ -820,14 +822,17 @@ const drawElementFromCanvas = (
|
||||
process.env.REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX &&
|
||||
hasBoundTextElement(element)
|
||||
) {
|
||||
const coords = computeBoundTextElementCoords(element);
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
) as ExcalidrawTextElementWithContainer;
|
||||
const coords = getContainerCoords(element);
|
||||
context.strokeStyle = "#c92a2a";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(
|
||||
(coords.x + renderConfig.scrollX) * window.devicePixelRatio,
|
||||
(coords.y + renderConfig.scrollY) * window.devicePixelRatio,
|
||||
getMaxContainerWidth(element) * window.devicePixelRatio,
|
||||
getMaxContainerHeight(element) * window.devicePixelRatio,
|
||||
getBoundTextMaxWidth(element) * window.devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1313,7 +1318,10 @@ export const renderElementToSvg = (
|
||||
}) rotate(${degree} ${cx} ${cy})`,
|
||||
);
|
||||
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 =
|
||||
element.textAlign === "center"
|
||||
? element.width / 2
|
||||
@@ -1331,7 +1339,7 @@ export const renderElementToSvg = (
|
||||
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
|
||||
text.textContent = lines[i];
|
||||
text.setAttribute("x", `${horizontalOffset}`);
|
||||
text.setAttribute("y", `${i * lineHeight}`);
|
||||
text.setAttribute("y", `${i * lineHeightPx}`);
|
||||
text.setAttribute("font-family", getFontFamilyString(element));
|
||||
text.setAttribute("font-size", `${element.fontSize}px`);
|
||||
text.setAttribute("fill", element.strokeColor);
|
||||
|
@@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
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: 10px; height: 24px; left: 35px; top: 8px; transform: translate(0px, 0px) scale(1) rotate(0deg); 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: translate(0px, 0px) scale(1) rotate(0deg); 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"
|
||||
wrap="off"
|
||||
/>
|
||||
|
@@ -4,6 +4,7 @@ import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { getTransformHandles } from "../element/transformHandles";
|
||||
import { API } from "./helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -209,4 +210,103 @@ describe("element binding", () => {
|
||||
).toBe(null);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@@ -3,10 +3,13 @@ import { render, waitFor, GlobalTestState } from "./test-utils";
|
||||
import { Pointer, Keyboard } from "./helpers/ui";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { KEYS } from "../keys";
|
||||
import { getApproxLineHeight } from "../element/textElement";
|
||||
import { getFontString } from "../utils";
|
||||
|
||||
import { getElementBounds } from "../element";
|
||||
import { NormalizedZoomValue } from "../types";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
} from "../element/textMeasurements";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -118,12 +121,10 @@ describe("paste text as single lines", () => {
|
||||
|
||||
it("should space items correctly", async () => {
|
||||
const text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
|
||||
const lineHeight =
|
||||
getApproxLineHeight(
|
||||
getFontString({
|
||||
fontSize: h.app.state.currentItemFontSize,
|
||||
fontFamily: h.app.state.currentItemFontFamily,
|
||||
}),
|
||||
const lineHeightPx =
|
||||
getLineHeightInPx(
|
||||
h.app.state.currentItemFontSize,
|
||||
getDefaultLineHeight(h.state.currentItemFontFamily),
|
||||
) +
|
||||
10 / h.app.state.zoom.value;
|
||||
mouse.moveTo(100, 100);
|
||||
@@ -135,19 +136,17 @@ describe("paste text as single lines", () => {
|
||||
for (let i = 1; i < h.elements.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
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 () => {
|
||||
const text = "hkhkjhki\n\njgkjhffjh";
|
||||
const lineHeight =
|
||||
getApproxLineHeight(
|
||||
getFontString({
|
||||
fontSize: h.app.state.currentItemFontSize,
|
||||
fontFamily: h.app.state.currentItemFontFamily,
|
||||
}),
|
||||
const lineHeightPx =
|
||||
getLineHeightInPx(
|
||||
h.app.state.currentItemFontSize,
|
||||
getDefaultLineHeight(h.state.currentItemFontFamily),
|
||||
) +
|
||||
10 / h.app.state.zoom.value;
|
||||
mouse.moveTo(100, 100);
|
||||
@@ -158,7 +157,7 @@ describe("paste text as single lines", () => {
|
||||
const [fx, firstElY] = getElementBounds(h.elements[0]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [lx, lastElY] = getElementBounds(h.elements[1]);
|
||||
expect(lastElY).toEqual(firstElY + lineHeight * 2);
|
||||
expect(lastElY).toEqual(firstElY + lineHeightPx * 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -224,7 +223,7 @@ describe("Paste bound text container", () => {
|
||||
await sleep(1);
|
||||
expect(h.elements.length).toEqual(2);
|
||||
const container = h.elements[0];
|
||||
expect(container.height).toBe(354);
|
||||
expect(container.height).toBe(368);
|
||||
expect(container.width).toBe(166);
|
||||
});
|
||||
});
|
||||
@@ -247,7 +246,7 @@ describe("Paste bound text container", () => {
|
||||
await sleep(1);
|
||||
expect(h.elements.length).toEqual(2);
|
||||
const container = h.elements[0];
|
||||
expect(container.height).toBe(740);
|
||||
expect(container.height).toBe(770);
|
||||
expect(container.width).toBe(166);
|
||||
});
|
||||
});
|
||||
|
@@ -291,6 +291,7 @@ Object {
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -312,7 +313,7 @@ Object {
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": -20,
|
||||
"y": -8.4,
|
||||
"y": -8.75,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -329,6 +330,7 @@ Object {
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
|
@@ -111,6 +111,9 @@ export class API {
|
||||
fileId?: T extends "image" ? string : never;
|
||||
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
|
||||
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
|
||||
startBinding?: T extends "arrow"
|
||||
? ExcalidrawLinearElement["startBinding"]
|
||||
: never;
|
||||
endBinding?: T extends "arrow"
|
||||
? ExcalidrawLinearElement["endBinding"]
|
||||
: never;
|
||||
@@ -178,11 +181,13 @@ export class API {
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
const fontSize = rest.fontSize ?? appState.currentItemFontSize;
|
||||
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
|
||||
element = newTextElement({
|
||||
...base,
|
||||
text: rest.text || "test",
|
||||
fontSize: rest.fontSize ?? appState.currentItemFontSize,
|
||||
fontFamily: rest.fontFamily ?? appState.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign: rest.textAlign ?? appState.currentItemTextAlign,
|
||||
verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: rest.containerId ?? undefined,
|
||||
@@ -221,6 +226,10 @@ export class API {
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (element.type === "arrow") {
|
||||
element.startBinding = rest.startBinding ?? null;
|
||||
element.endBinding = rest.endBinding ?? null;
|
||||
}
|
||||
if (id) {
|
||||
element.id = id;
|
||||
}
|
||||
|
@@ -17,11 +17,8 @@ import { KEYS } from "../keys";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { queryByTestId, queryByText } from "@testing-library/react";
|
||||
import { resize, rotate } from "./utils";
|
||||
import {
|
||||
getBoundTextElementPosition,
|
||||
wrapText,
|
||||
getMaxContainerWidth,
|
||||
} from "../element/textElement";
|
||||
import { getBoundTextMaxWidth } from "../element/textElement";
|
||||
import { wrapText } from "../element/textMeasurements";
|
||||
import * as textElementUtils from "../element/textElement";
|
||||
import { ROUNDNESS } from "../constants";
|
||||
|
||||
@@ -729,7 +726,7 @@ describe("Test Linear Elements", () => {
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: wrapText(text, font, getMaxContainerWidth(container)),
|
||||
text: wrapText(text, font, getBoundTextMaxWidth(container)),
|
||||
containerId: container.id,
|
||||
width: 30,
|
||||
height: 20,
|
||||
@@ -937,8 +934,9 @@ describe("Test Linear Elements", () => {
|
||||
|
||||
expect(container.angle).toBe(0);
|
||||
expect(textElement.angle).toBe(0);
|
||||
expect(getBoundTextElementPosition(arrow, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(arrow, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": 60,
|
||||
@@ -964,8 +962,9 @@ describe("Test Linear Elements", () => {
|
||||
rotate(container, -35, 55);
|
||||
expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`);
|
||||
expect(textElement.angle).toBe(0);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 21.73926141863671,
|
||||
"y": 73.31003398390868,
|
||||
@@ -1002,8 +1001,9 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
expect(container.width).toBe(70);
|
||||
expect(container.height).toBe(50);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": 60,
|
||||
@@ -1031,16 +1031,17 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 128,
|
||||
"height": 130,
|
||||
"width": 367,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 272,
|
||||
"y": 46,
|
||||
"y": 45,
|
||||
}
|
||||
`);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
@@ -1052,11 +1053,11 @@ describe("Test Linear Elements", () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
20,
|
||||
36,
|
||||
35,
|
||||
502,
|
||||
94,
|
||||
95,
|
||||
205.9061448421403,
|
||||
53,
|
||||
52.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1070,8 +1071,9 @@ describe("Test Linear Elements", () => {
|
||||
arrow,
|
||||
);
|
||||
expect(container.width).toBe(40);
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 25,
|
||||
"y": 10,
|
||||
@@ -1090,16 +1092,17 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 128,
|
||||
"height": 130,
|
||||
"width": 340,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(getBoundTextElementPosition(container, textElement))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
LinearElementEditor.getBoundTextElementPosition(container, textElement),
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": -4,
|
||||
"y": -5,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
@@ -1149,7 +1152,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(rect.x).toBe(400);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard collaboration
|
||||
made easy"
|
||||
@@ -1172,12 +1175,24 @@ describe("Test Linear Elements", () => {
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not render horizontal align tool when element selected", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
createBoundTextElement(DEFAULT_TEXT, arrow);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(queryByTestId(container, "align-left")).toBeNull();
|
||||
expect(queryByTestId(container, "align-horizontal-center")).toBeNull();
|
||||
expect(queryByTestId(container, "align-right")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -32,6 +32,7 @@ import type { FileSystemHandle } from "./data/filesystem";
|
||||
import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||
import { ContextMenuItems } from "./components/ContextMenu";
|
||||
import { Merge, ForwardRef } from "./utility-types";
|
||||
import React from "react";
|
||||
|
||||
export type Point = Readonly<RoughPoint>;
|
||||
|
||||
@@ -101,7 +102,7 @@ export type AppState = {
|
||||
} | null;
|
||||
showWelcomeScreen: boolean;
|
||||
isLoading: boolean;
|
||||
errorMessage: string | null;
|
||||
errorMessage: React.ReactNode;
|
||||
draggingElement: NonDeletedExcalidrawElement | null;
|
||||
resizingElement: NonDeletedExcalidrawElement | null;
|
||||
multiElement: NonDeleted<ExcalidrawLinearElement> | null;
|
||||
|
Reference in New Issue
Block a user