Merge branch 'master' into dwelle/support-disabling-image-tool
# Conflicts: # src/components/App.tsx # src/locales/en.json
This commit is contained in:
commit
6fb5c2acda
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
|
||||
|
@ -1,6 +1,19 @@
|
||||
# ref
|
||||
|
||||
<pre>
|
||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> | <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> | <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> | <br/>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">resolvablePromise</a> } }
|
||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
|
||||
createRef
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
|
||||
callbackRef
|
||||
</a>{" "}
|
||||
| <br />
|
||||
{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
|
||||
resolvablePromise
|
||||
</a> } }
|
||||
</pre>
|
||||
|
||||
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
|
||||
@ -139,7 +152,9 @@ function App() {
|
||||
return (
|
||||
<div style={{ height: "500px" }}>
|
||||
<p style={{ fontSize: "16px" }}> Click to update the scene</p>
|
||||
<button className="custom-button" onClick={updateScene}>Update Scene</button>
|
||||
<button className="custom-button" onClick={updateScene}>
|
||||
Update Scene
|
||||
</button>
|
||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
||||
</div>
|
||||
);
|
||||
@ -187,7 +202,8 @@ function App() {
|
||||
return (
|
||||
<div style={{ height: "500px" }}>
|
||||
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
|
||||
<button className="custom-button"
|
||||
<button
|
||||
className="custom-button"
|
||||
onClick={() => {
|
||||
const libraryItems = [
|
||||
{
|
||||
@ -205,10 +221,8 @@ function App() {
|
||||
];
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems,
|
||||
openLibraryMenu: true
|
||||
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
|
||||
}}
|
||||
>
|
||||
Update Library
|
||||
@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
|
||||
|
||||
<pre>
|
||||
() =>{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement[]
|
||||
</a>
|
||||
</pre>
|
||||
@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
|
||||
|
||||
<pre>
|
||||
() => NonDeleted<
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</a>
|
||||
[]>
|
||||
@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
|
||||
## scrollToContent
|
||||
|
||||
<pre>
|
||||
(target?:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
(<br />
|
||||
{" "}
|
||||
target?:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</a>
|
||||
[]) => void
|
||||
[],
|
||||
<br />
|
||||
{" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number
|
||||
}
|
||||
<br />) => void
|
||||
</pre>
|
||||
|
||||
Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene.
|
||||
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
|
||||
|
||||
| Attribute | type | default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| target | <code>ExcalidrawElement | ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
|
||||
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
|
||||
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
|
||||
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
|
||||
|
||||
## refresh
|
||||
|
||||
@ -323,7 +350,7 @@ For any other cases if the position of excalidraw is updated (example due to scr
|
||||
This API can be used to show the toast with custom message.
|
||||
|
||||
```tsx
|
||||
({ message: string, closable?:boolean,duration?:number
|
||||
({ message: string, closable?:boolean,duration?:number
|
||||
} | null) => void
|
||||
```
|
||||
|
||||
@ -358,15 +385,18 @@ This API can be used to get the files present in the scene. It may contain files
|
||||
|
||||
This API has the below signature. It sets the `tool` passed in param as the active tool.
|
||||
|
||||
|
||||
<pre>
|
||||
(tool: <br/> { type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]| "eraser" } |<br/> { type: "custom"; customType: string }) => void
|
||||
(tool: <br /> { type:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
|
||||
SHAPES
|
||||
</a>
|
||||
[number]["value"]| "eraser" } |
|
||||
<br /> { type: "custom"; customType: string }) => void
|
||||
</pre>
|
||||
|
||||
## setCursor
|
||||
|
||||
This API can be used to customise the mouse cursor on the canvas and has the below signature.
|
||||
It sets the mouse cursor to the cursor passed in param.
|
||||
This API can be used to customise the mouse cursor on the canvas and has the below signature. It sets the mouse cursor to the cursor passed in param.
|
||||
|
||||
```tsx
|
||||
(cursor: string) => void
|
||||
|
@ -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(
|
||||
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
};
|
||||
|
||||
const zoomToFitElements = (
|
||||
export const zoomToFitElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
zoomToSelection: boolean,
|
||||
|
@ -54,6 +54,7 @@ import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
@ -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(
|
||||
|
@ -12,7 +12,10 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
@ -92,12 +95,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) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { isDarwin } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { SubtypeOf } from "../utility-types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { ActionName } from "./types";
|
||||
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
} from "../types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
|
||||
|
@ -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,
|
||||
@ -229,6 +230,7 @@ import {
|
||||
updateActiveTool,
|
||||
getShortcutKey,
|
||||
isTransparent,
|
||||
easeToValuesRAF,
|
||||
} from "../utils";
|
||||
import {
|
||||
ContextMenu,
|
||||
@ -260,14 +262,16 @@ import throttle from "lodash.throttle";
|
||||
import { fileOpen, FileSystemHandle } from "../data/filesystem";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getApproxLineHeight,
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
getTextBindableContainerAtPosition,
|
||||
isMeasureTextSupported,
|
||||
isValidTextContainer,
|
||||
} from "../element/textElement";
|
||||
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
|
||||
@ -282,11 +286,15 @@ import {
|
||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import { actionPaste } from "../actions/actionClipboard";
|
||||
import { actionToggleHandTool } from "../actions/actionCanvas";
|
||||
import {
|
||||
actionToggleHandTool,
|
||||
zoomToFitElements,
|
||||
} from "../actions/actionCanvas";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||
import { ImageSceneDataError } from "../errors";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
isSmScreen: false,
|
||||
@ -431,7 +439,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
this.id = nanoid();
|
||||
|
||||
this.library = new Library(this);
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
@ -713,6 +720,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;
|
||||
}
|
||||
@ -728,7 +737,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
|
||||
@ -746,6 +754,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
gridSize,
|
||||
theme,
|
||||
name,
|
||||
errorMessage,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
@ -874,7 +883,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
|
||||
@ -1002,6 +1010,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() {
|
||||
@ -1727,12 +1742,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;
|
||||
@ -1741,14 +1758,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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1841,18 +1853,89 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.actionManager.executeAction(actionToggleHandTool);
|
||||
};
|
||||
|
||||
/**
|
||||
* Zooms on canvas viewport center
|
||||
*/
|
||||
zoomCanvas = (
|
||||
/** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
|
||||
value: number,
|
||||
) => {
|
||||
this.setState({
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: this.state.width / 2 + this.state.offsetLeft,
|
||||
viewportY: this.state.height / 2 + this.state.offsetTop,
|
||||
nextZoom: getNormalizedZoom(value),
|
||||
},
|
||||
this.state,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
private cancelInProgresAnimation: (() => void) | null = null;
|
||||
|
||||
scrollToContent = (
|
||||
target:
|
||||
| ExcalidrawElement
|
||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
|
||||
) => {
|
||||
this.setState({
|
||||
...calculateScrollCenter(
|
||||
Array.isArray(target) ? target : [target],
|
||||
this.state,
|
||||
this.canvas,
|
||||
),
|
||||
});
|
||||
this.cancelInProgresAnimation?.();
|
||||
|
||||
// convert provided target into ExcalidrawElement[] if necessary
|
||||
const targets = Array.isArray(target) ? target : [target];
|
||||
|
||||
let zoom = this.state.zoom;
|
||||
let scrollX = this.state.scrollX;
|
||||
let scrollY = this.state.scrollY;
|
||||
|
||||
if (opts?.fitToContent) {
|
||||
// compute an appropriate viewport location (scroll X, Y) and zoom level
|
||||
// that fit the target elements on the scene
|
||||
const { appState } = zoomToFitElements(targets, this.state, false);
|
||||
zoom = appState.zoom;
|
||||
scrollX = appState.scrollX;
|
||||
scrollY = appState.scrollY;
|
||||
} else {
|
||||
// compute only the viewport location, without any zoom adjustment
|
||||
const scroll = calculateScrollCenter(targets, this.state, this.canvas);
|
||||
scrollX = scroll.scrollX;
|
||||
scrollY = scroll.scrollY;
|
||||
}
|
||||
|
||||
// when animating, we use RequestAnimationFrame to prevent the animation
|
||||
// from slowing down other processes
|
||||
if (opts?.animate) {
|
||||
const origScrollX = this.state.scrollX;
|
||||
const origScrollY = this.state.scrollY;
|
||||
|
||||
// zoom animation could become problematic on scenes with large number
|
||||
// of elements, setting it to its final value to improve user experience.
|
||||
//
|
||||
// using zoomCanvas() to zoom on current viewport center
|
||||
this.zoomCanvas(zoom.value);
|
||||
|
||||
const cancel = easeToValuesRAF(
|
||||
[origScrollX, origScrollY],
|
||||
[scrollX, scrollY],
|
||||
(scrollX, scrollY) => this.setState({ scrollX, scrollY }),
|
||||
{ duration: opts?.duration ?? 500 },
|
||||
);
|
||||
this.cancelInProgresAnimation = () => {
|
||||
cancel();
|
||||
this.cancelInProgresAnimation = null;
|
||||
};
|
||||
} else {
|
||||
this.setState({ scrollX, scrollY, zoom });
|
||||
}
|
||||
};
|
||||
|
||||
/** use when changing scrollX/scrollY/zoom based on user interaction */
|
||||
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
||||
state,
|
||||
) => {
|
||||
this.cancelInProgresAnimation?.();
|
||||
this.setState(state);
|
||||
};
|
||||
|
||||
setToast = (
|
||||
@ -2053,9 +2136,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
offset = -offset;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
this.setState((state) => ({ scrollX: state.scrollX + offset }));
|
||||
this.translateCanvas((state) => ({
|
||||
scrollX: state.scrollX + offset,
|
||||
}));
|
||||
} else {
|
||||
this.setState((state) => ({ scrollY: state.scrollY + offset }));
|
||||
this.translateCanvas((state) => ({
|
||||
scrollY: state.scrollY + offset,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2624,6 +2711,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 &&
|
||||
@ -2631,11 +2725,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 = getApproxMinLineWidth(
|
||||
getFontString(fontString),
|
||||
lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
|
||||
const containerDims = getContainerDims(container);
|
||||
const newHeight = Math.max(containerDims.height, minHeight);
|
||||
const newWidth = Math.max(containerDims.width, minWidth);
|
||||
@ -2669,8 +2766,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,
|
||||
@ -2680,6 +2777,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
locked: false,
|
||||
lineHeight,
|
||||
});
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
@ -2946,12 +3044,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
state,
|
||||
);
|
||||
|
||||
return {
|
||||
this.translateCanvas({
|
||||
zoom: zoomState.zoom,
|
||||
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
||||
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
||||
shouldCacheIgnoreZoom: true,
|
||||
};
|
||||
});
|
||||
});
|
||||
this.resetShouldCacheIgnoreZoomDebounced();
|
||||
} else {
|
||||
@ -3727,7 +3825,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
|
||||
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
|
||||
});
|
||||
@ -4873,7 +4971,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
@ -4883,7 +4981,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (pointerDownState.scrollbars.isOverVertical) {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
this.translateCanvas({
|
||||
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
@ -6326,7 +6424,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// reduced amplification for small deltas (small movements on a trackpad)
|
||||
Math.min(1, absDelta / 20);
|
||||
|
||||
this.setState((state) => ({
|
||||
this.translateCanvas((state) => ({
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: cursorX,
|
||||
@ -6343,14 +6441,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// scroll horizontally when shift pressed
|
||||
if (event.shiftKey) {
|
||||
this.setState(({ zoom, scrollX }) => ({
|
||||
this.translateCanvas(({ zoom, scrollX }) => ({
|
||||
// on Mac, shift+wheel tends to result in deltaX
|
||||
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
||||
this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
|
||||
scrollX: scrollX - deltaX / zoom.value,
|
||||
scrollY: scrollY - deltaY / zoom.value,
|
||||
}));
|
||||
|
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 {
|
||||
|
@ -365,10 +365,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";
|
||||
|
||||
|
@ -7,6 +7,7 @@ import { CanvasError, ImageSceneDataError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState, DataURL, LibraryItem } from "../types";
|
||||
import { ValueOf } from "../utility-types";
|
||||
import { bytesToHexString } from "../utils";
|
||||
import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
|
||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||
@ -150,7 +151,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") {
|
||||
|
@ -34,6 +34,8 @@ import { bumpVersion } from "../element/mutateElement";
|
||||
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/textElement";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@ -164,17 +166,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) {
|
||||
@ -478,7 +495,9 @@ export const restoreAppState = (
|
||||
? {
|
||||
value: appState.zoom as NormalizedZoomValue,
|
||||
}
|
||||
: appState.zoom || defaultAppState.zoom,
|
||||
: appState.zoom?.value
|
||||
? appState.zoom
|
||||
: defaultAppState.zoom,
|
||||
// when sidebar docked and user left it open in last session,
|
||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||
openSidebar:
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
import { rescalePoints } from "../points";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [number, number, number, number];
|
||||
|
@ -38,6 +38,7 @@ import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
import { shouldShowBoundingBox } from "./transformHandles";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@ -785,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);
|
||||
|
@ -41,6 +41,7 @@ import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { DRAGGING_THRESHOLD } from "../constants";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
|
@ -5,6 +5,7 @@ import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { Point } from "../types";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
|
@ -29,9 +29,11 @@ import {
|
||||
normalizeText,
|
||||
wrapText,
|
||||
getMaxContainerWidth,
|
||||
getDefaultLineHeight,
|
||||
} from "./textElement";
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@ -136,10 +138,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(
|
||||
{
|
||||
@ -155,6 +159,7 @@ export const newTextElement = (
|
||||
height: metrics.height,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@ -175,6 +180,7 @@ const getAdjustedDimensions = (
|
||||
const { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
@ -184,7 +190,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,
|
||||
|
@ -39,13 +39,13 @@ import {
|
||||
import { Point, PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getMaxContainerWidth,
|
||||
getApproxMinLineHeight,
|
||||
} from "./textElement";
|
||||
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
@ -360,7 +360,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,9 +410,7 @@ export const resizeSingleElement = (
|
||||
boundTextElement.id,
|
||||
) as typeof boundTextElement | undefined;
|
||||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
};
|
||||
boundTextFontSize = stateOfBoundTextElementAtResize.fontSize;
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const updatedElement = {
|
||||
@ -428,12 +426,16 @@ export const resizeSingleElement = (
|
||||
if (nextFontSize === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFont = {
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
boundTextFontSize = nextFontSize;
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
}
|
||||
@ -566,8 +568,10 @@ 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);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
@ -6,6 +6,9 @@ import {
|
||||
getMaxContainerWidth,
|
||||
getMaxContainerHeight,
|
||||
wrapText,
|
||||
detectLineHeight,
|
||||
getLineHeightInPx,
|
||||
getDefaultLineHeight,
|
||||
} from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
|
||||
@ -16,7 +19,7 @@ describe("Test wrapText", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello whats up ");
|
||||
expect(res).toBe(text);
|
||||
});
|
||||
|
||||
it("should work with emojis", () => {
|
||||
@ -26,7 +29,7 @@ describe("Test wrapText", () => {
|
||||
expect(res).toBe("😀");
|
||||
});
|
||||
|
||||
it("should show the text correctly when min width reached", () => {
|
||||
it("should show the text correctly when max width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
@ -40,9 +43,7 @@ describe("Test wrapText", () => {
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
res: `Hello \nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
@ -64,8 +65,7 @@ p`,
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 140,
|
||||
res: `Hello whats
|
||||
up`,
|
||||
res: `Hello whats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
@ -95,9 +95,7 @@ whats up`;
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
res: `Hello\nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
@ -136,17 +134,14 @@ whats up`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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`,
|
||||
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
|
||||
},
|
||||
|
||||
{
|
||||
@ -165,8 +160,7 @@ 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`,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
@ -175,6 +169,20 @@ break it now`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test measureText", () => {
|
||||
@ -296,3 +304,35 @@ describe("Test measureText", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
height: 175,
|
||||
});
|
||||
|
||||
describe("Test detectLineHeight", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(detectLineHeight(textElement)).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getLineHeightInPx", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(
|
||||
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
|
||||
).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getDefaultLineHeight()).toBe(1.25);
|
||||
});
|
||||
it("should return correct line height", () => {
|
||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
|
@ -4,11 +4,19 @@ import {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from ".";
|
||||
@ -23,6 +31,7 @@ import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./textWysiwyg";
|
||||
import { ExtractSetType } from "../utility-types";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
@ -34,12 +43,15 @@ export const normalizeText = (text: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
@ -61,6 +73,7 @@ export const redrawTextBoundingBox = (
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
@ -178,7 +191,11 @@ export const handleBindTextResize = (
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
const dimensions = measureText(text, getFontString(textElement));
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
}
|
||||
@ -254,32 +271,52 @@ const computeBoundTextPosition = (
|
||||
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
|
||||
export const measureText = (text: string, font: FontString) => {
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
text = text
|
||||
.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 fontSize = parseFloat(font);
|
||||
const height = getTextHeight(text, fontSize, lineHeight);
|
||||
const width = getTextWidth(text, font);
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
const cacheApproxLineHeight: { [key: FontString]: number } = {};
|
||||
/**
|
||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||
* height-per-line by fontSize.
|
||||
*/
|
||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||
const lineCount = splitIntoLines(textElement.text).length;
|
||||
return (textElement.height /
|
||||
lineCount /
|
||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
||||
|
||||
export const getApproxLineHeight = (font: FontString) => {
|
||||
if (cacheApproxLineHeight[font]) {
|
||||
return cacheApproxLineHeight[font];
|
||||
}
|
||||
const fontSize = parseInt(font);
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
// Calculate line height relative to font size
|
||||
cacheApproxLineHeight[font] = fontSize * 1.2;
|
||||
return cacheApproxLineHeight[font];
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
@ -302,7 +339,7 @@ const getLineWidth = (text: string, font: FontString) => {
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
@ -310,35 +347,58 @@ export const getTextWidth = (text: string, font: FontString) => {
|
||||
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 getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
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 words = originalLine.split(" ");
|
||||
// This means its newline so push it
|
||||
if (words.length === 1 && words[0] === "") {
|
||||
lines.push(words[0]);
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
//Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
}
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
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]);
|
||||
@ -350,8 +410,8 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
push(currentLine);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
|
||||
resetParams();
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
@ -362,10 +422,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
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;
|
||||
@ -373,11 +429,11 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
resetParams();
|
||||
} else {
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
@ -385,7 +441,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
@ -395,8 +450,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
currentLineWidthTillNow = 0;
|
||||
currentLine = "";
|
||||
resetParams();
|
||||
|
||||
break;
|
||||
}
|
||||
@ -407,22 +461,15 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
const word = currentLine.slice(0, -1);
|
||||
push(word);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
resetParams();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentLineWidthTillNow === maxWidth) {
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine) {
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
}
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
@ -454,21 +501,23 @@ export const charWidth = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
export const getApproxMinLineWidth = (font: FontString) => {
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
// FIXME rename to getApproxMinContainerWidth
|
||||
export const getApproxMinLineWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).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) {
|
||||
@ -661,14 +710,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);
|
||||
});
|
||||
};
|
||||
|
||||
@ -793,3 +852,43 @@ export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unitless line height
|
||||
*
|
||||
* In previous versions we used `normal` line height, which browsers interpret
|
||||
* differently, and based on font-family and font-size.
|
||||
*
|
||||
* To make line heights consistent across browsers we hardcode the values for
|
||||
* each of our fonts based on most common average line-heights.
|
||||
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
|
||||
* where the values come from.
|
||||
*/
|
||||
const DEFAULT_LINE_HEIGHT = {
|
||||
// ~1.25 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko (FF) uses ~1.28.
|
||||
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.15 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko if all over the place.
|
||||
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
|
||||
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
||||
};
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily) {
|
||||
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,
|
||||
]
|
||||
`);
|
||||
|
||||
@ -1067,9 +1067,9 @@ describe("textWysiwyg", () => {
|
||||
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);
|
||||
@ -1112,7 +1112,7 @@ describe("textWysiwyg", () => {
|
||||
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,
|
||||
@ -1186,6 +1186,41 @@ describe("textWysiwyg", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@ -1245,7 +1280,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1257,7 +1292,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
30,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1269,7 +1304,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
45,
|
||||
45.5,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1281,7 +1316,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1292,7 +1327,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
30,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1303,7 +1338,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
45,
|
||||
66,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1333,7 +1368,7 @@ describe("textWysiwyg", () => {
|
||||
|
||||
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",
|
||||
@ -1365,7 +1400,7 @@ describe("textWysiwyg", () => {
|
||||
],
|
||||
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, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@ -22,7 +22,6 @@ import {
|
||||
import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
@ -150,9 +149,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;
|
||||
@ -213,7 +210,7 @@ export const textWysiwyg = ({
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
const diff = Math.min(
|
||||
textElementHeight - maxHeight,
|
||||
approxLineHeight,
|
||||
element.lineHeight,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
return;
|
||||
@ -226,7 +223,7 @@ export const textWysiwyg = ({
|
||||
) {
|
||||
const diff = Math.min(
|
||||
maxHeight - textElementHeight,
|
||||
approxLineHeight,
|
||||
element.lineHeight,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height - diff });
|
||||
}
|
||||
@ -266,15 +263,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 +275,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`,
|
||||
@ -389,7 +381,11 @@ export const textWysiwyg = ({
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(wrappedText, font);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { MarkNonNullable } from "../utility-types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
THEME,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { MarkNonNullable, ValueOf } from "../utility-types";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||
@ -134,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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -14,6 +14,7 @@ import { encryptData, decryptData } from "../../data/encryption";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import { reconcileElements } from "../collab/reconciliation";
|
||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||
import { ResolutionType } from "../../utility-types";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@ -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, {
|
||||
|
@ -85,6 +85,7 @@ import { useAtomWithInitialValue } from "../jotai";
|
||||
import { appJotaiStore } from "./app-jotai";
|
||||
|
||||
import "./index.scss";
|
||||
import { ResolutionType } from "../utility-types";
|
||||
|
||||
polyfill();
|
||||
|
||||
@ -672,10 +673,9 @@ const ExcalidrawWrapper = () => {
|
||||
</Excalidraw>
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
<ErrorDialog
|
||||
message={errorMessage}
|
||||
onClose={() => setErrorMessage("")}
|
||||
/>
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
49
src/global.d.ts
vendored
49
src/global.d.ts
vendored
@ -50,36 +50,6 @@ interface Clipboard extends EventTarget {
|
||||
write(data: any[]): Promise<void>;
|
||||
}
|
||||
|
||||
type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
type Merge<M, N> = Omit<M, keyof N> & N;
|
||||
|
||||
/** utility type to assert that the second type is a subtype of the first type.
|
||||
* Returns the subtype. */
|
||||
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
|
||||
|
||||
type ResolutionType<T extends (...args: any) => any> = T extends (
|
||||
...args: any
|
||||
) => Promise<infer R>
|
||||
? R
|
||||
: any;
|
||||
|
||||
// https://github.com/krzkaczor/ts-essentials
|
||||
type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
|
||||
Required<Pick<T, RK>>;
|
||||
|
||||
type MarkNonNullable<T, K extends keyof T> = {
|
||||
[P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
|
||||
} & { [P in keyof T]: T[P] };
|
||||
|
||||
type NonOptional<T> = Exclude<T, undefined>;
|
||||
|
||||
// PNG encoding/decoding
|
||||
// -----------------------------------------------------------------------------
|
||||
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
|
||||
@ -101,23 +71,6 @@ declare module "png-chunks-extract" {
|
||||
}
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// type getter for interface's callable type
|
||||
// src: https://stackoverflow.com/a/58658851/927631
|
||||
// -----------------------------------------------------------------------------
|
||||
type SignatureType<T> = T extends (...args: infer R) => any ? R : never;
|
||||
type CallableType<T extends (...args: any[]) => any> = (
|
||||
...args: SignatureType<T>
|
||||
) => ReturnType<T>;
|
||||
// --------------------------------------------------------------------------—
|
||||
|
||||
// Type for React.forwardRef --- supply only the first generic argument T
|
||||
type ForwardRef<T, P = any> = Parameters<
|
||||
CallableType<React.ForwardRefRenderFunction<T, P>>
|
||||
>[1];
|
||||
|
||||
// --------------------------------------------------------------------------—
|
||||
|
||||
interface Blob {
|
||||
handle?: import("browser-fs-acces").FileSystemHandle;
|
||||
name?: string;
|
||||
@ -165,5 +118,3 @@ declare module "image-blob-reduce" {
|
||||
const reduce: ImageBlobReduce.ImageBlobReduceStatic;
|
||||
export = reduce;
|
||||
}
|
||||
|
||||
type ExtractSetType<T extends Set<any>> = T extends Set<infer U> ? U : never;
|
||||
|
@ -2,6 +2,7 @@ import { AppState } from "./types";
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { isLinearElement } from "./element/typeChecks";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { Mutable } from "./utility-types";
|
||||
|
||||
export interface HistoryEntry {
|
||||
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
|
||||
|
@ -120,7 +120,6 @@
|
||||
"edit": "Edit line",
|
||||
"exit": "Exit line editor"
|
||||
},
|
||||
|
||||
"elementLock": {
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
@ -207,7 +206,22 @@
|
||||
"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.",
|
||||
"imageToolNotSupported": "Images are disabled."
|
||||
"imageToolNotSupported": "Images are disabled.",
|
||||
"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",
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
} from "./element/types";
|
||||
import { getShapeForElement } from "./renderer/renderElement";
|
||||
import { getCurvePathOps } from "./element/bounds";
|
||||
import { Mutable } from "./utility-types";
|
||||
|
||||
export const rotate = (
|
||||
x1: number,
|
||||
|
@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### Features
|
||||
|
||||
- [`ExcalidrawAPI.scrolToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319)
|
||||
|
||||
- Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `<Excalidraw>` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
|
||||
|
||||
- [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes
|
||||
|
@ -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:
|
||||
|
@ -34,16 +34,17 @@ import { AppState, BinaryFiles, Zoom } from "../types";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
FONT_FAMILY,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
MIME_TYPES,
|
||||
SVG_NS,
|
||||
} from "../constants";
|
||||
import { getStroke, StrokeOptions } from "perfect-freehand";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElement,
|
||||
getContainerCoords,
|
||||
getContainerElement,
|
||||
getLineHeightInPx,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
} from "../element/textElement";
|
||||
@ -279,22 +280,31 @@ 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
|
||||
: element.textAlign === "right"
|
||||
? element.width
|
||||
: 0;
|
||||
context.textBaseline = "bottom";
|
||||
|
||||
// FIXME temporary hack
|
||||
context.textBaseline =
|
||||
element.fontFamily === FONT_FAMILY.Virgil ||
|
||||
element.fontFamily === FONT_FAMILY.Cascadia
|
||||
? "ideographic"
|
||||
: "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();
|
||||
@ -1313,7 +1323,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 +1344,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,8 +3,10 @@ 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 {
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
} from "../element/textElement";
|
||||
import { getElementBounds } from "../element";
|
||||
import { NormalizedZoomValue } from "../types";
|
||||
|
||||
@ -118,12 +120,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 +135,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 +156,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 +222,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 +245,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,
|
||||
|
189
src/tests/fitToContent.test.tsx
Normal file
189
src/tests/fitToContent.test.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import { render } from "./test-utils";
|
||||
import { API } from "./helpers/api";
|
||||
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("fitToContent", () => {
|
||||
it("should zoom to fit the selected element", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 50,
|
||||
height: 100,
|
||||
x: 50,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
h.app.scrollToContent(rectElement, { fitToContent: true });
|
||||
|
||||
// element is 10x taller than the viewport size,
|
||||
// zoom should be at least 1/10
|
||||
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
|
||||
});
|
||||
|
||||
it("should zoom to fit multiple elements", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
const topLeft = API.createElement({
|
||||
width: 20,
|
||||
height: 20,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const bottomRight = API.createElement({
|
||||
width: 20,
|
||||
height: 20,
|
||||
x: 80,
|
||||
y: 80,
|
||||
});
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
h.app.scrollToContent([topLeft, bottomRight], {
|
||||
fitToContent: true,
|
||||
});
|
||||
|
||||
// elements take 100x100, which is 10x bigger than the viewport size,
|
||||
// zoom should be at least 1/10
|
||||
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
|
||||
});
|
||||
|
||||
it("should scroll the viewport to the selected element", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 100,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
h.app.scrollToContent(rectElement);
|
||||
|
||||
// zoom level should stay the same
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
|
||||
// state should reflect some scrolling
|
||||
expect(h.state.scrollX).not.toBe(0);
|
||||
expect(h.state.scrollY).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const waitForNextAnimationFrame = () => {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe("fitToContent animated", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(window, "requestAnimationFrame");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should ease scroll the viewport to the selected element", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
h.state.width = 10;
|
||||
h.state.height = 10;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: -100,
|
||||
y: -100,
|
||||
});
|
||||
|
||||
h.app.scrollToContent(rectElement, { animate: true });
|
||||
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
||||
|
||||
// Since this is an animation, we expect values to change through time.
|
||||
// We'll verify that the scroll values change at 50ms and 100ms
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
await waitForNextAnimationFrame();
|
||||
|
||||
const prevScrollX = h.state.scrollX;
|
||||
const prevScrollY = h.state.scrollY;
|
||||
|
||||
expect(h.state.scrollX).not.toBe(0);
|
||||
expect(h.state.scrollY).not.toBe(0);
|
||||
|
||||
await waitForNextAnimationFrame();
|
||||
|
||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||
});
|
||||
|
||||
it("should animate the scroll but not the zoom", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
h.state.width = 50;
|
||||
h.state.height = 50;
|
||||
|
||||
const rectElement = API.createElement({
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 100,
|
||||
y: 100,
|
||||
});
|
||||
|
||||
expect(h.state.scrollX).toBe(0);
|
||||
expect(h.state.scrollY).toBe(0);
|
||||
|
||||
h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
|
||||
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
||||
|
||||
// Since this is an animation, we expect values to change through time.
|
||||
// We'll verify that the zoom/scroll values change in each animation frame
|
||||
|
||||
// zoom is not animated, it should be set to its final value, which in our
|
||||
// case zooms out to 50% so that th element is fully visible (it's 2x large
|
||||
// as the canvas)
|
||||
expect(h.state.zoom.value).toBeLessThanOrEqual(0.5);
|
||||
|
||||
// FIXME I think this should be [-100, -100] so we may have a bug in our zoom
|
||||
// hadnling, alas
|
||||
expect(h.state.scrollX).toBe(25);
|
||||
expect(h.state.scrollY).toBe(25);
|
||||
|
||||
await waitForNextAnimationFrame();
|
||||
|
||||
const prevScrollX = h.state.scrollX;
|
||||
const prevScrollY = h.state.scrollY;
|
||||
|
||||
expect(h.state.scrollX).not.toBe(0);
|
||||
expect(h.state.scrollY).not.toBe(0);
|
||||
|
||||
await waitForNextAnimationFrame();
|
||||
|
||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||
});
|
||||
});
|
@ -19,6 +19,7 @@ import { newFreeDrawElement, newImageElement } from "../../element/newElement";
|
||||
import { Point } from "../../types";
|
||||
import { getSelectedElements } from "../../scene/selection";
|
||||
import { isLinearElementType } from "../../element/typeChecks";
|
||||
import { Mutable } from "../../utility-types";
|
||||
|
||||
const readFile = util.promisify(fs.readFile);
|
||||
|
||||
@ -110,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;
|
||||
@ -177,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,
|
||||
@ -220,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;
|
||||
}
|
||||
|
@ -1031,7 +1031,7 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 128,
|
||||
"height": 130,
|
||||
"width": 367,
|
||||
}
|
||||
`);
|
||||
@ -1040,7 +1040,7 @@ describe("Test Linear Elements", () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 272,
|
||||
"y": 46,
|
||||
"y": 45,
|
||||
}
|
||||
`);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
@ -1052,11 +1052,11 @@ describe("Test Linear Elements", () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
20,
|
||||
36,
|
||||
35,
|
||||
502,
|
||||
94,
|
||||
95,
|
||||
205.9061448421403,
|
||||
53,
|
||||
52.5,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@ -1090,7 +1090,7 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"height": 128,
|
||||
"height": 130,
|
||||
"width": 340,
|
||||
}
|
||||
`);
|
||||
@ -1099,7 +1099,7 @@ describe("Test Linear Elements", () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"x": 75,
|
||||
"y": -4,
|
||||
"y": -5,
|
||||
}
|
||||
`);
|
||||
expect(textElement.text).toMatchInlineSnapshot(`
|
||||
@ -1179,5 +1179,17 @@ describe("Test Linear Elements", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -31,6 +31,8 @@ import Library from "./data/library";
|
||||
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>;
|
||||
|
||||
@ -100,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;
|
||||
|
49
src/utility-types.ts
Normal file
49
src/utility-types.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
export type ValueOf<T> = T[keyof T];
|
||||
|
||||
export type Merge<M, N> = Omit<M, keyof N> & N;
|
||||
|
||||
/** utility type to assert that the second type is a subtype of the first type.
|
||||
* Returns the subtype. */
|
||||
export type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
|
||||
|
||||
export type ResolutionType<T extends (...args: any) => any> = T extends (
|
||||
...args: any
|
||||
) => Promise<infer R>
|
||||
? R
|
||||
: any;
|
||||
|
||||
// https://github.com/krzkaczor/ts-essentials
|
||||
export type MarkOptional<T, K extends keyof T> = Omit<T, K> &
|
||||
Partial<Pick<T, K>>;
|
||||
|
||||
export type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
|
||||
Required<Pick<T, RK>>;
|
||||
|
||||
export type MarkNonNullable<T, K extends keyof T> = {
|
||||
[P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
|
||||
} & { [P in keyof T]: T[P] };
|
||||
|
||||
export type NonOptional<T> = Exclude<T, undefined>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// type getter for interface's callable type
|
||||
// src: https://stackoverflow.com/a/58658851/927631
|
||||
// -----------------------------------------------------------------------------
|
||||
export type SignatureType<T> = T extends (...args: infer R) => any ? R : never;
|
||||
export type CallableType<T extends (...args: any[]) => any> = (
|
||||
...args: SignatureType<T>
|
||||
) => ReturnType<T>;
|
||||
// --------------------------------------------------------------------------—
|
||||
|
||||
// Type for React.forwardRef --- supply only the first generic argument T
|
||||
export type ForwardRef<T, P = any> = Parameters<
|
||||
CallableType<React.ForwardRefRenderFunction<T, P>>
|
||||
>[1];
|
||||
|
||||
export type ExtractSetType<T extends Set<any>> = T extends Set<infer U>
|
||||
? U
|
||||
: never;
|
74
src/utils.ts
74
src/utils.ts
@ -16,6 +16,7 @@ import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import { SHAPES } from "./shapes";
|
||||
import { isEraserActive, isHandToolActive } from "./appState";
|
||||
import { ResolutionType } from "./utility-types";
|
||||
|
||||
let mockDateTime: string | null = null;
|
||||
|
||||
@ -180,6 +181,79 @@ export const throttleRAF = <T extends any[]>(
|
||||
return ret;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exponential ease-out method
|
||||
*
|
||||
* @param {number} k - The value to be tweened.
|
||||
* @returns {number} The tweened value.
|
||||
*/
|
||||
function easeOut(k: number): number {
|
||||
return 1 - Math.pow(1 - k, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute new values based on the same ease function and trigger the
|
||||
* callback through a requestAnimationFrame call
|
||||
*
|
||||
* use `opts` to define a duration and/or an easeFn
|
||||
*
|
||||
* for example:
|
||||
* ```ts
|
||||
* easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c))
|
||||
* ```
|
||||
*
|
||||
* @param fromValues The initial values, must be numeric
|
||||
* @param toValues The destination values, must also be numeric
|
||||
* @param callback The callback receiving the values
|
||||
* @param opts default to 250ms duration and the easeOut function
|
||||
*/
|
||||
export const easeToValuesRAF = (
|
||||
fromValues: number[],
|
||||
toValues: number[],
|
||||
callback: (...values: number[]) => void,
|
||||
opts?: { duration?: number; easeFn?: (value: number) => number },
|
||||
) => {
|
||||
let canceled = false;
|
||||
let frameId = 0;
|
||||
let startTime: number;
|
||||
|
||||
const duration = opts?.duration || 250; // default animation to 0.25 seconds
|
||||
const easeFn = opts?.easeFn || easeOut; // default the easeFn to easeOut
|
||||
|
||||
function step(timestamp: number) {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
if (startTime === undefined) {
|
||||
startTime = timestamp;
|
||||
}
|
||||
|
||||
const elapsed = timestamp - startTime;
|
||||
|
||||
if (elapsed < duration) {
|
||||
// console.log(elapsed, duration, elapsed / duration);
|
||||
const factor = easeFn(elapsed / duration);
|
||||
const newValues = fromValues.map(
|
||||
(fromValue, index) =>
|
||||
(toValues[index] - fromValue) * factor + fromValue,
|
||||
);
|
||||
|
||||
callback(...newValues);
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
} else {
|
||||
// ensure final values are reached at the end of the transition
|
||||
callback(...toValues);
|
||||
}
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
};
|
||||
|
||||
// https://github.com/lodash/lodash/blob/es/chunk.js
|
||||
export const chunk = <T extends any>(
|
||||
array: readonly T[],
|
||||
|
Loading…
x
Reference in New Issue
Block a user