Compare commits

...
Author SHA1 Message Date
Ryan Di d308451584 fix offsets 2024-10-25 11:28:01 +08:00
dwelle f444383b46 fix: image cropping svg + compat mode 2024-10-25 00:04:04 +02:00
Milos VetesnikandGitHub 7d52176fea feat: added sitemap & fixed robot txt (#8699) 2024-10-24 12:49:33 +02:00
David LuzarandGitHub 958e03fcc6 fix: image render perf (#8697) 2024-10-23 11:58:43 +02:00
David LuzarandGitHub 4cedf3d966 feat: do not strip unknown element properties on restore (#8682) 2024-10-21 22:56:22 +02:00
e957c8e9ee feat: image cropping (#8613)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-10-21 22:26:52 +02:00
eb09b48ae6 fix: undo/redo action for international keyboard layouts (#8649)
Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
2024-10-21 17:08:39 +02:00
Marcel MrazandGitHub 61623bbeba fix: Comic Shanns issues, new fonts structure (#8641) 2024-10-21 00:11:53 +02:00
David LuzarandGitHub 15ca182333 fix: remove export-to-clip-as-svg shortcut for now (#8660) 2024-10-17 20:47:05 +02:00
Marcel MrazandGitHub b479f3bd65 feat: add first-class support for CJK (#8530) 2024-10-17 20:14:17 +02:00
Mathis BeauvilleandGitHub 21815fb930 fix: text disappearing on edit (#8558) (#8624) 2024-10-17 13:11:48 +02:00
David LuzarandGitHub 47ee8a0094 refactor: point() -> pointFrom() to fix compiler issue (#8578) 2024-10-01 21:27:17 +02:00
a977dd1bf5 feat: Added reddit links as embeddable (#8099)
feat: #8063 Added reddit links as embeddable

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2024-09-28 11:49:18 +05:30
Aakansha DoshiandGitHub 3fe1883f3f feat: prefer user defined coords and dimensions over calculated for for frame (#8517)
* feat: prefer user defined coords and dimensions over calculated for frame

* update changelog

* lint

* show the info only in dev mode and when children present
2024-09-24 21:09:15 +05:30
Marcel MrazandGitHub a80cb5896a feat: self-hosting existing google fonts (#8540) 2024-09-24 17:30:21 +02:00
David LuzarandGitHub 6dfa18414a test: decrease min coverage thresholds (#8541) 2024-09-24 12:01:28 +00:00
8ca4cf3260 feat: flip arrowheads if only arrow(s) selected (#8525)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2024-09-19 15:46:36 +02:00
Márk TolmácsandGitHub f3f0ab7c83 fix: Elbow arrow fixedpoint flipping now properly flips on inverted resize and flip action (#8324)
* Flipping action now properly mirrors selections with elbow arrows
* Flipping action now re-centers the selection to the original center to avoid "walking" selections on repeated flipping
2024-09-19 08:47:23 +02:00
David LuzarandGitHub 44a1c8d857 fix: svg and png frame clipping cases (#8515) 2024-09-18 00:20:22 +02:00
Márk TolmácsandGitHub e0a22edfbd fix: Re-route elbow arrows when pasted (#8448)
Re-route elbow arrows when pasted
2024-09-17 12:20:40 +02:00
Márk TolmácsandGitHub c07f5a0c80 feat: Common elbow mid segments (#8440)
Common start or end segment length for elbow arrows regardless of arrowhead is present
2024-09-17 10:11:07 +02:00
David LuzarandGitHub 508f16dc04 refactor: rename example App.tsx -> ExampleApp.tsx (#8501) 2024-09-13 16:56:32 +02:00
c1b310c56b fix: Buffer dependency (#8474)
* fix Buffer dependency

* moved to encode.ts

* move base64 parsing out

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-12 15:48:47 +02:00
zsviczianandGitHub d4900e8f19 fix: Linear element complete button disabled (#8492) 2024-09-12 14:59:38 +02:00
zsviczianandGitHub caf2db934c fix: aspect ratio of distorted images are not preserved in SVG exports (#8061) 2024-09-12 14:11:08 +02:00
zsviczianandGitHub 60e3801691 fix: WYSIWYG editor padding is not normalized with zoom.value (#8481) 2024-09-12 13:42:39 +02:00
David LuzarandGitHub 80f3b75d42 chore: revert vite 5.4.2 -> 5.0.12 (#8499) 2024-09-12 12:56:45 +02:00
hocinoandGitHub dc812bee19 docs: replace dead link (#8494)
* docs update dead link on main-menu page

* doc: fix dead link
2024-09-12 13:20:18 +05:30
David LuzarandGitHub 01e83cc9a5 fix: default sidebar triggers & behavior (#8498) 2024-09-11 20:19:14 +02:00
David LuzarandGitHub 813f9b702e feat: merge search sidebar back to default sidebar (#8497) 2024-09-11 19:26:01 +02:00
David LuzarandGitHub fd39712ba6 fix: improve canvas search scroll behavior further (#8491) 2024-09-11 18:01:18 +02:00
zsviczianandGitHub b46ca0192b fix: addFiles clears the whole image cache when each file is added - regression from #8471 (#8490)
Update App.tsx
2024-09-11 07:57:41 +02:00
David LuzarandGitHub 72b7c937b1 feat: smarter zooming when scrolling to match & only match on search/switch (#8488) 2024-09-10 21:26:01 +02:00
David LuzarandGitHub d107215564 fix: select instead of focus search input (#8483) 2024-09-09 19:57:22 +02:00
6959a363f0 feat: canvas search (#8438)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-09 17:12:07 +02:00
Abhishek MehandirattaandGitHub 5a11c70714 fix: image rendering issue when passed in initialData (#8471) 2024-09-08 23:56:00 +02:00
Aakansha DoshiandGitHub 6ff56c36e3 fix: add partial mocking (#8473)
* fix: add partial mocking

* lint

* Update packages/utils/export.test.ts
2024-09-06 16:41:37 +05:30
Aakansha DoshiandGitHub 51ea184938 build: upgrade vite to 5.4.x, vitest to 2.x and related vite packages (#8459)
* build: upgrade vite to 5.x, vitest to 2.x and related vite packages

* upgrade vitest-ui and coverage

* pass empty set to fix type error and update snap

* set ignoreEmptyLines to false

* update threshold

* update coverage threshold

* downgrade vite-plugin-pwa as its better to push separately with testing

* add package resolutions for strip-ansi, string-width and wrap-ansi

* disable pwa

* only add resolution for strip-ansi
2024-09-05 18:35:36 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
21fff26d31 build(deps): bump micromatch from 4.0.5 to 4.0.8 in /dev-docs (#8458)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-04 15:46:37 +05:30
425 changed files with 22942 additions and 2919 deletions
+1
View File
@@ -8,3 +8,4 @@ public/workbox
packages/excalidraw/types packages/excalidraw/types
examples/**/public examples/**/public
dev-dist dev-dist
coverage
@@ -31,7 +31,7 @@ The welcome screen consists of two main groups of subcomponents:
<img <img
src={require("@site/static/img/welcome-screen-overview.png").default} src={require("@site/static/img/welcome-screen-overview.png").default}
alt="Excalidraw logo: Sketch handrawn like diagrams." alt="Excalidraw logo: Sketch hand-drawn like diagrams."
/> />
### Center ### Center
@@ -12,7 +12,7 @@ import { FONT_FAMILY } from "@excalidraw/excalidraw";
| Font Family | Description | | Font Family | Description |
| ----------- | ---------------------- | | ----------- | ---------------------- |
| `Virgil` | The `handwritten` font | | `Virgil` | The `Hand-drawn` font |
| `Helvetica` | The `Normal` Font | | `Helvetica` | The `Normal` Font |
| `Cascadia` | The `Code` Font | | `Cascadia` | The `Code` Font |
@@ -20,7 +20,7 @@ exportToCanvas(&#123;<br/>&nbsp;
getDimensions,<br/>&nbsp; getDimensions,<br/>&nbsp;
files,<br/>&nbsp; files,<br/>&nbsp;
exportPadding?: number;<br/> exportPadding?: number;<br/>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a> &#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
</pre> </pre>
| Name | Type | Default | Description | | Name | Type | Default | Description |
+41 -22
View File
@@ -1547,7 +1547,7 @@
"@docusaurus/theme-search-algolia" "2.2.0" "@docusaurus/theme-search-algolia" "2.2.0"
"@docusaurus/types" "2.2.0" "@docusaurus/types" "2.2.0"
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2": "@docusaurus/react-loadable@5.5.2":
version "5.5.2" version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0" balanced-match "^1.0.0"
concat-map "0.0.1" concat-map "0.0.1"
braces@^3.0.2, braces@~3.0.2: braces@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.1.1"
braces@~3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
dependencies: dependencies:
to-regex-range "^5.0.1" to-regex-range "^5.0.1"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
finalhandler@1.2.0: finalhandler@1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
@@ -5207,11 +5221,11 @@ methods@~1.1.2:
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5" version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies: dependencies:
braces "^3.0.2" braces "^3.0.3"
picomatch "^2.3.1" picomatch "^2.3.1"
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
@@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
strip-ansi "^6.0.1" strip-ansi "^6.0.1"
text-table "^0.2.0" text-table "^0.2.0"
react-dom@^17.0.2: react-dom@18.2.0:
version "17.0.2" version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" scheduler "^0.23.0"
scheduler "^0.20.2"
react-error-overlay@^6.0.11: react-error-overlay@^6.0.11:
version "6.0.11" version "6.0.11"
@@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
dependencies: dependencies:
"@babel/runtime" "^7.10.3" "@babel/runtime" "^7.10.3"
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
dependencies:
"@types/react" "*"
prop-types "^15.6.2"
react-router-config@^5.1.1: react-router-config@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
@@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0" use-composed-ref "^1.3.0"
use-latest "^1.2.1" use-latest "^1.2.1"
react@^17.0.2: react@18.2.0:
version "17.0.2" version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1: readable-stream@^2.0.1:
version "2.3.7" version "2.3.7"
@@ -6664,13 +6684,12 @@ sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.20.2: scheduler@^0.23.0:
version "0.20.2" version "0.23.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@2.7.0: schema-utils@2.7.0:
version "2.7.0" version "2.7.0"
@@ -40,7 +40,7 @@ import type {
} from "@excalidraw/excalidraw/dist/excalidraw/element/types"; } from "@excalidraw/excalidraw/dist/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types"; import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
import "./App.scss"; import "./ExampleApp.scss";
type Comment = { type Comment = {
x: number; x: number;
@@ -73,7 +73,7 @@ export interface AppProps {
excalidrawLib: typeof TExcalidraw; excalidrawLib: typeof TExcalidraw;
} }
export default function App({ export default function ExampleApp({
appTitle, appTitle,
useCustom, useCustom,
customArgs, customArgs,
+1 -1
View File
@@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
# copied assets # copied assets
public/*.woff2 public/**/*.woff2
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", "copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"dev": "yarn build:workspace && next dev -p 3005", "dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build", "build": "yarn build:workspace && next build",
"start": "next start -p 3006", "start": "next start -p 3006",
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as excalidrawLib from "@excalidraw/excalidraw"; import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../components/App"; import App from "../../components/ExampleApp";
import "@excalidraw/excalidraw/index.css"; import "@excalidraw/excalidraw/index.css";
@@ -1,2 +1,2 @@
# copied assets # copied assets
public/*.woff2 public/**/*.woff2
@@ -1,4 +1,4 @@
import App from "../components/App"; import App from "../components/ExampleApp";
import React, { StrictMode } from "react"; import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
@@ -13,7 +13,7 @@
}, },
"scripts": { "scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets", "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public", "copy:assets": "cp -r ../../../packages/excalidraw/dist/prod/fonts ./public",
"start": "yarn build:workspace && vite", "start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build", "build": "yarn build:workspace && vite build",
"build:preview": "yarn build && vite preview --port 5002" "build:preview": "yarn build && vite preview --port 5002"
+6 -1
View File
@@ -649,7 +649,12 @@ const ExcalidrawWrapper = () => {
// Render the debug scene if the debug canvas is available // Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) { if (debugCanvasRef.current && excalidrawAPI) {
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio); debugRenderer(
debugCanvasRef.current,
appState,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
} }
}; };
@@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
/> />
)} )}
<MainMenu.DefaultItems.CommandPalette className="highlighted" /> <MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator /> <MainMenu.Separator />
+12 -2
View File
@@ -68,12 +68,17 @@ const _debugRenderer = (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
scale: number, scale: number,
refresh: () => void,
) => { ) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas, canvas,
scale, scale,
); );
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
scale, scale,
@@ -138,8 +143,13 @@ export const saveDebugState = (debug: { enabled: boolean }) => {
}; };
export const debugRenderer = throttleRAF( export const debugRenderer = throttleRAF(
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => { (
_debugRenderer(canvas, appState, scale); canvas: HTMLCanvasElement,
appState: AppState,
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
}, },
{ trailing: true }, { trailing: true },
); );
+14 -1
View File
@@ -20,6 +20,10 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
} from "../../packages/excalidraw/constants";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library"; import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types"; import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
appState: AppState, appState: AppState,
) => { ) => {
try { try {
const _appState = clearAppStateForLocalStorage(appState);
if (
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
_appState.openSidebar = null;
}
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)), JSON.stringify(clearElementsForLocalStorage(elements)),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)), JSON.stringify(_appState),
); );
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) { } catch (error: any) {
+1 -10
View File
@@ -130,19 +130,10 @@
</script> </script>
<% } %> <% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Register Assistant as the UI font, before the scene inits --> <!-- Register Assistant as the UI font, before the scene inits -->
<link <link
rel="stylesheet" rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css" href="../packages/excalidraw/fonts/fonts.css"
type="text/css" type="text/css"
/> />
+9 -6
View File
@@ -26,17 +26,17 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"jotai": "1.13.1", "jotai": "1.13.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"vite-plugin-html": "3.2.2", "socket.io-client": "4.7.2",
"@excalidraw/random-username": "1.0.0", "vite-plugin-html": "3.2.2"
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
}, },
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
@@ -49,5 +49,8 @@
"start:production": "yarn build && yarn serve", "start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o", "serve": "npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000" "build:preview": "yarn build && vite preview --port 5000"
},
"devDependencies": {
"vite-plugin-sitemap": "0.7.1"
} }
} }
+28 -9
View File
@@ -5,6 +5,7 @@ import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker"; import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html"; import { createHtmlPlugin } from "vite-plugin-html";
import Sitemap from "vite-plugin-sitemap";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins"; import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables // To load .env.local variables
@@ -25,8 +26,8 @@ export default defineConfig({
output: { output: {
assetFileNames(chunkInfo) { assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) { if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path const family = chunkInfo.name.split("-")[0];
return "[name]-[hash][extname]"; return `fonts/${family}/[name][extname]`;
} }
return "assets/[name]-[hash][extname]"; return "assets/[name]-[hash][extname]";
@@ -48,8 +49,17 @@ export default defineConfig({
}, },
}, },
sourcemap: true, sourcemap: true,
// don't auto-inline small assets (i.e. fonts hosted on CDN)
assetsInlineLimit: 0,
}, },
plugins: [ plugins: [
Sitemap({
hostname: "https://excalidraw.com",
outDir: "build",
changefreq: "monthly",
// its static in public folder
generateRobotsTxt: false,
}),
woff2BrowserPlugin(), woff2BrowserPlugin(),
react(), react(),
checker({ checker({
@@ -73,17 +83,26 @@ export default defineConfig({
}, },
workbox: { workbox: {
// Don't push fonts, locales and wasm to app precache // don't precache fonts, locales and separate chunks
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"], globIgnores: [
"fonts.css",
"**/locales/**",
"service-worker.js",
"**/*.chunk-*.js",
],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"), urlPattern: new RegExp(".+.woff2"),
handler: "CacheFirst", handler: "CacheFirst",
options: { options: {
cacheName: "fonts", cacheName: "fonts",
expiration: { expiration: {
maxEntries: 50, maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
},
cacheableResponse: {
// 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
statuses: [0, 200],
}, },
}, },
}, },
@@ -109,10 +128,10 @@ export default defineConfig({
}, },
}, },
{ {
urlPattern: new RegExp(".wasm-.+.js"), urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst", handler: "CacheFirst",
options: { options: {
cacheName: "wasm", cacheName: "chunk",
expiration: { expiration: {
maxEntries: 50, maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
+8 -7
View File
@@ -21,8 +21,8 @@
"@types/react-dom": "18.2.0", "@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0", "@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0", "@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0", "@vitest/coverage-v8": "2.0.5",
"@vitest/ui": "0.32.2", "@vitest/ui": "2.0.5",
"chai": "4.3.6", "chai": "4.3.6",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
@@ -37,12 +37,12 @@
"rewire": "6.0.0", "rewire": "6.0.0",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-checker": "0.6.1", "vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0", "vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4", "vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0", "vite-plugin-svgr": "4.2.0",
"vitest": "1.6.0", "vitest": "2.0.5",
"vitest-canvas-mock": "0.3.2" "vitest-canvas-mock": "0.3.3"
}, },
"engines": { "engines": {
"node": "18.0.0 - 20.x.x" "node": "18.0.0 - 20.x.x"
@@ -83,6 +83,7 @@
"clean-install": "yarn rm:node_modules && yarn install" "clean-install": "yarn rm:node_modules && yarn install"
}, },
"resolutions": { "resolutions": {
"@types/react": "18.2.0" "@types/react": "18.2.0",
"strip-ansi": "6.0.1"
} }
} }
+4
View File
@@ -15,6 +15,10 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- Added hand-drawn font for Chinese, Japanese and Korean (CJK) as a fallback for Excalifont. Improved overal text wrapping algorithm, not only accounting for CJK, but covering various edge cases with white spaces and text-align center/right. Added support for multi-codepoint emojis wrapping. Offloaded SVG export to Web Workers, with an automatic fallback to the main thread if not supported or not desired.[#8530](https://github.com/excalidraw/excalidraw/pull/8530)
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135) - `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
+58 -51
View File
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import type { AppState } from "../types"; import type { AppState, Offsets } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds"; import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { clamp } from "../../math"; import { clamp, roundToStep } from "../../math";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -259,89 +259,85 @@ const zoomValueToFitBoundsOnViewport = (
const adjustedZoomValue = const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1); smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps = return Math.min(adjustedZoomValue, 1);
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
}; };
export const zoomToFitBounds = ({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
canvasOffsets,
fitToViewport = false, fitToViewport = false,
viewportZoomFactor = 1, viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
}: { }: {
bounds: SceneBounds; bounds: SceneBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>; appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */ /** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean; fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */ /** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => { }) => {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2; const centerY = (y1 + y2) / 2;
let newZoomValue; const canvasOffsetLeft = canvasOffsets?.left ?? 0;
let scrollX; const canvasOffsetTop = canvasOffsets?.top ?? 0;
let scrollY; const canvasOffsetRight = canvasOffsets?.right ?? 0;
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
const effectiveCanvasWidth =
appState.width - canvasOffsetLeft - canvasOffsetRight;
const effectiveCanvasHeight =
appState.height - canvasOffsetTop - canvasOffsetBottom;
let adjustedZoomValue;
if (fitToViewport) { if (fitToViewport) {
const commonBoundsWidth = x2 - x1; const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1; const commonBoundsHeight = y2 - y1;
newZoomValue = adjustedZoomValue =
Math.min( Math.min(
appState.width / commonBoundsWidth, effectiveCanvasWidth / commonBoundsWidth,
appState.height / commonBoundsHeight, effectiveCanvasHeight / commonBoundsHeight,
) * clamp(viewportZoomFactor, 0.1, 1); ) * viewportZoomFactor;
newZoomValue = getNormalizedZoom(newZoomValue);
let appStateWidth = appState.width;
if (appState.openSidebar) {
const sidebarDOMElem = document.querySelector(
".sidebar",
) as HTMLElement | null;
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
appStateWidth = !isRTL
? appState.width - sidebarWidth
: appState.width + sidebarWidth;
}
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else { } else {
newZoomValue = zoomValueToFitBoundsOnViewport( adjustedZoomValue = zoomValueToFitBoundsOnViewport(
bounds, bounds,
{ {
width: appState.width, width: effectiveCanvasWidth,
height: appState.height, height: effectiveCanvasHeight,
}, },
viewportZoomFactor, viewportZoomFactor,
); );
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
} }
const newZoomValue = getNormalizedZoom(
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
);
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
offsets: canvasOffsets,
zoom: { value: newZoomValue },
});
return { return {
appState: { appState: {
...appState, ...appState,
scrollX, scrollX: centerScroll.scrollX,
scrollY, scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue }, zoom: { value: newZoomValue },
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
@@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
}; };
export const zoomToFit = ({ export const zoomToFit = ({
canvasOffsets,
targetElements, targetElements,
appState, appState,
fitToViewport, fitToViewport,
viewportZoomFactor, viewportZoomFactor,
minZoom,
maxZoom,
}: { }: {
canvasOffsets?: Offsets;
targetElements: readonly ExcalidrawElement[]; targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>; appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */ /** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean; fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */ /** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => { }) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
return zoomToFitBounds({ return zoomToFitBounds({
canvasOffsets,
bounds: commonBounds, bounds: commonBounds,
appState, appState,
fitToViewport, fitToViewport,
viewportZoomFactor, viewportZoomFactor,
minZoom,
maxZoom,
}); });
}; };
@@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
// NOTE shift-2 should have been assigned actionZoomToFitSelection. // NOTE shift-2 should have been assigned actionZoomToFitSelection.
@@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: true, fitToViewport: true,
viewportZoomFactor: 0.7, canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
// NOTE this action should use shift-2 per figma, alas // NOTE this action should use shift-2 per figma, alas
@@ -430,7 +436,7 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon, icon: zoomAreaIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => perform: (elements, appState, _, app) =>
zoomToFit({ zoomToFit({
targetElements: elements, targetElements: elements,
appState: { appState: {
@@ -438,6 +444,7 @@ export const actionZoomToFit = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}), }),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
@@ -147,14 +147,32 @@ export const actionCopyAsSvg = register({
name: app.getName(), name: app.getName(),
}, },
); );
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
return { return {
appState: {
toast: {
message: t("toast.copyToClipboardAsSvg", {
exportSelection: selectedElements.length
? t("toast.selection")
: t("toast.canvas"),
exportColorScheme: appState.exportWithDarkMode
? t("buttons.darkMode")
: t("buttons.lightMode"),
}),
},
},
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
}; };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
return { return {
appState: { appState: {
...appState,
errorMessage: error.message, errorMessage: error.message,
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
@@ -0,0 +1,55 @@
import { register } from "./register";
import { cropIcon } from "../components/icons";
import { StoreAction } from "../store";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { isImageElement } from "../element/typeChecks";
import type { ExcalidrawImageElement } from "../element/types";
export const actionToggleCropEditor = register({
name: "cropEditor",
label: "helpDialog.cropStart",
icon: cropIcon,
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["image", "crop"],
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawImageElement;
return {
appState: {
...appState,
isCropping: false,
croppingElementId: selectedElement.id,
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.croppingElementId &&
selectedElements.length === 1 &&
isImageElement(selectedElements[0])
) {
return true;
}
return false;
},
PanelComponent: ({ appState, updateData, app }) => {
const label = t("helpDialog.cropStart");
return (
<ToolButton
type="button"
icon={cropIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});
@@ -15,7 +15,7 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { point } from "../../math"; import { pointFrom } from "../../math";
import { isPathALoop } from "../shapes"; import { isPathALoop } from "../shapes";
export const actionFinalize = register({ export const actionFinalize = register({
@@ -115,7 +115,7 @@ export const actionFinalize = register({
mutateElement(multiPointElement, { mutateElement(multiPointElement, {
points: linePoints.map((p, index) => points: linePoints.map((p, index) =>
index === linePoints.length - 1 index === linePoints.length - 1
? point(firstPoint[0], firstPoint[1]) ? pointFrom(firstPoint[0], firstPoint[1])
: p, : p,
), ),
}); });
@@ -217,6 +217,7 @@ export const actionFinalize = register({
onClick={updateData} onClick={updateData}
visible={appState.multiElement != null} visible={appState.multiElement != null}
size={data?.size || "medium"} size={data?.size || "medium"}
style={{ pointerEvents: "all" }}
/> />
), ),
}); });
@@ -0,0 +1,211 @@
import React from "react";
import { Excalidraw } from "../index";
import { render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { pointFrom } from "../../math";
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window;
describe("flipping re-centers selection", () => {
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
const elements = [
API.createElement({
type: "rectangle",
id: "rec1",
x: 100,
y: 100,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "rectangle",
id: "rec2",
x: 220,
y: 250,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "arrow",
id: "arr",
x: 149.9,
y: 95,
width: 156,
height: 239.9,
startBinding: {
elementId: "rec1",
focus: 0,
gap: 5,
fixedPoint: [0.49, -0.05],
},
endBinding: {
elementId: "rec2",
focus: 0,
gap: 5,
fixedPoint: [-0.05, 0.49],
},
startArrowhead: null,
endArrowhead: "arrow",
points: [
pointFrom(0, 0),
pointFrom(0, -35),
pointFrom(-90.9, -35),
pointFrom(-90.9, 204.9),
pointFrom(65.1, 204.9),
],
elbowed: true,
}),
];
await render(<Excalidraw initialData={{ elements }} />);
API.setSelectedElements(elements);
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(100);
const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(250);
});
});
describe("flipping arrowheads", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
});
it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const rect2 = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
endBinding: {
elementId: rect2.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, rect2, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("circle");
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping unbound arrow shouldn't flip arrowheads", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
});
API.setElements([arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([rect, arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
});
});
+69 -2
View File
@@ -2,6 +2,8 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
NonDeleted, NonDeleted,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
@@ -18,7 +20,13 @@ import {
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons"; import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { isLinearElement } from "../element/typeChecks"; import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@@ -109,7 +117,23 @@ const flipElements = (
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
app: AppClassProperties, app: AppClassProperties,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); if (
selectedElements.every(
(element) =>
isArrowElement(element) && (element.startBinding || element.endBinding),
)
) {
return selectedElements.map((element) => {
const _element = element as ExcalidrawArrowElement;
return newElementWith(_element, {
startArrowhead: _element.endArrowhead,
endArrowhead: _element.startArrowhead,
});
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
resizeMultipleElements( resizeMultipleElements(
elementsMap, elementsMap,
@@ -131,5 +155,48 @@ const flipElements = (
[], [],
); );
// ---------------------------------------------------------------------------
// flipping arrow elements (and potentially other) makes the selection group
// "move" across the canvas because of how arrows can bump against the "wall"
// of the selection, so we need to center the group back to the original
// position so that repeated flips don't accumulate the offset
const { elbowArrows, otherElements } = selectedElements.reduce(
(
acc: {
elbowArrows: ExcalidrawElbowArrowElement[];
otherElements: ExcalidrawElement[];
},
element,
) =>
isElbowArrow(element)
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
: { ...acc, otherElements: acc.otherElements.concat(element) },
{ elbowArrows: [], otherElements: [] },
);
const { midX: newMidX, midY: newMidY } =
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
),
);
// ---------------------------------------------------------------------------
return selectedElements; return selectedElements;
}; };
@@ -5,7 +5,7 @@ import { t } from "../i18n";
import type { History } from "../history"; import type { History } from "../history";
import { HistoryChangedEvent } from "../history"; import { HistoryChangedEvent } from "../history";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { KEYS } from "../keys"; import { KEYS, matchKey } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { isWindows } from "../constants"; import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types"; import type { SceneElementsMap } from "../element/types";
@@ -63,9 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
), ),
), ),
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>( const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -104,10 +102,8 @@ export const createRedoAction: ActionCreator = (history, store) => ({
), ),
), ),
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
event.shiftKey && (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter( const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter, history.onHistoryChangedEmitter,
@@ -116,7 +116,7 @@ import {
import { mutateElbowArrow } from "../element/routing"; import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { point, vector } from "../../math"; import { pointFrom, vector } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -1651,7 +1651,7 @@ export const actionChangeArrowType = register({
elementsMap, elementsMap,
[finalStartPoint, finalEndPoint].map( [finalStartPoint, finalEndPoint].map(
(p): LocalPoint => (p): LocalPoint =>
point(p[0] - newElement.x, p[1] - newElement.y), pointFrom(p[0] - newElement.x, p[1] - newElement.y),
), ),
vector(0, 0), vector(0, 0),
{ {
@@ -1685,19 +1685,6 @@ export const actionChangeArrowType = register({
: {}), : {}),
}, },
); );
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
} }
return newElement; return newElement;
@@ -0,0 +1,55 @@
import { KEYS } from "../keys";
import { register } from "./register";
import type { AppState } from "../types";
import { searchIcon } from "../components/icons";
import { StoreAction } from "../store";
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
export const actionToggleSearchMenu = register({
name: "searchMenu",
icon: searchIcon,
keywords: ["search", "find"],
label: "search.title",
viewMode: true,
trackEvent: {
category: "search_menu",
action: "toggle",
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState, _, app) {
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
const searchInput =
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
if (searchInput?.matches(":focus")) {
return {
appState: { ...appState, openSidebar: null },
storeAction: StoreAction.NONE,
};
}
searchInput?.focus();
searchInput?.select();
return false;
}
return {
appState: {
...appState,
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
openDialog: null,
},
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
});
+4
View File
@@ -86,3 +86,7 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "./actionLink"; export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
export { actionToggleCropEditor } from "./actionCropEditor";
+3 -3
View File
@@ -23,7 +23,6 @@ export type ShortcutName =
| "sendToBack" | "sendToBack"
| "bringToFront" | "bringToFront"
| "copyAsPng" | "copyAsPng"
| "copyAsSvg"
| "group" | "group"
| "ungroup" | "ungroup"
| "gridMode" | "gridMode"
@@ -51,7 +50,8 @@ export type ShortcutName =
> >
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
| "commandPalette"; | "commandPalette"
| "searchMenu";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -87,7 +87,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
: getShortcutKey("CtrlOrCmd+Shift+]"), : getShortcutKey("CtrlOrCmd+Shift+]"),
], ],
copyAsPng: [getShortcutKey("Shift+Alt+C")], copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")], group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")], ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")], gridMode: [getShortcutKey("CtrlOrCmd+'")],
@@ -112,6 +111,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+6 -7
View File
@@ -10,7 +10,6 @@ import type {
BinaryFiles, BinaryFiles,
UIAppState, UIAppState,
} from "../types"; } from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store"; import type { StoreActionType } from "../store";
export type ActionSource = export type ActionSource =
@@ -24,10 +23,7 @@ export type ActionSource =
export type ActionResult = export type ActionResult =
| { | {
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: MarkOptional< appState?: Partial<AppState> | null;
AppState,
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null; files?: BinaryFiles | null;
storeAction: StoreActionType; storeAction: StoreActionType;
replaceFiles?: boolean; replaceFiles?: boolean;
@@ -137,7 +133,9 @@ export type ActionName =
| "wrapTextInContainer" | "wrapTextInContainer"
| "commandPalette" | "commandPalette"
| "autoResize" | "autoResize"
| "elementStats"; | "elementStats"
| "searchMenu"
| "cropEditor";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -191,7 +189,8 @@ export interface Action {
| "history" | "history"
| "menu" | "menu"
| "collab" | "collab"
| "hyperlink"; | "hyperlink"
| "search_menu";
action?: string; action?: string;
predicate?: ( predicate?: (
appState: Readonly<AppState>, appState: Readonly<AppState>,
+6
View File
@@ -116,6 +116,9 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
userToFollow: null, userToFollow: null,
followedBy: new Set(), followedBy: new Set(),
isCropping: false,
croppingElementId: null,
searchMatches: [],
}; };
}; };
@@ -236,6 +239,9 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false }, objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false }, userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+32 -1
View File
@@ -17,13 +17,16 @@ import {
hasBoundTextElement, hasBoundTextElement,
isBindableElement, isBindableElement,
isBoundToContainer, isBoundToContainer,
isImageElement,
isTextElement, isTextElement,
} from "./element/typeChecks"; } from "./element/typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
Ordered,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap, SceneElementsMap,
} from "./element/types"; } from "./element/types";
@@ -626,6 +629,18 @@ export class AppStateChange implements Change<AppState> {
); );
break; break;
case "croppingElementId": {
const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
break;
}
case "editingGroupId": case "editingGroupId":
const editingGroupId = nextAppState[key]; const editingGroupId = nextAppState[key];
@@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
selectedElementIds, selectedElementIds,
editingLinearElementId, editingLinearElementId,
selectedLinearElementId, selectedLinearElementId,
croppingElementId,
...standaloneProps ...standaloneProps
} = delta as ObservedAppState; } = delta as ObservedAppState;
@@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
} }
} }
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">; type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
ElementUpdate<Ordered<T>>,
"seed"
>;
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements change is a low level primitive to capture a change between two sets of elements.
@@ -1216,6 +1235,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
}); });
} }
if (isImageElement(element)) {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) { if (!flags.containsVisibleDifference) {
// strip away fractional as even if it would be different, it doesn't have to result in visible change // strip away fractional as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial; const { index, ...rest } = directlyApplicablePartial;
+5 -5
View File
@@ -1,5 +1,5 @@
import type { Radians } from "../math"; import type { Radians } from "../math";
import { point } from "../math"; import { pointFrom } from "../math";
import { import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX, DEFAULT_CHART_COLOR_INDEX,
@@ -260,7 +260,7 @@ const chartLines = (
x, x,
y, y,
width: chartWidth, width: chartWidth,
points: [point(0, 0), point(chartWidth, 0)], points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
}); });
const yLine = newLinearElement({ const yLine = newLinearElement({
@@ -271,7 +271,7 @@ const chartLines = (
x, x,
y, y,
height: chartHeight, height: chartHeight,
points: [point(0, 0), point(0, -chartHeight)], points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
}); });
const maxLine = newLinearElement({ const maxLine = newLinearElement({
@@ -284,7 +284,7 @@ const chartLines = (
strokeStyle: "dotted", strokeStyle: "dotted",
width: chartWidth, width: chartWidth,
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [point(0, 0), point(chartWidth, 0)], points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
}); });
return [xLine, yLine, maxLine]; return [xLine, yLine, maxLine];
@@ -441,7 +441,7 @@ const chartTypeLine = (
height: cy, height: cy,
strokeStyle: "dotted", strokeStyle: "dotted",
opacity: GRID_OPACITY, opacity: GRID_OPACITY,
points: [point(0, 0), point(0, cy)], points: [pointFrom(0, 0), pointFrom(0, cy)],
}); });
}); });
@@ -26,6 +26,7 @@ import { trackEvent } from "../analytics";
import { import {
hasBoundTextElement, hasBoundTextElement,
isElbowArrow, isElbowArrow,
isImageElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
@@ -127,6 +128,11 @@ export const SelectedShapeActions = ({
isLinearElement(targetElements[0]) && isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]); !isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> <div>
@@ -245,6 +251,7 @@ export const SelectedShapeActions = ({
{renderAction("group")} {renderAction("group")}
{renderAction("ungroup")} {renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")} {showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
{showLineEditorAction && renderAction("toggleLinearEditor")} {showLineEditorAction && renderAction("toggleLinearEditor")}
</div> </div>
</fieldset> </fieldset>
File diff suppressed because it is too large Load Diff
@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes"; import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions"; import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback"; import { useStableCallback } from "../../hooks/useStableCallback";
import { actionClearCanvas, actionLink } from "../../actions"; import {
actionClearCanvas,
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { jotaiStore } from "../../jotai"; import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types"; import type { CommandPaletteItem } from "./types";
@@ -275,6 +279,7 @@ function CommandPaletteInner({
actionManager.actions.increaseFontSize, actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize, actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor, actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink, actionLink,
].map((action: Action) => ].map((action: Action) =>
actionToCommand( actionToCommand(
@@ -382,6 +387,15 @@ function CommandPaletteInner({
} }
}, },
}, },
{
label: t("search.title"),
category: DEFAULT_CATEGORIES.app,
icon: searchIcon,
viewMode: true,
perform: () => {
actionManager.executeAction(actionToggleSearchMenu);
},
},
{ {
label: t("labels.changeStroke"), label: t("labels.changeStroke"),
keywords: ["color", "outline"], keywords: ["color", "outline"],
@@ -1,8 +1,11 @@
import clsx from "clsx"; import clsx from "clsx";
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants"; import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState"; import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import type { MarkOptional, Merge } from "../utility-types"; import type { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils"; import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App"; import { useExcalidrawSetAppState } from "./App";
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu"; import { LibraryMenu } from "./LibraryMenu";
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common"; import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar"; import { Sidebar } from "./Sidebar/Sidebar";
import "../components/dropdownMenu/DropdownMenu.scss";
import { SearchMenu } from "./SearchMenu";
import { LibraryIcon, searchIcon } from "./icons";
const DefaultSidebarTrigger = withInternalFallback( const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger", "DefaultSidebarTrigger",
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
); );
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger"; DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({ const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels(); const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return ( return (
<DefaultSidebarTabTriggersTunnel.In> <DefaultSidebarTabTriggersTunnel.In>
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers> {children}
</DefaultSidebarTabTriggersTunnel.In> </DefaultSidebarTabTriggersTunnel.In>
); );
}; };
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
const { DefaultSidebarTabTriggersTunnel } = useTunnels(); const { DefaultSidebarTabTriggersTunnel } = useTunnels();
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
return ( return (
<Sidebar <Sidebar
{...rest} {...rest}
name="default" name="default"
key="default" key="default"
className={clsx("default-sidebar", className)} className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference} docked={
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
}
onDock={ onDock={
// `onDock=false` disables docking. // `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking. // if `docked` passed, but no onDock passed, disable manual docking.
onDock === false || (!onDock && docked != null) isForceDocked || onDock === false || (!onDock && docked != null)
? undefined ? undefined
: // compose to allow the host app to listen on default behavior : // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => { composeEventHandlers(onDock, (docked) => {
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
> >
<Sidebar.Tabs> <Sidebar.Tabs>
<Sidebar.Header> <Sidebar.Header>
{rest.__fallback && ( <Sidebar.TabTriggers>
<div <Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
style={{ {searchIcon}
color: "var(--color-primary)", </Sidebar.TabTrigger>
fontSize: "1.2em", <Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
fontWeight: "bold", {LibraryIcon}
textOverflow: "ellipsis", </Sidebar.TabTrigger>
overflow: "hidden", <DefaultSidebarTabTriggersTunnel.Out />
whiteSpace: "nowrap", </Sidebar.TabTriggers>
paddingRight: "1em",
}}
>
{t("toolBar.library")}
</div>
)}
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}> <Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu /> <LibraryMenu />
</Sidebar.Tab> </Sidebar.Tab>
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
<SearchMenu />
</Sidebar.Tab>
{children} {children}
</Sidebar.Tabs> </Sidebar.Tabs>
</Sidebar> </Sidebar>
@@ -21,7 +21,7 @@ export const DEFAULT_FONTS = [
value: FONT_FAMILY.Excalifont, value: FONT_FAMILY.Excalifont,
icon: FreedrawIcon, icon: FreedrawIcon,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
testId: "font-family-handrawn", testId: "font-family-hand-drawn",
}, },
{ {
value: FONT_FAMILY.Nunito, value: FONT_FAMILY.Nunito,
@@ -21,6 +21,7 @@ import { t } from "../../i18n";
import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import { Fonts } from "../../fonts"; import { Fonts } from "../../fonts";
import type { ValueOf } from "../../utility-types"; import type { ValueOf } from "../../utility-types";
import { FontFamilyNormalIcon } from "../icons";
export interface FontDescriptor { export interface FontDescriptor {
value: number; value: number;
@@ -62,12 +63,14 @@ export const FontPickerList = React.memo(
const allFonts = useMemo( const allFonts = useMemo(
() => () =>
Array.from(Fonts.registered.entries()) Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide) .filter(
.map(([familyId, { metadata, fonts }]) => { ([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
)
.map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: metadata.icon, icon: metadata.icon ?? FontFamilyNormalIcon,
text: fonts[0].fontFace.family, text: fontFaces[0]?.fontFace?.family ?? "Unknown",
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
@@ -89,7 +92,7 @@ export const FontPickerList = React.memo(
); );
const sceneFamilies = useMemo( const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFontFamilies()), () => new Set(fonts.getSceneFamilies()),
// cache per selected font family, so hover re-render won't mess it up // cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily], [selectedFontFamily],
@@ -222,6 +222,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
]} ]}
isOr={false} isOr={false}
/> />
<Shortcut
label={t("helpDialog.cropStart")}
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.cropFinish")}
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} /> <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut <Shortcut
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
@@ -288,6 +298,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("stats.fullTitle")} label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]} shortcuts={[getShortcutKey("Alt+/")]}
/> />
<Shortcut
label={t("search.title")}
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
/>
<Shortcut <Shortcut
label={t("commandPalette.title")} label={t("commandPalette.title")}
shortcuts={ shortcuts={
@@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart"; import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping"; import { isGridModeEnabled } from "../snapping";
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: UIAppState;
@@ -30,6 +31,14 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.length
) {
return t("hints.dismissSearch");
}
if (appState.openSidebar && !device.editor.canFitSidebar) { if (appState.openSidebar && !device.editor.canFitSidebar) {
return null; return null;
} }
@@ -91,6 +100,14 @@ const getHints = ({
return t("hints.text_editing"); return t("hints.text_editing");
} }
if (appState.croppingElementId) {
return t("hints.leaveCropEditor");
}
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
return t("hints.enterCropEditor");
}
if (activeTool.type === "selection") { if (activeTool.type === "selection") {
if ( if (
appState.selectionElement && appState.selectionElement &&
+4 -3
View File
@@ -53,9 +53,6 @@ import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState"; import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar"; import { DefaultSidebar } from "./DefaultSidebar";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
@@ -64,6 +61,9 @@ import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
appState: UIAppState; appState: UIAppState;
@@ -99,6 +99,7 @@ const DefaultMainMenu: React.FC<{
{UIOptions.canvasActions.saveAsImage && ( {UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
)} )}
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator /> <MainMenu.Separator />
@@ -48,6 +48,9 @@ const ChartPreviewBtn = (props: {
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
}, },
null, // files null, // files
{
skipInliningFonts: true,
},
); );
svg.querySelector(".style-fonts")?.remove(); svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren(); previewNode.replaceChildren();
@@ -0,0 +1,110 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__search {
flex: 1 0 auto;
display: flex;
flex-direction: column;
padding: 8px 0 0 0;
}
.layer-ui__search-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.75rem;
.ExcTextField {
flex: 1 0 auto;
}
.ExcTextField__input {
background-color: #f5f5f9;
@at-root .excalidraw.theme--dark#{&} {
background-color: #31303b;
}
border-radius: var(--border-radius-md);
border: 0;
input::placeholder {
font-size: 0.9rem;
}
}
}
.layer-ui__search-count {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 8px 0 8px;
margin: 0 0.75rem 0.25rem 0.75rem;
font-size: 0.8em;
.result-nav {
display: flex;
.result-nav-btn {
width: 36px;
height: 36px;
--button-border: transparent;
&:active {
background-color: var(--color-surface-high);
}
&:first {
margin-right: 4px;
}
}
}
}
.layer-ui__search-result-container {
overflow-y: auto;
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.layer-ui__result-item {
display: flex;
align-items: center;
min-height: 2rem;
flex: 0 0 auto;
padding: 0.25rem 0.75rem;
cursor: pointer;
border: 1px solid transparent;
outline: none;
margin: 0 0.75rem;
border-radius: var(--border-radius-md);
.text-icon {
width: 1rem;
height: 1rem;
margin-right: 0.75rem;
}
.preview-text {
flex: 1;
max-height: 48px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
&:hover {
background-color: var(--color-surface-high);
}
&:active {
border-color: var(--color-primary);
}
&.active {
background-color: var(--color-surface-high);
}
}
}
@@ -0,0 +1,718 @@
import { Fragment, memo, useEffect, useRef, useState } from "react";
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
import { TextField } from "./TextField";
import { Button } from "./Button";
import { useApp, useExcalidrawSetAppState } from "./App";
import { debounce } from "lodash";
import type { AppClassProperties } from "../types";
import { isTextElement, newTextElement } from "../element";
import type { ExcalidrawTextElement } from "../element/types";
import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys";
import clsx from "clsx";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random";
import { CLASSES, EVENT } from "../constants";
import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss";
import { round } from "../../math";
const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null);
const SEARCH_DEBOUNCE = 350;
type SearchMatchItem = {
textElement: ExcalidrawTextElement;
searchQuery: SearchQuery;
index: number;
preview: {
indexInSearchQuery: number;
previewText: string;
moreBefore: boolean;
moreAfter: boolean;
};
matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[];
};
type SearchMatches = {
nonce: number | null;
items: SearchMatchItem[];
};
type SearchQuery = string & { _brand: "SearchQuery" };
export const SearchMenu = () => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false);
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
nonce: null,
items: [],
});
const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom(
searchItemInFocusAtom,
jotaiScope,
);
const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => {
if (isSearching) {
return;
}
if (
searchQuery !== searchedQueryRef.current ||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
) {
searchedQueryRef.current = null;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
});
}
}, [
isSearching,
searchQuery,
elementsMap,
app,
setAppState,
setFocusIndex,
lastSceneNonceRef,
]);
const goToNextItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return (focusIndex + 1) % searchMatches.items.length;
});
}
};
const goToPreviousItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return focusIndex - 1 < 0
? searchMatches.items.length - 1
: focusIndex - 1;
});
}
};
useEffect(() => {
setAppState((state) => {
return {
searchMatches: state.searchMatches.map((match, index) => {
if (index === focusIndex) {
return { ...match, focus: true };
}
return { ...match, focus: false };
}),
};
});
}, [focusIndex, setAppState]);
useEffect(() => {
if (searchMatches.items.length > 0 && focusIndex !== null) {
const match = searchMatches.items[focusIndex];
if (match) {
const zoomValue = app.state.zoom.value;
const matchAsElement = newTextElement({
text: match.searchQuery,
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
width: match.matchedLines[0]?.width,
height: match.matchedLines[0]?.height,
fontSize: match.textElement.fontSize,
fontFamily: match.textElement.fontFamily,
});
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
const fontSize = match.textElement.fontSize;
const isTextTiny =
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
if (
!isElementCompletelyInViewport(
[matchAsElement],
app.canvas.width / window.devicePixelRatio,
app.canvas.height / window.devicePixelRatio,
{
offsetLeft: app.state.offsetLeft,
offsetTop: app.state.offsetTop,
scrollX: app.state.scrollX,
scrollY: app.state.scrollY,
zoom: app.state.zoom,
},
app.scene.getNonDeletedElementsMap(),
app.getEditorUIOffsets(),
) ||
isTextTiny
) {
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
if (isTextTiny) {
if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
zoomOptions = { fitToContent: true };
} else {
zoomOptions = {
fitToViewport: true,
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
};
}
} else {
zoomOptions = { fitToContent: true };
}
app.scrollToContent(matchAsElement, {
animate: true,
duration: 300,
...zoomOptions,
canvasOffsets: app.getEditorUIOffsets(),
});
}
}
}
}, [focusIndex, searchMatches, app]);
useEffect(() => {
return () => {
setFocusIndex(null);
searchedQueryRef.current = null;
lastSceneNonceRef.current = undefined;
setAppState({
searchMatches: [],
});
setIsSearching(false);
};
}, [setAppState, setFocusIndex]);
const stableState = useStable({
goToNextItem,
goToPreviousItem,
searchMatches,
});
useEffect(() => {
const eventHandler = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
!app.state.openDialog &&
!app.state.openPopup
) {
event.preventDefault();
event.stopPropagation();
setAppState({
openSidebar: null,
});
return;
}
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
event.preventDefault();
event.stopPropagation();
if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) {
setAppState({
openDialog: null,
});
}
searchInputRef.current?.focus();
searchInputRef.current?.select();
} else {
setAppState({
openSidebar: null,
});
}
}
if (
event.target instanceof HTMLElement &&
event.target.closest(".layer-ui__search")
) {
if (stableState.searchMatches.items.length) {
if (event.key === KEYS.ENTER) {
event.stopPropagation();
stableState.goToNextItem();
}
if (event.key === KEYS.ARROW_UP) {
event.stopPropagation();
stableState.goToPreviousItem();
} else if (event.key === KEYS.ARROW_DOWN) {
event.stopPropagation();
stableState.goToNextItem();
}
}
}
};
// `capture` needed to prevent firing on initial open from App.tsx,
// as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true,
});
}, [setAppState, stableState, app]);
const matchCount = `${searchMatches.items.length} ${
searchMatches.items.length === 1
? t("search.singleResult")
: t("search.multipleResults")
}`;
return (
<div className="layer-ui__search">
<div className="layer-ui__search-header">
<TextField
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
value={inputValue}
ref={searchInputRef}
placeholder={t("search.placeholder")}
icon={searchIcon}
onChange={(value) => {
setInputValue(value);
setIsSearching(true);
const searchQuery = value.trim() as SearchQuery;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
setFocusIndex(index);
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
setIsSearching(false);
});
}}
selectOnRender
/>
</div>
<div className="layer-ui__search-count">
{searchMatches.items.length > 0 && (
<>
{focusIndex !== null && focusIndex > -1 ? (
<div>
{focusIndex + 1} / {matchCount}
</div>
) : (
<div>{matchCount}</div>
)}
<div className="result-nav">
<Button
onSelect={() => {
goToNextItem();
}}
className="result-nav-btn"
>
{collapseDownIcon}
</Button>
<Button
onSelect={() => {
goToPreviousItem();
}}
className="result-nav-btn"
>
{upIcon}
</Button>
</div>
</>
)}
{searchMatches.items.length === 0 &&
searchQuery &&
searchedQueryRef.current && (
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
)}
</div>
<MatchList
matches={searchMatches}
onItemClick={setFocusIndex}
focusIndex={focusIndex}
searchQuery={searchQuery}
/>
</div>
);
};
const ListItem = (props: {
preview: SearchMatchItem["preview"];
searchQuery: SearchQuery;
highlighted: boolean;
onClick?: () => void;
}) => {
const preview = [
props.preview.moreBefore ? "..." : "",
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
props.preview.previewText.slice(
props.preview.indexInSearchQuery,
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.previewText.slice(
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.moreAfter ? "..." : "",
];
return (
<div
tabIndex={-1}
className={clsx("layer-ui__result-item", {
active: props.highlighted,
})}
onClick={props.onClick}
ref={(ref) => {
if (props.highlighted) {
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
}
}}
>
<div className="preview-text">
{preview.flatMap((text, idx) => (
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
))}
</div>
</div>
);
};
interface MatchListProps {
matches: SearchMatches;
onItemClick: (index: number) => void;
focusIndex: number | null;
searchQuery: SearchQuery;
}
const MatchListBase = (props: MatchListProps) => {
return (
<div className="layer-ui__search-result-container">
{props.matches.items.map((searchMatch, index) => (
<ListItem
key={searchMatch.textElement.id + searchMatch.index}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index === props.focusIndex}
onClick={() => props.onItemClick(index)}
/>
))}
</div>
);
};
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
return (
prevProps.matches.nonce === nextProps.matches.nonce &&
prevProps.focusIndex === nextProps.focusIndex
);
};
const MatchList = memo(MatchListBase, areEqual);
const getMatchPreview = (
text: string,
index: number,
searchQuery: SearchQuery,
) => {
const WORDS_BEFORE = 2;
const WORDS_AFTER = 5;
const substrBeforeQuery = text.slice(0, index);
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
// text = "small", query = "mall", not complete before
// text = "small", query = "smal", complete before
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
const startWordIndex =
wordsBeforeQuery.length -
WORDS_BEFORE -
1 -
(isQueryCompleteBefore ? 0 : 1);
let wordsBeforeAsString =
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
(isQueryCompleteBefore ? " " : "");
const MAX_ALLOWED_CHARS = 20;
wordsBeforeAsString =
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
: wordsBeforeAsString;
const substrAfterQuery = text.slice(index + searchQuery.length);
const wordsAfter = substrAfterQuery.split(/\s+/);
// text = "small", query = "mall", complete after
// text = "small", query = "smal", not complete after
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
const numberOfWordsToTake = isQueryCompleteAfter
? WORDS_AFTER + 1
: WORDS_AFTER;
const wordsAfterAsString =
(isQueryCompleteAfter ? "" : " ") +
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
return {
indexInSearchQuery: wordsBeforeAsString.length,
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
moreBefore: startWordIndex > 0,
moreAfter: wordsAfter.length > numberOfWordsToTake,
};
};
const normalizeWrappedText = (
wrappedText: string,
originalText: string,
): string => {
const wrappedLines = wrappedText.split("\n");
const normalizedLines: string[] = [];
let originalIndex = 0;
for (let i = 0; i < wrappedLines.length; i++) {
let currentLine = wrappedLines[i];
const nextLine = wrappedLines[i + 1];
if (nextLine) {
const nextLineIndexInOriginal = originalText.indexOf(
nextLine,
originalIndex,
);
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
while (j > 0) {
currentLine += " ";
j--;
}
}
}
normalizedLines.push(currentLine);
originalIndex = originalIndex + currentLine.length;
}
return normalizedLines.join("\n");
};
const getMatchedLines = (
textElement: ExcalidrawTextElement,
searchQuery: SearchQuery,
index: number,
) => {
const normalizedText = normalizeWrappedText(
textElement.text,
textElement.originalText,
);
const lines = normalizedText.split("\n");
const lineIndexRanges = [];
let currentIndex = 0;
let lineNumber = 0;
for (const line of lines) {
const startIndex = currentIndex;
const endIndex = startIndex + line.length - 1;
lineIndexRanges.push({
line,
startIndex,
endIndex,
lineNumber,
});
// Move to the next line's start index
currentIndex = endIndex + 1;
lineNumber++;
}
let startIndex = index;
let remainingQuery = textElement.originalText.slice(
index,
index + searchQuery.length,
);
const matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[] = [];
for (const lineIndexRange of lineIndexRanges) {
if (remainingQuery === "") {
break;
}
if (
startIndex >= lineIndexRange.startIndex &&
startIndex <= lineIndexRange.endIndex
) {
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
const textToStart = lineIndexRange.line.slice(
0,
startIndex - lineIndexRange.startIndex,
);
const matchedWord = remainingQuery.slice(0, matchCapacity);
remainingQuery = remainingQuery.slice(matchCapacity);
const offset = measureText(
textToStart,
getFontString(textElement),
textElement.lineHeight,
true,
);
// measureText returns a non-zero width for the empty string
// which is not what we're after here, hence the check and the correction
if (textToStart === "") {
offset.width = 0;
}
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
const lineLength = measureText(
lineIndexRange.line,
getFontString(textElement),
textElement.lineHeight,
true,
);
const spaceToStart =
textElement.textAlign === "center"
? (textElement.width - lineLength.width) / 2
: textElement.width - lineLength.width;
offset.width += spaceToStart;
}
const { width, height } = measureText(
matchedWord,
getFontString(textElement),
textElement.lineHeight,
);
const offsetX = offset.width;
const offsetY = lineIndexRange.lineNumber * offset.height;
matchedLines.push({
offsetX,
offsetY,
width,
height,
});
startIndex += matchCapacity;
}
}
return matchedLines;
};
const escapeSpecialCharacters = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
};
const handleSearch = debounce(
(
searchQuery: SearchQuery,
app: AppClassProperties,
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
) => {
if (!searchQuery || searchQuery === "") {
cb([], null);
return;
}
const elements = app.scene.getNonDeletedElements();
const texts = elements.filter((el) =>
isTextElement(el),
) as ExcalidrawTextElement[];
texts.sort((a, b) => a.y - b.y);
const matchItems: SearchMatchItem[] = [];
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
for (const textEl of texts) {
let match = null;
const text = textEl.originalText;
while ((match = regex.exec(text)) !== null) {
const preview = getMatchPreview(text, match.index, searchQuery);
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
if (matchedLines.length > 0) {
matchItems.push({
textElement: textEl,
searchQuery,
preview,
index: match.index,
matchedLines,
});
}
}
}
const visibleIds = new Set(
app.visibleElements.map((visibleElement) => visibleElement.id),
);
const focusIndex =
matchItems.findIndex((matchItem) =>
visibleIds.has(matchItem.textElement.id),
) ?? null;
cb(matchItems, focusIndex);
},
SEARCH_DEBOUNCE,
);
@@ -20,7 +20,7 @@ import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { point, type GlobalPoint } from "../../../math"; import { pointFrom, type GlobalPoint } from "../../../math";
interface MultiDimensionProps { interface MultiDimensionProps {
property: "width" | "height"; property: "width" | "height";
@@ -182,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
initialHeight, initialHeight,
aspectRatio, aspectRatio,
point(x1, y1), pointFrom(x1, y1),
property, property,
latestElements, latestElements,
originalElements, originalElements,
@@ -287,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
initialHeight, initialHeight,
aspectRatio, aspectRatio,
point(x1, y1), pointFrom(x1, y1),
property, property,
latestElements, latestElements,
originalElements, originalElements,
@@ -13,7 +13,7 @@ import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils"; import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { point, pointRotateRads } from "../../../math"; import { pointFrom, pointRotateRads } from "../../../math";
interface MultiPositionProps { interface MultiPositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -44,8 +44,8 @@ const moveElements = (
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y), pointFrom(origElement.x, origElement.y),
point(cx, cy), pointFrom(cx, cy),
origElement.angle, origElement.angle,
); );
@@ -97,8 +97,8 @@ const moveGroupTo = (
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(latestElement.x, latestElement.y), pointFrom(latestElement.x, latestElement.y),
point(cx, cy), pointFrom(cx, cy),
latestElement.angle, latestElement.angle,
); );
@@ -171,8 +171,8 @@ const handlePositionChange: DragInputCallbackType<
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y), pointFrom(origElement.x, origElement.y),
point(cx, cy), pointFrom(cx, cy),
origElement.angle, origElement.angle,
); );
@@ -241,8 +241,8 @@ const MultiPosition = ({
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2]; const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(el.x, el.y), pointFrom(el.x, el.y),
point(cx, cy), pointFrom(cx, cy),
el.angle, el.angle,
); );
@@ -4,7 +4,7 @@ import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils"; import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene"; import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
import { point, pointRotateRads } from "../../../math"; import { pointFrom, pointRotateRads } from "../../../math";
interface PositionProps { interface PositionProps {
property: "x" | "y"; property: "x" | "y";
@@ -33,8 +33,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.y + origElement.height / 2, origElement.y + origElement.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y), pointFrom(origElement.x, origElement.y),
point(cx, cy), pointFrom(cx, cy),
origElement.angle, origElement.angle,
); );
@@ -93,8 +93,8 @@ const Position = ({
appState, appState,
}: PositionProps) => { }: PositionProps) => {
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(element.x, element.y), pointFrom(element.x, element.y),
point(element.x + element.width / 2, element.y + element.height / 2), pointFrom(element.x + element.width / 2, element.y + element.height / 2),
element.angle, element.angle,
); );
const value = const value =
@@ -25,7 +25,7 @@ import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions"; import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups"; import { isInGroup } from "../../groups";
import type { Degrees } from "../../../math"; import type { Degrees } from "../../../math";
import { degreesToRadians, point, pointRotateRads } from "../../../math"; import { degreesToRadians, pointFrom, pointRotateRads } from "../../../math";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -264,8 +264,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y), pointFrom(rectangle.x, rectangle.y),
point(cx, cy), pointFrom(cx, cy),
rectangle.angle, rectangle.angle,
); );
@@ -283,8 +283,8 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 0, 45); testInputProperty(rectangle, "angle", "A", 0, 45);
let [newTopLeftX, newTopLeftY] = pointRotateRads( let [newTopLeftX, newTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y), pointFrom(rectangle.x, rectangle.y),
point(cx, cy), pointFrom(cx, cy),
rectangle.angle, rectangle.angle,
); );
@@ -294,8 +294,8 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 45, 66); testInputProperty(rectangle, "angle", "A", 45, 66);
[newTopLeftX, newTopLeftY] = pointRotateRads( [newTopLeftX, newTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y), pointFrom(rectangle.x, rectangle.y),
point(cx, cy), pointFrom(cx, cy),
rectangle.angle, rectangle.angle,
); );
expect(newTopLeftX.toString()).not.toEqual(xInput.value); expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -311,8 +311,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y), pointFrom(rectangle.x, rectangle.y),
point(cx, cy), pointFrom(cx, cy),
rectangle.angle, rectangle.angle,
); );
testInputProperty(rectangle, "width", "W", rectangle.width, 400); testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -321,8 +321,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
let [currentTopLeftX, currentTopLeftY] = pointRotateRads( let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y), pointFrom(rectangle.x, rectangle.y),
point(cx, cy), pointFrom(cx, cy),
rectangle.angle, rectangle.angle,
); );
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4); expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -334,8 +334,8 @@ describe("stats for a generic element", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
]; ];
[currentTopLeftX, currentTopLeftY] = pointRotateRads( [currentTopLeftX, currentTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y), pointFrom(rectangle.x, rectangle.y),
point(cx, cy), pointFrom(cx, cy),
rectangle.angle, rectangle.angle,
); );
@@ -1,5 +1,5 @@
import type { Radians } from "../../../math"; import type { Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math"; import { pointFrom, pointRotateRads } from "../../../math";
import { import {
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
updateBoundElements, updateBoundElements,
@@ -231,8 +231,8 @@ export const moveElement = (
originalElement.y + originalElement.height / 2, originalElement.y + originalElement.height / 2,
]; ];
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
point(originalElement.x, originalElement.y), pointFrom(originalElement.x, originalElement.y),
point(cx, cy), pointFrom(cx, cy),
originalElement.angle, originalElement.angle,
); );
@@ -240,8 +240,8 @@ export const moveElement = (
const changeInY = newTopLeftY - topLeftY; const changeInY = newTopLeftY - topLeftY;
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
point(newTopLeftX, newTopLeftY), pointFrom(newTopLeftX, newTopLeftY),
point(cx + changeInX, cy + changeInY), pointFrom(cx + changeInX, cy + changeInY),
-originalElement.angle as Radians, -originalElement.angle as Radians,
); );
+21 -7
View File
@@ -3,16 +3,29 @@
.excalidraw { .excalidraw {
--ExcTextField--color: var(--color-on-surface); --ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-color: var(--color-on-surface); --ExcTextField--label-color: var(--color-on-surface);
--ExcTextField--background: transparent; --ExcTextField--background: var(--color-surface-low);
--ExcTextField--readonly--background: var(--color-surface-high); --ExcTextField--readonly--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-on-surface); --ExcTextField--readonly--color: var(--color-on-surface);
--ExcTextField--border: var(--color-border-outline); --ExcTextField--border: var(--color-gray-20);
--ExcTextField--readonly--border: var(--color-border-outline-variant); --ExcTextField--readonly--border: var(--color-border-outline-variant);
--ExcTextField--border-hover: var(--color-brand-hover); --ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active); --ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant); --ExcTextField--placeholder: var(--color-border-outline-variant);
.ExcTextField { .ExcTextField {
position: relative;
svg {
position: absolute;
top: 50%; // 50% is not exactly in the center of the input
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
&--fullWidth { &--fullWidth {
width: 100%; width: 100%;
flex-grow: 1; flex-grow: 1;
@@ -37,7 +50,6 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: 0 1rem;
height: 3rem; height: 3rem;
@@ -45,6 +57,8 @@
border: 1px solid var(--ExcTextField--border); border: 1px solid var(--ExcTextField--border);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0 0.75rem;
&:not(&--readonly) { &:not(&--readonly) {
&:hover { &:hover {
border-color: var(--ExcTextField--border-hover); border-color: var(--ExcTextField--border-hover);
@@ -80,10 +94,6 @@
width: 100%; width: 100%;
&::placeholder {
color: var(--ExcTextField--placeholder);
}
&:not(:focus) { &:not(:focus) {
&:hover { &:hover {
background-color: initial; background-color: initial;
@@ -105,5 +115,9 @@
} }
} }
} }
&--hasIcon .ExcTextField__input {
padding-left: 2.5rem;
}
} }
} }
+10 -2
View File
@@ -21,7 +21,9 @@ type TextFieldProps = {
fullWidth?: boolean; fullWidth?: boolean;
selectOnRender?: boolean; selectOnRender?: boolean;
icon?: React.ReactNode;
label?: string; label?: string;
className?: string;
placeholder?: string; placeholder?: string;
isRedacted?: boolean; isRedacted?: boolean;
} & ({ value: string } | { defaultValue: string }); } & ({ value: string } | { defaultValue: string });
@@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
selectOnRender, selectOnRender,
onKeyDown, onKeyDown,
isRedacted = false, isRedacted = false,
icon,
className,
...rest ...rest
}, },
ref, ref,
@@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
useLayoutEffect(() => { useLayoutEffect(() => {
if (selectOnRender) { if (selectOnRender) {
// focusing first is needed because vitest/jsdom
innerRef.current?.focus();
innerRef.current?.select(); innerRef.current?.select();
} }
}, [selectOnRender]); }, [selectOnRender]);
@@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
return ( return (
<div <div
className={clsx("ExcTextField", { className={clsx("ExcTextField", className, {
"ExcTextField--fullWidth": fullWidth, "ExcTextField--fullWidth": fullWidth,
"ExcTextField--hasIcon": !!icon,
})} })}
onClick={() => { onClick={() => {
innerRef.current?.focus(); innerRef.current?.focus();
}} }}
> >
<div className="ExcTextField__label">{label}</div> {icon}
{label && <div className="ExcTextField__label">{label}</div>}
<div <div
className={clsx("ExcTextField__input", { className={clsx("ExcTextField__input", {
"ExcTextField__input--readonly": readonly, "ExcTextField__input--readonly": readonly,
@@ -203,6 +203,9 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines, snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled, zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement, editingTextElement: appState.editingTextElement,
isCropping: appState.isCropping,
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches,
}); });
const areEqual = ( const areEqual = (
@@ -107,6 +107,7 @@ const getRelevantAppStateProps = (
frameToHighlight: appState.frameToHighlight, frameToHighlight: appState.frameToHighlight,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily, currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
}); });
const areEqual = ( const areEqual = (
@@ -36,7 +36,7 @@ import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App"; import { useAppProps, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks"; import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers"; import { getLinkHandleFromCoords } from "./helpers";
import { point, type GlobalPoint } from "../../../math"; import { pointFrom, type GlobalPoint } from "../../../math";
const CONTAINER_WIDTH = 320; const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
@@ -181,7 +181,7 @@ export const Hyperlink = ({
element, element,
elementsMap, elementsMap,
appState, appState,
point(event.clientX, event.clientY), pointFrom(event.clientX, event.clientY),
) as boolean; ) as boolean;
if (shouldHide) { if (shouldHide) {
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
@@ -1,5 +1,5 @@
import type { GlobalPoint, Radians } from "../../../math"; import type { GlobalPoint, Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math"; import { pointFrom, pointRotateRads } from "../../../math";
import { MIME_TYPES } from "../../constants"; import { MIME_TYPES } from "../../constants";
import type { Bounds } from "../../element/bounds"; import type { Bounds } from "../../element/bounds";
import { getElementAbsoluteCoords } from "../../element/bounds"; import { getElementAbsoluteCoords } from "../../element/bounds";
@@ -35,8 +35,8 @@ export const getLinkHandleFromCoords = (
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
const [rotatedX, rotatedY] = pointRotateRads( const [rotatedX, rotatedY] = pointRotateRads(
point(x + linkWidth / 2, y + linkHeight / 2), pointFrom(x + linkWidth / 2, y + linkHeight / 2),
point(centerX, centerY), pointFrom(centerX, centerY),
angle, angle,
); );
return [ return [
@@ -85,5 +85,10 @@ export const isPointHittingLink = (
) { ) {
return true; return true;
} }
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y)); return isPointHittingLinkIcon(
element,
elementsMap,
appState,
pointFrom(x, y),
);
}; };
+17
View File
@@ -2139,3 +2139,20 @@ export const collapseUpIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const upIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 15l6 -6l6 6" />
</g>,
tablerIconProps,
);
export const cropIcon = createIcon(
<g strokeWidth="1.25">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 5v10a1 1 0 0 0 1 1h10" />
<path d="M5 8h10a1 1 0 0 1 1 1v10" />
</g>,
tablerIconProps,
);
@@ -15,6 +15,7 @@ import {
LoadIcon, LoadIcon,
MoonIcon, MoonIcon,
save, save,
searchIcon,
SunIcon, SunIcon,
TrashIcon, TrashIcon,
usersIcon, usersIcon,
@@ -27,6 +28,7 @@ import {
actionLoadScene, actionLoadScene,
actionSaveToActiveFile, actionSaveToActiveFile,
actionShortcuts, actionShortcuts,
actionToggleSearchMenu,
actionToggleTheme, actionToggleTheme,
} from "../../actions"; } from "../../actions";
import clsx from "clsx"; import clsx from "clsx";
@@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
import { THEME } from "../../constants"; import { THEME } from "../../constants";
import type { Theme } from "../../element/types"; import type { Theme } from "../../element/types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import "./DefaultItems.scss"; import "./DefaultItems.scss";
export const LoadScene = () => { export const LoadScene = () => {
@@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
}; };
CommandPalette.displayName = "CommandPalette"; CommandPalette.displayName = "CommandPalette";
export const SearchMenu = (opts?: { className?: string }) => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<DropdownMenuItem
icon={searchIcon}
data-testid="search-menu-button"
onSelect={() => {
actionManager.executeAction(actionToggleSearchMenu);
}}
shortcut={getShortcutFromShortcutName("searchMenu")}
aria-label={t("search.title")}
className={opts?.className}
>
{t("search.title")}
</DropdownMenuItem>
);
};
SearchMenu.displayName = "SearchMenu";
export const Help = () => { export const Help = () => {
const { t } = useI18n(); const { t } = useI18n();
+21 -2
View File
@@ -113,8 +113,12 @@ export const ENV = {
export const CLASSES = { export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
}; };
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
/** /**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash. * // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
* *
@@ -135,6 +139,22 @@ export const FONT_FAMILY = {
"Liberation Sans": 9, "Liberation Sans": 9,
}; };
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
default:
return [WINDOWS_EMOJI_FALLBACK_FONT];
}
};
export const THEME = { export const THEME = {
LIGHT: "light", LIGHT: "light",
DARK: "dark", DARK: "dark",
@@ -156,8 +176,6 @@ export const FRAME_STYLE = {
nameLineHeight: 1.25, nameLineHeight: 1.25,
}; };
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const MIN_FONT_SIZE = 1; export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont; export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
@@ -376,6 +394,7 @@ export const DEFAULT_ELEMENT_PROPS: {
}; };
export const LIBRARY_SIDEBAR_TAB = "library"; export const LIBRARY_SIDEBAR_TAB = "library";
export const CANVAS_SEARCH_TAB = "search";
export const DEFAULT_SIDEBAR = { export const DEFAULT_SIDEBAR = {
name: "default", name: "default",
+3 -3
View File
@@ -144,9 +144,9 @@
--border-radius-md: 0.375rem; --border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem; --border-radius-lg: 0.5rem;
--color-surface-high: hsl(244, 100%, 97%); --color-surface-high: #f1f0ff;
--color-surface-mid: hsl(240 25% 96%); --color-surface-mid: #f2f2f7;
--color-surface-low: hsl(240 25% 94%); --color-surface-low: #ececf4;
--color-surface-lowest: #ffffff; --color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f; --color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0; --color-brand-hover: #5753d0;
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "#d8f5a2", "backgroundColor": "#d8f5a2",
"boundElements": [ "boundElements": [
{ {
"id": "id45", "id": "id47",
"type": "arrow", "type": "arrow",
}, },
{ {
"id": "id46", "id": "id48",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -47,7 +47,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id46", "id": "id48",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -118,7 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id47", "elementId": "id49",
"fixedPoint": null, "fixedPoint": null,
"focus": -0.08139534883720931, "focus": -0.08139534883720931,
"gap": 1, "gap": 1,
@@ -200,7 +200,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id45", "id": "id47",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -238,7 +238,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id48", "id": "id50",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -284,7 +284,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id48", "id": "id50",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -329,7 +329,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id49", "id": "id51",
"type": "text", "type": "text",
}, },
], ],
@@ -392,7 +392,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id48", "containerId": "id50",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -433,7 +433,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id38", "id": "id40",
"type": "text", "type": "text",
}, },
], ],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"elbowed": false, "elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id40", "elementId": "id42",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -472,7 +472,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id39", "elementId": "id41",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -496,7 +496,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id37", "containerId": "id39",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -537,7 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id37", "id": "id39",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -574,7 +574,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id37", "id": "id39",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -611,7 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id42", "id": "id44",
"type": "text", "type": "text",
}, },
], ],
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"elbowed": false, "elbowed": false,
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id44", "elementId": "id46",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -650,7 +650,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id43", "elementId": "id45",
"fixedPoint": null, "fixedPoint": null,
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
@@ -674,7 +674,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"autoResize": true, "autoResize": true,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"containerId": "id41", "containerId": "id43",
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 5, "fontFamily": 5,
@@ -716,7 +716,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id41", "id": "id43",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -762,7 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id41", "id": "id43",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -1303,7 +1303,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id54", "id": "id56",
"type": "text", "type": "text",
}, },
{ {
@@ -1346,7 +1346,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id55", "id": "id57",
"type": "text", "type": "text",
}, },
], ],
@@ -1385,7 +1385,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id56", "id": "id58",
"type": "text", "type": "text",
}, },
{ {
@@ -1428,7 +1428,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id57", "id": "id59",
"type": "text", "type": "text",
}, },
{ {
@@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id58", "id": "id60",
"type": "text", "type": "text",
}, },
], ],
@@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id59", "id": "id61",
"type": "text", "type": "text",
}, },
], ],
@@ -2171,7 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#1098ad", "strokeColor": "#1098ad",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "ANOTHER STYLED "text": "ANOTHER STYLED
LABELLED ARROW", LABELLED ARROW",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2179,8 +2179,8 @@ LABELLED ARROW",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 150, "width": 140,
"x": 75, "x": 80,
"y": 275, "y": 275,
} }
`; `;
@@ -2213,7 +2213,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "ANOTHER STYLED "text": "ANOTHER STYLED
LABELLED ARROW", LABELLED ARROW",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2221,8 +2221,8 @@ LABELLED ARROW",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 150, "width": 140,
"x": 75, "x": 80,
"y": 375, "y": 375,
} }
`; `;
@@ -2518,7 +2518,7 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "ELLIPSE TEXT "text": "ELLIPSE TEXT
CONTAINER", CONTAINER",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2526,8 +2526,8 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 120,
"x": 534.7893218813452, "x": 539.7893218813452,
"y": 117.44796179957173, "y": 117.44796179957173,
} }
`; `;
@@ -2562,7 +2562,7 @@ TEXT CONTAINER",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "DIAMOND "text": "DIAMOND
TEXT TEXT
CONTAINER", CONTAINER",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2646,8 +2646,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "TOP LEFT ALIGNED "text": "TOP LEFT ALIGNED
RECTANGLE TEXT RECTANGLE TEXT
CONTAINER", CONTAINER",
"textAlign": "left", "textAlign": "left",
"type": "text", "type": "text",
@@ -2655,7 +2655,7 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "top", "verticalAlign": "top",
"width": 170, "width": 160,
"x": 505, "x": 505,
"y": 305, "y": 305,
} }
@@ -2689,8 +2689,8 @@ exports[`Test Transform > should transform to text containers when label provide
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"text": "STYLED "text": "STYLED
ELLIPSE TEXT ELLIPSE TEXT
CONTAINER", CONTAINER",
"textAlign": "center", "textAlign": "center",
"type": "text", "type": "text",
@@ -2698,8 +2698,8 @@ CONTAINER",
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 120,
"x": 534.7893218813452, "x": 539.7893218813452,
"y": 522.5735931288071, "y": 522.5735931288071,
} }
`; `;
+9
View File
@@ -57,6 +57,15 @@ export const base64ToString = async (base64: string, isByteString = false) => {
: byteStringToString(window.atob(base64)); : byteStringToString(window.atob(base64));
}; };
export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
if (typeof Buffer !== "undefined") {
// Node.js environment
return Buffer.from(base64, "base64").buffer;
}
// Browser environment
return byteStringToArrayBuffer(atob(base64));
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// text encoding // text encoding
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+17 -8
View File
@@ -5,6 +5,7 @@ import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPointBinding,
FontFamilyValues, FontFamilyValues,
OrderedExcalidrawElement, OrderedExcalidrawElement,
PointBinding, PointBinding,
@@ -21,6 +22,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
@@ -55,7 +57,7 @@ import {
getNormalizedZoom, getNormalizedZoom,
} from "../scene"; } from "../scene";
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, point } from "../../math"; import { isFiniteNumber, pointFrom } from "../../math";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -101,8 +103,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const repairBinding = ( const repairBinding = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
binding: PointBinding | null, binding: PointBinding | FixedPointBinding | null,
): PointBinding | null => { ): PointBinding | FixedPointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
@@ -110,9 +112,11 @@ const repairBinding = (
return { return {
...binding, ...binding,
focus: binding.focus || 0, focus: binding.focus || 0,
fixedPoint: isElbowArrow(element) ...(isElbowArrow(element) && isFixedPointBinding(binding)
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0]) ? {
: null, fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: {}),
}; };
}; };
@@ -186,6 +190,10 @@ const restoreElementWithProperties = <
} }
return { return {
// spread the original element properties to not lose unknown ones
// for forward-compatibility
...element,
// normalized properties
...base, ...base,
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),
...extra, ...extra,
@@ -254,6 +262,7 @@ const restoreElement = (
status: element.status || "pending", status: element.status || "pending",
fileId: element.fileId, fileId: element.fileId,
scale: element.scale || [1, 1], scale: element.scale || [1, 1],
crop: element.crop ?? null,
}); });
case "line": case "line":
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
@@ -264,7 +273,7 @@ const restoreElement = (
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2 !Array.isArray(element.points) || element.points.length < 2
? [point(0, 0), point(element.width, element.height)] ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -292,7 +301,7 @@ const restoreElement = (
let y: number | undefined = element.y; let y: number | undefined = element.y;
let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2 !Array.isArray(element.points) || element.points.length < 2
? [point(0, 0), point(element.width, element.height)] ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
+50 -45
View File
@@ -2,7 +2,7 @@ import { vi } from "vitest";
import type { ExcalidrawElementSkeleton } from "./transform"; import type { ExcalidrawElementSkeleton } from "./transform";
import { convertToExcalidrawElements } from "./transform"; import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawArrowElement } from "../element/types"; import type { ExcalidrawArrowElement } from "../element/types";
import { point } from "../../math"; import { pointFrom } from "../../math";
const opts = { regenerateIds: false }; const opts = { regenerateIds: false };
@@ -309,28 +309,32 @@ describe("Test Transform", () => {
}); });
describe("Test Frames", () => { describe("Test Frames", () => {
const elements: ExcalidrawElementSkeleton[] = [
{
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
];
it("should transform frames and update frame ids when regenerated", () => { it("should transform frames and update frame ids when regenerated", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{ ...elements,
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{ {
type: "frame", type: "frame",
children: ["1", "2"], children: ["1", "2"],
@@ -352,28 +356,9 @@ describe("Test Transform", () => {
}); });
}); });
it("should consider max of calculated and frame dimensions when provided", () => { it("should consider user defined frame dimensions over calculated when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [ const elementsSkeleton: ExcalidrawElementSkeleton[] = [
{ ...elements,
type: "rectangle",
x: 10,
y: 10,
strokeWidth: 2,
id: "1",
},
{
type: "diamond",
x: 120,
y: 20,
backgroundColor: "#fff3bf",
strokeWidth: 2,
label: {
text: "HELLO EXCALIDRAW",
strokeColor: "#099268",
fontSize: 30,
},
id: "2",
},
{ {
type: "frame", type: "frame",
children: ["1", "2"], children: ["1", "2"],
@@ -388,7 +373,27 @@ describe("Test Transform", () => {
); );
const frame = excalidrawElements.find((ele) => ele.type === "frame")!; const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
expect(frame.width).toBe(800); expect(frame.width).toBe(800);
expect(frame.height).toBe(126); expect(frame.height).toBe(100);
});
it("should consider user defined frame coordinates calculated when provided", () => {
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
...elements,
{
type: "frame",
children: ["1", "2"],
name: "My frame",
x: 100,
y: 300,
},
];
const excalidrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
expect(frame.x).toBe(100);
expect(frame.y).toBe(300);
}); });
}); });
@@ -912,7 +917,7 @@ describe("Test Transform", () => {
x: 111.262, x: 111.262,
y: 57, y: 57,
strokeWidth: 2, strokeWidth: 2,
points: [point(0, 0), point(272.985, 0)], points: [pointFrom(0, 0), pointFrom(272.985, 0)],
label: { label: {
text: "How are you?", text: "How are you?",
fontSize: 20, fontSize: 20,
@@ -935,7 +940,7 @@ describe("Test Transform", () => {
x: 77.017, x: 77.017,
y: 79, y: 79,
strokeWidth: 2, strokeWidth: 2,
points: [point(0, 0)], points: [pointFrom(0, 0)],
label: { label: {
text: "Friendship", text: "Friendship",
fontSize: 20, fontSize: 20,
+25 -8
View File
@@ -46,6 +46,7 @@ import {
assertNever, assertNever,
cloneJSON, cloneJSON,
getFontString, getFontString,
isDevEnv,
toBrandedType, toBrandedType,
} from "../utils"; } from "../utils";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
@@ -53,7 +54,7 @@ import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks"; import { isArrowElement } from "../element/typeChecks";
import { point, type LocalPoint } from "../../math"; import { pointFrom, type LocalPoint } from "../../math";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
@@ -536,7 +537,7 @@ export const convertToExcalidrawElements = (
excalidrawElement = newLinearElement({ excalidrawElement = newLinearElement({
width, width,
height, height,
points: [point(0, 0), point(width, height)], points: [pointFrom(0, 0), pointFrom(width, height)],
...element, ...element,
}); });
@@ -549,7 +550,7 @@ export const convertToExcalidrawElements = (
width, width,
height, height,
endArrowhead: "arrow", endArrowhead: "arrow",
points: [point(0, 0), point(width, height)], points: [pointFrom(0, 0), pointFrom(width, height)],
...element, ...element,
type: "arrow", type: "arrow",
}); });
@@ -717,7 +718,7 @@ export const convertToExcalidrawElements = (
} }
// Once all the excalidraw elements are created, we can add frames since we // Once all the excalidraw elements are created, we can add frames since we
// need to calculate coordinates and dimensions of frame which is possibe after all // need to calculate coordinates and dimensions of frame which is possible after all
// frame children are processed. // frame children are processed.
for (const [id, element] of elementsWithIds) { for (const [id, element] of elementsWithIds) {
if (element.type !== "frame" && element.type !== "magicframe") { if (element.type !== "frame" && element.type !== "magicframe") {
@@ -764,10 +765,26 @@ export const convertToExcalidrawElements = (
maxX = maxX + PADDING; maxX = maxX + PADDING;
maxY = maxY + PADDING; maxY = maxY + PADDING;
// Take the max of calculated and provided frame dimensions, whichever is higher const frameX = frame?.x || minX;
const width = Math.max(frame?.width, maxX - minX); const frameY = frame?.y || minY;
const height = Math.max(frame?.height, maxY - minY); const frameWidth = frame?.width || maxX - minX;
Object.assign(frame, { x: minX, y: minY, width, height }); const frameHeight = frame?.height || maxY - minY;
Object.assign(frame, {
x: frameX,
y: frameY,
width: frameWidth,
height: frameHeight,
});
if (
isDevEnv() &&
element.children.length &&
(frame?.x || frame?.y || frame?.width || frame?.height)
) {
console.info(
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
);
}
} }
return elementStore.getElements(); return elementStore.getElements();
+45 -35
View File
@@ -39,6 +39,7 @@ import {
isBindingElement, isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isRectangularElement, isRectangularElement,
@@ -65,7 +66,7 @@ import {
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { import {
lineSegment, lineSegment,
point, pointFrom,
pointRotateRads, pointRotateRads,
type GlobalPoint, type GlobalPoint,
vectorFromPoint, vectorFromPoint,
@@ -719,7 +720,7 @@ export const getHeadingForElbowArrowSnap = (
return vectorToHeading( return vectorToHeading(
vectorFromPoint( vectorFromPoint(
p, p,
point<GlobalPoint>( pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2, bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2, bindableElement.y + bindableElement.height / 2,
), ),
@@ -765,15 +766,15 @@ export const bindPointToSnapToElementOutline = (
const intersections = [ const intersections = [
...(intersectElementWithLine( ...(intersectElementWithLine(
bindableElement, bindableElement,
point(p[0], p[1] - 2 * bindableElement.height), pointFrom(p[0], p[1] - 2 * bindableElement.height),
point(p[0], p[1] + 2 * bindableElement.height), pointFrom(p[0], p[1] + 2 * bindableElement.height),
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, elementsMap,
) ?? []), ) ?? []),
...(intersectElementWithLine( ...(intersectElementWithLine(
bindableElement, bindableElement,
point(p[0] - 2 * bindableElement.width, p[1]), pointFrom(p[0] - 2 * bindableElement.width, p[1]),
point(p[0] + 2 * bindableElement.width, p[1]), pointFrom(p[0] + 2 * bindableElement.width, p[1]),
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
elementsMap, elementsMap,
) ?? []), ) ?? []),
@@ -797,7 +798,7 @@ export const bindPointToSnapToElementOutline = (
isVertical isVertical
? Math.abs(p[1] - i[1]) < 0.1 ? Math.abs(p[1] - i[1]) < 0.1
: Math.abs(p[0] - i[0]) < 0.1, : Math.abs(p[0] - i[0]) < 0.1,
)[0] ?? point; )[0] ?? p;
} }
return p; return p;
@@ -814,25 +815,25 @@ const headingToMidBindPoint = (
switch (true) { switch (true) {
case compareHeading(heading, HEADING_UP): case compareHeading(heading, HEADING_UP):
return pointRotateRads( return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]), pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center, center,
bindableElement.angle, bindableElement.angle,
); );
case compareHeading(heading, HEADING_RIGHT): case compareHeading(heading, HEADING_RIGHT):
return pointRotateRads( return pointRotateRads(
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1), pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center, center,
bindableElement.angle, bindableElement.angle,
); );
case compareHeading(heading, HEADING_DOWN): case compareHeading(heading, HEADING_DOWN):
return pointRotateRads( return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]), pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center, center,
bindableElement.angle, bindableElement.angle,
); );
default: default:
return pointRotateRads( return pointRotateRads(
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1), pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center, center,
bindableElement.angle, bindableElement.angle,
); );
@@ -843,7 +844,7 @@ export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = point<GlobalPoint>( const center = pointFrom<GlobalPoint>(
element.x + element.width / 2, element.x + element.width / 2,
element.y + element.height / 2, element.y + element.height / 2,
); );
@@ -853,13 +854,13 @@ export const avoidRectangularCorner = (
// Top left // Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return pointRotateRads<GlobalPoint>( return pointRotateRads<GlobalPoint>(
point(element.x - FIXED_BINDING_DISTANCE, element.y), pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
center, center,
element.angle, element.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
point(element.x, element.y - FIXED_BINDING_DISTANCE), pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
center, center,
element.angle, element.angle,
); );
@@ -870,13 +871,16 @@ export const avoidRectangularCorner = (
// Bottom left // Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return pointRotateRads( return pointRotateRads(
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE), pointFrom(
element.x,
element.y + element.height + FIXED_BINDING_DISTANCE,
),
center, center,
element.angle, element.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height), pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
center, center,
element.angle, element.angle,
); );
@@ -890,7 +894,7 @@ export const avoidRectangularCorner = (
element.width + FIXED_BINDING_DISTANCE element.width + FIXED_BINDING_DISTANCE
) { ) {
return pointRotateRads( return pointRotateRads(
point( pointFrom(
element.x + element.width, element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE, element.y + element.height + FIXED_BINDING_DISTANCE,
), ),
@@ -899,7 +903,7 @@ export const avoidRectangularCorner = (
); );
} }
return pointRotateRads( return pointRotateRads(
point( pointFrom(
element.x + element.width + FIXED_BINDING_DISTANCE, element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height, element.y + element.height,
), ),
@@ -916,13 +920,16 @@ export const avoidRectangularCorner = (
element.width + FIXED_BINDING_DISTANCE element.width + FIXED_BINDING_DISTANCE
) { ) {
return pointRotateRads( return pointRotateRads(
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE), pointFrom(
element.x + element.width,
element.y - FIXED_BINDING_DISTANCE,
),
center, center,
element.angle, element.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y), pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
center, center,
element.angle, element.angle,
); );
@@ -937,7 +944,10 @@ export const snapToMid = (
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = element;
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1); const center = pointFrom<GlobalPoint>(
x + width / 2 - 0.1,
y + height / 2 - 0.1,
);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
@@ -952,7 +962,7 @@ export const snapToMid = (
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads(
point(x - FIXED_BINDING_DISTANCE, center[1]), pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
@@ -963,7 +973,7 @@ export const snapToMid = (
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(
point(center[0], y - FIXED_BINDING_DISTANCE), pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
center, center,
angle, angle,
); );
@@ -974,7 +984,7 @@ export const snapToMid = (
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
point(x + width + FIXED_BINDING_DISTANCE, center[1]), pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
@@ -985,7 +995,7 @@ export const snapToMid = (
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
point(center[0], y + height + FIXED_BINDING_DISTANCE), pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
center, center,
angle, angle,
); );
@@ -1013,7 +1023,7 @@ const updateBoundPoint = (
const direction = startOrEnd === "startBinding" ? -1 : 1; const direction = startOrEnd === "startBinding" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement)) { if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) {
const fixedPoint = const fixedPoint =
normalizeFixedPoint(binding.fixedPoint) ?? normalizeFixedPoint(binding.fixedPoint) ??
calculateFixedPointForElbowArrowBinding( calculateFixedPointForElbowArrowBinding(
@@ -1022,11 +1032,11 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap, elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = point<GlobalPoint>( const globalMidPoint = pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2, bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2, bindableElement.y + bindableElement.height / 2,
); );
const global = point<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
); );
@@ -1117,7 +1127,7 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement, hoveredElement,
elementsMap, elementsMap,
); );
const globalMidPoint = point( const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
); );
@@ -1336,9 +1346,9 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap); const shape = getElementShape(element, elementsMap);
return ( return (
isPointOnShape(point(x, y), shape, threshold) || isPointOnShape(pointFrom(x, y), shape, threshold) ||
(fullShape === true && (fullShape === true &&
pointInsideBounds(point(x, y), aabbForElement(element))) pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
); );
}; };
@@ -2196,11 +2206,11 @@ export const getGlobalFixedPointForBindableElement = (
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return pointRotateRads( return pointRotateRads(
point( pointFrom(
element.x + element.width * fixedX, element.x + element.width * fixedX,
element.y + element.height * fixedY, element.y + element.height * fixedY,
), ),
point<GlobalPoint>( pointFrom<GlobalPoint>(
element.x + element.width / 2, element.x + element.width / 2,
element.y + element.height / 2, element.y + element.height / 2,
), ),
@@ -2228,7 +2238,7 @@ const getGlobalFixedPoints = (
arrow.startBinding.fixedPoint, arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement, startElement as ExcalidrawBindableElement,
) )
: point<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0], arrow.x + arrow.points[0][0],
arrow.y + arrow.points[0][1], arrow.y + arrow.points[0][1],
); );
@@ -2238,7 +2248,7 @@ const getGlobalFixedPoints = (
arrow.endBinding.fixedPoint, arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement, endElement as ExcalidrawBindableElement,
) )
: point<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0], arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1], arrow.y + arrow.points[arrow.points.length - 1][1],
); );
+4 -4
View File
@@ -1,5 +1,5 @@
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { point } from "../../math"; import { pointFrom } from "../../math";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@@ -125,9 +125,9 @@ describe("getElementBounds", () => {
a: 0.6447741904932416, a: 0.6447741904932416,
}), }),
points: [ points: [
point<LocalPoint>(0, 0), pointFrom<LocalPoint>(0, 0),
point<LocalPoint>(67.33984375, 92.48828125), pointFrom<LocalPoint>(67.33984375, 92.48828125),
point<LocalPoint>(-102.7890625, 52.15625), pointFrom<LocalPoint>(-102.7890625, 52.15625),
], ],
} as ExcalidrawLinearElement; } as ExcalidrawLinearElement;
+46 -46
View File
@@ -34,7 +34,7 @@ import type {
import { import {
degreesToRadians, degreesToRadians,
lineSegment, lineSegment,
point, pointFrom,
pointDistance, pointDistance,
pointFromArray, pointFromArray,
pointRotateRads, pointRotateRads,
@@ -113,8 +113,8 @@ export class ElementBounds {
const [minX, minY, maxX, maxY] = getBoundsFromPoints( const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) => element.points.map(([x, y]) =>
pointRotateRads( pointRotateRads(
point(x, y), pointFrom(x, y),
point(cx - element.x, cy - element.y), pointFrom(cx - element.x, cy - element.y),
element.angle, element.angle,
), ),
), ),
@@ -130,23 +130,23 @@ export class ElementBounds {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const [x11, y11] = pointRotateRads( const [x11, y11] = pointRotateRads(
point(cx, y1), pointFrom(cx, y1),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const [x12, y12] = pointRotateRads( const [x12, y12] = pointRotateRads(
point(cx, y2), pointFrom(cx, y2),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const [x22, y22] = pointRotateRads( const [x22, y22] = pointRotateRads(
point(x1, cy), pointFrom(x1, cy),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const [x21, y21] = pointRotateRads( const [x21, y21] = pointRotateRads(
point(x2, cy), pointFrom(x2, cy),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const minX = Math.min(x11, x12, x22, x21); const minX = Math.min(x11, x12, x22, x21);
@@ -164,23 +164,23 @@ export class ElementBounds {
bounds = [cx - ww, cy - hh, cx + ww, cy + hh]; bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else { } else {
const [x11, y11] = pointRotateRads( const [x11, y11] = pointRotateRads(
point(x1, y1), pointFrom(x1, y1),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const [x12, y12] = pointRotateRads( const [x12, y12] = pointRotateRads(
point(x1, y2), pointFrom(x1, y2),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const [x22, y22] = pointRotateRads( const [x22, y22] = pointRotateRads(
point(x2, y2), pointFrom(x2, y2),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const [x21, y21] = pointRotateRads( const [x21, y21] = pointRotateRads(
point(x2, y1), pointFrom(x2, y1),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const minX = Math.min(x11, x12, x22, x21); const minX = Math.min(x11, x12, x22, x21);
@@ -255,7 +255,7 @@ export const getElementLineSegments = (
elementsMap, elementsMap,
); );
const center: GlobalPoint = point(cx, cy); const center: GlobalPoint = pointFrom(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: LineSegment<GlobalPoint>[] = []; const segments: LineSegment<GlobalPoint>[] = [];
@@ -266,7 +266,7 @@ export const getElementLineSegments = (
segments.push( segments.push(
lineSegment( lineSegment(
pointRotateRads( pointRotateRads(
point( pointFrom(
element.points[i][0] + element.x, element.points[i][0] + element.x,
element.points[i][1] + element.y, element.points[i][1] + element.y,
), ),
@@ -274,7 +274,7 @@ export const getElementLineSegments = (
element.angle, element.angle,
), ),
pointRotateRads( pointRotateRads(
point( pointFrom(
element.points[i + 1][0] + element.x, element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y, element.points[i + 1][1] + element.y,
), ),
@@ -470,7 +470,7 @@ export const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (p: GlobalPoint) => GlobalPoint, transformXY?: (p: GlobalPoint) => GlobalPoint,
): Bounds => { ): Bounds => {
let currentP: GlobalPoint = point(0, 0); let currentP: GlobalPoint = pointFrom(0, 0);
const { minX, minY, maxX, maxY } = ops.reduce( const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => { (limits, { op, data }) => {
@@ -484,9 +484,9 @@ export const getMinMaxXYFromCurvePathOps = (
// move operation does not draw anything; so, it always // move operation does not draw anything; so, it always
// returns false // returns false
} else if (op === "bcurveTo") { } else if (op === "bcurveTo") {
const _p1 = point<GlobalPoint>(data[0], data[1]); const _p1 = pointFrom<GlobalPoint>(data[0], data[1]);
const _p2 = point<GlobalPoint>(data[2], data[3]); const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
const _p3 = point<GlobalPoint>(data[4], data[5]); const _p3 = pointFrom<GlobalPoint>(data[4], data[5]);
const p1 = transformXY ? transformXY(_p1) : _p1; const p1 = transformXY ? transformXY(_p1) : _p1;
const p2 = transformXY ? transformXY(_p2) : _p2; const p2 = transformXY ? transformXY(_p2) : _p2;
@@ -591,21 +591,21 @@ export const getArrowheadPoints = (
invariant(data.length === 6, "Op data length is not 6"); invariant(data.length === 6, "Op data length is not 6");
const p3 = point(data[4], data[5]); const p3 = pointFrom(data[4], data[5]);
const p2 = point(data[2], data[3]); const p2 = pointFrom(data[2], data[3]);
const p1 = point(data[0], data[1]); const p1 = pointFrom(data[0], data[1]);
// We need to find p0 of the bezier curve. // We need to find p0 of the bezier curve.
// It is typically the last point of the previous // It is typically the last point of the previous
// curve; it can also be the position of moveTo operation. // curve; it can also be the position of moveTo operation.
const prevOp = ops[index - 1]; const prevOp = ops[index - 1];
let p0 = point(0, 0); let p0 = pointFrom(0, 0);
if (prevOp.op === "move") { if (prevOp.op === "move") {
const p = pointFromArray(prevOp.data); const p = pointFromArray(prevOp.data);
invariant(p != null, "Op data is not a point"); invariant(p != null, "Op data is not a point");
p0 = p; p0 = p;
} else if (prevOp.op === "bcurveTo") { } else if (prevOp.op === "bcurveTo") {
p0 = point(prevOp.data[4], prevOp.data[5]); p0 = pointFrom(prevOp.data[4], prevOp.data[5]);
} }
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
@@ -671,13 +671,13 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
point(xs, ys), pointFrom(xs, ys),
point(x2, y2), pointFrom(x2, y2),
((-angle * Math.PI) / 180) as Radians, ((-angle * Math.PI) / 180) as Radians,
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
point(xs, ys), pointFrom(xs, ys),
point(x2, y2), pointFrom(x2, y2),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -690,8 +690,8 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
point(x2 + minSize * 2, y2), pointFrom(x2 + minSize * 2, y2),
point(x2, y2), pointFrom(x2, y2),
Math.atan2(py - y2, px - x2) as Radians, Math.atan2(py - y2, px - x2) as Radians,
); );
} else { } else {
@@ -701,8 +701,8 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
point(x2 - minSize * 2, y2), pointFrom(x2 - minSize * 2, y2),
point(x2, y2), pointFrom(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians, Math.atan2(y2 - py, x2 - px) as Radians,
); );
} }
@@ -746,8 +746,8 @@ const getLinearElementRotatedBounds = (
if (element.points.length < 2) { if (element.points.length < 2) {
const [pointX, pointY] = element.points[0]; const [pointX, pointY] = element.points[0];
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
point(element.x + pointX, element.y + pointY), pointFrom(element.x + pointX, element.y + pointY),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
@@ -775,8 +775,8 @@ const getLinearElementRotatedBounds = (
const ops = getCurvePathOps(shape); const ops = getCurvePathOps(shape);
const transformXY = ([x, y]: GlobalPoint) => const transformXY = ([x, y]: GlobalPoint) =>
pointRotateRads<GlobalPoint>( pointRotateRads<GlobalPoint>(
point(element.x + x, element.y + y), pointFrom(element.x + x, element.y + y),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
const res = getMinMaxXYFromCurvePathOps(ops, transformXY); const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
@@ -931,8 +931,8 @@ export const getClosestElementBounds = (
elements.forEach((element) => { elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const distance = pointDistance( const distance = pointDistance(
point((x1 + x2) / 2, (y1 + y2) / 2), pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
point(from.x, from.y), pointFrom(from.x, from.y),
); );
if (distance < minDistance) { if (distance < minDistance) {
@@ -990,7 +990,7 @@ export const getVisibleSceneBounds = ({
}; };
export const getCenterForBounds = (bounds: Bounds): GlobalPoint => export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
point( pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2,
); );
+11 -7
View File
@@ -17,7 +17,7 @@ import {
} from "./typeChecks"; } from "./typeChecks";
import { getBoundTextShape, isPathALoop } from "../shapes"; import { getBoundTextShape, isPathALoop } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math"; import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
import { isPointWithinBounds, point } from "../../math"; import { isPointWithinBounds, pointFrom } from "../../math";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (element.type === "arrow") {
@@ -61,13 +61,13 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
let hit = shouldTestInside(element) let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape ? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders" // we would need `onShape` as well to include the "borders"
isPointInShape(point(x, y), shape) || isPointInShape(pointFrom(x, y), shape) ||
isPointOnShape(point(x, y), shape, threshold) isPointOnShape(pointFrom(x, y), shape, threshold)
: isPointOnShape(point(x, y), shape, threshold); : isPointOnShape(pointFrom(x, y), shape, threshold);
// hit test against a frame's name // hit test against a frame's name
if (!hit && frameNameBound) { if (!hit && frameNameBound) {
hit = isPointInShape(point(x, y), { hit = isPointInShape(pointFrom(x, y), {
type: "polygon", type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon<Point>, .data as Polygon<Point>,
@@ -89,7 +89,11 @@ export const hitElementBoundingBox = (
y1 -= tolerance; y1 -= tolerance;
x2 += tolerance; x2 += tolerance;
y2 += tolerance; y2 += tolerance;
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2)); return isPointWithinBounds(
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
}; };
export const hitElementBoundingBoxOnly = < export const hitElementBoundingBoxOnly = <
@@ -115,5 +119,5 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
y: number, y: number,
textShape: GeometricShape<Point> | null, textShape: GeometricShape<Point> | null,
): boolean => { ): boolean => {
return !!textShape && isPointInShape(point(x, y), textShape); return !!textShape && isPointInShape(pointFrom(x, y), textShape);
}; };
+587
View File
@@ -0,0 +1,587 @@
import { type Point } from "points-on-curve";
import {
type Radians,
pointFrom,
pointCenter,
pointRotateRads,
vectorFromPoint,
vectorNormalize,
vectorSubtract,
vectorAdd,
vectorScale,
pointFromVector,
clamp,
isCloseTo,
} from "../../math";
import type { TransformHandleType } from "./transformHandles";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawImageElement,
ImageCrop,
NonDeleted,
} from "./types";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
transformHandle: TransformHandleType,
naturalWidth: number,
naturalHeight: number,
pointerX: number,
pointerY: number,
widthAspectRatio?: number,
) => {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
/**
* uncropped width
* *––––––––––––––––––––––––*
* | (x,y) (natural) |
* | *–––––––* |
* | |///////| height | uncropped height
* | *–––––––* |
* | width (natural) |
* *––––––––––––––––––––––––*
*/
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
-element.angle as Radians,
);
pointerX = rotatedPointer[0];
pointerY = rotatedPointer[1];
let nextWidth = element.width;
let nextHeight = element.height;
let crop: ImageCrop | null = element.crop ?? {
x: 0,
y: 0,
width: naturalWidth,
height: naturalHeight,
naturalWidth,
naturalHeight,
};
const previousCropHeight = crop.height;
const previousCropWidth = crop.width;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let changeInHeight = pointerY - element.y;
let changeInWidth = pointerX - element.x;
if (transformHandle.includes("n")) {
nextHeight = clamp(
element.height - changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
);
}
if (transformHandle.includes("s")) {
changeInHeight = pointerY - element.y - element.height;
nextHeight = clamp(
element.height + changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
);
}
if (transformHandle.includes("e")) {
changeInWidth = pointerX - element.x - element.width;
nextWidth = clamp(
element.width + changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
);
}
if (transformHandle.includes("w")) {
nextWidth = clamp(
element.width - changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
);
}
const updateCropWidthAndHeight = (crop: ImageCrop) => {
crop.height = nextHeight * naturalHeightToUncropped;
crop.width = nextWidth * naturalWidthToUncropped;
};
updateCropWidthAndHeight(crop);
const adjustFlipForHandle = (
handle: TransformHandleType,
crop: ImageCrop,
) => {
updateCropWidthAndHeight(crop);
if (handle.includes("n")) {
if (!isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("s")) {
if (isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("e")) {
if (isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
if (handle.includes("w")) {
if (!isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
};
switch (transformHandle) {
case "n": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "s": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "w": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "e": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "ne": {
if (widthAspectRatio) {
if (changeInWidth > -changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "nw": {
if (widthAspectRatio) {
if (changeInWidth < changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "se": {
if (widthAspectRatio) {
if (changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "sw": {
if (widthAspectRatio) {
if (-changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
default:
break;
}
const newOrigin = recomputeOrigin(
element,
transformHandle,
nextWidth,
nextHeight,
!!widthAspectRatio,
);
// reset crop to null if we're back to orig size
if (
isCloseTo(crop.width, crop.naturalWidth) &&
isCloseTo(crop.height, crop.naturalHeight)
) {
crop = null;
}
return {
x: newOrigin[0],
y: newOrigin[1],
width: nextWidth,
height: nextHeight,
crop,
};
};
const recomputeOrigin = (
stateAtCropStart: NonDeleted<ExcalidrawElement>,
transformHandle: TransformHandleType,
width: number,
height: number,
shouldMaintainAspectRatio?: boolean,
) => {
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtCropStart,
stateAtCropStart.width,
stateAtCropStart.height,
true,
);
const startTopLeft = pointFrom(x1, y1);
const startBottomRight = pointFrom(x2, y2);
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
// Calculate new topLeft based on fixed corner during resize
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandle)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startBottomRight[1] - Math.abs(newBoundsHeight),
];
}
if (transformHandle === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
}
if (transformHandle === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
}
if (shouldMaintainAspectRatio) {
if (["s", "n"].includes(transformHandle)) {
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
if (["e", "w"].includes(transformHandle)) {
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
}
}
// adjust topLeft to new rotation point
const angle = stateAtCropStart.angle;
const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtCropStart.x - newBoundsX1;
newOrigin[1] += stateAtCropStart.y - newBoundsY1;
return newOrigin;
};
// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
export const getUncroppedImageElement = (
element: ExcalidrawImageElement,
elementsMap: ElementsMap,
) => {
if (element.crop) {
const { width, height } = getUncroppedWidthAndHeight(element);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const topLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
);
const topRightVector = vectorFromPoint(
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
);
const topEdgeNormalized = vectorNormalize(
vectorSubtract(topRightVector, topLeftVector),
);
const bottomLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
);
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
const rotatedTopLeft = vectorAdd(
vectorAdd(
topLeftVector,
vectorScale(
topEdgeNormalized,
(-cropX * width) / element.crop.naturalWidth,
),
),
vectorScale(
leftEdgeNormalized,
(-cropY * height) / element.crop.naturalHeight,
),
);
const center = pointFromVector(
vectorAdd(
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
vectorScale(leftEdgeNormalized, height / 2),
),
);
const unrotatedTopLeft = pointRotateRads(
pointFromVector(rotatedTopLeft),
center,
-element.angle as Radians,
);
const uncroppedElement: ExcalidrawImageElement = {
...element,
x: unrotatedTopLeft[0],
y: unrotatedTopLeft[1],
width,
height,
crop: null,
};
return uncroppedElement;
}
return element;
};
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
if (element.crop) {
const width =
element.width / (element.crop.width / element.crop.naturalWidth);
const height =
element.height / (element.crop.height / element.crop.naturalHeight);
return {
width,
height,
};
}
return {
width: element.width,
height: element.height,
};
};
const adjustCropPosition = (
crop: ImageCrop,
scale: ExcalidrawImageElement["scale"],
) => {
let cropX = crop.x;
let cropY = crop.y;
const flipX = scale[0] === -1;
const flipY = scale[1] === -1;
if (flipX) {
cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
}
if (flipY) {
cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
}
return {
cropX,
cropY,
};
};
+11 -8
View File
@@ -16,6 +16,7 @@ import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isImageElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
@@ -35,7 +36,6 @@ export const dragSelectedElements = (
) => { ) => {
if ( if (
_selectedElements.length === 1 && _selectedElements.length === 1 &&
isArrowElement(_selectedElements[0]) &&
isElbowArrow(_selectedElements[0]) && isElbowArrow(_selectedElements[0]) &&
(_selectedElements[0].startBinding || _selectedElements[0].endBinding) (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) { ) {
@@ -43,13 +43,7 @@ export const dragSelectedElements = (
} }
const selectedElements = _selectedElements.filter( const selectedElements = _selectedElements.filter(
(el) => (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
!(
isArrowElement(el) &&
isElbowArrow(el) &&
el.startBinding &&
el.endBinding
),
); );
// we do not want a frame and its elements to be selected at the same time // we do not want a frame and its elements to be selected at the same time
@@ -258,6 +252,14 @@ export const dragNewElement = ({
} }
if (width !== 0 && height !== 0) { if (width !== 0 && height !== 0) {
let imageInitialDimension = null;
if (isImageElement(newElement)) {
imageInitialDimension = {
initialWidth: width,
initialHeight: height,
};
}
mutateElement( mutateElement(
newElement, newElement,
{ {
@@ -266,6 +268,7 @@ export const dragNewElement = ({
width, width,
height, height,
...textAutoResize, ...textAutoResize,
...imageInitialDimension,
}, },
informMutation, informMutation,
); );
+31
View File
@@ -45,6 +45,12 @@ const RE_GENERIC_EMBED =
const RE_GIPHY = const RE_GIPHY =
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/; /giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
const RE_REDDIT =
/^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
@@ -59,6 +65,7 @@ const ALLOWED_DOMAINS = new Set([
"stackblitz.com", "stackblitz.com",
"val.town", "val.town",
"giphy.com", "giphy.com",
"reddit.com",
]); ]);
const ALLOW_SAME_ORIGIN = new Set([ const ALLOW_SAME_ORIGIN = new Set([
@@ -71,6 +78,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"x.com", "x.com",
"*.simplepdf.eu", "*.simplepdf.eu",
"stackblitz.com", "stackblitz.com",
"reddit.com",
]); ]);
export const createSrcDoc = (body: string) => { export const createSrcDoc = (body: string) => {
@@ -218,6 +226,24 @@ export const getEmbedLink = (
return ret; return ret;
} }
if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = sanitizeHTMLAttribute(
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
);
const ret: IframeDataWithSandbox = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
),
intrinsicSize: { w: 480, h: 480 },
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!; const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute( const safeURL = sanitizeHTMLAttribute(
@@ -361,6 +387,11 @@ export const maybeParseEmbedSrc = (str: string): string => {
return twitterMatch[1]; return twitterMatch[1];
} }
const redditMatch = str.match(RE_REDDIT_EMBED);
if (redditMatch && redditMatch.length === 2) {
return redditMatch[1];
}
const gistMatch = str.match(RE_GH_GIST_EMBED); const gistMatch = str.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 2) { if (gistMatch && gistMatch.length === 2) {
return gistMatch[1]; return gistMatch[1];
+2 -2
View File
@@ -29,7 +29,7 @@ import {
isFlowchartNodeElement, isFlowchartNodeElement,
} from "./typeChecks"; } from "./typeChecks";
import { invariant } from "../utils"; import { invariant } from "../utils";
import { point, type LocalPoint } from "../../math"; import { pointFrom, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes"; import { aabbForElement } from "../shapes";
type LinkDirection = "up" | "right" | "down" | "left"; type LinkDirection = "up" | "right" | "down" | "left";
@@ -421,7 +421,7 @@ const createBindingArrow = (
strokeColor: appState.currentItemStrokeColor, strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle, strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth, strokeWidth: appState.currentItemStrokeWidth,
points: [point(0, 0), point(endX, endY)], points: [pointFrom(0, 0), pointFrom(endX, endY)],
elbowed: true, elbowed: true,
}); });
+9 -9
View File
@@ -6,7 +6,7 @@ import type {
Radians, Radians,
} from "../../math"; } from "../../math";
import { import {
point, pointFrom,
pointRotateRads, pointRotateRads,
pointScaleFromOrigin, pointScaleFromOrigin,
radiansToDegrees, radiansToDegrees,
@@ -82,7 +82,7 @@ export const headingForPointFromElement = <
const top = pointRotateRads( const top = pointRotateRads(
pointScaleFromOrigin( pointScaleFromOrigin(
point(element.x + element.width / 2, element.y), pointFrom(element.x + element.width / 2, element.y),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -91,7 +91,7 @@ export const headingForPointFromElement = <
); );
const right = pointRotateRads( const right = pointRotateRads(
pointScaleFromOrigin( pointScaleFromOrigin(
point(element.x + element.width, element.y + element.height / 2), pointFrom(element.x + element.width, element.y + element.height / 2),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -100,7 +100,7 @@ export const headingForPointFromElement = <
); );
const bottom = pointRotateRads( const bottom = pointRotateRads(
pointScaleFromOrigin( pointScaleFromOrigin(
point(element.x + element.width / 2, element.y + element.height), pointFrom(element.x + element.width / 2, element.y + element.height),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -109,7 +109,7 @@ export const headingForPointFromElement = <
); );
const left = pointRotateRads( const left = pointRotateRads(
pointScaleFromOrigin( pointScaleFromOrigin(
point(element.x, element.y + element.height / 2), pointFrom(element.x, element.y + element.height / 2),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
), ),
@@ -133,22 +133,22 @@ export const headingForPointFromElement = <
} }
const topLeft = pointScaleFromOrigin( const topLeft = pointScaleFromOrigin(
point(aabb[0], aabb[1]), pointFrom(aabb[0], aabb[1]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
const topRight = pointScaleFromOrigin( const topRight = pointScaleFromOrigin(
point(aabb[2], aabb[1]), pointFrom(aabb[2], aabb[1]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
const bottomLeft = pointScaleFromOrigin( const bottomLeft = pointScaleFromOrigin(
point(aabb[0], aabb[3]), pointFrom(aabb[0], aabb[3]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
const bottomRight = pointScaleFromOrigin( const bottomRight = pointScaleFromOrigin(
point(aabb[2], aabb[3]), pointFrom(aabb[2], aabb[3]),
midPoint, midPoint,
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
@@ -49,7 +49,7 @@ import type Scene from "../scene/Scene";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { import {
pointCenter, pointCenter,
point, pointFrom,
pointRotateRads, pointRotateRads,
pointsEqual, pointsEqual,
vector, vector,
@@ -102,12 +102,13 @@ export class LinearElementEditor {
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
constructor(element: NonDeleted<ExcalidrawLinearElement>) { constructor(element: NonDeleted<ExcalidrawLinearElement>) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
if (!pointsEqual(element.points[0], point(0, 0))) { if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack); console.error("Linear element is not normalized", Error().stack);
} }
@@ -131,6 +132,7 @@ export class LinearElementEditor {
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -285,7 +287,7 @@ export class LinearElementEditor {
element, element,
elementsMap, elementsMap,
referencePoint, referencePoint,
point(scenePointerX, scenePointerY), pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
@@ -294,7 +296,7 @@ export class LinearElementEditor {
[ [
{ {
index: selectedIndex, index: selectedIndex,
point: point( point: pointFrom(
width + referencePoint[0], width + referencePoint[0],
height + referencePoint[1], height + referencePoint[1],
), ),
@@ -327,7 +329,7 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
) )
: point( : pointFrom(
element.points[pointIndex][0] + deltaX, element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY, element.points[pointIndex][1] + deltaY,
); );
@@ -588,11 +590,11 @@ export class LinearElementEditor {
linearElementEditor.segmentMidPointHoveredCoords; linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) { if (existingSegmentMidpointHitCoords) {
const distance = pointDistance( const distance = pointDistance(
point( pointFrom(
existingSegmentMidpointHitCoords[0], existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1], existingSegmentMidpointHitCoords[1],
), ),
point(scenePointer.x, scenePointer.y), pointFrom(scenePointer.x, scenePointer.y),
); );
if (distance <= threshold) { if (distance <= threshold) {
return existingSegmentMidpointHitCoords; return existingSegmentMidpointHitCoords;
@@ -604,8 +606,8 @@ export class LinearElementEditor {
while (index < midPoints.length) { while (index < midPoints.length) {
if (midPoints[index] !== null) { if (midPoints[index] !== null) {
const distance = pointDistance( const distance = pointDistance(
point(midPoints[index]![0], midPoints[index]![1]), pointFrom(midPoints[index]![0], midPoints[index]![1]),
point(scenePointer.x, scenePointer.y), pointFrom(scenePointer.x, scenePointer.y),
); );
if (distance <= threshold) { if (distance <= threshold) {
return midPoints[index]; return midPoints[index];
@@ -624,8 +626,8 @@ export class LinearElementEditor {
zoom: AppState["zoom"], zoom: AppState["zoom"],
) { ) {
let distance = pointDistance( let distance = pointDistance(
point(startPoint[0], startPoint[1]), pointFrom(startPoint[0], startPoint[1]),
point(endPoint[0], endPoint[1]), pointFrom(endPoint[0], endPoint[1]),
); );
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint); distance = getBezierCurveLength(element, endPoint);
@@ -827,11 +829,11 @@ export class LinearElementEditor {
const targetPoint = const targetPoint =
clickedPointIndex > -1 && clickedPointIndex > -1 &&
pointRotateRads( pointRotateRads(
point( pointFrom(
element.x + element.points[clickedPointIndex][0], element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1], element.y + element.points[clickedPointIndex][1],
), ),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
@@ -926,11 +928,11 @@ export class LinearElementEditor {
element, element,
elementsMap, elementsMap,
lastCommittedPoint, lastCommittedPoint,
point(scenePointerX, scenePointerY), pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
newPoint = point( newPoint = pointFrom(
width + lastCommittedPoint[0], width + lastCommittedPoint[0],
height + lastCommittedPoint[1], height + lastCommittedPoint[1],
); );
@@ -982,8 +984,8 @@ export class LinearElementEditor {
const { x, y } = element; const { x, y } = element;
return pointRotateRads( return pointRotateRads(
point(x + p[0], y + p[1]), pointFrom(x + p[0], y + p[1]),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
} }
@@ -999,8 +1001,8 @@ export class LinearElementEditor {
return element.points.map((p) => { return element.points.map((p) => {
const { x, y } = element; const { x, y } = element;
return pointRotateRads( return pointRotateRads(
point(x + p[0], y + p[1]), pointFrom(x + p[0], y + p[1]),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
}); });
@@ -1023,8 +1025,12 @@ export class LinearElementEditor {
const { x, y } = element; const { x, y } = element;
return p return p
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle) ? pointRotateRads(
: pointRotateRads(point(x, y), point(cx, cy), element.angle); pointFrom(x + p[0], y + p[1]),
pointFrom(cx, cy),
element.angle,
)
: pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle);
} }
static pointFromAbsoluteCoords( static pointFromAbsoluteCoords(
@@ -1034,7 +1040,7 @@ export class LinearElementEditor {
): LocalPoint { ): LocalPoint {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
// No rotation for elbow arrows // No rotation for elbow arrows
return point( return pointFrom(
absoluteCoords[0] - element.x, absoluteCoords[0] - element.x,
absoluteCoords[1] - element.y, absoluteCoords[1] - element.y,
); );
@@ -1044,11 +1050,11 @@ export class LinearElementEditor {
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
point(absoluteCoords[0], absoluteCoords[1]), pointFrom(absoluteCoords[0], absoluteCoords[1]),
point(cx, cy), pointFrom(cx, cy),
-element.angle as Radians, -element.angle as Radians,
); );
return point(x - element.x, y - element.y); return pointFrom(x - element.x, y - element.y);
} }
static getPointIndexUnderCursor( static getPointIndexUnderCursor(
@@ -1069,7 +1075,7 @@ export class LinearElementEditor {
while (--idx > -1) { while (--idx > -1) {
const p = pointHandles[idx]; const p = pointHandles[idx];
if ( if (
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value < pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke // +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1 LinearElementEditor.POINT_HANDLE_SIZE + 1
) { ) {
@@ -1091,12 +1097,12 @@ export class LinearElementEditor {
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const [rotatedX, rotatedY] = pointRotateRads( const [rotatedX, rotatedY] = pointRotateRads(
point(pointerOnGrid[0], pointerOnGrid[1]), pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
point(cx, cy), pointFrom(cx, cy),
-element.angle as Radians, -element.angle as Radians,
); );
return point(rotatedX - element.x, rotatedY - element.y); return pointFrom(rotatedX - element.x, rotatedY - element.y);
} }
/** /**
@@ -1116,7 +1122,7 @@ export class LinearElementEditor {
return { return {
points: points.map((p) => { points: points.map((p) => {
return point(p[0] - offsetX, p[1] - offsetY); return pointFrom(p[0] - offsetX, p[1] - offsetY);
}), }),
x: element.x + offsetX, x: element.x + offsetX,
y: element.y + offsetY, y: element.y + offsetY,
@@ -1170,8 +1176,8 @@ export class LinearElementEditor {
} }
acc.push( acc.push(
nextPoint nextPoint
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2) ? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
: point(p[0], p[1]), : pointFrom(p[0], p[1]),
); );
nextSelectedIndices.push(indexCursor + 1); nextSelectedIndices.push(indexCursor + 1);
@@ -1192,7 +1198,7 @@ export class LinearElementEditor {
[ [
{ {
index: element.points.length - 1, index: element.points.length - 1,
point: point(lastPoint[0] + 30, lastPoint[1] + 30), point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
}, },
], ],
elementsMap, elementsMap,
@@ -1233,7 +1239,9 @@ export class LinearElementEditor {
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) { if (!pointIndices.includes(idx)) {
acc.push( acc.push(
!acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY), !acc.length
? pointFrom(0, 0)
: pointFrom(p[0] - offsetX, p[1] - offsetY),
); );
} }
return acc; return acc;
@@ -1310,9 +1318,9 @@ export class LinearElementEditor {
const deltaY = const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1]; selectedPointData.point[1] - points[selectedPointData.index][1];
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
} }
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p; return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p;
}); });
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
@@ -1366,8 +1374,8 @@ export class LinearElementEditor {
const origin = linearElementEditor.pointerDownState.origin!; const origin = linearElementEditor.pointerDownState.origin!;
const dist = pointDistance( const dist = pointDistance(
point(origin.x, origin.y), pointFrom(origin.x, origin.y),
point(pointerCoords.x, pointerCoords.y), pointFrom(pointerCoords.x, pointerCoords.y),
); );
if ( if (
!appState.editingLinearElement && !appState.editingLinearElement &&
@@ -1477,7 +1485,9 @@ export class LinearElementEditor {
nextPoints, nextPoints,
vector(offsetX, offsetY), vector(offsetX, offsetY),
bindings, bindings,
options, {
isDragging: options?.isDragging,
},
); );
} else { } else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
@@ -1489,8 +1499,8 @@ export class LinearElementEditor {
const dX = prevCenterX - nextCenterX; const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY; const dY = prevCenterY - nextCenterY;
const rotated = pointRotateRads( const rotated = pointRotateRads(
point(offsetX, offsetY), pointFrom(offsetX, offsetY),
point(dX, dY), pointFrom(dX, dY),
element.angle, element.angle,
); );
mutateElement(element, { mutateElement(element, {
@@ -1536,8 +1546,8 @@ export class LinearElementEditor {
); );
return pointRotateRads( return pointRotateRads(
point(width, height), pointFrom(width, height),
point(0, 0), pointFrom(0, 0),
-element.angle as Radians, -element.angle as Radians,
); );
} }
@@ -1607,36 +1617,36 @@ export class LinearElementEditor {
); );
const boundTextX2 = boundTextX1 + boundTextElement.width; const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height; const boundTextY2 = boundTextY1 + boundTextElement.height;
const centerPoint = point(cx, cy); const centerPoint = pointFrom(cx, cy);
const topLeftRotatedPoint = pointRotateRads( const topLeftRotatedPoint = pointRotateRads(
point(x1, y1), pointFrom(x1, y1),
centerPoint, centerPoint,
element.angle, element.angle,
); );
const topRightRotatedPoint = pointRotateRads( const topRightRotatedPoint = pointRotateRads(
point(x2, y1), pointFrom(x2, y1),
centerPoint, centerPoint,
element.angle, element.angle,
); );
const counterRotateBoundTextTopLeft = pointRotateRads( const counterRotateBoundTextTopLeft = pointRotateRads(
point(boundTextX1, boundTextY1), pointFrom(boundTextX1, boundTextY1),
centerPoint, centerPoint,
-element.angle as Radians, -element.angle as Radians,
); );
const counterRotateBoundTextTopRight = pointRotateRads( const counterRotateBoundTextTopRight = pointRotateRads(
point(boundTextX2, boundTextY1), pointFrom(boundTextX2, boundTextY1),
centerPoint, centerPoint,
-element.angle as Radians, -element.angle as Radians,
); );
const counterRotateBoundTextBottomLeft = pointRotateRads( const counterRotateBoundTextBottomLeft = pointRotateRads(
point(boundTextX1, boundTextY2), pointFrom(boundTextX1, boundTextY2),
centerPoint, centerPoint,
-element.angle as Radians, -element.angle as Radians,
); );
const counterRotateBoundTextBottomRight = pointRotateRads( const counterRotateBoundTextBottomRight = pointRotateRads(
point(boundTextX2, boundTextY2), pointFrom(boundTextX2, boundTextY2),
centerPoint, centerPoint,
-element.angle as Radians, -element.angle as Radians,
); );
@@ -5,7 +5,7 @@ import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
import type { ExcalidrawLinearElement } from "./types"; import type { ExcalidrawLinearElement } from "./types";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { point } from "../../math"; import { pointFrom } from "../../math";
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) { for (const key in clone) {
@@ -38,7 +38,7 @@ describe("duplicating single elements", () => {
element.__proto__ = { hello: "world" }; element.__proto__ = { hello: "world" };
mutateElement(element, { mutateElement(element, {
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)], points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
}); });
const copy = duplicateElement(null, new Map(), element); const copy = duplicateElement(null, new Map(), element);
+2 -1
View File
@@ -223,7 +223,6 @@ export const newTextElement = (
verticalAlign?: VerticalAlign; verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null; containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
autoResize?: ExcalidrawTextElement["autoResize"]; autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
@@ -478,6 +477,7 @@ export const newImageElement = (
status?: ExcalidrawImageElement["status"]; status?: ExcalidrawImageElement["status"];
fileId?: ExcalidrawImageElement["fileId"]; fileId?: ExcalidrawImageElement["fileId"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
crop?: ExcalidrawImageElement["crop"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => { ): NonDeleted<ExcalidrawImageElement> => {
return { return {
@@ -488,6 +488,7 @@ export const newImageElement = (
status: opts.status ?? "pending", status: opts.status ?? "pending",
fileId: opts.fileId ?? null, fileId: opts.fileId ?? null,
scale: opts.scale ?? [1, 1], scale: opts.scale ?? [1, 1],
crop: opts.crop ?? null,
}; };
}; };
+58 -56
View File
@@ -9,6 +9,7 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
@@ -57,7 +58,7 @@ import type { GlobalPoint } from "../../math";
import { import {
pointCenter, pointCenter,
normalizeRadians, normalizeRadians,
point, pointFrom,
pointFromPair, pointFromPair,
pointRotateRads, pointRotateRads,
type Radians, type Radians,
@@ -239,8 +240,8 @@ const resizeSingleTextElement = (
); );
// rotation pointer with reverse angle // rotation pointer with reverse angle
const [rotatedX, rotatedY] = pointRotateRads( const [rotatedX, rotatedY] = pointRotateRads(
point(pointerX, pointerY), pointFrom(pointerX, pointerY),
point(cx, cy), pointFrom(cx, cy),
-element.angle as Radians, -element.angle as Radians,
); );
let scaleX = 0; let scaleX = 0;
@@ -275,23 +276,23 @@ const resizeSingleTextElement = (
const startBottomRight = [x2, y2]; const startBottomRight = [x2, y2];
const startCenter = [cx, cy]; const startCenter = [cx, cy];
let newTopLeft = point<GlobalPoint>(x1, y1); let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) { if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = point<GlobalPoint>( newTopLeft = pointFrom<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth), startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight), startBottomRight[1] - Math.abs(nextHeight),
); );
} }
if (transformHandleType === "ne") { if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]]; const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = point<GlobalPoint>( newTopLeft = pointFrom<GlobalPoint>(
bottomLeft[0], bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight), bottomLeft[1] - Math.abs(nextHeight),
); );
} }
if (transformHandleType === "sw") { if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]]; const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = point<GlobalPoint>( newTopLeft = pointFrom<GlobalPoint>(
topRight[0] - Math.abs(nextWidth), topRight[0] - Math.abs(nextWidth),
topRight[1], topRight[1],
); );
@@ -310,12 +311,20 @@ const resizeSingleTextElement = (
} }
const angle = element.angle; const angle = element.angle;
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle); const rotatedTopLeft = pointRotateRads(
const newCenter = point<GlobalPoint>( newTopLeft,
pointFrom(cx, cy),
angle,
);
const newCenter = pointFrom<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2, newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2, newTopLeft[1] + Math.abs(nextHeight) / 2,
); );
const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle); const rotatedNewCenter = pointRotateRads(
newCenter,
pointFrom(cx, cy),
angle,
);
newTopLeft = pointRotateRads( newTopLeft = pointRotateRads(
rotatedTopLeft, rotatedTopLeft,
rotatedNewCenter, rotatedNewCenter,
@@ -340,12 +349,12 @@ const resizeSingleTextElement = (
stateAtResizeStart.height, stateAtResizeStart.height,
true, true,
); );
const startTopLeft = point<GlobalPoint>(x1, y1); const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
const startBottomRight = point<GlobalPoint>(x2, y2); const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight); const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
point(pointerX, pointerY), pointFrom(pointerX, pointerY),
startCenter, startCenter,
-stateAtResizeStart.angle as Radians, -stateAtResizeStart.angle as Radians,
); );
@@ -418,7 +427,7 @@ const resizeSingleTextElement = (
startCenter, startCenter,
angle, angle,
); );
const newCenter = point( const newCenter = pointFrom(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
); );
@@ -460,13 +469,13 @@ export const resizeSingleElement = (
stateAtResizeStart.height, stateAtResizeStart.height,
true, true,
); );
const startTopLeft = point(x1, y1); const startTopLeft = pointFrom(x1, y1);
const startBottomRight = point(x2, y2); const startBottomRight = pointFrom(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight); const startCenter = pointCenter(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position // Calculate new dimensions based on cursor position
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
point(pointerX, pointerY), pointFrom(pointerX, pointerY),
startCenter, startCenter,
-stateAtResizeStart.angle as Radians, -stateAtResizeStart.angle as Radians,
); );
@@ -647,7 +656,7 @@ export const resizeSingleElement = (
startCenter, startCenter,
angle, angle,
); );
const newCenter = point( const newCenter = pointFrom(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
); );
@@ -816,20 +825,20 @@ export const resizeMultipleElements = (
const direction = transformHandleType; const direction = transformHandleType;
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = { const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
ne: point(minX, maxY), ne: pointFrom(minX, maxY),
se: point(minX, minY), se: pointFrom(minX, minY),
sw: point(maxX, minY), sw: pointFrom(maxX, minY),
nw: point(maxX, maxY), nw: pointFrom(maxX, maxY),
e: point(minX, minY + height / 2), e: pointFrom(minX, minY + height / 2),
w: point(maxX, minY + height / 2), w: pointFrom(maxX, minY + height / 2),
n: point(minX + width / 2, maxY), n: pointFrom(minX + width / 2, maxY),
s: point(minX + width / 2, minY), s: pointFrom(minX + width / 2, minY),
}; };
// anchor point must be on the opposite side of the dragged selection handle // anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter // or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY] = shouldResizeFromCenter const [anchorX, anchorY] = shouldResizeFromCenter
? point(midX, midY) ? pointFrom(midX, midY)
: anchorsMap[direction]; : anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1; const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@@ -909,6 +918,8 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"]; fontSize?: ExcalidrawTextElement["fontSize"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"];
startBinding?: ExcalidrawArrowElement["startBinding"];
endBinding?: ExcalidrawArrowElement["endBinding"];
}; };
}[] = []; }[] = [];
@@ -993,19 +1004,6 @@ export const resizeMultipleElements = (
mutateElement(element, update, false); mutateElement(element, update, false);
if (isArrowElement(element) && isElbowArrow(element)) {
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
);
}
updateBoundElements(element, elementsMap, { updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
oldSize: { width: oldWidth, height: oldHeight }, oldSize: { width: oldWidth, height: oldHeight },
@@ -1054,12 +1052,12 @@ const rotateMultipleElements = (
const origAngle = const origAngle =
originalElements.get(element.id)?.angle ?? element.angle; originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = pointRotateRads( const [rotatedCX, rotatedCY] = pointRotateRads(
point(cx, cy), pointFrom(cx, cy),
point(centerX, centerY), pointFrom(centerX, centerY),
(centerAngle + origAngle - element.angle) as Radians, (centerAngle + origAngle - element.angle) as Radians,
); );
if (isArrowElement(element) && isElbowArrow(element)) { if (isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap); const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, elementsMap, points); mutateElbowArrow(element, elementsMap, points);
} else { } else {
@@ -1111,40 +1109,44 @@ export const getResizeOffsetXY = (
const angle = ( const angle = (
selectedElements.length === 1 ? selectedElements[0].angle : 0 selectedElements.length === 1 ? selectedElements[0].angle : 0
) as Radians; ) as Radians;
[x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians); [x, y] = pointRotateRads(
pointFrom(x, y),
pointFrom(cx, cy),
-angle as Radians,
);
switch (transformHandleType) { switch (transformHandleType) {
case "n": case "n":
return pointRotateRads( return pointRotateRads(
point(x - (x1 + x2) / 2, y - y1), pointFrom(x - (x1 + x2) / 2, y - y1),
point(0, 0), pointFrom(0, 0),
angle, angle,
); );
case "s": case "s":
return pointRotateRads( return pointRotateRads(
point(x - (x1 + x2) / 2, y - y2), pointFrom(x - (x1 + x2) / 2, y - y2),
point(0, 0), pointFrom(0, 0),
angle, angle,
); );
case "w": case "w":
return pointRotateRads( return pointRotateRads(
point(x - x1, y - (y1 + y2) / 2), pointFrom(x - x1, y - (y1 + y2) / 2),
point(0, 0), pointFrom(0, 0),
angle, angle,
); );
case "e": case "e":
return pointRotateRads( return pointRotateRads(
point(x - x2, y - (y1 + y2) / 2), pointFrom(x - x2, y - (y1 + y2) / 2),
point(0, 0), pointFrom(0, 0),
angle, angle,
); );
case "nw": case "nw":
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle); return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
case "ne": case "ne":
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle); return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
case "sw": case "sw":
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle); return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
case "se": case "se":
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle); return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
default: default:
return [0, 0]; return [0, 0];
} }
+23 -15
View File
@@ -20,10 +20,10 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants"; import { SIDE_RESIZING_THRESHOLD } from "../constants";
import { isLinearElement } from "./typeChecks"; import { isImageElement, isLinearElement } from "./typeChecks";
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math"; import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
import { import {
point, pointFrom,
pointOnLineSegment, pointOnLineSegment,
pointRotateRads, pointRotateRads,
type Radians, type Radians,
@@ -90,18 +90,26 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
// do not resize from the sides for linear elements with only two points // do not resize from the sides for linear elements with only two points
if (!(isLinearElement(element) && element.points.length <= 2)) { if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const SPACING = isImageElement(element)
? 0
: SIDE_RESIZING_THRESHOLD / zoom.value;
const ZOOMED_SIDE_RESIZING_THRESHOLD =
SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
point(x1 - SPACING, y1 - SPACING), pointFrom(x1 - SPACING, y1 - SPACING),
point(x2 + SPACING, y2 + SPACING), pointFrom(x2 + SPACING, y2 + SPACING),
point(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
for (const [dir, side] of Object.entries(sides)) { for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment // test to see if x, y are on the line segment
if ( if (
pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING) pointOnLineSegment(
pointFrom(x, y),
side as LineSegment<Point>,
ZOOMED_SIDE_RESIZING_THRESHOLD,
)
) { ) {
return dir as TransformHandleType; return dir as TransformHandleType;
} }
@@ -178,9 +186,9 @@ export const getTransformHandleTypeFromCoords = <
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders( const sides = getSelectionBorders(
point(x1 - SPACING, y1 - SPACING), pointFrom(x1 - SPACING, y1 - SPACING),
point(x2 + SPACING, y2 + SPACING), pointFrom(x2 + SPACING, y2 + SPACING),
point(cx, cy), pointFrom(cx, cy),
0 as Radians, 0 as Radians,
); );
@@ -188,7 +196,7 @@ export const getTransformHandleTypeFromCoords = <
// test to see if x, y are on the line segment // test to see if x, y are on the line segment
if ( if (
pointOnLineSegment( pointOnLineSegment(
point(scenePointerX, scenePointerY), pointFrom(scenePointerX, scenePointerY),
side as LineSegment<Point>, side as LineSegment<Point>,
SPACING, SPACING,
) )
@@ -265,10 +273,10 @@ const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
center: Point, center: Point,
angle: Radians, angle: Radians,
) => { ) => {
const topLeft = pointRotateRads(point(x1, y1), center, angle); const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
const topRight = pointRotateRads(point(x2, y1), center, angle); const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
const bottomLeft = pointRotateRads(point(x1, y2), center, angle); const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
const bottomRight = pointRotateRads(point(x2, y2), center, angle); const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
return { return {
n: [topLeft, topRight], n: [topLeft, topRight],
+18 -17
View File
@@ -17,7 +17,7 @@ import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { ARROW_TYPE } from "../constants"; import { ARROW_TYPE } from "../constants";
import { point } from "../../math"; import { pointFrom } from "../../math";
const { h } = window; const { h } = window;
@@ -32,8 +32,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow); scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
point(-45 - arrow.x, -100.1 - arrow.y), pointFrom(-45 - arrow.x, -100.1 - arrow.y),
point(45 - arrow.x, 99.9 - arrow.y), pointFrom(45 - arrow.x, 99.9 - arrow.y),
]); ]);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
@@ -69,7 +69,7 @@ describe("elbow arrow routing", () => {
y: -100.1, y: -100.1,
width: 90, width: 90,
height: 200, height: 200,
points: [point(0, 0), point(90, 200)], points: [pointFrom(0, 0), pointFrom(90, 200)],
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1); scene.insertElement(rectangle1);
scene.insertElement(rectangle2); scene.insertElement(rectangle2);
@@ -81,7 +81,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]); mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
@@ -94,7 +94,16 @@ describe("elbow arrow routing", () => {
describe("elbow arrow ui", () => { describe("elbow arrow ui", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
}); });
it("can follow bound shapes", async () => { it("can follow bound shapes", async () => {
@@ -130,8 +139,8 @@ describe("elbow arrow ui", () => {
expect(arrow.elbowed).toBe(true); expect(arrow.elbowed).toBe(true);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[35, 0], [45, 0],
[35, 200], [45, 200],
[90, 200], [90, 200],
]); ]);
}); });
@@ -163,14 +172,6 @@ describe("elbow arrow ui", () => {
h.state, h.state,
)[0] as ExcalidrawArrowElement; )[0] as ExcalidrawArrowElement;
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
mouse.click(51, 51); mouse.click(51, 51);
const inputAngle = UI.queryStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
@@ -182,8 +183,8 @@ describe("elbow arrow ui", () => {
[0, 0], [0, 0],
[35, 0], [35, 0],
[35, 90], [35, 90],
[25, 90], [35, 90], // Note that coordinates are rounded above!
[25, 165], [35, 165],
[103, 165], [103, 165],
]); ]);
}); });
+67 -37
View File
@@ -1,6 +1,6 @@
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { import {
point, pointFrom,
pointScaleFromOrigin, pointScaleFromOrigin,
pointTranslate, pointTranslate,
vector, vector,
@@ -36,11 +36,11 @@ import {
HEADING_UP, HEADING_UP,
vectorToHeading, vectorToHeading,
} from "./heading"; } from "./heading";
import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks"; import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import type { import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPointBinding,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
@@ -72,16 +72,48 @@ export const mutateElbowArrow = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
offset?: Vector, offset?: Vector,
otherUpdates?: { otherUpdates?: Omit<
startBinding?: FixedPointBinding | null; ElementUpdate<ExcalidrawElbowArrowElement>,
endBinding?: FixedPointBinding | null; "angle" | "x" | "y" | "width" | "height" | "elbowed" | "points"
>,
options?: {
isDragging?: boolean;
informMutation?: boolean;
}, },
) => {
const update = updateElbowArrow(
arrow,
elementsMap,
nextPoints,
offset,
options,
);
if (update) {
mutateElement(
arrow,
{
...otherUpdates,
...update,
angle: 0 as Radians,
},
options?.informMutation,
);
} else {
console.error("Elbow arrow cannot find a route");
}
};
export const updateElbowArrow = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly LocalPoint[],
offset?: Vector,
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
disableBinding?: boolean; disableBinding?: boolean;
informMutation?: boolean; informMutation?: boolean;
}, },
) => { ): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
const origStartGlobalPoint: GlobalPoint = pointTranslate( const origStartGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>( pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[0], nextPoints[0],
@@ -235,6 +267,8 @@ export const mutateElbowArrow = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement),
hoveredEndElement && aabbForElement(hoveredEndElement),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@@ -295,18 +329,10 @@ export const mutateElbowArrow = (
startDongle && points.unshift(startGlobalPoint); startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint); endDongle && points.push(endGlobalPoint);
mutateElement( return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
arrow,
{
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0 as Radians,
},
options?.informMutation,
);
} else {
console.error("Elbow arrow cannot find a route");
} }
return null;
}; };
const offsetFromHeading = ( const offsetFromHeading = (
@@ -475,7 +501,11 @@ const generateDynamicAABBs = (
startDifference?: [number, number, number, number], startDifference?: [number, number, number, number],
endDifference?: [number, number, number, number], endDifference?: [number, number, number, number],
disableSideHack?: boolean, disableSideHack?: boolean,
startElementBounds?: Bounds | null,
endElementBounds?: Bounds | null,
): Bounds[] => { ): Bounds[] => {
const startEl = startElementBounds ?? a;
const endEl = endElementBounds ?? b;
const [startUp, startRight, startDown, startLeft] = startDifference ?? [ const [startUp, startRight, startDown, startLeft] = startDifference ?? [
0, 0, 0, 0, 0, 0, 0, 0,
]; ];
@@ -484,29 +514,29 @@ const generateDynamicAABBs = (
const first = [ const first = [
a[0] > b[2] a[0] > b[2]
? a[1] > b[3] || a[3] < b[1] ? a[1] > b[3] || a[3] < b[1]
? Math.min((a[0] + b[2]) / 2, a[0] - startLeft) ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
: (a[0] + b[2]) / 2 : (startEl[0] + endEl[2]) / 2
: a[0] > b[0] : a[0] > b[0]
? a[0] - startLeft ? a[0] - startLeft
: common[0] - startLeft, : common[0] - startLeft,
a[1] > b[3] a[1] > b[3]
? a[0] > b[2] || a[2] < b[0] ? a[0] > b[2] || a[2] < b[0]
? Math.min((a[1] + b[3]) / 2, a[1] - startUp) ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
: (a[1] + b[3]) / 2 : (startEl[1] + endEl[3]) / 2
: a[1] > b[1] : a[1] > b[1]
? a[1] - startUp ? a[1] - startUp
: common[1] - startUp, : common[1] - startUp,
a[2] < b[0] a[2] < b[0]
? a[1] > b[3] || a[3] < b[1] ? a[1] > b[3] || a[3] < b[1]
? Math.max((a[2] + b[0]) / 2, a[2] + startRight) ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
: (a[2] + b[0]) / 2 : (startEl[2] + endEl[0]) / 2
: a[2] < b[2] : a[2] < b[2]
? a[2] + startRight ? a[2] + startRight
: common[2] + startRight, : common[2] + startRight,
a[3] < b[1] a[3] < b[1]
? a[0] > b[2] || a[2] < b[0] ? a[0] > b[2] || a[2] < b[0]
? Math.max((a[3] + b[1]) / 2, a[3] + startDown) ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
: (a[3] + b[1]) / 2 : (startEl[3] + endEl[1]) / 2
: a[3] < b[3] : a[3] < b[3]
? a[3] + startDown ? a[3] + startDown
: common[3] + startDown, : common[3] + startDown,
@@ -514,29 +544,29 @@ const generateDynamicAABBs = (
const second = [ const second = [
b[0] > a[2] b[0] > a[2]
? b[1] > a[3] || b[3] < a[1] ? b[1] > a[3] || b[3] < a[1]
? Math.min((b[0] + a[2]) / 2, b[0] - endLeft) ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
: (b[0] + a[2]) / 2 : (endEl[0] + startEl[2]) / 2
: b[0] > a[0] : b[0] > a[0]
? b[0] - endLeft ? b[0] - endLeft
: common[0] - endLeft, : common[0] - endLeft,
b[1] > a[3] b[1] > a[3]
? b[0] > a[2] || b[2] < a[0] ? b[0] > a[2] || b[2] < a[0]
? Math.min((b[1] + a[3]) / 2, b[1] - endUp) ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
: (b[1] + a[3]) / 2 : (endEl[1] + startEl[3]) / 2
: b[1] > a[1] : b[1] > a[1]
? b[1] - endUp ? b[1] - endUp
: common[1] - endUp, : common[1] - endUp,
b[2] < a[0] b[2] < a[0]
? b[1] > a[3] || b[3] < a[1] ? b[1] > a[3] || b[3] < a[1]
? Math.max((b[2] + a[0]) / 2, b[2] + endRight) ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
: (b[2] + a[0]) / 2 : (endEl[2] + startEl[0]) / 2
: b[2] < a[2] : b[2] < a[2]
? b[2] + endRight ? b[2] + endRight
: common[2] + endRight, : common[2] + endRight,
b[3] < a[1] b[3] < a[1]
? b[0] > a[2] || b[2] < a[0] ? b[0] > a[2] || b[2] < a[0]
? Math.max((b[3] + a[1]) / 2, b[3] + endDown) ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
: (b[3] + a[1]) / 2 : (endEl[3] + startEl[1]) / 2
: b[3] < a[3] : b[3] < a[3]
? b[3] + endDown ? b[3] + endDown
: common[3] + endDown, : common[3] + endDown,
@@ -713,13 +743,13 @@ const getDonglePosition = (
): GlobalPoint => { ): GlobalPoint => {
switch (heading) { switch (heading) {
case HEADING_UP: case HEADING_UP:
return point(p[0], bounds[1]); return pointFrom(p[0], bounds[1]);
case HEADING_RIGHT: case HEADING_RIGHT:
return point(bounds[2], p[1]); return pointFrom(bounds[2], p[1]);
case HEADING_DOWN: case HEADING_DOWN:
return point(p[0], bounds[3]); return pointFrom(p[0], bounds[3]);
} }
return point(bounds[0], p[1]); return pointFrom(bounds[0], p[1]);
}; };
const estimateSegmentCount = ( const estimateSegmentCount = (
+2 -7
View File
@@ -2,7 +2,7 @@ import type { ElementsMap, ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import type { AppState, Zoom } from "../types"; import type { AppState, Offsets, Zoom } from "../types";
import { getCommonBounds, getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils"; import { viewportCoordsToSceneCoords } from "../utils";
@@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
scrollY: number; scrollY: number;
}, },
elementsMap: ElementsMap, elementsMap: ElementsMap,
padding?: Partial<{ padding?: Offsets,
top: number;
right: number;
bottom: number;
left: number;
}>,
) => { ) => {
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords( const topLeftSceneCoords = viewportCoordsToSceneCoords(
+488 -81
View File
@@ -14,20 +14,23 @@ import {
import type { ExcalidrawTextElementWithContainer, FontString } from "./types"; import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
describe("Test wrapText", () => { describe("Test wrapText", () => {
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; // font is irrelevant as jsdom does not support FontFace API
// `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
// https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
const font = "10px Cascadia, Segoe UI Emoji" as FontString;
it("shouldn't add new lines for trailing spaces", () => { it("should wrap the text correctly when word length is exactly equal to max width", () => {
const text = "Hello whats up "; const text = "Hello Excalidraw";
const maxWidth = 200 - BOUND_TEXT_PADDING * 2; // Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, maxWidth); const res = wrapText(text, font, 100);
expect(res).toBe(text); expect(res).toEqual(`Hello\nExcalidraw`);
}); });
it("should work with emojis", () => { it("should return the text as is if max width is invalid", () => {
const text = "😀"; const text = "Hello Excalidraw";
const maxWidth = 1; expect(wrapText(text, font, NaN)).toEqual(text);
const res = wrapText(text, font, maxWidth); expect(wrapText(text, font, -1)).toEqual(text);
expect(res).toBe("😀"); expect(wrapText(text, font, Infinity)).toEqual(text);
}); });
it("should show the text correctly when max width reached", () => { it("should show the text correctly when max width reached", () => {
@@ -37,6 +40,237 @@ describe("Test wrapText", () => {
expect(res).toBe("H\ne\nl\nl\no\n😀"); expect(res).toBe("H\ne\nl\nl\no\n😀");
}); });
it("should not wrap number when wrapping line", () => {
const text = "don't wrap this number 99,100.99";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("don't wrap this number\n99,100.99");
});
it("should support multiple (multi-codepoint) emojis", () => {
const text = "😀🗺🔥👩🏽‍🦰👨‍👩‍👧‍👦🇨🇿";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀\n🗺\n🔥\n👩🏽‍🦰\n👨‍👩‍👧‍👦\n🇨🇿");
});
it("should wrap the text correctly when text contains hyphen", () => {
let text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
const res = wrapText(text, font, 110);
expect(res).toBe(
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
);
text = "Hello thereusing-now";
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
});
it("should support wrapping nested lists", () => {
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
const maxWidth = 100;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
});
describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
const text = "안녕하세요こんにちは世界コンニチハ你好";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
);
});
it("should break CJK text into longer segments when width is larger", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
const text = "안녕하세요こんにちは世界コンニチハ你好";
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
// measureText is mocked, so it's not precisely what would happen in prod
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
});
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
const maxWidth = 150;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
const maxWidth3 = 30;
const res3 = wrapText(text, font, maxWidth3);
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
});
it("should break before and after a regular CJK character", () => {
const text = "HelloたWorld";
const maxWidth1 = 50;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe("Hello\nた\nWorld");
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe("Helloた\nWorld");
});
it("should break before and after certain CJK symbols", () => {
const text = "こんにちは〃世界";
const maxWidth1 = 50;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe("こんにちは\n〃世界");
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe("こんにちは〃\n世界");
});
it("should break after, not before for certain CJK pairs", () => {
const text = "Hello た。";
const maxWidth = 70;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello\nた。");
});
it("should break before, not after for certain CJK pairs", () => {
const text = "Hello「たWorld」";
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello\n「た\nWorld」");
});
it("should break after, not before for certain CJK character pairs", () => {
const text = "「Helloた」World";
const maxWidth = 70;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("「Hello\nた」World");
});
it("should break Chinese sentences", () => {
const text = `中国你好!这是一个测试。
我们来看看:人民币¥1234「很贵」
(括号)、逗号,句号。空格 换行 全角符号…—`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`中国你好!这是一\n个测试。
我们来看看:人民\n币¥1234「很\n贵」
(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`中国你好!\n这是一个测\n试。
我们来看\n看:人民币\n¥1234\n「很贵」
(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
});
});
it("should break Japanese sentences", () => {
const text = `日本こんにちは!これはテストです。
見てみましょう:円¥1234「高い」
(括弧)、読点、句点。
空白 改行 全角記号…ー`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
見てみましょ\nう:円¥1234\n「高い」
(括弧)、読\n点、句点。
空白 改行\n全角記号…ー`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
見てみ\nましょう:\n円\n¥1234\n「高い」
(括\n弧)、読\n点、句点。
空白\n改行 全角\n記号…ー`);
});
it("should break Korean sentences", () => {
const text = `한국 안녕하세요! 이것은 테스트입니다.
우리 보자: 원화₩1234「비싸다」
(괄호), 쉼표, 마침표.
공백 줄바꿈 전각기호…—`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
우리 보자: 원\n화₩1234「비\n싸다」
(괄호), 쉼\n표, 마침표.
공백 줄바꿈 전\n각기호…—`);
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
우리 보자:\n원화\n₩1234\n「비싸다」
(괄호),\n쉼표, 마침\n표.
공백 줄바꿈\n전각기호…—`);
});
describe("When text contains leading whitespaces", () => {
const text = " \t Hello world";
it("should preserve leading whitespaces", () => {
const maxWidth = 120;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" \t Hello\nworld");
});
it("should break and collapse leading whitespaces when line breaks", () => {
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHello\nworld");
});
it("should break and collapse leading whitespaces whe words break", () => {
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHel\nlo\nwor\nld");
});
});
describe("When text contains trailing whitespaces", () => {
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 ignore trailing whitespaces when line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 400;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
});
it("should not ignore trailing whitespaces when word breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
});
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 180;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
});
});
describe("When text doesn't contain new lines", () => { describe("When text doesn't contain new lines", () => {
const text = "Hello whats up"; const text = "Hello whats up";
@@ -44,7 +278,7 @@ describe("Test wrapText", () => {
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 80, width: 80,
res: `Hello \nwhats \nup`, res: `Hello\nwhats\nup`,
}, },
{ {
desc: "break all characters when width of each character is less than container width", desc: "break all characters when width of each character is less than container width",
@@ -66,7 +300,7 @@ p`,
desc: "break words as per the width", desc: "break words as per the width",
width: 140, width: 140,
res: `Hello whats \nup`, res: `Hello whats\nup`,
}, },
{ {
desc: "fit the container", desc: "fit the container",
@@ -96,7 +330,7 @@ whats up`;
{ {
desc: "break all words when width of each word is less than container width", desc: "break all words when width of each word is less than container width",
width: 80, width: 80,
res: `Hello\nwhats \nup`, res: `Hello\nwhats\nup`,
}, },
{ {
desc: "break all characters when width of each character is less than container width", desc: "break all characters when width of each character is less than container width",
@@ -142,26 +376,24 @@ whats up`,
{ {
desc: "fit characters of long string as per container width", desc: "fit characters of long string as per container width",
width: 170, width: 170,
res: `hellolongtextth\nisiswhatsupwith\nyouIamtypingggg\ngandtypinggg \nbreak it now`, res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
}, },
{ {
desc: "fit characters of long string as per container width and break words as per the width", desc: "fit characters of long string as per container width and break words as per the width",
width: 130, width: 130,
res: `hellolongte res: `hellolongtex
xtthisiswha tthisiswhats
tsupwithyou upwithyouIam
Iamtypinggg typingggggan
ggandtyping dtypinggg
gg break it break it now`,
now`,
}, },
{ {
desc: "fit the long text when container width is greater than text length and move the rest to next line", desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 600, width: 600,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg \nbreak it now`, res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
}, },
].forEach((data) => { ].forEach((data) => {
it(`should ${data.desc}`, () => { it(`should ${data.desc}`, () => {
@@ -171,68 +403,243 @@ now`,
}); });
}); });
it("should wrap the text correctly when word length is exactly equal to max width", () => { describe("Test parseTokens", () => {
const text = "Hello Excalidraw"; it("should tokenize latin", () => {
// Length of "Excalidraw" is 100 and exacty equal to max width let text = "Excalidraw is a virtual collaborative whiteboard";
const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello \nExcalidraw`);
});
it("should return the text as is if max width is invalid", () => { expect(parseTokens(text)).toEqual([
const text = "Hello Excalidraw"; "Excalidraw",
expect(wrapText(text, font, NaN)).toEqual(text); " ",
expect(wrapText(text, font, -1)).toEqual(text); "is",
expect(wrapText(text, font, Infinity)).toEqual(text); " ",
}); "a",
" ",
"virtual",
" ",
"collaborative",
" ",
"whiteboard",
]);
it("should wrap the text correctly when text contains hyphen", () => { text =
let text = "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects"; expect(parseTokens(text)).toEqual([
const res = wrapText(text, font, 110); "Wikipedia",
expect(res).toBe( " ",
`Wikipedia \nis hosted \nby \nWikimedia-\nFoundation,\na non-\nprofit \norganizati\non that \nalso hosts\na range-of\nother \nprojects`, "is",
); " ",
"hosted",
" ",
"by",
" ",
"Wikimedia-",
" ",
"Foundation,",
" ",
"a",
" ",
"non-",
"profit",
" ",
"organization",
" ",
"that",
" ",
"also",
" ",
"hosts",
" ",
"a",
" ",
"range-",
"of",
" ",
"other",
" ",
"projects",
]);
});
text = "Hello thereusing-now"; it("should not tokenize number", () => {
expect(wrapText(text, font, 100)).toEqual("Hello \nthereusin\ng-now"); const text = "99,100.99";
}); const tokens = parseTokens(text);
}); expect(tokens).toEqual(["99,100.99"]);
});
describe("Test parseTokens", () => { it("should tokenize joined emojis", () => {
it("should split into tokens correctly", () => { const text = `😬🌍🗺🔥☂️👩🏽‍🦰👨‍👩‍👧‍👦👩🏾‍🔬🏳️‍🌈🧔‍♀️🧑‍🤝‍🧑🙅🏽‍♂️✅0️⃣🇨🇿🦅`;
let text = "Excalidraw is a virtual collaborative whiteboard"; const tokens = parseTokens(text);
expect(parseTokens(text)).toEqual([
"Excalidraw",
"is",
"a",
"virtual",
"collaborative",
"whiteboard",
]);
text = expect(tokens).toEqual([
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects"; "😬",
expect(parseTokens(text)).toEqual([ "🌍",
"Wikipedia", "🗺",
"is", "🔥",
"hosted", "☂️",
"by", "👩🏽‍🦰",
"Wikimedia-", "👨‍👩‍👧‍👦",
"", "👩🏾‍🔬",
"Foundation,", "🏳️‍🌈",
"a", "🧔‍♀️",
"non-", "🧑‍🤝‍🧑",
"profit", "🙅🏽‍♂️",
"organization", "✅",
"that", "0️⃣",
"also", "🇨🇿",
"hosts", "🦅",
"a", ]);
"range-", });
"of",
"other", it("should tokenize emojis mixed with mixed text", () => {
"projects", const text = `😬a🌍b🗺c🔥d☂️《👩🏽‍🦰》👨‍👩‍👧‍👦德👩🏾‍🔬こ🏳️‍🌈안🧔‍♀️g🧑‍🤝‍🧑h🙅🏽‍♂️e✅f0️⃣g🇨🇿10🦅#hash`;
]); const tokens = parseTokens(text);
expect(tokens).toEqual([
"😬",
"a",
"🌍",
"b",
"🗺",
"c",
"🔥",
"d",
"☂️",
"《",
"👩🏽‍🦰",
"》",
"👨‍👩‍👧‍👦",
"德",
"👩🏾‍🔬",
"こ",
"🏳️‍🌈",
"안",
"🧔‍♀️",
"g",
"🧑‍🤝‍🧑",
"h",
"🙅🏽‍♂️",
"e",
"✅",
"f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
"🇨🇿",
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
"🦅",
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
]);
});
it("should tokenize decomposed chars into their composed variants", () => {
// each input character is in a decomposed form
const text = "čでäぴέ다й한";
expect(text.normalize("NFC").length).toEqual(8);
expect(text).toEqual(text.normalize("NFD"));
const tokens = parseTokens(text);
expect(tokens.length).toEqual(8);
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
});
it("should tokenize artificial CJK", () => {
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;다.다...원/달(((다)))[[1]]〚({((한))>)〛た…[Hello] World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
// [
// '《道', '德', '經》', '醫-',
// '醫', 'こ', 'ん', 'に',
// 'ち', 'は', '世', '界!',
// '안', '녕', '하', '세',
// '요', '세', '계;', '다.',
// '다...', '원/', '달', '(((다)))',
// '[[1]]', '〚({((한))>)〛', 'た…', '[Hello]',
// ' ', 'World?', 'ニ', 'ュ',
// 'ー', 'ヨ', 'ー', 'ク・',
// '¥3700.55', 'す。', '090-', '1234-',
// '5678¥1,000', '〜', '$5,000', '「素',
// '晴', 'ら', 'し', 'い!」',
// '〔重', '要〕', '#', '1:',
// 'Taro', '君', '30%', 'は、',
// '(た', 'な', 'ば', 'た)',
// '〰', '¥110±', '¥570', 'で',
// '20℃', '〜', '9:30', '〜',
// '10:00', '【一', '番】'
// ]
const tokens = parseTokens(text);
// Latin
expect(tokens).toContain("[[1]]");
expect(tokens).toContain("[Hello]");
expect(tokens).toContain("World?");
expect(tokens).toContain("Taro");
// Chinese
expect(tokens).toContain("《道");
expect(tokens).toContain("德");
expect(tokens).toContain("經》");
expect(tokens).toContain("醫-");
expect(tokens).toContain("醫");
// Japanese
expect(tokens).toContain("こ");
expect(tokens).toContain("ん");
expect(tokens).toContain("に");
expect(tokens).toContain("ち");
expect(tokens).toContain("は");
expect(tokens).toContain("世");
expect(tokens).toContain("ニ");
expect(tokens).toContain("ク・");
expect(tokens).toContain("界!");
expect(tokens).toContain("た…");
expect(tokens).toContain("す。");
expect(tokens).toContain("ュ");
expect(tokens).toContain("ー");
expect(tokens).toContain("「素");
expect(tokens).toContain("晴");
expect(tokens).toContain("ら");
expect(tokens).toContain("し");
expect(tokens).toContain("い!」");
expect(tokens).toContain("君");
expect(tokens).toContain("は、");
expect(tokens).toContain("(た");
expect(tokens).toContain("な");
expect(tokens).toContain("ば");
expect(tokens).toContain("た)");
expect(tokens).toContain("で");
expect(tokens).toContain("【一");
expect(tokens).toContain("番】");
// Check for Korean
expect(tokens).toContain("안");
expect(tokens).toContain("녕");
expect(tokens).toContain("하");
expect(tokens).toContain("세");
expect(tokens).toContain("요");
expect(tokens).toContain("세");
expect(tokens).toContain("계;");
expect(tokens).toContain("다.");
expect(tokens).toContain("다...");
expect(tokens).toContain("원/");
expect(tokens).toContain("달");
expect(tokens).toContain("(((다)))");
expect(tokens).toContain("〚({((한))>)〛");
// Numbers and units
expect(tokens).toContain("¥3700.55");
expect(tokens).toContain("090-");
expect(tokens).toContain("1234-");
expect(tokens).toContain("5678¥1,000");
expect(tokens).toContain("$5,000");
expect(tokens).toContain("1:");
expect(tokens).toContain("30%");
expect(tokens).toContain("¥110±");
expect(tokens).toContain("¥570");
expect(tokens).toContain("20℃");
expect(tokens).toContain("9:30");
expect(tokens).toContain("10:00");
// Punctuation and symbols
expect(tokens).toContain("〜");
expect(tokens).toContain("〰");
expect(tokens).toContain("#");
});
}); });
}); });
+308 -141
View File
@@ -16,6 +16,7 @@ import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
ENV,
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
@@ -30,6 +31,172 @@ import {
} from "./containerCache"; } from "./containerCache";
import type { ExtractSetType } from "../utility-types"; import type { ExtractSetType } from "../utility-types";
/**
* Matches various emoji types.
*
* 1. basic emojis (😀, 🌍)
* 2. flags (🇨🇿)
* 3. multi-codepoint emojis:
* - skin tones (👍🏽)
* - variation selectors (☂️)
* - keycaps (1️⃣)
* - tag sequences (🏴󠁧󠁢󠁥󠁮󠁧󠁿)
* - emoji sequences (👨‍👩‍👧‍👦, 👩‍🚀, 🏳️‍🌈)
*
* Unicode points:
* - \uFE0F: presentation selector
* - \u20E3: enclosing keycap
* - \u200D: ZWJ (zero width joiner)
* - \u{E0020}-\u{E007E}: tags
* - \u{E007F}: cancel tag
*
* @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
* - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
* - replaced \p{Emod} with \p{Emoji_Modifier} as some do not understand the abbreviation (i.e. https://devina.io/redos-checker)
*/
const _EMOJI_CHAR =
/(\p{RI}\p{RI}|[\p{Extended_Pictographic}\p{Emoji_Presentation}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?(?:\u200D(?:\p{RI}\p{RI}|[\p{Emoji}](?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?))*)/u;
/**
* Detect a CJK char, though does not include every possible char used in CJK texts,
* such as symbols and punctuations.
*
* By default every CJK is a breaking point, though CJK has additional breaking points,
* including full width punctuations or symbols (Chinese and Japanese) and western punctuations (Korean).
*
* Additional CJK breaking point rules:
* - expect a break before (lookahead), but not after (negative lookbehind), i.e. "(" or "("
* - expect a break after (lookbehind), but not before (negative lookahead), i.e. ")" or ")"
* - expect a break always (lookahead and lookbehind), i.e. "〃"
*/
const _CJK_CHAR =
/\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}/u;
/**
* Following characters break only with CJK, not with alphabetic characters.
* This is essential for Korean, as it uses alphabetic punctuation, but expects CJK-like breaking points.
*
* Hello((た)) → ["Hello", "((た))"]
* Hello((World)) → ["Hello((World))"]
*/
const _CJK_BREAK_NOT_AFTER_BUT_BEFORE = /<\(\[\{/u;
const _CJK_BREAK_NOT_BEFORE_BUT_AFTER = />\)\]\}.,:;\?!/u;
const _CJK_BREAK_ALWAYS = / 〃〜~〰#&*+-ー/=|¬ ̄¦/u;
const _CJK_SYMBOLS_AND_PUNCTUATION =
/()[]{}〈〉《》⦅⦆「」「」『』【】〖〗〔〕〘〙〚〛<>〝〞'〟・。゚゙,、.:;?!%ー/u;
/**
* Following characters break with any character, even though are mostly used with CJK.
*
* Hello た。→ ["Hello", "た。"]
* ↑ DON'T BREAK "た。" (negative lookahead)
* Hello「た」 World → ["Hello", "「た」", "World"]
* ↑ DON'T BREAK "「た" (negative lookbehind)
* ↑ DON'T BREAK "た」"(negative lookahead)
* ↑ BREAK BEFORE "「" (lookahead)
* ↑ BREAK AFTER "」" (lookbehind)
*/
const _ANY_BREAK_NOT_AFTER_BUT_BEFORE = /([{〈《⦅「「『【〖〔〘〚<〝/u;
const _ANY_BREAK_NOT_BEFORE_BUT_AFTER =
/)]}〉》⦆」」』】〗〕〙〛>〞'〟・。゚゙,、.:;?!%±‥…\//u;
/**
* Natural breaking points for any grammars.
*
* Hello-world
* ↑ BREAK AFTER "-" → ["Hello-", "world"]
* Hello world
* ↑ BREAK ALWAYS " " → ["Hello", " ", "world"]
*/
const _ANY_BREAK_AFTER = /-/u;
const _ANY_BREAK_ALWAYS = /\s/u;
/**
* Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
*
* Browser support as of 10/2024:
* - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
* - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
*
* Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
*/
const BREAK_LINE_REGEX_SIMPLE = new RegExp(
`${_EMOJI_CHAR.source}|([${_ANY_BREAK_ALWAYS.source}${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`,
"u",
);
// Hello World → ["Hello", " World"]
// ↑ BREAK BEFORE " "
// HelloたWorld → ["Hello", "たWorld"]
// ↑ BREAK BEFORE "た"
// Hello「World」→ ["Hello", "「World」"]
// ↑ BREAK BEFORE "「"
const getLookaheadBreakingPoints = () => {
const ANY_BREAKING_POINT = `(?<![${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}])(?=[${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}${_ANY_BREAK_ALWAYS.source}])`;
const CJK_BREAKING_POINT = `(?<![${_ANY_BREAK_NOT_AFTER_BUT_BEFORE.source}${_CJK_BREAK_NOT_AFTER_BUT_BEFORE.source}])(?=[${_CJK_BREAK_NOT_AFTER_BUT_BEFORE.source}]*[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}])`;
return new RegExp(`(?:${ANY_BREAKING_POINT}|${CJK_BREAKING_POINT})`, "u");
};
// Hello World → ["Hello ", "World"]
// ↑ BREAK AFTER " "
// Hello-World → ["Hello-", "World"]
// ↑ BREAK AFTER "-"
// HelloたWorld → ["Helloた", "World"]
// ↑ BREAK AFTER "た"
//「Hello」World → ["「Hello」", "World"]
// ↑ BREAK AFTER "」"
const getLookbehindBreakingPoints = () => {
const ANY_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}])(?<=[${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_ALWAYS.source}${_ANY_BREAK_AFTER.source}])`;
const CJK_BREAKING_POINT = `(?![${_ANY_BREAK_NOT_BEFORE_BUT_AFTER.source}${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}${_ANY_BREAK_AFTER.source}])(?<=[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}][${_CJK_BREAK_NOT_BEFORE_BUT_AFTER.source}]*)`;
return new RegExp(`(?:${ANY_BREAKING_POINT}|${CJK_BREAKING_POINT})`, "u");
};
/**
* Break a line based on the whitespaces, CJK / emoji chars and language specific breaking points,
* like hyphen for alphabetic and various full-width codepoints for CJK - especially Japanese, e.g.:
*
* "Hello 世界。🌎🗺" → ["Hello", " ", "世", "界。", "🌎", "🗺"]
* "Hello-world" → ["Hello-", "world"]
* "「Hello World」" → ["「Hello", " ", "World」"]
*/
const getBreakLineRegexAdvanced = () =>
new RegExp(
`${_EMOJI_CHAR.source}|${getLookaheadBreakingPoints().source}|${
getLookbehindBreakingPoints().source
}`,
"u",
);
let cachedBreakLineRegex: RegExp | undefined;
// Lazy-load for browsers that don't support "Lookbehind assertion"
const getBreakLineRegex = () => {
if (!cachedBreakLineRegex) {
try {
cachedBreakLineRegex = getBreakLineRegexAdvanced();
} catch {
cachedBreakLineRegex = BREAK_LINE_REGEX_SIMPLE;
}
}
return cachedBreakLineRegex;
};
const CJK_REGEX = new RegExp(
`[${_CJK_CHAR.source}${_CJK_BREAK_ALWAYS.source}${_CJK_SYMBOLS_AND_PUNCTUATION.source}]`,
"u",
);
const EMOJI_REGEX = new RegExp(`${_EMOJI_CHAR.source}`, "u");
export const containsCJK = (text: string) => {
return CJK_REGEX.test(text);
};
export const containsEmoji = (text: string) => {
return EMOJI_REGEX.test(text);
};
export const normalizeText = (text: string) => { export const normalizeText = (text: string) => {
return ( return (
normalizeEOL(text) normalizeEOL(text)
@@ -284,16 +451,17 @@ export const measureText = (
text: string, text: string,
font: FontString, font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"], lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => { ) => {
text = text const _text = text
.split("\n") .split("\n")
// replace empty lines with single space because leading/trailing empty // replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation // lines would be stripped from computation
.map((x) => x || " ") .map((x) => x || " ")
.join("\n"); .join("\n");
const fontSize = parseFloat(font); const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight); const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(text, font); const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height }; return { width, height };
}; };
@@ -407,22 +575,132 @@ export const getTextHeight = (
return getLineHeightInPx(fontSize, lineHeight) * lineCount; return getLineHeightInPx(fontSize, lineHeight) * lineCount;
}; };
export const parseTokens = (text: string) => { export const parseTokens = (line: string) => {
// Splitting words containing "-" as those are treated as separate words const breakLineRegex = getBreakLineRegex();
// by css wrapping algorithm eg non-profit => non-, profit
const words = text.split("-"); // normalizing to single-codepoint composed chars due to canonical equivalence of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
if (words.length > 1) { // filtering due to multi-codepoint chars like 👨‍👩‍👧‍👦, 👩🏽‍🦰
// non-proft org => ['non-', 'profit org'] return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
words.forEach((word, index) => { };
if (index !== words.length - 1) {
words[index] = word += "-"; // handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨‍👩‍👧‍👦, 👩🏽‍🦰)
} const isSingleCharacter = (maybeSingleCharacter: string) => {
}); return (
maybeSingleCharacter.codePointAt(0) !== undefined &&
maybeSingleCharacter.codePointAt(1) === undefined
);
};
const satisfiesWordInvariant = (word: string) => {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
if (/\s/.test(word)) {
throw new Error("Word should not contain any whitespaces!");
}
} }
// Joining the words with space and splitting them again with space to get the };
// final list of tokens
// ['non-', 'profit org'] =>,'non- proft org' => ['non-','profit','org'] const wrapWord = (
return words.join(" ").split(" "); word: string,
font: FontString,
maxWidth: number,
): Array<string> => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (EMOJI_REGEX.test(word)) {
return [word];
}
satisfiesWordInvariant(word);
const lines: Array<string> = [];
const chars = Array.from(word);
let currentLine = "";
let currentLineWidth = 0;
for (const char of chars) {
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
currentLine = currentLine + char;
currentLineWidth = testLineWidth;
continue;
}
if (currentLine) {
lines.push(currentLine);
}
currentLine = char;
currentLineWidth = _charWidth;
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
};
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
): string[] => {
const lines: Array<string> = [];
const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = "";
let currentLineWidth = 0;
let iterator = tokenIterator.next();
while (!iterator.done) {
const token = iterator.value;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
const testLineWidth = isSingleCharacter(token)
? currentLineWidth + charWidth.calculate(token, font)
: getLineWidth(testLine, font, true);
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
currentLine = testLine;
currentLineWidth = testLineWidth;
iterator = tokenIterator.next();
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font, true);
iterator = tokenIterator.next();
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd());
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
lines.push(currentLine.trimEnd());
}
return lines;
}; };
export const wrapText = ( export const wrapText = (
@@ -439,134 +717,17 @@ export const wrapText = (
const lines: Array<string> = []; const lines: Array<string> = [];
const originalLines = text.split("\n"); const originalLines = text.split("\n");
const spaceAdvanceWidth = getLineWidth(" ", font, true);
let currentLine = "";
let currentLineWidthTillNow = 0;
const push = (str: string) => {
if (str.trim()) {
lines.push(str);
}
};
const resetParams = () => {
currentLine = "";
currentLineWidthTillNow = 0;
};
for (const originalLine of originalLines) { for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font, true); const currentLineWidth = getLineWidth(originalLine, font, true);
// Push the line if its <= maxWidth
if (currentLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push(originalLine); lines.push(originalLine);
continue; continue;
} }
const words = parseTokens(originalLine); const wrappedLine = wrapLine(originalLine, font, maxWidth);
resetParams(); lines.push(...wrappedLine);
let index = 0;
while (index < words.length) {
const currentWordWidth = getLineWidth(words[index], font, true);
// 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 line = currentLine + currentChar;
// use advance width instead of the actual width as it's closest to the browser wapping algo
// use width of the whole line instead of calculating individual chars to accomodate for kerning
const lineAdvanceWidth = getLineWidth(line, font, true);
const charAdvanceWidth = charWidth.calculate(currentChar, font);
currentLineWidthTillNow = lineAdvanceWidth;
words[index] = words[index].slice(currentChar.length);
if (currentLineWidthTillNow >= maxWidth) {
push(currentLine);
currentLine = currentChar;
currentLineWidthTillNow = charAdvanceWidth;
} else {
currentLine = line;
}
}
// push current line if appending space exceeds max width
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
push(currentLine);
resetParams();
// space needs to be appended before next word
// as currentLine contains chars which couldn't be appended
// to previous line unless the line ends with hyphen to sync
// with css word-wrap
} else if (!currentLine.endsWith("-")) {
currentLine += " ";
currentLineWidthTillNow += spaceAdvanceWidth;
}
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,
true,
);
if (currentLineWidthTillNow > maxWidth) {
push(currentLine);
resetParams();
break;
}
index++;
// if word ends with "-" then we don't need to add space
// to sync with css word-wrap
const shouldAppendSpace = !word.endsWith("-");
currentLine += word;
if (shouldAppendSpace) {
currentLine += " ";
}
// Push the word if appending space exceeds max width
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
if (shouldAppendSpace) {
lines.push(currentLine.slice(0, -1));
} else {
lines.push(currentLine);
}
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"); return lines.join("\n");
@@ -576,24 +737,30 @@ export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {}; const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => { const calculate = (char: string, font: FontString) => {
const ascii = char.charCodeAt(0); const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) { if (!cachedCharWidth[font]) {
cachedCharWidth[font] = []; cachedCharWidth[font] = [];
} }
if (!cachedCharWidth[font][ascii]) { if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font, true); const width = getLineWidth(char, font, true);
cachedCharWidth[font][ascii] = width; cachedCharWidth[font][unicode] = width;
} }
return cachedCharWidth[font][ascii]; return cachedCharWidth[font][unicode];
}; };
const getCache = (font: FontString) => { const getCache = (font: FontString) => {
return cachedCharWidth[font]; return cachedCharWidth[font];
}; };
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return { return {
calculate, calculate,
getCache, getCache,
clearCache,
}; };
})(); })();
@@ -19,7 +19,7 @@ import type {
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { getOriginalContainerHeightFromCache } from "./containerCache"; import { getOriginalContainerHeightFromCache } from "./containerCache";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
import { point } from "../../math"; import { pointFrom } from "../../math";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -42,7 +42,7 @@ describe("textWysiwyg", () => {
type: "line", type: "line",
width: 100, width: 100,
height: 0, height: 0,
points: [point(0, 0), point(100, 0)], points: [pointFrom(0, 0), pointFrom(100, 0)],
}); });
const textSize = 20; const textSize = 20;
const text = API.createElement({ const text = API.createElement({
@@ -917,7 +917,7 @@ describe("textWysiwyg", () => {
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
text = h.elements[1] as ExcalidrawTextElementWithContainer; text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.text).toBe("Hello \nWorld!"); expect(text.text).toBe("Hello\nWorld!");
expect(text.originalText).toBe("Hello World!"); expect(text.originalText).toBe("Hello World!");
expect(text.y).toBe( expect(text.y).toBe(
rectangle.y + h.elements[0].height / 2 - text.height / 2, rectangle.y + h.elements[0].height / 2 - text.height / 2,
@@ -1220,7 +1220,7 @@ describe("textWysiwyg", () => {
); );
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe( expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy", "Online\nwhiteboa\nrd\ncollabor\nation\nmade\neasy",
); );
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
+1 -1
View File
@@ -247,7 +247,7 @@ export const textWysiwyg = ({
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari) // adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
const padding = !isSafari const padding = !isSafari
? Math.ceil(updatedTextElement.fontSize / 2) ? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
: 0; : 0;
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
@@ -11,6 +11,7 @@ import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { import {
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isImageElement,
isLinearElement, isLinearElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
@@ -19,7 +20,7 @@ import {
isIOS, isIOS,
} from "../constants"; } from "../constants";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { point, pointRotateRads } from "../../math"; import { pointFrom, pointRotateRads } from "../../math";
export type TransformHandleDirection = export type TransformHandleDirection =
| "n" | "n"
@@ -95,8 +96,8 @@ const generateTransformHandle = (
angle: Radians, angle: Radians,
): TransformHandle => { ): TransformHandle => {
const [xx, yy] = pointRotateRads( const [xx, yy] = pointRotateRads(
point(x + width / 2, y + height / 2), pointFrom(x + width / 2, y + height / 2),
point(cx, cy), pointFrom(cx, cy),
angle, angle,
); );
return [xx - width / 2, yy - height / 2, width, height]; return [xx - width / 2, yy - height / 2, width, height];
@@ -129,6 +130,7 @@ export const getTransformHandlesFromCoords = (
pointerType: PointerType, pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {}, omitSides: { [T in TransformHandleType]?: boolean } = {},
margin = 4, margin = 4,
spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
): TransformHandles => { ): TransformHandles => {
const size = transformHandleSizes[pointerType]; const size = transformHandleSizes[pointerType];
const handleWidth = size / zoom.value; const handleWidth = size / zoom.value;
@@ -140,8 +142,7 @@ export const getTransformHandlesFromCoords = (
const width = x2 - x1; const width = x2 - x1;
const height = y2 - y1; const height = y2 - y1;
const dashedLineMargin = margin / zoom.value; const dashedLineMargin = margin / zoom.value;
const centeringOffset = const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
(size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value);
const transformHandles: TransformHandles = { const transformHandles: TransformHandles = {
nw: omitSides.nw nw: omitSides.nw
@@ -301,8 +302,10 @@ export const getTransformHandles = (
rotation: true, rotation: true,
}; };
} }
const dashedLineMargin = isLinearElement(element) const margin = isLinearElement(element)
? DEFAULT_TRANSFORM_HANDLE_SPACING + 8 ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
: isImageElement(element)
? 0
: DEFAULT_TRANSFORM_HANDLE_SPACING; : DEFAULT_TRANSFORM_HANDLE_SPACING;
return getTransformHandlesFromCoords( return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element, elementsMap, true), getElementAbsoluteCoords(element, elementsMap, true),
@@ -310,7 +313,8 @@ export const getTransformHandles = (
zoom, zoom,
pointerType, pointerType,
omitSides, omitSides,
dashedLineMargin, margin,
isImageElement(element) ? 0 : undefined,
); );
}; };
+5 -2
View File
@@ -320,9 +320,12 @@ export const getDefaultRoundnessTypeForElement = (
}; };
export const isFixedPointBinding = ( export const isFixedPointBinding = (
binding: PointBinding, binding: PointBinding | FixedPointBinding,
): binding is FixedPointBinding => { ): binding is FixedPointBinding => {
return binding.fixedPoint != null; return (
Object.hasOwn(binding, "fixedPoint") &&
(binding as FixedPointBinding).fixedPoint != null
);
}; };
// TODO: Move this to @excalidraw/math // TODO: Move this to @excalidraw/math
+23 -7
View File
@@ -132,6 +132,15 @@ export type IframeData =
| { type: "document"; srcdoc: (theme: Theme) => string } | { type: "document"; srcdoc: (theme: Theme) => string }
); );
export type ImageCrop = {
x: number;
y: number;
width: number;
height: number;
naturalWidth: number;
naturalHeight: number;
};
export type ExcalidrawImageElement = _ExcalidrawElementBase & export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "image"; type: "image";
@@ -140,6 +149,8 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
status: "pending" | "saved" | "error"; status: "pending" | "saved" | "error";
/** X and Y scale factors <-1, 1>, used for image axis flipping */ /** X and Y scale factors <-1, 1>, used for image axis flipping */
scale: [number, number]; scale: [number, number];
/** whether an element is cropped */
crop: ImageCrop | null;
}>; }>;
export type InitializedExcalidrawImageElement = MarkNonNullable< export type InitializedExcalidrawImageElement = MarkNonNullable<
@@ -193,6 +204,7 @@ export type ExcalidrawElement =
| ExcalidrawGenericElement | ExcalidrawGenericElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawArrowElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawFrameElement | ExcalidrawFrameElement
@@ -268,15 +280,19 @@ export type PointBinding = {
elementId: ExcalidrawBindableElement["id"]; elementId: ExcalidrawBindableElement["id"];
focus: number; focus: number;
gap: number; gap: number;
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint | null;
}; };
export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>; export type FixedPointBinding = Merge<
PointBinding,
{
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
}
>;
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
+25
View File
@@ -36,3 +36,28 @@ export class ImageSceneDataError extends Error {
export class InvalidFractionalIndexError extends Error { export class InvalidFractionalIndexError extends Error {
public code = "ELEMENT_HAS_INVALID_INDEX" as const; public code = "ELEMENT_HAS_INVALID_INDEX" as const;
} }
type WorkerErrorCodes = "WORKER_URL_NOT_DEFINED" | "WORKER_IN_THE_MAIN_CHUNK";
export class WorkerUrlNotDefinedError extends Error {
public code;
constructor(
message = "Worker URL is not defined!",
code: WorkerErrorCodes = "WORKER_URL_NOT_DEFINED",
) {
super(message);
this.name = "WorkerUrlNotDefinedError";
this.code = code;
}
}
export class WorkerInTheMainChunkError extends Error {
public code;
constructor(
message = "Worker has to be in a separate chunk!",
code: WorkerErrorCodes = "WORKER_IN_THE_MAIN_CHUNK",
) {
super(message);
this.name = "WorkerInTheMainChunkError";
this.code = code;
}
}
@@ -0,0 +1,9 @@
import CascadiaCodeRegular from "./CascadiaCode-Regular.woff2";
import { type ExcalidrawFontFaceDescriptor } from "../Fonts";
export const CascadiaFontFaces: ExcalidrawFontFaceDescriptor[] = [
{
uri: CascadiaCodeRegular,
},
];

Some files were not shown because too many files have changed in this diff Show More