Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 993294ac08 | |||
| f584416c9a | |||
| 87b0c7a679 | |||
| ee8fff8e8b | |||
| b799490ece | |||
| 83383977f5 | |||
| ac4c8b3ca7 | |||
| 5c8941467d | |||
| 0726911fa6 | |||
| fd18896293 | |||
| e900cb0b64 | |||
| 7e330c8ee1 | |||
| 7d21747644 | |||
| e718136aea | |||
| 54bf3d9092 | |||
| 15f19835fe | |||
| 96c4cff805 | |||
| fe83e2922d | |||
| 20edddcd4e | |||
| f6e8be399e | |||
| ab49cad6b1 | |||
| 6aeb18b784 | |||
| 023313e92f | |||
| 1eee488dab | |||
| 1ac580136d | |||
| dd4c333925 | |||
| 8542c95a7a | |||
| cef6094d4c | |||
| 3322f0fa6f | |||
| 34a7d48b95 | |||
| 5c0b15ce2b | |||
| 9f9666110e | |||
| 05ffce62ef | |||
| 0f06fa3851 | |||
| 1ce933d2f5 | |||
| 15655acb5a | |||
| d5b264c2d2 | |||
| 8c89fdfa51 | |||
| 0e54994187 | |||
| bd4424bbe3 | |||
| 91f6e87317 | |||
| a05db6864e | |||
| eacee9a158 | |||
| 7722de4ef2 | |||
| 0a295e523b | |||
| 38fc51b4e3 | |||
| 60deddb0e2 | |||
| e1dc748aef | |||
| 0e95e2b386 | |||
| 9659254fd6 | |||
| 39b96cb011 | |||
| 04a8c22f39 | |||
| e4506be3e8 | |||
| 1e816e87bf | |||
| 5368ddef74 | |||
| 88ff32e9b3 | |||
| 0fcbddda8e |
@@ -22,3 +22,8 @@ REACT_APP_DEV_ENABLE_SW=
|
||||
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
||||
#Debug flags
|
||||
|
||||
# To enable bounding box for text containers
|
||||
REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports:
|
||||
|
||||
## Excalidraw.com
|
||||
|
||||
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/maielo/new-readme/src/excalidraw-app) is part of this repository as well, and the app features:
|
||||
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
|
||||
|
||||
- 📡 PWA support (works offline).
|
||||
- 🤼 Real-time collaboration.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: Introduction to the codebase
|
||||
slug: ../
|
||||
---
|
||||
|
||||
This section is documenting the Excalidraw codebase itself for developers who want to contribute to the project.
|
||||
@@ -92,16 +92,6 @@ const sidebars = {
|
||||
"@excalidraw/excalidraw/development",
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
type: "category",
|
||||
label: "Excalidraw codebase",
|
||||
link: {
|
||||
type: "doc",
|
||||
id: "codebase/introduction-to-the-codebase",
|
||||
},
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
+9
-9
@@ -1785,9 +1785,9 @@
|
||||
"@hapi/hoek" "^9.0.0"
|
||||
|
||||
"@sideway/formula@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c"
|
||||
integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f"
|
||||
integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==
|
||||
|
||||
"@sideway/pinpoint@^2.0.0":
|
||||
version "2.0.0"
|
||||
@@ -4376,9 +4376,9 @@ htmlparser2@^8.0.1:
|
||||
entities "^4.3.0"
|
||||
|
||||
http-cache-semantics@^4.0.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
|
||||
integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
|
||||
integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
|
||||
|
||||
http-deceiver@^1.2.7:
|
||||
version "1.2.7"
|
||||
@@ -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"
|
||||
|
||||
+7
-7
@@ -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(
|
||||
|
||||
+159
-15
@@ -1,11 +1,12 @@
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { BOUND_TEXT_PADDING, ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
||||
import { getNonDeletedElements, isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import { measureText } from "../element/textMeasurements";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
@@ -13,8 +14,11 @@ import {
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isTextBindableContainer,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
@@ -38,9 +42,10 @@ export const actionUnbindText = register({
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
const { width, height } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||
element.id,
|
||||
@@ -51,7 +56,6 @@ export const actionUnbindText = register({
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
@@ -130,19 +134,159 @@ export const actionBindText = register({
|
||||
}),
|
||||
});
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
const updatedElements = elements.slice();
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 1);
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
|
||||
return {
|
||||
elements: updatedElements,
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const pushTextAboveContainer = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
container: ExcalidrawElement,
|
||||
textElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
const updatedElements = elements.slice();
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 1);
|
||||
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
const pushContainerBelowText = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
container: ExcalidrawElement,
|
||||
textElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
const updatedElements = elements.slice();
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex, 1);
|
||||
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 0, container);
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
export const actionCreateContainerFromText = register({
|
||||
name: "createContainerFromText",
|
||||
contextItemLabel: "labels.createContainerFromText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.length === 1 && isTextElement(selectedElements[0]);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const updatedElements = elements.slice();
|
||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||
const textElement = selectedElements[0];
|
||||
const container = newElement({
|
||||
type: "rectangle",
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
boundElements: [
|
||||
...(textElement.boundElements || []),
|
||||
{ id: textElement.id, type: "text" },
|
||||
],
|
||||
angle: textElement.angle,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
roughness: appState.currentItemRoughness,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
roundness:
|
||||
appState.currentItemRoundness === "round"
|
||||
? {
|
||||
type: isUsingAdaptiveRadius("rectangle")
|
||||
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
opacity: 100,
|
||||
locked: false,
|
||||
x: textElement.x - BOUND_TEXT_PADDING,
|
||||
y: textElement.y - BOUND_TEXT_PADDING,
|
||||
width: computeContainerDimensionForBoundText(
|
||||
textElement.width,
|
||||
"rectangle",
|
||||
),
|
||||
height: computeContainerDimensionForBoundText(
|
||||
textElement.height,
|
||||
"rectangle",
|
||||
),
|
||||
groupIds: textElement.groupIds,
|
||||
});
|
||||
|
||||
// update bindings
|
||||
if (textElement.boundElements?.length) {
|
||||
const linearElementIds = textElement.boundElements
|
||||
.filter((ele) => ele.type === "arrow")
|
||||
.map((el) => el.id);
|
||||
const linearElements = updatedElements.filter((ele) =>
|
||||
linearElementIds.includes(ele.id),
|
||||
) as ExcalidrawLinearElement[];
|
||||
linearElements.forEach((ele) => {
|
||||
let startBinding = ele.startBinding;
|
||||
let endBinding = ele.endBinding;
|
||||
|
||||
if (startBinding?.elementId === textElement.id) {
|
||||
startBinding = {
|
||||
...startBinding,
|
||||
elementId: container.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (endBinding?.elementId === textElement.id) {
|
||||
endBinding = { ...endBinding, elementId: container.id };
|
||||
}
|
||||
|
||||
if (startBinding || endBinding) {
|
||||
mutateElement(ele, { startBinding, endBinding });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
});
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
|
||||
return {
|
||||
elements: pushContainerBelowText(
|
||||
[...elements, container],
|
||||
container,
|
||||
textElement,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {
|
||||
[container.id]: true,
|
||||
[textElement.id]: false,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "../element/textElement";
|
||||
import { getDefaultLineHeight } from "../element/textMeasurements";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
@@ -637,6 +638,7 @@ export const actionChangeFontFamily = register({
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: value,
|
||||
lineHeight: getDefaultLineHeight(value),
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
@@ -745,16 +747,19 @@ export const actionChangeTextAlign = register({
|
||||
value: "left",
|
||||
text: t("labels.left"),
|
||||
icon: TextAlignLeftIcon,
|
||||
testId: "align-left",
|
||||
},
|
||||
{
|
||||
value: "center",
|
||||
text: t("labels.center"),
|
||||
icon: TextAlignCenterIcon,
|
||||
testId: "align-horizontal-center",
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
text: t("labels.right"),
|
||||
icon: TextAlignRightIcon,
|
||||
testId: "align-right",
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getDefaultRoundnessTypeForElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getDefaultLineHeight } from "../element/textMeasurements";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
@@ -92,12 +93,18 @@ export const actionPasteStyles = register({
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize: elementStylesToCopyFrom?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily:
|
||||
elementStylesToCopyFrom?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
elementStylesToCopyFrom?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
elementStylesToCopyFrom.lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -113,7 +114,8 @@ export type ActionName =
|
||||
| "toggleLock"
|
||||
| "toggleLinearEditor"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool";
|
||||
| "toggleHandTool"
|
||||
| "createContainerFromText";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
||||
@@ -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")}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { actionClearCanvas } from "../actions";
|
||||
import { t } from "../i18n";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
|
||||
@@ -9,6 +10,7 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
|
||||
export const ActiveConfirmDialog = () => {
|
||||
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
||||
activeConfirmDialogAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+54
-29
@@ -62,6 +62,7 @@ import {
|
||||
GRID_SIZE,
|
||||
IMAGE_RENDER_TIMEOUT,
|
||||
isAndroid,
|
||||
isBrave,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
MAX_ALLOWED_FILE_BYTES,
|
||||
MIME_TYPES,
|
||||
@@ -108,6 +109,7 @@ import {
|
||||
textWysiwyg,
|
||||
transformElements,
|
||||
updateTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
@@ -258,15 +260,20 @@ import throttle from "lodash.throttle";
|
||||
import { fileOpen, FileSystemHandle } from "../data/filesystem";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getApproxLineHeight,
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextBindableContainerAtPosition,
|
||||
isValidTextContainer,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
getApproxMinContainerHeight,
|
||||
getApproxMinContainerWidth,
|
||||
isMeasureTextSupported,
|
||||
getLineHeightInPx,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textMeasurements";
|
||||
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
|
||||
import {
|
||||
normalizeLink,
|
||||
@@ -282,6 +289,8 @@ import { actionPaste } from "../actions/actionClipboard";
|
||||
import { actionToggleHandTool } from "../actions/actionCanvas";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
isSmScreen: false,
|
||||
@@ -426,7 +435,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
this.id = nanoid();
|
||||
|
||||
this.library = new Library(this);
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
@@ -708,6 +716,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const theme =
|
||||
actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
|
||||
let name = actionResult?.appState?.name ?? this.state.name;
|
||||
const errorMessage =
|
||||
actionResult?.appState?.errorMessage ?? this.state.errorMessage;
|
||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||
viewModeEnabled = this.props.viewModeEnabled;
|
||||
}
|
||||
@@ -723,7 +733,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (typeof this.props.name !== "undefined") {
|
||||
name = this.props.name;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
(state) => {
|
||||
// using Object.assign instead of spread to fool TS 4.2.2+ into
|
||||
@@ -741,6 +750,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
gridSize,
|
||||
theme,
|
||||
name,
|
||||
errorMessage,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
@@ -869,7 +879,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// FontFaceSet loadingdone event we listen on may not always fire
|
||||
// (looking at you Safari), so on init we manually load fonts for current
|
||||
// text elements on canvas, and rerender them once done. This also
|
||||
@@ -997,6 +1006,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} else {
|
||||
this.updateDOMRect(this.initializeScene);
|
||||
}
|
||||
|
||||
// note that this check seems to always pass in localhost
|
||||
if (isBrave() && !isMeasureTextSupported()) {
|
||||
this.setState({
|
||||
errorMessage: <BraveMeasureTextError />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
@@ -1625,6 +1641,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
return newElement;
|
||||
});
|
||||
|
||||
bindTextToShapeAfterDuplication(newElements, elements, oldIdToDuplicatedId);
|
||||
const nextElements = [
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
@@ -1637,6 +1654,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
|
||||
newElements.forEach((newElement) => {
|
||||
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
|
||||
const container = getContainerElement(newElement);
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
}
|
||||
});
|
||||
|
||||
this.history.resumeRecording();
|
||||
|
||||
this.setState(
|
||||
@@ -1709,12 +1734,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(acc: ExcalidrawTextElement[], line, idx) => {
|
||||
const text = line.trim();
|
||||
|
||||
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
|
||||
if (text.length) {
|
||||
const element = newTextElement({
|
||||
...textElementProps,
|
||||
x,
|
||||
y: currentY,
|
||||
text,
|
||||
lineHeight,
|
||||
});
|
||||
acc.push(element);
|
||||
currentY += element.height + LINE_GAP;
|
||||
@@ -1723,14 +1750,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// add paragraph only if previous line was not empty, IOW don't add
|
||||
// more than one empty line
|
||||
if (prevLine) {
|
||||
const defaultLineHeight = getApproxLineHeight(
|
||||
getFontString({
|
||||
fontSize: textElementProps.fontSize,
|
||||
fontFamily: textElementProps.fontFamily,
|
||||
}),
|
||||
);
|
||||
|
||||
currentY += defaultLineHeight + LINE_GAP;
|
||||
currentY +=
|
||||
getLineHeightInPx(textElementProps.fontSize, lineHeight) +
|
||||
LINE_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2585,6 +2607,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
|
||||
}
|
||||
|
||||
const fontFamily =
|
||||
existingTextElement?.fontFamily || this.state.currentItemFontFamily;
|
||||
|
||||
const lineHeight =
|
||||
existingTextElement?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const fontSize = this.state.currentItemFontSize;
|
||||
|
||||
if (
|
||||
!existingTextElement &&
|
||||
shouldBindToContainer &&
|
||||
@@ -2592,11 +2621,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!isArrowElement(container)
|
||||
) {
|
||||
const fontString = {
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
};
|
||||
const minWidth = getApproxMinLineWidth(getFontString(fontString));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(fontString));
|
||||
const minWidth = getApproxMinContainerWidth(
|
||||
getFontString(fontString),
|
||||
lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinContainerHeight(fontSize, lineHeight);
|
||||
const containerDims = getContainerDims(container);
|
||||
const newHeight = Math.max(containerDims.height, minHeight);
|
||||
const newWidth = Math.max(containerDims.width, minWidth);
|
||||
@@ -2630,8 +2662,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness: null,
|
||||
text: "",
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign: parentCenterPosition
|
||||
? "center"
|
||||
: this.state.currentItemTextAlign,
|
||||
@@ -2641,6 +2673,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
locked: false,
|
||||
lineHeight,
|
||||
});
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
@@ -2663,14 +2696,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
element,
|
||||
]);
|
||||
}
|
||||
|
||||
// case: creating new text not centered to parent element → offset Y
|
||||
// so that the text is centered to cursor position
|
||||
if (!parentCenterPosition) {
|
||||
mutateElement(element, {
|
||||
y: element.y - element.baseline / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
@@ -2764,7 +2789,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
if (container) {
|
||||
if (
|
||||
isArrowElement(container) ||
|
||||
hasBoundTextElement(container) ||
|
||||
!isTransparent(container.backgroundColor) ||
|
||||
isHittingElementNotConsideringBoundingBox(container, this.state, [
|
||||
@@ -6235,6 +6259,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionGroup,
|
||||
actionUnbindText,
|
||||
actionBindText,
|
||||
actionCreateContainerFromText,
|
||||
actionUngroup,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionAddToLibrary,
|
||||
|
||||
@@ -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;
|
||||
@@ -6,6 +6,7 @@ import DialogActionButton from "./DialogActionButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||
onConfirm: () => void;
|
||||
@@ -24,7 +25,7 @@ const ConfirmDialog = (props: Props) => {
|
||||
...rest
|
||||
} = props;
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AppState } from "../types";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
export interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
@@ -72,7 +73,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
}, [islandNode, props.autofocus]);
|
||||
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||
|
||||
const onClose = () => {
|
||||
setAppState({ openMenu: null });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "../i18n";
|
||||
import { HelpIcon } from "./icons";
|
||||
|
||||
type HelpButtonProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
onClick?(): void;
|
||||
@@ -12,8 +12,8 @@ export const HelpButton = (props: HelpButtonProps) => (
|
||||
className="help-icon"
|
||||
onClick={props.onClick}
|
||||
type="button"
|
||||
title={`${props.title} — ?`}
|
||||
aria-label={props.title}
|
||||
title={`${t("helpDialog.title")} — ?`}
|
||||
aria-label={t("helpDialog.title")}
|
||||
>
|
||||
{HelpIcon}
|
||||
</button>
|
||||
|
||||
@@ -364,10 +364,9 @@ const LayerUI = ({
|
||||
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
message={appState.errorMessage}
|
||||
onClose={() => setAppState({ errorMessage: null })}
|
||||
/>
|
||||
<ErrorDialog onClose={() => setAppState({ errorMessage: null })}>
|
||||
{appState.errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
{appState.openDialog === "help" && (
|
||||
<HelpDialog
|
||||
|
||||
@@ -48,6 +48,7 @@ export const LibraryMenuHeader: React.FC<{
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||
isLibraryMenuOpenAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { t } from "../../i18n";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
useExcalidrawAppState,
|
||||
useExcalidrawSetAppState,
|
||||
@@ -31,11 +31,10 @@ import "./DefaultItems.scss";
|
||||
import clsx from "clsx";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
|
||||
export const LoadScene = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionLoadScene)) {
|
||||
@@ -57,9 +56,7 @@ export const LoadScene = () => {
|
||||
LoadScene.displayName = "LoadScene";
|
||||
|
||||
export const SaveToActiveFile = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
|
||||
@@ -80,9 +77,7 @@ SaveToActiveFile.displayName = "SaveToActiveFile";
|
||||
|
||||
export const SaveAsImage = () => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={ExportImageIcon}
|
||||
@@ -98,9 +93,7 @@ export const SaveAsImage = () => {
|
||||
SaveAsImage.displayName = "SaveAsImage";
|
||||
|
||||
export const Help = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const { t } = useI18n();
|
||||
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
@@ -119,10 +112,12 @@ export const Help = () => {
|
||||
Help.displayName = "Help";
|
||||
|
||||
export const ClearCanvas = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
|
||||
const { t } = useI18n();
|
||||
|
||||
const setActiveConfirmDialog = useSetAtom(
|
||||
activeConfirmDialogAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
||||
@@ -143,6 +138,7 @@ export const ClearCanvas = () => {
|
||||
ClearCanvas.displayName = "ClearCanvas";
|
||||
|
||||
export const ToggleTheme = () => {
|
||||
const { t } = useI18n();
|
||||
const appState = useExcalidrawAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
@@ -175,6 +171,7 @@ export const ToggleTheme = () => {
|
||||
ToggleTheme.displayName = "ToggleTheme";
|
||||
|
||||
export const ChangeCanvasBackground = () => {
|
||||
const { t } = useI18n();
|
||||
const appState = useExcalidrawAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
@@ -195,9 +192,7 @@ export const ChangeCanvasBackground = () => {
|
||||
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
|
||||
|
||||
export const Export = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const { t } = useI18n();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -248,9 +243,7 @@ export const LiveCollaborationTrigger = ({
|
||||
onSelect: () => void;
|
||||
isCollaborating: boolean;
|
||||
}) => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-testid="collab-button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { actionLoadScene, actionShortcuts } from "../../actions";
|
||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { t } from "../../i18n";
|
||||
import { t, useI18n } from "../../i18n";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawActionManager,
|
||||
@@ -172,10 +172,7 @@ const MenuItemLiveCollaborationTrigger = ({
|
||||
}: {
|
||||
onSelect: () => any;
|
||||
}) => {
|
||||
// FIXME when we tie t() to lang state
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
|
||||
{t("labels.liveCollaboration")}
|
||||
|
||||
@@ -9,6 +9,12 @@ export const isFirefox =
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
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";
|
||||
|
||||
|
||||
+3
-2
@@ -530,6 +530,7 @@
|
||||
// (doesn't work in Firefox)
|
||||
::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@@ -567,8 +568,8 @@
|
||||
}
|
||||
|
||||
.App-toolbar--mobile {
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
overflow-x: auto;
|
||||
max-width: 90vw;
|
||||
|
||||
.ToolIcon__keybinding {
|
||||
display: none;
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ import { CanvasError } 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";
|
||||
@@ -156,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{ repairBindings: true },
|
||||
{ repairBindings: true, refreshDimensions: true },
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
|
||||
+3
-1
@@ -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") {
|
||||
|
||||
+23
-4
@@ -34,6 +34,11 @@ 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/textMeasurements";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -164,18 +169,32 @@ const restoreElement = (
|
||||
const [fontPx, _fontFamily]: [string, string] = (
|
||||
element as any
|
||||
).font.split(" ");
|
||||
fontSize = parseInt(fontPx, 10);
|
||||
fontSize = parseFloat(fontPx);
|
||||
fontFamily = getFontFamilyByName(_fontFamily);
|
||||
}
|
||||
const text = element.text ?? "";
|
||||
|
||||
element = restoreElementWithProperties(element, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
text: element.text ?? "",
|
||||
baseline: element.baseline,
|
||||
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) {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
+20
-51
@@ -22,16 +22,20 @@ import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import {
|
||||
measureText,
|
||||
wrapText,
|
||||
getDefaultLineHeight,
|
||||
} from "./textMeasurements";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@@ -136,10 +140,12 @@ export const newTextElement = (
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const lineHeight = opts.lineHeight || getDefaultLineHeight(opts.fontFamily);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureText(text, getFontString(opts));
|
||||
const metrics = measureText(text, getFontString(opts), lineHeight);
|
||||
const offsets = getTextElementPositionOffsets(opts, metrics);
|
||||
const textElement = newElementWith(
|
||||
{
|
||||
@@ -153,9 +159,9 @@ export const newTextElement = (
|
||||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -170,18 +176,14 @@ const getAdjustedDimensions = (
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
let maxWidth = null;
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
}
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
} = measureText(nextText, getFontString(element), maxWidth);
|
||||
|
||||
const { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
@@ -193,7 +195,7 @@ const getAdjustedDimensions = (
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
element.lineHeight,
|
||||
);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
@@ -258,7 +260,6 @@ const getAdjustedDimensions = (
|
||||
height: nextHeight,
|
||||
x: Number.isFinite(x) ? x : element.x,
|
||||
y: Number.isFinite(y) ? y : element.y,
|
||||
baseline: nextBaseline,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -271,45 +272,13 @@ export const refreshTextDimensions = (
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
getBoundTextMaxWidth(container),
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
return { text, ...dimensions };
|
||||
};
|
||||
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerWidth <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.width;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return containerWidth;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.height;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const updateTextElement = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
{
|
||||
|
||||
@@ -39,17 +39,16 @@ import {
|
||||
import { Point, PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
getBoundTextElementOffset,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
measureText,
|
||||
getBoundTextMaxWidth,
|
||||
} from "./textElement";
|
||||
import { getMaxContainerWidth } from "./newElement";
|
||||
|
||||
import {
|
||||
getApproxMinContainerHeight,
|
||||
getApproxMinContainerWidth,
|
||||
} from "./textMeasurements";
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle >= 2 * Math.PI) {
|
||||
return angle - 2 * Math.PI;
|
||||
@@ -192,11 +191,10 @@ const rescalePointsInElement = (
|
||||
|
||||
const MIN_FONT_SIZE = 1;
|
||||
|
||||
const measureFontSizeFromWH = (
|
||||
const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
): { size: number; baseline: number } | null => {
|
||||
): number | null => {
|
||||
// We only use width to scale font on resize
|
||||
let width = element.width;
|
||||
|
||||
@@ -204,22 +202,15 @@ const measureFontSizeFromWH = (
|
||||
if (hasContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
width = getMaxContainerWidth(container);
|
||||
width = getBoundTextMaxWidth(container);
|
||||
}
|
||||
}
|
||||
const nextFontSize = element.fontSize * (nextWidth / width);
|
||||
if (nextFontSize < MIN_FONT_SIZE) {
|
||||
return null;
|
||||
}
|
||||
const metrics = measureText(
|
||||
element.text,
|
||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
||||
element.containerId ? width : null,
|
||||
);
|
||||
return {
|
||||
size: nextFontSize,
|
||||
baseline: metrics.baseline + (nextHeight - metrics.height),
|
||||
};
|
||||
|
||||
return nextFontSize;
|
||||
};
|
||||
|
||||
const getSidesForTransformHandle = (
|
||||
@@ -290,8 +281,8 @@ const resizeSingleTextElement = (
|
||||
if (scale > 0) {
|
||||
const nextWidth = element.width * scale;
|
||||
const nextHeight = element.height * scale;
|
||||
const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight);
|
||||
if (nextFont === null) {
|
||||
const nextFontSize = measureFontSizeFromWidth(element, nextWidth);
|
||||
if (nextFontSize === null) {
|
||||
return;
|
||||
}
|
||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||
@@ -315,10 +306,9 @@ const resizeSingleTextElement = (
|
||||
deltaY2,
|
||||
);
|
||||
mutateElement(element, {
|
||||
fontSize: nextFont.size,
|
||||
fontSize: nextFontSize,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextFont.baseline,
|
||||
x: nextElementX,
|
||||
y: nextElementY,
|
||||
});
|
||||
@@ -371,7 +361,7 @@ export const resizeSingleElement = (
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
let boundTextFont: { fontSize?: number; baseline?: number } = {};
|
||||
let boundTextFontSize: number | null = null;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
@@ -421,29 +411,32 @@ export const resizeSingleElement = (
|
||||
boundTextElement.id,
|
||||
) as typeof boundTextElement | undefined;
|
||||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
baseline: stateOfBoundTextElementAtResize.baseline,
|
||||
};
|
||||
boundTextFontSize = stateOfBoundTextElementAtResize.fontSize;
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const boundTextElementPadding =
|
||||
getBoundTextElementOffset(boundTextElement);
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
const updatedElement = {
|
||||
...element,
|
||||
width: eleNewWidth,
|
||||
height: eleNewHeight,
|
||||
};
|
||||
|
||||
const nextFontSize = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
eleNewWidth - boundTextElementPadding * 2,
|
||||
eleNewHeight - boundTextElementPadding * 2,
|
||||
getBoundTextMaxWidth(updatedElement),
|
||||
);
|
||||
if (nextFont === null) {
|
||||
if (nextFontSize === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFont = {
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
};
|
||||
boundTextFontSize = nextFontSize;
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
|
||||
const minWidth = getApproxMinContainerWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinContainerHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
}
|
||||
@@ -576,8 +569,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);
|
||||
}
|
||||
@@ -683,7 +678,6 @@ const resizeMultipleElements = (
|
||||
y: number;
|
||||
points?: Point[];
|
||||
fontSize?: number;
|
||||
baseline?: number;
|
||||
} = {
|
||||
width,
|
||||
height,
|
||||
@@ -692,31 +686,34 @@ const resizeMultipleElements = (
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
||||
let boundTextUpdates: { fontSize: number } | null = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(element.latest);
|
||||
|
||||
if (boundTextElement || isTextElement(element.orig)) {
|
||||
const optionalPadding = getBoundTextElementOffset(boundTextElement) * 2;
|
||||
const textMeasurements = measureFontSizeFromWH(
|
||||
const updatedElement = {
|
||||
...element.latest,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const fontSize = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
width - optionalPadding,
|
||||
height - optionalPadding,
|
||||
boundTextElement
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
);
|
||||
|
||||
if (!textMeasurements) {
|
||||
if (!fontSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(element.orig)) {
|
||||
update.fontSize = textMeasurements.size;
|
||||
update.baseline = textMeasurements.baseline;
|
||||
update.fontSize = fontSize;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundTextUpdates = {
|
||||
fontSize: textMeasurements.size,
|
||||
baseline: textMeasurements.baseline,
|
||||
fontSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+164
-187
@@ -1,196 +1,173 @@
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { measureText, wrapText } from "./textElement";
|
||||
import { FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello whats up ");
|
||||
});
|
||||
|
||||
it("should work with emojis", () => {
|
||||
const text = "😀";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀");
|
||||
});
|
||||
|
||||
it("should show the text correctly when min width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 90,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 150,
|
||||
res: `Hello whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello
|
||||
whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 90,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 150,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 170,
|
||||
res: `hellolongtextth
|
||||
isiswhatsupwith
|
||||
youIamtypingggg
|
||||
gandtypinggg
|
||||
break it now`,
|
||||
},
|
||||
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `hellolongte
|
||||
xtthisiswha
|
||||
tsupwithyou
|
||||
Iamtypinggg
|
||||
ggandtyping
|
||||
gg break it
|
||||
now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 600,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg
|
||||
break it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
} from "./textElement";
|
||||
import { ExcalidrawTextElementWithContainer } from "./types";
|
||||
|
||||
describe("Test measureText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
const text = "Hello World";
|
||||
describe("Test getContainerCoords", () => {
|
||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||
|
||||
it("should add correct attributes when maxWidth is passed", () => {
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = measureText(text, font, maxWidth);
|
||||
it("should compute coords correctly when ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 44.2893218813452455,
|
||||
y: 39.64466094067262,
|
||||
});
|
||||
});
|
||||
|
||||
expect(res.container).toMatchInlineSnapshot(`
|
||||
<div
|
||||
style="position: absolute; white-space: pre-wrap; font: Emoji 20px 20px; min-height: 1em; max-width: 191px; overflow: hidden; word-break: break-word; line-height: 0px;"
|
||||
>
|
||||
<span
|
||||
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
it("should compute coords correctly when rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 15,
|
||||
y: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 65,
|
||||
y: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should add correct attributes when maxWidth is not passed", () => {
|
||||
const res = measureText(text, font);
|
||||
describe("Test computeContainerDimensionForBoundText", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
expect(res.container).toMatchInlineSnapshot(`
|
||||
<div
|
||||
style="position: absolute; white-space: pre; font: Emoji 20px 20px; min-height: 1em;"
|
||||
>
|
||||
<span
|
||||
style="display: inline-block; overflow: hidden; width: 1px; height: 1px;"
|
||||
/>
|
||||
</div>
|
||||
`);
|
||||
it("should compute container height correctly for rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
160,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
226,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
320,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(79);
|
||||
});
|
||||
|
||||
it("should return max width when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxWidth(container)).toBe(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
id: "container-id",
|
||||
};
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: params.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
height: 70,
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
});
|
||||
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||
boundTextElement.height,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+201
-395
@@ -1,10 +1,9 @@
|
||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
||||
import { getFontString, arrayToMap } from "../utils";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -12,12 +11,7 @@ import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
|
||||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isTextElement } from ".";
|
||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isImageElement,
|
||||
isArrowElement,
|
||||
} from "./typeChecks";
|
||||
import { isBoundToContainer, isArrowElement } from "./typeChecks";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { AppState } from "../types";
|
||||
import { isTextBindableContainer } from "./typeChecks";
|
||||
@@ -28,6 +22,8 @@ import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./textWysiwyg";
|
||||
import { ExtractSetType } from "../utility-types";
|
||||
import { measureText, wrapText } from "./textMeasurements";
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
@@ -44,68 +40,59 @@ export const redrawTextBoundingBox = (
|
||||
container: ExcalidrawElement | null,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
let text = textElement.text;
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
text: textElement.text,
|
||||
width: textElement.width,
|
||||
height: textElement.height,
|
||||
};
|
||||
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
text = wrapText(
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
const metrics = measureText(text, getFontString(textElement), maxWidth);
|
||||
let coordY = textElement.y;
|
||||
let coordX = textElement.x;
|
||||
// Resize container and vertically center align the text
|
||||
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
boundTextUpdates.height = metrics.height;
|
||||
|
||||
if (container) {
|
||||
if (!isArrowElement(container)) {
|
||||
const containerDims = getContainerDims(container);
|
||||
let nextHeight = containerDims.height;
|
||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
coordY = container.y;
|
||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
metrics.height -
|
||||
BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordY = container.y + containerDims.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > getMaxContainerHeight(container)) {
|
||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||
}
|
||||
}
|
||||
if (textElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
coordX =
|
||||
container.x +
|
||||
containerDims.width -
|
||||
metrics.width -
|
||||
BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordX = container.x + containerDims.width / 2 - metrics.width / 2;
|
||||
}
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let nextHeight = containerDims.height;
|
||||
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
} else {
|
||||
const centerX = textElement.x + textElement.width / 2;
|
||||
const centerY = textElement.y + textElement.height / 2;
|
||||
const diffWidth = metrics.width - textElement.width;
|
||||
const diffHeight = metrics.height - textElement.height;
|
||||
coordY = centerY - (textElement.height + diffHeight) / 2;
|
||||
coordX = centerX - (textElement.width + diffWidth) / 2;
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(container, updatedTextElement);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
}
|
||||
mutateElement(textElement, {
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
y: coordY,
|
||||
x: coordX,
|
||||
text,
|
||||
});
|
||||
|
||||
mutateElement(textElement, boundTextUpdates);
|
||||
};
|
||||
|
||||
export const bindTextToShapeAfterDuplication = (
|
||||
@@ -174,10 +161,12 @@ export const handleBindTextResize = (
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getMaxContainerWidth(container);
|
||||
const maxHeight = getMaxContainerHeight(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let containerHeight = containerDims.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
@@ -189,15 +178,18 @@ export const handleBindTextResize = (
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
textElement.lineHeight,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > maxHeight) {
|
||||
containerHeight = nextHeight + getBoundTextElementOffset(textElement) * 2;
|
||||
containerHeight = computeContainerDimensionForBoundText(
|
||||
nextHeight,
|
||||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - containerDims.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
@@ -217,325 +209,54 @@ export const handleBindTextResize = (
|
||||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
|
||||
if (!isArrowElement(container)) {
|
||||
updateBoundTextPosition(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
mutateElement(
|
||||
textElement,
|
||||
computeBoundTextPosition(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateBoundTextPosition = (
|
||||
export const computeBoundTextPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const containerDims = getContainerDims(container);
|
||||
const boundTextElementPadding = getBoundTextElementOffset(boundTextElement);
|
||||
const containerCoords = getContainerCoords(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
|
||||
let x;
|
||||
let y;
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y = container.y + boundTextElementPadding;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
boundTextElement.height -
|
||||
boundTextElementPadding;
|
||||
} else {
|
||||
y = container.y + containerDims.height / 2 - boundTextElement.height / 2;
|
||||
}
|
||||
const x =
|
||||
boundTextElement.textAlign === TEXT_ALIGN.LEFT
|
||||
? container.x + boundTextElementPadding
|
||||
: boundTextElement.textAlign === TEXT_ALIGN.RIGHT
|
||||
? container.x +
|
||||
containerDims.width -
|
||||
boundTextElement.width -
|
||||
boundTextElementPadding
|
||||
: container.x + containerDims.width / 2 - boundTextElement.width / 2;
|
||||
|
||||
mutateElement(boundTextElement, { x, y });
|
||||
};
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth?: number | null,
|
||||
) => {
|
||||
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 container = document.createElement("div");
|
||||
container.style.position = "absolute";
|
||||
container.style.whiteSpace = "pre";
|
||||
container.style.font = font;
|
||||
container.style.minHeight = "1em";
|
||||
|
||||
if (maxWidth) {
|
||||
const lineHeight = getApproxLineHeight(font);
|
||||
// since we are adding a span of width 1px later
|
||||
container.style.maxWidth = `${maxWidth + 1}px`;
|
||||
container.style.overflow = "hidden";
|
||||
container.style.wordBreak = "break-word";
|
||||
container.style.lineHeight = `${String(lineHeight)}px`;
|
||||
container.style.whiteSpace = "pre-wrap";
|
||||
}
|
||||
document.body.appendChild(container);
|
||||
container.innerText = text;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.style.display = "inline-block";
|
||||
span.style.overflow = "hidden";
|
||||
span.style.width = "1px";
|
||||
span.style.height = "1px";
|
||||
container.appendChild(span);
|
||||
// Baseline is important for positioning text on canvas
|
||||
const baseline = span.offsetTop + span.offsetHeight;
|
||||
// since we are adding a span of width 1px
|
||||
const width = container.offsetWidth + 1;
|
||||
const height = container.offsetHeight;
|
||||
document.body.removeChild(container);
|
||||
if (isTestEnv()) {
|
||||
return { width, height, baseline, container };
|
||||
}
|
||||
return { width, height, baseline };
|
||||
};
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
const cacheApproxLineHeight: { [key: FontString]: number } = {};
|
||||
|
||||
export const getApproxLineHeight = (font: FontString) => {
|
||||
if (cacheApproxLineHeight[font]) {
|
||||
return cacheApproxLineHeight[font];
|
||||
}
|
||||
cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
|
||||
return cacheApproxLineHeight[font];
|
||||
};
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
|
||||
const metrics = canvas2dContext.measureText(text);
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return metrics.width * 10;
|
||||
}
|
||||
// Since measureText behaves differently in different browsers
|
||||
// OS so considering a adjustment factor of 0.2
|
||||
const adjustmentFactor = 0.2;
|
||||
|
||||
return metrics.width + adjustmentFactor;
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = text.split("\n");
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
};
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
|
||||
const push = (str: string) => {
|
||||
if (str.trim()) {
|
||||
lines.push(str);
|
||||
}
|
||||
};
|
||||
originalLines.forEach((originalLine) => {
|
||||
const words = originalLine.split(" ");
|
||||
// This means its newline so push it
|
||||
if (words.length === 1 && words[0] === "") {
|
||||
lines.push(words[0]);
|
||||
return; // continue
|
||||
}
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
let index = 0;
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
if (currentWordWidth >= maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
push(currentLine);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
}
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
} else {
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLineWidthTillNow = 0;
|
||||
currentLine = "";
|
||||
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
currentLine += `${word} `;
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
const word = currentLine.slice(0, -1);
|
||||
push(word);
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentLineWidthTillNow === maxWidth) {
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine) {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
}
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const ascii = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][ascii];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
export const getApproxMinLineWidth = (font: FontString) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getApproxMinLineHeight = (font: FontString) => {
|
||||
return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMinCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
|
||||
} else {
|
||||
y =
|
||||
containerCoords.y +
|
||||
(maxContainerHeight / 2 - boundTextElement.height / 2);
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
|
||||
return Math.min(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
x = containerCoords.x;
|
||||
} else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
|
||||
} else {
|
||||
x =
|
||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
const batchLength = 6;
|
||||
let index = 0;
|
||||
let widthTillNow = 0;
|
||||
let str = "";
|
||||
while (widthTillNow <= width) {
|
||||
const batch = dummyText.substr(index, index + batchLength);
|
||||
str += batch;
|
||||
widthTillNow += getLineWidth(str, font);
|
||||
if (index === dummyText.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
index = index + batchLength;
|
||||
}
|
||||
|
||||
while (widthTillNow > width) {
|
||||
str = str.substr(0, str.length - 1);
|
||||
widthTillNow = getLineWidth(str, font);
|
||||
}
|
||||
return str.length;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
@@ -621,6 +342,26 @@ export const getContainerCenter = (
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
};
|
||||
|
||||
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
||||
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
|
||||
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
|
||||
}
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
|
||||
if (container.type === "diamond") {
|
||||
offsetX += container.width / 4;
|
||||
offsetY += container.height / 4;
|
||||
}
|
||||
return {
|
||||
x: container.x + offsetX,
|
||||
y: container.y + offsetY,
|
||||
};
|
||||
};
|
||||
|
||||
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
|
||||
const container = getContainerElement(textElement);
|
||||
if (!container || isArrowElement(container)) {
|
||||
@@ -633,25 +374,14 @@ export const getBoundTextElementOffset = (
|
||||
boundTextElement: ExcalidrawTextElement | null,
|
||||
) => {
|
||||
const container = getContainerElement(boundTextElement);
|
||||
if (!container) {
|
||||
if (!container || !boundTextElement) {
|
||||
return 0;
|
||||
}
|
||||
if (isArrowElement(container)) {
|
||||
return BOUND_TEXT_PADDING * 8;
|
||||
}
|
||||
return BOUND_TEXT_PADDING;
|
||||
};
|
||||
|
||||
export const getBoundTextElementPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
);
|
||||
}
|
||||
return BOUND_TEXT_PADDING;
|
||||
};
|
||||
|
||||
export const shouldAllowVerticalAlign = (
|
||||
@@ -666,14 +396,24 @@ export const shouldAllowVerticalAlign = (
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
if (isArrowElement(element)) {
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const suppportsHorizontalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
const hasBoundContainer = isBoundToContainer(element);
|
||||
if (hasBoundContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (isTextElement(element) && isArrowElement(container)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
return isTextElement(element);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -714,12 +454,78 @@ export const getTextBindableContainerAtPosition = (
|
||||
return isTextBindableContainer(hitElement, false) ? hitElement : null;
|
||||
};
|
||||
|
||||
export const isValidTextContainer = (element: ExcalidrawElement) => {
|
||||
return (
|
||||
element.type === "rectangle" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "diamond" ||
|
||||
isImageElement(element) ||
|
||||
isArrowElement(element)
|
||||
);
|
||||
const VALID_CONTAINER_TYPES = new Set([
|
||||
"rectangle",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"image",
|
||||
"arrow",
|
||||
]);
|
||||
|
||||
export const isValidTextContainer = (element: ExcalidrawElement) =>
|
||||
VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
dimension: number,
|
||||
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
|
||||
) => {
|
||||
dimension = Math.ceil(dimension);
|
||||
const padding = BOUND_TEXT_PADDING * 2;
|
||||
|
||||
if (containerType === "ellipse") {
|
||||
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
|
||||
}
|
||||
if (containerType === "arrow") {
|
||||
return dimension + padding * 8;
|
||||
}
|
||||
if (containerType === "diamond") {
|
||||
return 2 * (dimension + padding);
|
||||
}
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The width of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
|
||||
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
// The width of the largest rectangle inscribed inside a rhombus is
|
||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
return boundTextElement.height;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
if (container.type === "ellipse") {
|
||||
// The height of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
||||
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
|
||||
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
// The height of the largest rectangle inscribed inside a rhombus is
|
||||
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getDefaultLineHeight,
|
||||
getLineHeightInPx,
|
||||
wrapText,
|
||||
} from "./textMeasurements";
|
||||
import { FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 200 - BOUND_TEXT_PADDING * 2;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(text);
|
||||
});
|
||||
|
||||
it("should work with emojis", () => {
|
||||
const text = "😀";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀");
|
||||
});
|
||||
|
||||
it("should show the text correctly when max width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello \nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 140,
|
||||
res: `Hello whats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "should push the word if its equal to max width",
|
||||
width: 60,
|
||||
res: `Hello
|
||||
whats
|
||||
up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello
|
||||
whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 80,
|
||||
res: `Hello\nwhats \nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 25,
|
||||
res: `H
|
||||
e
|
||||
l
|
||||
l
|
||||
o
|
||||
w
|
||||
h
|
||||
a
|
||||
t
|
||||
s
|
||||
u
|
||||
p`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 150,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 250,
|
||||
res: `Hello
|
||||
whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 170,
|
||||
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`,
|
||||
},
|
||||
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `hellolongte
|
||||
xtthisiswha
|
||||
tsupwithyou
|
||||
Iamtypinggg
|
||||
ggandtyping
|
||||
gg break it
|
||||
now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 600,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should wrap the text correctly when word length is exactly equal to max width", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||
const res = wrapText(text, font, 100);
|
||||
expect(res).toEqual(`Hello \nExcalidraw`);
|
||||
});
|
||||
|
||||
it("should return the text as is if max width is invalid", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
expect(wrapText(text, font, NaN)).toEqual(text);
|
||||
expect(wrapText(text, font, -1)).toEqual(text);
|
||||
expect(wrapText(text, font, Infinity)).toEqual(text);
|
||||
});
|
||||
});
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
height: 175,
|
||||
});
|
||||
|
||||
describe("Test detectLineHeight", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(detectLineHeight(textElement)).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getLineHeightInPx", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(
|
||||
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
|
||||
).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getDefaultLineHeight()).toBe(1.25);
|
||||
});
|
||||
it("should return correct line height", () => {
|
||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { getFontString, isTestEnv } from "../utils";
|
||||
import { normalizeText } from "./textElement";
|
||||
import { ExcalidrawTextElement, FontFamilyValues, FontString } from "./types";
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letter is 10px
|
||||
const DUMMY_CHAR_WIDTH = 10;
|
||||
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const width = canvas2dContext.measureText(text).width;
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (isTestEnv()) {
|
||||
return width * DUMMY_CHAR_WIDTH;
|
||||
}
|
||||
/* istanbul ignore next */
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
export const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(text, fontSize, lineHeight);
|
||||
const width = getTextWidth(text, font);
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const getApproxMinContainerWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getApproxMinContainerHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const ascii = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][ascii];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
/** this is not used currently but might be useful
|
||||
* in future hence keeping it
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
const batchLength = 6;
|
||||
let index = 0;
|
||||
let widthTillNow = 0;
|
||||
let str = "";
|
||||
while (widthTillNow <= width) {
|
||||
const batch = dummyText.substr(index, index + batchLength);
|
||||
str += batch;
|
||||
widthTillNow += getLineWidth(str, font);
|
||||
if (index === dummyText.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
index = index + batchLength;
|
||||
}
|
||||
|
||||
while (widthTillNow > width) {
|
||||
str = str.substr(0, str.length - 1);
|
||||
widthTillNow = getLineWidth(str, font);
|
||||
}
|
||||
return str.length;
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
||||
const push = (str: string) => {
|
||||
if (str.trim()) {
|
||||
lines.push(str);
|
||||
}
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
|
||||
originalLines.forEach((originalLine) => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
//Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
}
|
||||
const words = originalLine.split(" ");
|
||||
|
||||
resetParams();
|
||||
|
||||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
push(words[index]);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Start breaking longer words exceeding max width
|
||||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
|
||||
while (words[index].length > 0) {
|
||||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
}
|
||||
}
|
||||
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
} else {
|
||||
// space needs to be appended before next word
|
||||
// as currentLine contains chars which couldn't be appended
|
||||
// to previous line
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
}
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
currentLine += `${word} `;
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
const word = currentLine.slice(0, -1);
|
||||
push(word);
|
||||
resetParams();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* We calculate the line height from the font size and the unitless line height,
|
||||
* aligning with the W3C spec.
|
||||
*/
|
||||
export const getLineHeightInPx = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||
* height-per-line by fontSize.
|
||||
*/
|
||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||
const lineCount = splitIntoLines(textElement.text).length;
|
||||
return (textElement.height /
|
||||
lineCount /
|
||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Unitless line height
|
||||
*
|
||||
* In previous versions we used `normal` line height, which browsers interpret
|
||||
* differently, and based on font-family and font-size.
|
||||
*
|
||||
* To make line heights consistent across browsers we hardcode the values for
|
||||
* each of our fonts based on most common average line-heights.
|
||||
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
|
||||
* where the values come from.
|
||||
*/
|
||||
const DEFAULT_LINE_HEIGHT = {
|
||||
// ~1.25 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko (FF) uses ~1.28.
|
||||
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.15 is the average for Helvetica in WebKit and Blink.
|
||||
// Gecko if all over the place.
|
||||
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
|
||||
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
||||
};
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily) {
|
||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||
}
|
||||
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
||||
};
|
||||
+225
-141
@@ -3,19 +3,23 @@ import ExcalidrawApp from "../excalidraw-app";
|
||||
import { GlobalTestState, render, screen } from "../tests/test-utils";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { fireEvent } from "../tests/test-utils";
|
||||
import {
|
||||
fireEvent,
|
||||
mockBoundingClientRect,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "../tests/test-utils";
|
||||
import { queryByText } from "@testing-library/react";
|
||||
|
||||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import * as textElementUtils from "./textElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { resize } from "../tests/utils";
|
||||
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
@@ -222,11 +226,19 @@ describe("textWysiwyg", () => {
|
||||
|
||||
describe("Test container-unbound text", () => {
|
||||
const { h } = window;
|
||||
const dimensions = { height: 400, width: 800 };
|
||||
|
||||
let textarea: HTMLTextAreaElement;
|
||||
let textElement: ExcalidrawTextElement;
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect(dimensions);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
//@ts-ignore
|
||||
h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
|
||||
|
||||
textElement = UI.createElement("text");
|
||||
|
||||
@@ -236,6 +248,10 @@ describe("textWysiwyg", () => {
|
||||
)!;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should add a tab at the start of the first line", () => {
|
||||
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
|
||||
textarea.value = "Line#1\nLine#2";
|
||||
@@ -434,23 +450,33 @@ describe("textWysiwyg", () => {
|
||||
);
|
||||
expect(h.state.zoom.value).toBe(1);
|
||||
});
|
||||
|
||||
it("text should never go beyond max width", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
fireEvent.change(textarea, {
|
||||
target: {
|
||||
value:
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
},
|
||||
});
|
||||
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
textarea.blur();
|
||||
expect(textarea.style.width).toBe("792px");
|
||||
expect(h.elements[0].width).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test container-bound text", () => {
|
||||
let rectangle: any;
|
||||
const { h } = window;
|
||||
|
||||
const DUMMY_HEIGHT = 240;
|
||||
const DUMMY_WIDTH = 160;
|
||||
const APPROX_LINE_HEIGHT = 25;
|
||||
const INITIAL_WIDTH = 10;
|
||||
|
||||
beforeAll(() => {
|
||||
jest
|
||||
.spyOn(textElementUtils, "getApproxLineHeight")
|
||||
.mockReturnValue(APPROX_LINE_HEIGHT);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
h.elements = [];
|
||||
@@ -643,11 +669,11 @@ describe("textWysiwyg", () => {
|
||||
["freedraw", "line"].forEach((type: any) => {
|
||||
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
|
||||
h.elements = [];
|
||||
const elemnet = UI.createElement(type, {
|
||||
const element = UI.createElement(type, {
|
||||
width: 100,
|
||||
height: 50,
|
||||
});
|
||||
API.setSelectedElements([elemnet]);
|
||||
API.setSelectedElements([element]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
@@ -732,39 +758,6 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
|
||||
it("should wrap text and vertcially center align once text submitted", async () => {
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation((text, font, maxWidth) => {
|
||||
let width = INITIAL_WIDTH;
|
||||
let height = APPROX_LINE_HEIGHT;
|
||||
let baseline = 10;
|
||||
if (!text) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
}
|
||||
baseline = 30;
|
||||
width = DUMMY_WIDTH;
|
||||
if (text === "Hello \nWorld!") {
|
||||
height = APPROX_LINE_HEIGHT * 2;
|
||||
}
|
||||
if (maxWidth) {
|
||||
width = maxWidth;
|
||||
// To capture cases where maxWidth passed is initial width
|
||||
// due to which the text is not wrapped correctly
|
||||
if (maxWidth === INITIAL_WIDTH) {
|
||||
height = DUMMY_HEIGHT;
|
||||
}
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
@@ -773,11 +766,6 @@ describe("textWysiwyg", () => {
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
// mock scroll height
|
||||
jest
|
||||
.spyOn(editor, "scrollHeight", "get")
|
||||
.mockImplementation(() => APPROX_LINE_HEIGHT * 2);
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
@@ -792,11 +780,11 @@ describe("textWysiwyg", () => {
|
||||
expect(text.text).toBe("Hello \nWorld!");
|
||||
expect(text.originalText).toBe("Hello World!");
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2,
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
|
||||
expect(text.height).toBe(APPROX_LINE_HEIGHT * 2);
|
||||
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(text.x).toBe(25);
|
||||
expect(text.height).toBe(50);
|
||||
expect(text.width).toBe(60);
|
||||
|
||||
// Edit and text by removing second line and it should
|
||||
// still vertically align correctly
|
||||
@@ -813,11 +801,6 @@ describe("textWysiwyg", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// mock scroll height
|
||||
jest
|
||||
.spyOn(editor, "scrollHeight", "get")
|
||||
.mockImplementation(() => APPROX_LINE_HEIGHT);
|
||||
editor.style.height = "25px";
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -827,12 +810,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
expect(text.text).toBe("Hello");
|
||||
expect(text.originalText).toBe("Hello");
|
||||
expect(text.height).toBe(25);
|
||||
expect(text.width).toBe(50);
|
||||
expect(text.y).toBe(
|
||||
rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2,
|
||||
rectangle.y + h.elements[0].height / 2 - text.height / 2,
|
||||
);
|
||||
expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
|
||||
expect(text.height).toBe(APPROX_LINE_HEIGHT);
|
||||
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
|
||||
expect(text.x).toBe(30);
|
||||
});
|
||||
|
||||
it("should unbind bound text when unbind action from context menu is triggered", async () => {
|
||||
@@ -919,8 +902,8 @@ describe("textWysiwyg", () => {
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
109.5,
|
||||
17,
|
||||
85,
|
||||
4.5,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -934,6 +917,8 @@ describe("textWysiwyg", () => {
|
||||
editor.select();
|
||||
|
||||
fireEvent.click(screen.getByTitle("Left"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
@@ -944,7 +929,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
90,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -967,7 +952,7 @@ describe("textWysiwyg", () => {
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
424,
|
||||
375,
|
||||
-539,
|
||||
]
|
||||
`);
|
||||
@@ -1082,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(85);
|
||||
expect(text.x).toBe(89.5);
|
||||
expect(text.y).toBe(90);
|
||||
expect(rectangle.y).toBe(-40);
|
||||
expect(text.x).toBe(85);
|
||||
expect(text.y).toBe(-35);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.Z);
|
||||
@@ -1114,29 +1099,6 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation((text, font, maxWidth) => {
|
||||
let width = INITIAL_WIDTH;
|
||||
let height = APPROX_LINE_HEIGHT;
|
||||
let baseline = 10;
|
||||
if (!text) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
}
|
||||
baseline = 30;
|
||||
width = DUMMY_WIDTH;
|
||||
height = APPROX_LINE_HEIGHT * 5;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
});
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
||||
@@ -1150,7 +1112,7 @@ describe("textWysiwyg", () => {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(135);
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
@@ -1176,7 +1138,7 @@ describe("textWysiwyg", () => {
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect(rectangle.height).toBe(215);
|
||||
expect(rectangle.height).toBe(156);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
|
||||
mouse.select(rectangle);
|
||||
@@ -1188,13 +1150,12 @@ describe("textWysiwyg", () => {
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(215);
|
||||
expect(rectangle.height).toBe(156);
|
||||
// cache updated again
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
|
||||
});
|
||||
|
||||
//@todo fix this test later once measureText is mocked correctly
|
||||
it.skip("should reset the container height cache when font properties updated", async () => {
|
||||
it("should reset the container height cache when font properties updated", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
@@ -1220,7 +1181,44 @@ describe("textWysiwyg", () => {
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||
).toEqual(36);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(
|
||||
96.39999999999999,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update line height when font family updated", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.25);
|
||||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.2);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/normal/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Helvetica);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.15);
|
||||
});
|
||||
|
||||
describe("should align correctly", () => {
|
||||
@@ -1248,7 +1246,7 @@ describe("textWysiwyg", () => {
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
20,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1258,8 +1256,8 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
94.5,
|
||||
20,
|
||||
30,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1269,22 +1267,22 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
20,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
45,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when center left", async () => {
|
||||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
fireEvent.click(screen.getByTitle("Left"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
15,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when center center", async () => {
|
||||
@@ -1292,11 +1290,11 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
-25,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
30,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when center right", async () => {
|
||||
@@ -1304,11 +1302,11 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
45,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when bottom left", async () => {
|
||||
@@ -1316,34 +1314,120 @@ describe("textWysiwyg", () => {
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
15,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
15,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when bottom center", async () => {
|
||||
fireEvent.click(screen.getByTitle("Center"));
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
94.5,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
30,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("when bottom right", async () => {
|
||||
fireEvent.click(screen.getByTitle("Right"));
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
174,
|
||||
25,
|
||||
]
|
||||
`);
|
||||
Array [
|
||||
45,
|
||||
65,
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle("Left"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
editor.blur();
|
||||
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(textElement.width).toBe(600);
|
||||
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",
|
||||
);
|
||||
|
||||
API.setSelectedElements([textElement]);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(
|
||||
queryByText(contextMenu as HTMLElement, "Wrap text in a container")!,
|
||||
);
|
||||
expect(h.elements.length).toBe(3);
|
||||
|
||||
expect(h.elements[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
angle: 0,
|
||||
backgroundColor: "transparent",
|
||||
boundElements: [
|
||||
{
|
||||
id: h.elements[2].id,
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
fillStyle: "hachure",
|
||||
groupIds: [],
|
||||
height: 35,
|
||||
isDeleted: false,
|
||||
link: null,
|
||||
locked: false,
|
||||
opacity: 100,
|
||||
roughness: 1,
|
||||
roundness: {
|
||||
type: 3,
|
||||
},
|
||||
strokeColor: "#000000",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
type: "rectangle",
|
||||
updated: 1,
|
||||
version: 1,
|
||||
width: 610,
|
||||
x: 15,
|
||||
y: 25,
|
||||
}),
|
||||
);
|
||||
expect(h.elements[2] as ExcalidrawTextElement).toEqual(
|
||||
expect.objectContaining({
|
||||
text: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.LEFT,
|
||||
boundElements: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+51
-94
@@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -22,15 +22,15 @@ import {
|
||||
import { AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getApproxLineHeight,
|
||||
getBoundTextElementId,
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
redrawTextBoundingBox,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
@@ -38,9 +38,14 @@ import {
|
||||
} from "../actions/actionProperties";
|
||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||
import App from "../components/App";
|
||||
import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
import {
|
||||
getTextWidth,
|
||||
measureText,
|
||||
wrapText,
|
||||
getTextHeight,
|
||||
} from "./textMeasurements";
|
||||
|
||||
const getTransform = (
|
||||
width: number,
|
||||
@@ -147,9 +152,7 @@ export const textWysiwyg = ({
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedTextElement;
|
||||
const approxLineHeight = getApproxLineHeight(
|
||||
getFontString(updatedTextElement),
|
||||
);
|
||||
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
let coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
@@ -157,7 +160,7 @@ export const textWysiwyg = ({
|
||||
let maxWidth = updatedTextElement.width;
|
||||
|
||||
let maxHeight = updatedTextElement.height;
|
||||
const width = updatedTextElement.width;
|
||||
let textElementWidth = updatedTextElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = updatedTextElement.height;
|
||||
@@ -177,15 +180,12 @@ export const textWysiwyg = ({
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
textElementHeight = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
textElementHeight = updatedTextElement.height;
|
||||
}
|
||||
|
||||
textElementHeight = getTextHeight(
|
||||
updatedTextElement.text,
|
||||
updatedTextElement.fontSize,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
@@ -203,14 +203,17 @@ export const textWysiwyg = ({
|
||||
}
|
||||
}
|
||||
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxHeight = getMaxContainerHeight(container);
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
const diff = Math.min(
|
||||
textElementHeight - maxHeight,
|
||||
approxLineHeight,
|
||||
element.lineHeight,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height + diff });
|
||||
return;
|
||||
@@ -223,27 +226,15 @@ export const textWysiwyg = ({
|
||||
) {
|
||||
const diff = Math.min(
|
||||
maxHeight - textElementHeight,
|
||||
approxLineHeight,
|
||||
element.lineHeight,
|
||||
);
|
||||
mutateElement(container, { height: containerDims.height - diff });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
container.y + containerDims.height / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y +
|
||||
containerDims.height -
|
||||
textElementHeight -
|
||||
getBoundTextElementOffset(updatedTextElement);
|
||||
}
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
@@ -265,27 +256,25 @@ 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 {
|
||||
textElementWidth += 0.5;
|
||||
}
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: `${lineHeight}px`,
|
||||
width: `${Math.min(width, maxWidth)}px`,
|
||||
lineHeight: element.lineHeight,
|
||||
width: `${textElementWidth}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
transform: getTransform(
|
||||
width,
|
||||
textElementWidth,
|
||||
textElementHeight,
|
||||
getTextElementAngle(updatedTextElement),
|
||||
appState,
|
||||
@@ -366,7 +355,7 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
`${editable.value}${data}`,
|
||||
font,
|
||||
getMaxContainerWidth(container),
|
||||
getBoundTextMaxWidth(container),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
@@ -378,55 +367,20 @@ export const textWysiwyg = ({
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
// using scrollHeight here since we need to calculate
|
||||
// number of lines so cannot use editable.style.height
|
||||
// as that gets updated below
|
||||
// Rounding here so that the lines calculated is more accurate in all browsers.
|
||||
// The scrollHeight and approxLineHeight differs in diff browsers
|
||||
// eg it gives 1.05 in firefox for handewritten small font due to which
|
||||
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
|
||||
// hence rounding here to avoid that
|
||||
const lines = Math.round(
|
||||
editable.scrollHeight / getApproxLineHeight(font),
|
||||
);
|
||||
// auto increase height only when lines > 1 so its
|
||||
// measured correctly and vertically aligns for
|
||||
// first line as well as setting height to "auto"
|
||||
// doubles the height as soon as user starts typing
|
||||
if (isBoundToContainer(element) && lines > 1) {
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element);
|
||||
|
||||
let height = "auto";
|
||||
editable.style.height = "0px";
|
||||
let heightSet = false;
|
||||
if (lines === 2) {
|
||||
const actualLineCount = wrapText(
|
||||
editable.value,
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
).split("\n").length;
|
||||
// This is browser behaviour when setting height to "auto"
|
||||
// It sets the height needed for 2 lines even if actual
|
||||
// line count is 1 as mentioned above as well
|
||||
// hence reducing the height by half if actual line count is 1
|
||||
// so single line aligns vertically when deleting
|
||||
if (actualLineCount === 1) {
|
||||
height = `${editable.scrollHeight / 2}px`;
|
||||
editable.style.height = height;
|
||||
heightSet = true;
|
||||
}
|
||||
}
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getMaxContainerWidth(container!),
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
|
||||
if (!heightSet) {
|
||||
editable.style.height = `${editable.scrollHeight}px`;
|
||||
}
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
@@ -463,7 +417,9 @@ export const textWysiwyg = ({
|
||||
event.code === CODES.BRACKET_RIGHT))
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
} else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
|
||||
outdent();
|
||||
} else {
|
||||
indent();
|
||||
@@ -612,6 +568,7 @@ export const textWysiwyg = ({
|
||||
),
|
||||
});
|
||||
}
|
||||
redrawTextBoundingBox(updateElement, container);
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
describe("Test TypeChecks", () => {
|
||||
describe("Test hasBoundTextElement", () => {
|
||||
it("should return true for text bindable containers with bound text", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "ellipse",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "image",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false for text bindable containers without bound text", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
boundElements: [{ type: "arrow", id: "arrow-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return false for non text bindable containers", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { MarkNonNullable } from "../utility-types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -139,7 +140,7 @@ export const hasBoundTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
||||
return (
|
||||
isBindableElement(element) &&
|
||||
isTextBindableContainer(element) &&
|
||||
!!element.boundElements?.some(({ type }) => type === "text")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -130,11 +131,15 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
text: string;
|
||||
baseline: number;
|
||||
textAlign: TextAlign;
|
||||
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 =
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { unstable_createStore } from "jotai";
|
||||
|
||||
export const appJotaiStore = unstable_createStore();
|
||||
@@ -70,7 +70,7 @@ import { decryptData } from "../../data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const collabDialogShownAtom = atom(false);
|
||||
@@ -167,7 +167,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
setUsername: this.setUsername,
|
||||
};
|
||||
|
||||
jotaiStore.set(collabAPIAtom, collabAPI);
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
if (
|
||||
@@ -185,7 +185,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
|
||||
onOfflineStatusToggle = () => {
|
||||
jotaiStore.set(isOfflineAtom, !window.navigator.onLine);
|
||||
appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -208,10 +208,10 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
}
|
||||
|
||||
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
|
||||
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
|
||||
|
||||
private setIsCollaborating = (isCollaborating: boolean) => {
|
||||
jotaiStore.set(isCollaboratingAtom, isCollaborating);
|
||||
appJotaiStore.set(isCollaboratingAtom, isCollaborating);
|
||||
};
|
||||
|
||||
private onUnload = () => {
|
||||
@@ -804,7 +804,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
handleClose = () => {
|
||||
jotaiStore.set(collabDialogShownAtom, false);
|
||||
appJotaiStore.set(collabDialogShownAtom, false);
|
||||
};
|
||||
|
||||
setUsername = (username: string) => {
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
shareWindows,
|
||||
} from "../../components/icons";
|
||||
import { ToolButton } from "../../components/ToolButton";
|
||||
import { t } from "../../i18n";
|
||||
import "./RoomDialog.scss";
|
||||
import Stack from "../../components/Stack";
|
||||
import { AppState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { getFrame } from "../../utils";
|
||||
import DialogActionButton from "../../components/DialogActionButton";
|
||||
import { useI18n } from "../../i18n";
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
@@ -51,6 +51,7 @@ const RoomDialog = ({
|
||||
setErrorMessage: (message: string) => void;
|
||||
theme: AppState["theme"];
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const roomLinkInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from "react";
|
||||
import { PlusPromoIcon } from "../../components/icons";
|
||||
import { t } from "../../i18n";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
let headingContent;
|
||||
|
||||
if (isExcalidrawPlusSignedUser) {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { shield } from "../../components/icons";
|
||||
import { Tooltip } from "../../components/Tooltip";
|
||||
import { t } from "../../i18n";
|
||||
import { useI18n } from "../../i18n";
|
||||
|
||||
export const EncryptedIcon = () => (
|
||||
<a
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
export const EncryptedIcon = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<a
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||
import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { t } from "../../i18n";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { excalidrawPlusIcon } from "./icons";
|
||||
import { encryptData, generateEncryptionKey } from "../../data/encryption";
|
||||
import { isInitializedImageElement } from "../../element/typeChecks";
|
||||
@@ -79,6 +79,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
files: BinaryFiles;
|
||||
onError: (error: Error) => void;
|
||||
}> = ({ elements, appState, files, onError }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Card color="primary">
|
||||
<div className="Card-icon">{excalidrawPlusIcon}</div>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { langCodeAtom } from "..";
|
||||
import * as i18n from "../../i18n";
|
||||
import { appLangCodeAtom } from "..";
|
||||
import { defaultLang, useI18n } from "../../i18n";
|
||||
import { languages } from "../../i18n";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const [langCode, setLangCode] = useAtom(langCodeAtom);
|
||||
const { t, langCode } = useI18n();
|
||||
const setLangCode = useSetAtom(appLangCodeAtom);
|
||||
|
||||
return (
|
||||
<select
|
||||
className="dropdown-select dropdown-select__language"
|
||||
onChange={({ target }) => setLangCode(target.value)}
|
||||
value={langCode}
|
||||
aria-label={i18n.t("buttons.selectLanguage")}
|
||||
aria-label={t("buttons.selectLanguage")}
|
||||
style={style}
|
||||
>
|
||||
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
|
||||
{i18n.defaultLang.label}
|
||||
<option key={defaultLang.code} value={defaultLang.code}>
|
||||
{defaultLang.label}
|
||||
</option>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -75,15 +75,17 @@ import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { jotaiStore, useAtomWithInitialValue } from "../jotai";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useAtomWithInitialValue } from "../jotai";
|
||||
import { appJotaiStore } from "./app-jotai";
|
||||
|
||||
import "./index.scss";
|
||||
import { ResolutionType } from "../utility-types";
|
||||
|
||||
polyfill();
|
||||
|
||||
@@ -226,15 +228,15 @@ const initializeScene = async (opts: {
|
||||
return { scene: null, isExternalScene: false };
|
||||
};
|
||||
|
||||
const currentLangCode = languageDetector.detect() || defaultLang.code;
|
||||
|
||||
export const langCodeAtom = atom(
|
||||
Array.isArray(currentLangCode) ? currentLangCode[0] : currentLangCode,
|
||||
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
||||
export const appLangCodeAtom = atom(
|
||||
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
||||
);
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(langCodeAtom);
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -671,10 +673,9 @@ const ExcalidrawWrapper = () => {
|
||||
</Excalidraw>
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
<ErrorDialog
|
||||
message={errorMessage}
|
||||
onClose={() => setErrorMessage("")}
|
||||
/>
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -683,7 +684,7 @@ const ExcalidrawWrapper = () => {
|
||||
const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider unstable_createStore={() => jotaiStore}>
|
||||
<Provider unstable_createStore={() => appJotaiStore}>
|
||||
<ExcalidrawWrapper />
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
|
||||
Vendored
-47
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
+16
@@ -1,6 +1,8 @@
|
||||
import fallbackLangData from "./locales/en.json";
|
||||
import percentages from "./locales/percentages.json";
|
||||
import { ENV } from "./constants";
|
||||
import { jotaiScope, jotaiStore } from "./jotai";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
|
||||
const COMPLETION_THRESHOLD = 85;
|
||||
|
||||
@@ -99,6 +101,8 @@ export const setLanguage = async (lang: Language) => {
|
||||
currentLangData = fallbackLangData;
|
||||
}
|
||||
}
|
||||
|
||||
jotaiStore.set(editorLangCodeAtom, lang.code);
|
||||
};
|
||||
|
||||
export const getLanguage = () => currentLang;
|
||||
@@ -143,3 +147,15 @@ export const t = (
|
||||
}
|
||||
return translation;
|
||||
};
|
||||
|
||||
/** @private atom used solely to rerender components using `useI18n` hook */
|
||||
const editorLangCodeAtom = atom(defaultLang.code);
|
||||
|
||||
// Should be used in components that fall under these cases:
|
||||
// - component is rendered as an <Excalidraw> child
|
||||
// - component is rendered internally by <Excalidraw>, but the component
|
||||
// is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
|
||||
export const useI18n = () => {
|
||||
const langCode = useAtomValue(editorLangCodeAtom, jotaiScope);
|
||||
return { t, langCode };
|
||||
};
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { unstable_createStore, useAtom, WritableAtom } from "jotai";
|
||||
import { PrimitiveAtom, unstable_createStore, useAtom } from "jotai";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
export const jotaiScope = Symbol();
|
||||
@@ -6,7 +6,7 @@ export const jotaiStore = unstable_createStore();
|
||||
|
||||
export const useAtomWithInitialValue = <
|
||||
T extends unknown,
|
||||
A extends WritableAtom<T, T>,
|
||||
A extends PrimitiveAtom<T>,
|
||||
>(
|
||||
atom: A,
|
||||
initialValue: T | (() => T),
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
|
||||
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
|
||||
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "نوع الملف غير مدعوم.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Този файлов формат не се поддържа.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "সরবরাহ করা লিঙ্ক থেকে দৃশ্য লোড করা যায়নি৷ এটি হয় বিকৃত, অথবা বৈধ এক্সক্যালিড্র জেসন তথ্য নেই৷",
|
||||
"resetLibrary": "এটি আপনার সংগ্রহ পরিষ্কার করবে। আপনি কি নিশ্চিত?",
|
||||
"removeItemsFromsLibrary": "সংগ্রহ থেকে {{count}} বস্তু বিয়োগ করা হবে। আপনি কি নিশ্চিত?",
|
||||
"invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।"
|
||||
"invalidEncryptionKey": "অবৈধ এনক্রীপশন কী।",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "অসমর্থিত ফাইল।",
|
||||
|
||||
+27
-26
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "Enganxa",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteAsPlaintext": "Enganxar com a text pla",
|
||||
"pasteCharts": "Enganxa els diagrames",
|
||||
"selectAll": "Selecciona-ho tot",
|
||||
"multiSelect": "Afegeix un element a la selecció",
|
||||
@@ -72,7 +72,7 @@
|
||||
"layers": "Capes",
|
||||
"actions": "Accions",
|
||||
"language": "Llengua",
|
||||
"liveCollaboration": "",
|
||||
"liveCollaboration": "Col·laboració en directe...",
|
||||
"duplicateSelection": "Duplica",
|
||||
"untitled": "Sense títol",
|
||||
"name": "Nom",
|
||||
@@ -116,8 +116,8 @@
|
||||
"label": "Enllaç"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
"edit": "Editar línia",
|
||||
"exit": "Sortir de l'editor de línia"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Bloca",
|
||||
@@ -136,8 +136,8 @@
|
||||
"buttons": {
|
||||
"clearReset": "Neteja el llenç",
|
||||
"exportJSON": "Exporta a un fitxer",
|
||||
"exportImage": "",
|
||||
"export": "",
|
||||
"exportImage": "Exporta la imatge...",
|
||||
"export": "Guardar a...",
|
||||
"exportToPng": "Exporta a PNG",
|
||||
"exportToSvg": "Exporta a SNG",
|
||||
"copyToClipboard": "Copia al porta-retalls",
|
||||
@@ -145,7 +145,7 @@
|
||||
"scale": "Escala",
|
||||
"save": "Desa al fitxer actual",
|
||||
"saveAs": "Anomena i desa",
|
||||
"load": "",
|
||||
"load": "Obrir",
|
||||
"getShareableLink": "Obté l'enllaç per a compartir",
|
||||
"close": "Tanca",
|
||||
"selectLanguage": "Trieu la llengua",
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
|
||||
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
|
||||
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?",
|
||||
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada."
|
||||
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada.",
|
||||
"collabOfflineWarning": "Sense connexió a internet disponible.\nEls vostres canvis no seran guardats!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipus de fitxer no suportat.",
|
||||
@@ -202,8 +203,8 @@
|
||||
"invalidSVGString": "SVG no vàlid.",
|
||||
"cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.",
|
||||
"importLibraryError": "No s'ha pogut carregar la biblioteca",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": ""
|
||||
"collabSaveFailed": "No s'ha pogut desar a la base de dades de fons. Si els problemes persisteixen, hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball.",
|
||||
"collabSaveFailed_sizeExceeded": "No s'ha pogut desar a la base de dades de fons, sembla que el llenç és massa gran. Hauríeu de desar el fitxer localment per assegurar-vos que no perdeu el vostre treball."
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selecció",
|
||||
@@ -217,10 +218,10 @@
|
||||
"text": "Text",
|
||||
"library": "Biblioteca",
|
||||
"lock": "Mantenir activa l'eina seleccionada desprès de dibuixar",
|
||||
"penMode": "",
|
||||
"penMode": "Mode de llapis - evita tocar",
|
||||
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
|
||||
"eraser": "Esborrador",
|
||||
"hand": ""
|
||||
"hand": "Mà (eina de desplaçament)"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Accions del llenç",
|
||||
@@ -228,7 +229,7 @@
|
||||
"shapes": "Formes"
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"canvasPanning": "Per moure el llenç, manteniu premuda la roda del ratolí o la barra espaiadora mentre arrossegueu o utilitzeu l'eina manual",
|
||||
"linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia",
|
||||
"freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar",
|
||||
"text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció",
|
||||
@@ -239,7 +240,7 @@
|
||||
"resize": "Per restringir les proporcions mentres es canvia la mida, mantenir premut el majúscul (SHIFT); per canviar la mida des del centre, mantenir premut ALT",
|
||||
"resizeImage": "Podeu redimensionar lliurement prement MAJÚSCULA;\nper a redimensionar des del centre, premeu ALT",
|
||||
"rotate": "Per restringir els angles mentre gira, mantenir premut el majúscul (SHIFT)",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_info": "Mantingueu premut Ctrl o Cmd i feu doble clic o premeu Ctrl o Cmd + Retorn per editar els punts",
|
||||
"lineEditor_pointSelected": "Premeu Suprimir per a eliminar el(s) punt(s), CtrlOrCmd+D per a duplicar-lo, o arrossegueu-lo per a moure'l",
|
||||
"lineEditor_nothingSelected": "Seleccioneu un punt per a editar-lo (premeu SHIFT si voleu\nselecció múltiple), o manteniu Alt i feu clic per a afegir més punts",
|
||||
"placeImage": "Feu clic per a col·locar la imatge o clic i arrossegar per a establir-ne la mida manualment",
|
||||
@@ -247,7 +248,7 @@
|
||||
"bindTextToElement": "Premeu enter per a afegir-hi text",
|
||||
"deepBoxSelect": "Manteniu CtrlOrCmd per a selecció profunda, i per a evitar l'arrossegament",
|
||||
"eraserRevert": "Mantingueu premuda Alt per a revertir els elements seleccionats per a esborrar",
|
||||
"firefox_clipboard_write": ""
|
||||
"firefox_clipboard_write": "És probable que aquesta funció es pugui activar posant la marca \"dom.events.asyncClipboard.clipboardItem\" a \"true\". Per canviar les marques del navegador al Firefox, visiteu la pàgina \"about:config\"."
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "No es pot mostrar la previsualització",
|
||||
@@ -295,7 +296,7 @@
|
||||
"blog": "Llegiu el nostre blog",
|
||||
"click": "clic",
|
||||
"deepSelect": "Selecció profunda",
|
||||
"deepBoxSelect": "",
|
||||
"deepBoxSelect": "Seleccioneu profundament dins del quadre i eviteu arrossegar",
|
||||
"curvedArrow": "Fletxa corba",
|
||||
"curvedLine": "Línia corba",
|
||||
"documentation": "Documentació",
|
||||
@@ -316,8 +317,8 @@
|
||||
"zoomToFit": "Zoom per veure tots els elements",
|
||||
"zoomToSelection": "Zoom per veure la selecció",
|
||||
"toggleElementLock": "Blocar/desblocar la selecció",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
"movePageUpDown": "Mou la pàgina cap amunt/a baix",
|
||||
"movePageLeftRight": "Mou la pàgina cap a l'esquerra/dreta"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Neteja el llenç"
|
||||
@@ -399,7 +400,7 @@
|
||||
"fileSavedToFilename": "S'ha desat a {filename}",
|
||||
"canvas": "el llenç",
|
||||
"selection": "la selecció",
|
||||
"pasteAsSingleElement": ""
|
||||
"pasteAsSingleElement": "Fer servir {{shortcut}} per enganxar com un sol element,\no enganxeu-lo en un editor de text existent"
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "Blanc",
|
||||
@@ -450,15 +451,15 @@
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
"center_heading": "Totes les vostres dades es guarden localment al vostre navegador.",
|
||||
"center_heading_plus": "Vols anar a Excalidraw+ en comptes?",
|
||||
"menuHint": "Exportar, preferències, llenguatges..."
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
"menuHint": "Exportar, preferències i més...",
|
||||
"center_heading": "Diagrames. Fer. Simple.",
|
||||
"toolbarHint": "Selecciona una eina i comença a dibuixar!",
|
||||
"helpHint": "Dreceres i ajuda"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.",
|
||||
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
|
||||
"removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?",
|
||||
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert."
|
||||
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert.",
|
||||
"collabOfflineWarning": "Keine Internetverbindung verfügbar.\nDeine Änderungen werden nicht gespeichert!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Nicht unterstützter Dateityp.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.",
|
||||
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
|
||||
"removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;",
|
||||
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη."
|
||||
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη.",
|
||||
"collabOfflineWarning": "Δεν υπάρχει διαθέσιμη σύνδεση στο internet.\nΟι αλλαγές σας δεν θα αποθηκευτούν!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.",
|
||||
|
||||
+17
-2
@@ -110,6 +110,7 @@
|
||||
"increaseFontSize": "Increase font size",
|
||||
"unbindText": "Unbind text",
|
||||
"bindText": "Bind text to the container",
|
||||
"createContainerFromText": "Wrap text in a container",
|
||||
"link": {
|
||||
"edit": "Edit link",
|
||||
"create": "Create link",
|
||||
@@ -119,7 +120,6 @@
|
||||
"edit": "Edit line",
|
||||
"exit": "Exit line editor"
|
||||
},
|
||||
|
||||
"elementLock": {
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
@@ -205,7 +205,22 @@
|
||||
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
|
||||
"importLibraryError": "Couldn't load library",
|
||||
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
|
||||
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work."
|
||||
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
|
||||
"brave_measure_text_error": {
|
||||
"start": "Looks like you are using Brave browser with the",
|
||||
"aggressive_block_fingerprint": "Aggressively Block Fingerprinting",
|
||||
"setting_enabled": "setting enabled",
|
||||
"break": "This could result in breaking the",
|
||||
"text_elements": "Text Elements",
|
||||
"in_your_drawings": "in your drawings",
|
||||
"strongly_recommend": "We strongly recommend disabling this setting. You can follow",
|
||||
"steps": "these steps",
|
||||
"how": "on how to do so",
|
||||
"disable_setting": " If disabling this setting doesn't fix the display of text elements, please open an",
|
||||
"issue": "issue",
|
||||
"write": "on our GitHub, or write us on",
|
||||
"discord": "Discord"
|
||||
}
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selection",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"share": "Compartir",
|
||||
"showStroke": "Mostrar selector de color de trazo",
|
||||
"showBackground": "Mostrar el selector de color de fondo",
|
||||
"toggleTheme": "Alternar tema",
|
||||
"toggleTheme": "Cambiar tema",
|
||||
"personalLib": "Biblioteca personal",
|
||||
"excalidrawLib": "Biblioteca Excalidraw",
|
||||
"decreaseFontSize": "Disminuir tamaño de letra",
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.",
|
||||
"resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?",
|
||||
"removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?",
|
||||
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada."
|
||||
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada.",
|
||||
"collabOfflineWarning": "No hay conexión a internet disponible.\n¡No se guardarán los cambios!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipo de archivo no admitido.",
|
||||
@@ -233,7 +234,7 @@
|
||||
"freeDraw": "Haz clic y arrastra, suelta al terminar",
|
||||
"text": "Consejo: también puedes añadir texto haciendo doble clic en cualquier lugar con la herramienta de selección",
|
||||
"text_selected": "Doble clic o pulse ENTER para editar el texto",
|
||||
"text_editing": "Pulse Escape o CtrlOrCmd+ENTER para terminar de editar",
|
||||
"text_editing": "Pulse Escape o Ctrl/Cmd + ENTER para terminar de editar",
|
||||
"linearElementMulti": "Haz clic en el último punto o presiona Escape o Enter para finalizar",
|
||||
"lockAngle": "Puedes restringir el ángulo manteniendo presionado el botón SHIFT",
|
||||
"resize": "Para mantener las proporciones mantén SHIFT presionado mientras modificas el tamaño, \nmantén presionado ALT para modificar el tamaño desde el centro",
|
||||
@@ -314,7 +315,7 @@
|
||||
"title": "Ayuda",
|
||||
"view": "Vista",
|
||||
"zoomToFit": "Ajustar la vista para mostrar todos los elementos",
|
||||
"zoomToSelection": "Zoom a la selección",
|
||||
"zoomToSelection": "Ampliar selección",
|
||||
"toggleElementLock": "Bloquear/desbloquear selección",
|
||||
"movePageUpDown": "Mover página hacia arriba/abajo",
|
||||
"movePageLeftRight": "Mover página hacia la izquierda/derecha"
|
||||
@@ -326,9 +327,9 @@
|
||||
"title": "Publicar biblioteca",
|
||||
"itemName": "Nombre del artículo",
|
||||
"authorName": "Nombre del autor",
|
||||
"githubUsername": "Nombre de usuario de Github",
|
||||
"githubUsername": "Nombre de usuario de GitHub",
|
||||
"twitterUsername": "Nombre de usuario de Twitter",
|
||||
"libraryName": "Nombre de la librería",
|
||||
"libraryName": "Nombre de la biblioteca",
|
||||
"libraryDesc": "Descripción de la biblioteca",
|
||||
"website": "Sitio Web",
|
||||
"placeholder": {
|
||||
@@ -336,7 +337,7 @@
|
||||
"libraryName": "Nombre de tu biblioteca",
|
||||
"libraryDesc": "Descripción de su biblioteca para ayudar a la gente a entender su uso",
|
||||
"githubHandle": "Nombre de usuario de GitHub (opcional), así podrá editar la biblioteca una vez enviada para su revisión",
|
||||
"twitterHandle": "Nombre de usuario de Twitter (opcional), así que sabemos a quién acreditar cuando se promociona en Twitter",
|
||||
"twitterHandle": "Nombre de usuario de Twitter (opcional), así sabemos a quién acreditar cuando se promociona en Twitter",
|
||||
"website": "Enlace a su sitio web personal o en cualquier otro lugar (opcional)"
|
||||
},
|
||||
"errors": {
|
||||
@@ -458,7 +459,7 @@
|
||||
"menuHint": "Exportar, preferencias y más...",
|
||||
"center_heading": "Diagramas. Hecho. Simplemente.",
|
||||
"toolbarHint": "¡Elige una herramienta y empieza a dibujar!",
|
||||
"helpHint": "Atajos & ayuda"
|
||||
"helpHint": "Atajos y ayuda"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.",
|
||||
"resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?",
|
||||
"removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?",
|
||||
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago."
|
||||
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago.",
|
||||
"collabOfflineWarning": "Ez dago Interneteko konexiorik.\nZure aldaketak ez dira gordeko!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Onartu gabeko fitxategi mota.",
|
||||
@@ -220,7 +221,7 @@
|
||||
"penMode": "Luma modua - ukipena saihestu",
|
||||
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
|
||||
"eraser": "Borragoma",
|
||||
"hand": ""
|
||||
"hand": "Eskua (panoratze tresna)"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Canvas ekintzak",
|
||||
@@ -228,7 +229,7 @@
|
||||
"shapes": "Formak"
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"canvasPanning": "Oihala mugitzeko, eutsi saguaren gurpila edo zuriune-barra arrastatzean, edo erabili esku tresna",
|
||||
"linearElement": "Egin klik hainbat puntu hasteko, arrastatu lerro bakarrerako",
|
||||
"freeDraw": "Egin klik eta arrastatu, askatu amaitutakoan",
|
||||
"text": "Aholkua: testua gehitu dezakezu edozein lekutan klik bikoitza eginez hautapen tresnarekin",
|
||||
@@ -247,7 +248,7 @@
|
||||
"bindTextToElement": "Sakatu Sartu testua gehitzeko",
|
||||
"deepBoxSelect": "Eutsi Ctrl edo Cmd sakatuta aukeraketa sakona egiteko eta arrastatzea saihesteko",
|
||||
"eraserRevert": "Eduki Alt sakatuta ezabatzeko markatutako elementuak leheneratzeko",
|
||||
"firefox_clipboard_write": ""
|
||||
"firefox_clipboard_write": "Ezaugarri hau \"dom.events.asyncClipboard.clipboardItem\" marka \"true\" gisa ezarrita gaitu daiteke. Firefox-en arakatzailearen banderak aldatzeko, bisitatu \"about:config\" orrialdera."
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Ezin da oihala aurreikusi",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.",
|
||||
"resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?",
|
||||
"removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?",
|
||||
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است."
|
||||
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "نوع فایل پشتیبانی نشده.",
|
||||
|
||||
+32
-31
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "Liitä",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteAsPlaintext": "Liitä pelkkänä tekstinä",
|
||||
"pasteCharts": "Liitä kaaviot",
|
||||
"selectAll": "Valitse kaikki",
|
||||
"multiSelect": "Lisää kohde valintaan",
|
||||
@@ -72,7 +72,7 @@
|
||||
"layers": "Tasot",
|
||||
"actions": "Toiminnot",
|
||||
"language": "Kieli",
|
||||
"liveCollaboration": "",
|
||||
"liveCollaboration": "Live Yhteistyö...",
|
||||
"duplicateSelection": "Monista",
|
||||
"untitled": "Nimetön",
|
||||
"name": "Nimi",
|
||||
@@ -116,14 +116,14 @@
|
||||
"label": "Linkki"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
"edit": "Muokkaa riviä",
|
||||
"exit": "Poistu rivieditorista"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
"lock": "Lukitse",
|
||||
"unlock": "Poista lukitus",
|
||||
"lockAll": "Lukitse kaikki",
|
||||
"unlockAll": "Poista lukitus kaikista"
|
||||
},
|
||||
"statusPublished": "Julkaistu",
|
||||
"sidebarLock": "Pidä sivupalkki avoinna"
|
||||
@@ -136,8 +136,8 @@
|
||||
"buttons": {
|
||||
"clearReset": "Tyhjennä piirtoalue",
|
||||
"exportJSON": "Vie tiedostoon",
|
||||
"exportImage": "",
|
||||
"export": "",
|
||||
"exportImage": "Vie kuva...",
|
||||
"export": "Tallenna nimellä...",
|
||||
"exportToPng": "Vie PNG-tiedostona",
|
||||
"exportToSvg": "Vie SVG-tiedostona",
|
||||
"copyToClipboard": "Kopioi leikepöydälle",
|
||||
@@ -145,7 +145,7 @@
|
||||
"scale": "Koko",
|
||||
"save": "Tallenna nykyiseen tiedostoon",
|
||||
"saveAs": "Tallenna nimellä",
|
||||
"load": "",
|
||||
"load": "Avaa",
|
||||
"getShareableLink": "Hae jaettava linkki",
|
||||
"close": "Sulje",
|
||||
"selectLanguage": "Valitse kieli",
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
|
||||
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
|
||||
"removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?",
|
||||
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä."
|
||||
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä.",
|
||||
"collabOfflineWarning": "Internet-yhteyttä ei ole saatavilla.\nMuutoksiasi ei tallenneta!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tiedostotyyppiä ei tueta.",
|
||||
@@ -201,9 +202,9 @@
|
||||
"svgImageInsertError": "SVG- kuvaa ei voitu lisätä. Tiedoston SVG-sisältö näyttää virheelliseltä.",
|
||||
"invalidSVGString": "Virheellinen SVG.",
|
||||
"cannotResolveCollabServer": "Yhteyden muodostaminen collab-palvelimeen epäonnistui. Virkistä sivu ja yritä uudelleen.",
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": ""
|
||||
"importLibraryError": "Kokoelman lataaminen epäonnistui",
|
||||
"collabSaveFailed": "Ei voitu tallentaan palvelimen tietokantaan. Jos ongelmia esiintyy, sinun kannatta tallentaa tallentaa tiedosto paikallisesti varmistaaksesi, että et menetä työtäsi.",
|
||||
"collabSaveFailed_sizeExceeded": "Ei voitu tallentaan palvelimen tietokantaan. Jos ongelmia esiintyy, sinun kannatta tallentaa tallentaa tiedosto paikallisesti varmistaaksesi, että et menetä työtäsi."
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Valinta",
|
||||
@@ -217,10 +218,10 @@
|
||||
"text": "Teksti",
|
||||
"library": "Kirjasto",
|
||||
"lock": "Pidä valittu työkalu aktiivisena piirron jälkeen",
|
||||
"penMode": "",
|
||||
"penMode": "Kynätila - estä kosketus",
|
||||
"link": "Lisää/päivitä linkki valitulle muodolle",
|
||||
"eraser": "Poistotyökalu",
|
||||
"hand": ""
|
||||
"hand": "Käsi (panning-työkalu)"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Piirtoalueen toiminnot",
|
||||
@@ -228,7 +229,7 @@
|
||||
"shapes": "Muodot"
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"canvasPanning": "Piirtoalueen liikuttamiseksi pidä hiiren pyörää tai välilyöntiä pohjassa tai käytä käsityökalua",
|
||||
"linearElement": "Klikkaa piirtääksesi useampi piste, raahaa piirtääksesi yksittäinen viiva",
|
||||
"freeDraw": "Paina ja raahaa, päästä irti kun olet valmis",
|
||||
"text": "Vinkki: voit myös lisätä tekstiä kaksoisnapsauttamalla mihin tahansa valintatyökalulla",
|
||||
@@ -239,7 +240,7 @@
|
||||
"resize": "Voit rajoittaa mittasuhteet pitämällä SHIFT-näppäintä alaspainettuna kun muutat kokoa, pidä ALT-näppäintä alaspainettuna muuttaaksesi kokoa keskipisteen suhteen",
|
||||
"resizeImage": "Voit muuttaa kokoa vapaasti pitämällä SHIFTiä pohjassa, pidä ALT pohjassa muuttaaksesi kokoa keskipisteen ympäri",
|
||||
"rotate": "Voit rajoittaa kulman pitämällä SHIFT pohjassa pyörittäessäsi",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_info": "Pidä CtrlOrCmd pohjassa ja kaksoisnapsauta tai paina CtrlOrCmd + Enter muokataksesi pisteitä",
|
||||
"lineEditor_pointSelected": "Poista piste(et) painamalla delete, monista painamalla CtrlOrCmd+D, tai liikuta raahaamalla",
|
||||
"lineEditor_nothingSelected": "Valitse muokattava piste (monivalinta pitämällä SHIFT pohjassa), tai paina Alt ja klikkaa lisätäksesi uusia pisteitä",
|
||||
"placeImage": "Klikkaa asettaaksesi kuvan, tai klikkaa ja raahaa asettaaksesi sen koon manuaalisesti",
|
||||
@@ -247,7 +248,7 @@
|
||||
"bindTextToElement": "Lisää tekstiä painamalla enter",
|
||||
"deepBoxSelect": "Käytä syvävalintaa ja estä raahaus painamalla CtrlOrCmd",
|
||||
"eraserRevert": "Pidä Alt alaspainettuna, kumotaksesi merkittyjen elementtien poistamisen",
|
||||
"firefox_clipboard_write": ""
|
||||
"firefox_clipboard_write": "Tämä ominaisuus voidaan todennäköisesti ottaa käyttöön asettamalla \"dom.events.asyncClipboard.clipboardItem\" kohta \"true\":ksi. Vaihtaaksesi selaimen kohdan Firefoxissa, käy \"about:config\" sivulla."
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Esikatselua ei voitu näyttää",
|
||||
@@ -315,9 +316,9 @@
|
||||
"view": "Näkymä",
|
||||
"zoomToFit": "Näytä kaikki elementit",
|
||||
"zoomToSelection": "Näytä valinta",
|
||||
"toggleElementLock": "",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
"toggleElementLock": "Lukitse / poista lukitus valinta",
|
||||
"movePageUpDown": "Siirrä sivua ylös/alas",
|
||||
"movePageLeftRight": "Siirrä sivua vasemmalle/oikealle"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Pyyhi piirtoalue"
|
||||
@@ -399,7 +400,7 @@
|
||||
"fileSavedToFilename": "Tallennettiin kohteeseen {filename}",
|
||||
"canvas": "piirtoalue",
|
||||
"selection": "valinta",
|
||||
"pasteAsSingleElement": ""
|
||||
"pasteAsSingleElement": "Käytä {{shortcut}} liittääksesi yhtenä elementtinä,\ntai liittääksesi olemassa olevaan tekstieditoriin"
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "Valkoinen",
|
||||
@@ -450,15 +451,15 @@
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
"center_heading": "Kaikki tietosi on tallennettu paikallisesti selaimellesi.",
|
||||
"center_heading_plus": "Haluatko sen sijaan mennä Excalidraw+:aan?",
|
||||
"menuHint": "Vie, asetukset, kielet, ..."
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
"menuHint": "Vie, asetukset ja lisää...",
|
||||
"center_heading": "Kaaviot. Tehty. Yksinkertaiseksi.",
|
||||
"toolbarHint": "Valitse työkalu ja aloita piirtäminen!",
|
||||
"helpHint": "Pikanäppäimet & ohje"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Impossible d'importer la scène depuis l'URL fournie. Elle est soit incorrecte, soit ne contient pas de données JSON Excalidraw valides.",
|
||||
"resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?",
|
||||
"removeItemsFromsLibrary": "Supprimer {{count}} élément(s) de la bibliothèque ?",
|
||||
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée."
|
||||
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée.",
|
||||
"collabOfflineWarning": "Aucune connexion internet disponible.\nVos modifications ne seront pas enregistrées !"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Type de fichier non supporté.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Non se puido importar a escena dende a URL proporcionada. Ou ben está malformada ou non contén un JSON con información válida para Excalidraw.",
|
||||
"resetLibrary": "Isto limpará a súa biblioteca. Está seguro?",
|
||||
"removeItemsFromsLibrary": "Eliminar {{count}} elemento(s) da biblioteca?",
|
||||
"invalidEncryptionKey": "A clave de cifrado debe ter 22 caracteres. A colaboración en directo está desactivada."
|
||||
"invalidEncryptionKey": "A clave de cifrado debe ter 22 caracteres. A colaboración en directo está desactivada.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipo de ficheiro non soportado.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
|
||||
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
|
||||
"removeItemsFromsLibrary": "מחיקת {{count}} פריטים(ים) מתוך הספריה?",
|
||||
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל."
|
||||
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "סוג הקובץ אינו נתמך.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "दिये गये युआरेल से दृश्य आयात नहीं किया जा सका. यह या तो अनुचित है, या इसमें उचित Excalidraw JSON डेटा नहीं है।",
|
||||
"resetLibrary": "यह पूरा संग्रह रिक्त करेगा. क्या आपको यक़ीन हैं?",
|
||||
"removeItemsFromsLibrary": "{{count}} वस्तु(यें) संग्रह से हटायें?",
|
||||
"invalidEncryptionKey": "कूटलेखन कुंजी 22 अक्षरों की होनी चाहिये, इसलिये जीवंत सहयोग अक्षम हैं"
|
||||
"invalidEncryptionKey": "कूटलेखन कुंजी 22 अक्षरों की होनी चाहिये, इसलिये जीवंत सहयोग अक्षम हैं",
|
||||
"collabOfflineWarning": "कोई इंटरनेट कनेक्शन उपलब्ध नहीं है।\nआपके बदलाव सहेजे नहीं जाएंगे!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "असमर्थित फाइल प्रकार",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Nem sikerült importálni a jelenetet a megadott URL-ről. Rossz formátumú, vagy nem tartalmaz érvényes Excalidraw JSON-adatokat.",
|
||||
"resetLibrary": "Ezzel törlöd a könyvtárát. biztos vagy ebben?",
|
||||
"removeItemsFromsLibrary": "{{count}} elemet törölsz a könyvtárból?",
|
||||
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva."
|
||||
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Nem támogatott fájltípus.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.",
|
||||
"resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?",
|
||||
"removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?",
|
||||
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan."
|
||||
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipe file tidak didukung.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Impossibile importare la scena dall'URL fornito. Potrebbe essere malformato o non contenere dati JSON Excalidraw validi.",
|
||||
"resetLibrary": "Questa azione cancellerà l'intera libreria. Sei sicuro?",
|
||||
"removeItemsFromsLibrary": "Eliminare {{count}} elementi dalla libreria?",
|
||||
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata."
|
||||
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata.",
|
||||
"collabOfflineWarning": "Nessuna connessione internet disponibile.\nLe tue modifiche non verranno salvate!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipo di file non supportato.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。",
|
||||
"resetLibrary": "ライブラリを消去します。本当によろしいですか?",
|
||||
"removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?",
|
||||
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。"
|
||||
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。",
|
||||
"collabOfflineWarning": "インターネットに接続されていません。\n変更は保存されません!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "サポートされていないファイル形式です。",
|
||||
@@ -220,7 +221,7 @@
|
||||
"penMode": "ペンモード - タッチ防止",
|
||||
"link": "選択した図形のリンクを追加/更新",
|
||||
"eraser": "消しゴム",
|
||||
"hand": ""
|
||||
"hand": "手 (パンニングツール)"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "キャンバス操作",
|
||||
@@ -228,7 +229,7 @@
|
||||
"shapes": "図形"
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"canvasPanning": "キャンバスを移動するには、マウスホイールまたはスペースバーを押しながらドラッグするか、手ツールを使用します",
|
||||
"linearElement": "クリックすると複数の頂点からなる曲線を開始、ドラッグすると直線",
|
||||
"freeDraw": "クリックしてドラッグします。離すと終了します",
|
||||
"text": "ヒント: 選択ツールを使用して任意の場所をダブルクリックしてテキストを追加することもできます",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Ulamek taktert n usayes seg URL i d-ittunefken. Ahat mačči d tameɣtut neɣ ur tegbir ara isefka JSON n Excalidraw.",
|
||||
"resetLibrary": "Ayagi ad isfeḍ tamkarḍit-inek•m. Tetḥeqqeḍ?",
|
||||
"removeItemsFromsLibrary": "Ad tekkseḍ {{count}} n uferdis (en) si temkarḍit?",
|
||||
"invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa."
|
||||
"invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Anaw n ufaylu ur yettwasefrak ara.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "제공된 URL에서 화면을 가져오는데 실패했습니다. 주소가 잘못되거나, 유효한 Excalidraw JSON 데이터를 포함하고 있지 않은 것일 수 있습니다.",
|
||||
"resetLibrary": "당신의 라이브러리를 초기화 합니다. 계속하시겠습니까?",
|
||||
"removeItemsFromsLibrary": "{{count}}개의 아이템을 라이브러리에서 삭제하시겠습니까?",
|
||||
"invalidEncryptionKey": "암호화 키는 반드시 22글자여야 합니다. 실시간 협업이 비활성화됩니다."
|
||||
"invalidEncryptionKey": "암호화 키는 반드시 22글자여야 합니다. 실시간 협업이 비활성화됩니다.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "지원하지 않는 파일 형식 입니다.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "ناتوانێت دیمەنەکە هاوردە بکات لە URL ی دابینکراو. یان نادروستە، یان داتای \"ئێکسکالیدراو\" JSON ی دروستی تێدا نییە.",
|
||||
"resetLibrary": "ئەمە کتێبخانەکەت خاوێن دەکاتەوە. ئایا دڵنیایت?",
|
||||
"removeItemsFromsLibrary": "سڕینەوەی {{count}} ئایتم(ەکان) لە کتێبخانە؟",
|
||||
"invalidEncryptionKey": "کلیلی رەمزاندن دەبێت لە 22 پیت بێت. هاوکاری ڕاستەوخۆ لە کارخراوە."
|
||||
"invalidEncryptionKey": "کلیلی رەمزاندن دەبێت لە 22 پیت بێت. هاوکاری ڕاستەوخۆ لە کارخراوە.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "جۆری فایلی پشتگیری نەکراو.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Nepavyko suimportuoti scenos iš pateiktos nuorodos (URL). Ji arba blogai suformatuota, arba savyje neturi teisingų Excalidraw JSON duomenų.",
|
||||
"resetLibrary": "Tai išvalys tavo biblioteką. Ar tikrai to nori?",
|
||||
"removeItemsFromsLibrary": "Ištrinti {{count}} elementą/-us iš bibliotekos?",
|
||||
"invalidEncryptionKey": "Šifravimo raktas turi būti iš 22 simbolių. Redagavimas gyvai yra išjungtas."
|
||||
"invalidEncryptionKey": "Šifravimo raktas turi būti iš 22 simbolių. Redagavimas gyvai yra išjungtas.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Nepalaikomas failo tipas.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Nevarēja importēt ainu no norādītā URL. Vai nu tas ir nederīgs, vai nesatur derīgus Excalidraw JSON datus.",
|
||||
"resetLibrary": "Šī funkcija iztukšos bibliotēku. Vai turpināt?",
|
||||
"removeItemsFromsLibrary": "Vai izņemt {{count}} vienumu(s) no bibliotēkas?",
|
||||
"invalidEncryptionKey": "Šifrēšanas atslēgai jābūt 22 simbolus garai. Tiešsaistes sadarbība ir izslēgta."
|
||||
"invalidEncryptionKey": "Šifrēšanas atslēgai jābūt 22 simbolus garai. Tiešsaistes sadarbība ir izslēgta.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Neatbalstīts datnes veids.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "दिलेल्या यू-आर-एल पासून दृश्य आणू शकलो नाही. तो एकतर बरोबार नाही आहे किंवा त्यात वैध एक्सकेलीड्रॉ जेसन डेटा नाही.",
|
||||
"resetLibrary": "पटल स्वच्छ होणार, तुम्हाला खात्री आहे का?",
|
||||
"removeItemsFromsLibrary": "संग्रहातून {{count}} तत्व (एक किव्हा अनेक) काढू?",
|
||||
"invalidEncryptionKey": "कूटबद्धन कुंजी 22 अक्षरांची असणे आवश्यक आहे. थेट सहयोग अक्षम केले आहे."
|
||||
"invalidEncryptionKey": "कूटबद्धन कुंजी 22 अक्षरांची असणे आवश्यक आहे. थेट सहयोग अक्षम केले आहे.",
|
||||
"collabOfflineWarning": "इंटरनेट कनेक्शन उपलब्ध नाही.\nतुमचे बदल जतन केले जाणार नाहीत!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "असमर्थित फाइल प्रकार.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Kunne ikke importere scene fra den oppgitte URL-en. Den er enten ødelagt, eller inneholder ikke gyldig Excalidraw JSON-data.",
|
||||
"resetLibrary": "Dette vil tømme biblioteket ditt. Er du sikker?",
|
||||
"removeItemsFromsLibrary": "Slett {{count}} element(er) fra biblioteket?",
|
||||
"invalidEncryptionKey": "Krypteringsnøkkel må ha 22 tegn. Live-samarbeid er deaktivert."
|
||||
"invalidEncryptionKey": "Krypteringsnøkkel må ha 22 tegn. Live-samarbeid er deaktivert.",
|
||||
"collabOfflineWarning": "Ingen Internett-tilkobling tilgjengelig.\nEndringer dine vil ikke bli lagret!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Filtypen støttes ikke.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Kan scène niet importeren vanuit de opgegeven URL. Het is onjuist of bevat geen geldige Excalidraw JSON-gegevens.",
|
||||
"resetLibrary": "Dit zal je bibliotheek wissen. Weet je het zeker?",
|
||||
"removeItemsFromsLibrary": "Verwijder {{count}} item(s) uit bibliotheek?",
|
||||
"invalidEncryptionKey": "Encryptiesleutel moet 22 tekens zijn. Live samenwerking is uitgeschakeld."
|
||||
"invalidEncryptionKey": "Encryptiesleutel moet 22 tekens zijn. Live samenwerking is uitgeschakeld.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Niet-ondersteund bestandstype.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Kunne ikkje hente noko scene frå den URL-en. Ho er anten øydelagd eller inneheld ikkje gyldig Excalidraw JSON-data.",
|
||||
"resetLibrary": "Dette vil fjerne alt innhald frå biblioteket. Er du sikker?",
|
||||
"removeItemsFromsLibrary": "Slette {{count}} element frå biblioteket?",
|
||||
"invalidEncryptionKey": "Krypteringsnøkkelen må ha 22 teikn. Sanntidssamarbeid er deaktivert."
|
||||
"invalidEncryptionKey": "Krypteringsnøkkelen må ha 22 teikn. Sanntidssamarbeid er deaktivert.",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Filtypen er ikkje støtta.",
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "Importacion impossibla de la scèna a partir de l’URL provesida. Es siá mal formatada o siá conten pas cap de donada JSON Excalidraw valida.",
|
||||
"resetLibrary": "Aquò suprimirà vòstra bibliotèca. O volètz vertadièrament ?",
|
||||
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la bibliotèca ?",
|
||||
"invalidEncryptionKey": "La clau de chiframent deu conténer 22 caractèrs. La collaboracion en dirèct es desactivada."
|
||||
"invalidEncryptionKey": "La clau de chiframent deu conténer 22 caractèrs. La collaboracion en dirèct es desactivada.",
|
||||
"collabOfflineWarning": "Cap de connexion pas disponibla.\nVòstras modificacions seràn pas salvadas !"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipe de fichièr pas pres en carga.",
|
||||
@@ -220,7 +221,7 @@
|
||||
"penMode": "Mòde estilo - empachar lo contact",
|
||||
"link": "Apondre/Actualizar lo ligam per una fòrma seleccionada",
|
||||
"eraser": "Goma",
|
||||
"hand": ""
|
||||
"hand": "Man (aisina de desplaçament de la vista)"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Accions del canabàs",
|
||||
@@ -239,7 +240,7 @@
|
||||
"resize": "Podètz servar las proporcions en mantenent la tòca MAJ pendent lo redimensionament,\nmantenètz la tòca ALT per redimensionar a partir del centre",
|
||||
"resizeImage": "Podètz retalhar liurament en quichant CTRL,\nquichatz ALT per retalhar a partir del centre",
|
||||
"rotate": "Podètz restrénger los angles en mantenent MAJ pendent la rotacion",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_info": "Tenètz quichat Ctrl o Cmd e doble clic o quichatz Ctrl o Cmd + Entrada per modificar los ponches",
|
||||
"lineEditor_pointSelected": "Quichar Suprimir per tirar lo(s) punt(s),\nCtrlOCmd+D per duplicar, o lisatz per desplaçar",
|
||||
"lineEditor_nothingSelected": "Seleccionar un punt d’editar (manténer Maj. per ne seleccionar mantun),\no manténer Alt e clicar per n’apondre de novèls",
|
||||
"placeImage": "Clicatz per plaçar l’imatge, o clicatz e lisatz per definir sa talha manualament",
|
||||
@@ -316,8 +317,8 @@
|
||||
"zoomToFit": "Zoomar per veire totes los elements",
|
||||
"zoomToSelection": "Zoomar la seleccion",
|
||||
"toggleElementLock": "Verrolhar/Desverrolhar la seleccion",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
"movePageUpDown": "Desplaçar la pagina ennaut/enbàs",
|
||||
"movePageLeftRight": "Desplaçar la pagina a esquèrra/drecha"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Escafar canabàs"
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"invalidSceneUrl": "ਦਿੱਤੀ ਗਈ URL 'ਚੋਂ ਦ੍ਰਿਸ਼ ਨੂੰ ਆਯਾਤ ਨਹੀਂ ਕਰ ਸਕੇ। ਇਹ ਜਾਂ ਤਾਂ ਖਰਾਬ ਹੈ, ਜਾਂ ਇਸ ਵਿੱਚ ਜਾਇਜ਼ Excalidraw JSON ਡਾਟਾ ਸ਼ਾਮਲ ਨਹੀਂ ਹੈ।",
|
||||
"resetLibrary": "ਇਹ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਸਾਫ ਕਰ ਦੇਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
|
||||
"removeItemsFromsLibrary": "ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ {{count}} ਚੀਜ਼(-ਜ਼ਾਂ) ਮਿਟਾਉਣੀਆਂ ਹਨ?",
|
||||
"invalidEncryptionKey": ""
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user