Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| baf3ab7d81 | |||
| ef0fcc1537 | |||
| ec26aeead2 | |||
| 62f5475c4a | |||
| 7225915b82 | |||
| 8eb3191b3f | |||
| 4d6d6cf129 | |||
| 208285b7ba | |||
| 372a4868da | |||
| 05800d8599 | |||
| 1f496d9f64 | |||
| e0221ddf20 | |||
| 1bd86942f3 | |||
| fd9a172da9 | |||
| 1f9847ed98 | |||
| 4e4802b19e | |||
| 23eb08088e | |||
| e8a6053251 | |||
| 456433e8f0 | |||
| 38e3a4e8e1 | |||
| 27a8cda8fd | |||
| dd5053149a | |||
| 40ec02b280 | |||
| b81aa19ff9 | |||
| e4ddd08bb1 | |||
| 795176b256 | |||
| be057bde39 | |||
| 94f4b727bb | |||
| 63698572db | |||
| ab3467973f | |||
| 91fe07d9c5 | |||
| 28cc821047 | |||
| 7dc728a459 | |||
| 12c651af6d | |||
| 9d0cafe10b | |||
| fb24221587 | |||
| ef347cc685 | |||
| 2d3b9e0c66 | |||
| bdb0dd064b | |||
| b17ed4dc29 | |||
| b988f67759 | |||
| 089aaa8792 | |||
| 28261c4b29 | |||
| 3fbed86d3e | |||
| 38b3d90fa6 | |||
| 82b597ab8b | |||
| 4c939cefad | |||
| 8f0d9f5230 | |||
| fcde0ac3de | |||
| b07dfba4b8 | |||
| 1089cdb278 | |||
| 7246a6b17a | |||
| 04a96caf78 | |||
| 14c6ea938a | |||
| 87aba3f619 | |||
| c8d4e8c421 | |||
| 512e506798 | |||
| b4e742bda0 | |||
| 5a3f4fd08f | |||
| 34515f2952 | |||
| 08f430b3ac | |||
| 59e74f94e6 | |||
| ddc393bd9d | |||
| 9e5948ac28 | |||
| f86d0f9102 | |||
| ace031e992 | |||
| 45faf7d58f | |||
| 8c558a0f33 | |||
| 65059cb166 | |||
| 9158e2d989 | |||
| 12da1862a0 | |||
| 67fb3210ab | |||
| 13d69d8cef | |||
| 0f6ad916c0 | |||
| 9ee2bf36cf | |||
| 86f5c2ebcf |
@@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports:
|
||||
|
||||
## Excalidraw.com
|
||||
|
||||
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/excalidraw-app) is part of this repository as well, and the app features:
|
||||
The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/src/excalidraw-app) is part of this repository as well, and the app features:
|
||||
|
||||
- 📡 PWA support (works offline).
|
||||
- 🤼 Real-time collaboration.
|
||||
|
||||
@@ -38,7 +38,6 @@ To render an item, its recommended to use `MainMenu.Item`.
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| --- | --- | :-: | :-: | --- |
|
||||
| `onSelect` | `function` | Yes | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
|
||||
| `selected` | `boolean` | No | `false` | Whether item is active |
|
||||
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
|
||||
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
|
||||
| `shortcut` | `string` | No | - | The shortcut to be shown for the menu item |
|
||||
@@ -71,7 +70,6 @@ function App() {
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| --- | --- | :-: | :-: | --- |
|
||||
| `onSelect` | `function` | No | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. |
|
||||
| `selected` | `boolean` | No | `false` | Whether item is active |
|
||||
| `href` | `string` | Yes | - | The `href` attribute to be added to the `anchor` element. |
|
||||
| `children` | `React.ReactNode` | Yes | - | The content of the menu item |
|
||||
| `icon` | `JSX.Element` | No | - | The icon used in the menu item |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Customizing Styles
|
||||
|
||||
Excalidraw uses CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
|
||||
Excalidraw is using CSS variables to style certain components. To override them, you should set your own on the `.excalidraw` and `.excalidraw.theme--dark` (for dark mode variables) selectors.
|
||||
|
||||
Make sure the selector has higher specificity, e.g. by prefixing it with your app's selector:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
### Does this package support collaboration ?
|
||||
|
||||
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
|
||||
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
|
||||
|
||||
### Turning off Aggressive Anti-Fingerprinting in Brave browser
|
||||
|
||||
@@ -18,7 +18,7 @@ We strongly recommend turning it off. You can follow the steps below on how to d
|
||||
|
||||
2. Once opened, look for **Aggressively Block Fingerprinting**
|
||||
|
||||

|
||||

|
||||
|
||||
3. Switch to **Block Fingerprinting**
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function App() {
|
||||
|
||||
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
|
||||
|
||||
The following workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
|
||||
The following worfklow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
|
||||
|
||||
```jsx showLineNumbers
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# Frames
|
||||
|
||||
## Ordering
|
||||
|
||||
Frames should be ordered where frame children come first, followed by the frame element itself:
|
||||
|
||||
```
|
||||
[
|
||||
other_element,
|
||||
frame1_child1,
|
||||
frame1_child2,
|
||||
frame1,
|
||||
other_element,
|
||||
frame2_child1,
|
||||
frame2_child2,
|
||||
frame2,
|
||||
other_element,
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
If not oredered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.
|
||||
@@ -15,7 +15,7 @@ In case you want to pick up something from the roadmap, comment on that issue an
|
||||
1. Run `yarn` to install dependencies
|
||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork, run:
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
|
||||
>
|
||||
> ```bash
|
||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||
|
||||
@@ -23,11 +23,7 @@ const sidebars = {
|
||||
},
|
||||
items: ["introduction/development", "introduction/contributing"],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Codebase",
|
||||
items: ["codebase/json-schema", "codebase/frames"],
|
||||
},
|
||||
{ type: "category", label: "Codebase", items: ["codebase/json-schema"] },
|
||||
{
|
||||
type: "category",
|
||||
label: "@excalidraw/excalidraw",
|
||||
|
||||
@@ -15,7 +15,7 @@ const FeatureList = [
|
||||
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Want to build your own app powered by Excalidraw but don't know where to
|
||||
Want to build your own app powered by Excalidraw by don't know where to
|
||||
start?
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
|
||||
type: "MOUSE_LOCATION";
|
||||
payload: {
|
||||
socketId: string;
|
||||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||||
pointer: { x: number; y: number };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { trackEvent } from "../src/analytics";
|
||||
import { getDefaultAppState } from "../src/appState";
|
||||
import { ErrorDialog } from "../src/components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "../src/components/TopErrorBoundary";
|
||||
import { useMathSubtype } from "../src/element/subtypes/mathjax";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
@@ -303,6 +304,8 @@ const ExcalidrawWrapper = () => {
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
useMathSubtype(excalidrawAPI);
|
||||
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
|
||||
+6
-1
@@ -20,7 +20,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/laser-pointer": "1.2.0",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
@@ -32,6 +31,7 @@
|
||||
"browser-fs-access": "0.29.1",
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
"clsx": "1.1.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
@@ -41,18 +41,22 @@
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "1.13.1",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"mathjax-full": "https://github.com/MathJax/MathJax-src#develop",
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
"pako": "1.0.11",
|
||||
"patch-package": "8.0.0",
|
||||
"perfect-freehand": "1.2.0",
|
||||
"pica": "7.1.1",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
"png-chunks-extract": "1.0.0",
|
||||
"points-on-curve": "0.2.0",
|
||||
"postinstall-postinstall": "2.1.0",
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"replace-in-file": "7.0.1",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "2.3.1",
|
||||
@@ -112,6 +116,7 @@
|
||||
"fix": "yarn fix:other && yarn fix:code",
|
||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"postinstall": "patch-package && yarn --cwd node_modules/mathjax-full compile-mjs && node scripts/beta-mathjax-import-paths.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"start": "vite",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
diff --git a/node_modules/mathjax-full/ts/input/asciimath/legacy/shim.js b/node_modules/mathjax-full/ts/input/asciimath/legacy/shim.js
|
||||
index 3b228bb9..c8bcdea5 100644
|
||||
--- a/node_modules/mathjax-full/ts/input/asciimath/legacy/shim.js
|
||||
+++ b/node_modules/mathjax-full/ts/input/asciimath/legacy/shim.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-MathJax = Object.assign(global.MathJax || {}, require("./MathJax.js").MathJax);
|
||||
+window.MathJax = Object.assign(window.MathJax || {}, require("./MathJax.js").MathJax);
|
||||
|
||||
//
|
||||
// Load component-based configuration, if any
|
||||
@@ -13,10 +13,13 @@ MathJax.Ajax.Preloading(
|
||||
"[MathJax]/jax/element/mml/jax.js"
|
||||
);
|
||||
|
||||
-require("./jax/element/mml/jax.js");
|
||||
-require("./jax/input/AsciiMath/config.js");
|
||||
-require("./jax/input/AsciiMath/jax.js");
|
||||
+module.exports.AsciiMath = void 0;
|
||||
+(async () => {
|
||||
+ await import("./jax/element/mml/jax.js");
|
||||
+ await import("./jax/input/AsciiMath/config.js");
|
||||
+ await import("./jax/input/AsciiMath/jax.js");
|
||||
|
||||
-require("./jax/element/MmlNode.js");
|
||||
+ await import("./jax/element/MmlNode.js");
|
||||
|
||||
-module.exports.AsciiMath = MathJax.InputJax.AsciiMath;
|
||||
+ module.exports.AsciiMath = MathJax.InputJax.AsciiMath;
|
||||
+})();
|
||||
diff --git a/node_modules/mathjax-full/ts/input/asciimath/legacy/MathJax.js b/node_modules/mathjax-full/ts/input/asciimath/legacy/MathJax.js
|
||||
index 853b0a0e..1e009028 100644
|
||||
--- a/node_modules/mathjax-full/ts/input/asciimath/legacy/MathJax.js
|
||||
+++ b/node_modules/mathjax-full/ts/input/asciimath/legacy/MathJax.js
|
||||
@@ -19,7 +19,7 @@ exports.MathJax = MathJax;
|
||||
return obj;
|
||||
};
|
||||
var CONSTRUCTOR = function () {
|
||||
- return function () {return arguments.callee.Init.call(this,arguments)};
|
||||
+ return function fn() {return fn.Init.call(this,Object.assign(arguments,{call:fn}))};
|
||||
};
|
||||
|
||||
BASE.Object = OBJECT({
|
||||
@@ -40,7 +40,7 @@ exports.MathJax = MathJax;
|
||||
Init: function (args) {
|
||||
var obj = this;
|
||||
if (args.length === 1 && args[0] === PROTO) {return obj}
|
||||
- if (!(obj instanceof args.callee)) {obj = new args.callee(PROTO)}
|
||||
+ if (!(obj instanceof args.call)) {obj = new args.call(PROTO)}
|
||||
return obj.Init.apply(obj,args) || obj;
|
||||
},
|
||||
|
||||
@@ -65,7 +65,7 @@ exports.MathJax = MathJax;
|
||||
|
||||
prototype: {
|
||||
Init: function () {},
|
||||
- SUPER: function (fn) {return fn.callee.SUPER},
|
||||
+ SUPER: function (fn) {return fn.SUPER},
|
||||
can: function (method) {return typeof(this[method]) === "function"},
|
||||
has: function (property) {return typeof(this[property]) !== "undefined"},
|
||||
isa: function (obj) {return (obj instanceof Object) && (this instanceof obj)}
|
||||
@@ -177,7 +177,7 @@ exports.MathJax = MathJax;
|
||||
// Create a callback from an associative array
|
||||
//
|
||||
var CALLBACK = function (data) {
|
||||
- var cb = function () {return arguments.callee.execute.apply(arguments.callee,arguments)};
|
||||
+ var cb = function fn() {return fn.execute.apply(fn,arguments)};
|
||||
for (var id in CALLBACK.prototype) {
|
||||
if (CALLBACK.prototype.hasOwnProperty(id)) {
|
||||
if (typeof(data[id]) !== 'undefined') {cb[id] = data[id]}
|
||||
diff --git a/node_modules/mathjax-full/ts/input/asciimath/legacy/jax/element/mml/jax.js b/node_modules/mathjax-full/ts/input/asciimath/legacy/jax/element/mml/jax.js
|
||||
index 96fb9186..473aca11 100644
|
||||
--- a/node_modules/mathjax-full/ts/input/asciimath/legacy/jax/element/mml/jax.js
|
||||
+++ b/node_modules/mathjax-full/ts/input/asciimath/legacy/jax/element/mml/jax.js
|
||||
@@ -813,9 +813,9 @@ MathJax.ElementJax.mml.Augment({
|
||||
if (!(this.isEmbellished()) || typeof(this.core) === "undefined") {return this}
|
||||
return this.data[this.core].CoreMO();
|
||||
},
|
||||
- toString: function () {
|
||||
+ toString: function fn() {
|
||||
if (this.inferred) {return '[' + this.data.join(',') + ']'}
|
||||
- return this.SUPER(arguments).toString.call(this);
|
||||
+ return this.SUPER(fn).toString.call(this);
|
||||
},
|
||||
setTeXclass: function (prev) {
|
||||
var i, m = this.data.length;
|
||||
@@ -1196,12 +1196,12 @@ MathJax.ElementJax.mml.Augment({
|
||||
}
|
||||
},
|
||||
linebreakContainer: true,
|
||||
- Append: function () {
|
||||
+ Append: function fn() {
|
||||
for (var i = 0, m = arguments.length; i < m; i++) {
|
||||
if (!((arguments[i] instanceof MML.mtr) ||
|
||||
(arguments[i] instanceof MML.mlabeledtr))) {arguments[i] = MML.mtr(arguments[i])}
|
||||
}
|
||||
- this.SUPER(arguments).Append.apply(this,arguments);
|
||||
+ this.SUPER(fn).Append.apply(this,arguments);
|
||||
},
|
||||
setTeXclass: MML.mbase.setSeparateTeXclasses
|
||||
});
|
||||
@@ -1221,11 +1221,11 @@ MathJax.ElementJax.mml.Augment({
|
||||
mtable: {rowalign: true, columnalign: true, groupalign: true}
|
||||
},
|
||||
linebreakContainer: true,
|
||||
- Append: function () {
|
||||
+ Append: function fn() {
|
||||
for (var i = 0, m = arguments.length; i < m; i++) {
|
||||
if (!(arguments[i] instanceof MML.mtd)) {arguments[i] = MML.mtd(arguments[i])}
|
||||
}
|
||||
- this.SUPER(arguments).Append.apply(this,arguments);
|
||||
+ this.SUPER(fn).Append.apply(this,arguments);
|
||||
},
|
||||
setTeXclass: MML.mbase.setSeparateTeXclasses
|
||||
});
|
||||
@@ -1420,9 +1420,9 @@ MathJax.ElementJax.mml.Augment({
|
||||
|
||||
MML.xml = MML.mbase.Subclass({
|
||||
type: "xml",
|
||||
- Init: function () {
|
||||
+ Init: function fn() {
|
||||
this.div = document.createElement("div");
|
||||
- return this.SUPER(arguments).Init.apply(this,arguments);
|
||||
+ return this.SUPER(fn).Init.apply(this,arguments);
|
||||
},
|
||||
Append: function () {
|
||||
for (var i = 0, m = arguments.length; i < m; i++) {
|
||||
@@ -11,7 +11,7 @@
|
||||
{
|
||||
"src": "apple-touch-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180"
|
||||
"sizes": "256x256"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// When building MathJax 4.0-beta from source within the Excalidraw tree, some
|
||||
// import paths don't properly translate from `ts/` to `mjs/`. This makes the
|
||||
// Excalidraw build process parse MathJax TypeScript files. The resulting error
|
||||
// messages do not occur if MathJax was built from source outside the
|
||||
// Excalidraw tree. The following regexp eliminates those error messages.
|
||||
require("replace-in-file").sync({
|
||||
files: "node_modules/mathjax-full/mjs/**/*",
|
||||
from: /mathjax-full\/ts/g,
|
||||
to: "mathjax-full/mjs",
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { Bounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { updateActiveTool, resetCursor } from "../utils";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../element/binding";
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
@@ -91,9 +90,7 @@ export const actionFinalize = register({
|
||||
}
|
||||
}
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
newElements = newElements.slice(0, -1);
|
||||
}
|
||||
|
||||
// If the multi point line closes the loop,
|
||||
|
||||
@@ -4,8 +4,7 @@ import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameElements } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
|
||||
@@ -15,7 +15,6 @@ export const actionToggleGridMode = register({
|
||||
appState: {
|
||||
...appState,
|
||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||
objectsSnapModeEnabled: false,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
name: "objectsSnapMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridSize: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.objectsSnapMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||
});
|
||||
@@ -80,7 +80,6 @@ export {
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
|
||||
+62
-11
@@ -2,10 +2,10 @@ import React from "react";
|
||||
import {
|
||||
Action,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
ActionPredicateFn,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
@@ -40,7 +40,8 @@ const trackAction = (
|
||||
};
|
||||
|
||||
export class ActionManager {
|
||||
actions = {} as Record<ActionName, Action>;
|
||||
actions = {} as Record<Action["name"], Action>;
|
||||
actionPredicates = [] as ActionPredicateFn[];
|
||||
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
@@ -68,6 +69,37 @@ export class ActionManager {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
registerActionPredicate(predicate: ActionPredicateFn) {
|
||||
if (!this.actionPredicates.includes(predicate)) {
|
||||
this.actionPredicates.push(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
filterActions(
|
||||
filter: ActionPredicateFn,
|
||||
opts?: {
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
data?: Record<string, any>;
|
||||
},
|
||||
): Action[] {
|
||||
// For testing
|
||||
if (this === undefined) {
|
||||
return [];
|
||||
}
|
||||
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const data = opts?.data;
|
||||
|
||||
const actions: Action[] = [];
|
||||
for (const key in this.actions) {
|
||||
const action = this.actions[key];
|
||||
if (filter(action, elements, appState, data)) {
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
registerAction(action: Action) {
|
||||
this.actions[action.name] = action;
|
||||
}
|
||||
@@ -84,7 +116,7 @@ export class ActionManager {
|
||||
(action) =>
|
||||
(action.name in canvasActions
|
||||
? canvasActions[action.name as keyof typeof canvasActions]
|
||||
: true) &&
|
||||
: this.isActionEnabled(action, { noPredicates: true })) &&
|
||||
action.keyTest &&
|
||||
action.keyTest(
|
||||
event,
|
||||
@@ -135,7 +167,7 @@ export class ActionManager {
|
||||
/**
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
renderAction = (name: Action["name"], data?: PanelComponentProps["data"]) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
@@ -143,7 +175,7 @@ export class ActionManager {
|
||||
"PanelComponent" in this.actions[name] &&
|
||||
(name in canvasActions
|
||||
? canvasActions[name as keyof typeof canvasActions]
|
||||
: true)
|
||||
: this.isActionEnabled(this.actions[name], { noPredicates: true }))
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
@@ -165,6 +197,7 @@ export class ActionManager {
|
||||
|
||||
return (
|
||||
<PanelComponent
|
||||
key={name}
|
||||
elements={this.getElementsIncludingDeleted()}
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
@@ -178,13 +211,31 @@ export class ActionManager {
|
||||
return null;
|
||||
};
|
||||
|
||||
isActionEnabled = (action: Action) => {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
isActionEnabled = (
|
||||
action: Action,
|
||||
opts?: {
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
data?: Record<string, any>;
|
||||
noPredicates?: boolean;
|
||||
},
|
||||
): boolean => {
|
||||
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const data = opts?.data;
|
||||
|
||||
return (
|
||||
!action.predicate ||
|
||||
action.predicate(elements, appState, this.app.props, this.app)
|
||||
);
|
||||
if (
|
||||
!opts?.noPredicates &&
|
||||
action.predicate &&
|
||||
!action.predicate(elements, appState, this.app.props, this.app, data)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let enabled = true;
|
||||
this.actionPredicates.forEach((fn) => {
|
||||
if (!fn(action, elements, appState, data)) {
|
||||
enabled = false;
|
||||
}
|
||||
});
|
||||
return enabled;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export type ShortcutName =
|
||||
| "ungroup"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "stats"
|
||||
| "addToLibrary"
|
||||
| "viewMode"
|
||||
@@ -75,7 +74,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
objectsSnapMode: [getShortcutKey("Alt+S")],
|
||||
stats: [getShortcutKey("Alt+/")],
|
||||
addToLibrary: [],
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
@@ -85,8 +83,23 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
const shortcuts = shortcutMap[name];
|
||||
export type CustomShortcutName = string;
|
||||
|
||||
let customShortcutMap: Record<CustomShortcutName, string[]> = {};
|
||||
|
||||
export const registerCustomShortcuts = (
|
||||
shortcuts: Record<CustomShortcutName, string[]>,
|
||||
) => {
|
||||
customShortcutMap = { ...customShortcutMap, ...shortcuts };
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (
|
||||
name: ShortcutName | CustomShortcutName,
|
||||
) => {
|
||||
const shortcuts =
|
||||
name in customShortcutMap
|
||||
? customShortcutMap[name as CustomShortcutName]
|
||||
: shortcutMap[name as ShortcutName];
|
||||
// if multiple shortcuts available, take the first one
|
||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||
};
|
||||
|
||||
+11
-2
@@ -32,6 +32,15 @@ type ActionFn = (
|
||||
app: AppClassProperties,
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
// Return `true` *unless* `Action` should be disabled
|
||||
// given `elements`, `appState`, and optionally `data`.
|
||||
export type ActionPredicateFn = (
|
||||
action: Action,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
data?: Record<string, any>,
|
||||
) => boolean;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
export type ActionFilterFn = (action: Action) => void;
|
||||
|
||||
@@ -51,7 +60,6 @@ export type ActionName =
|
||||
| "pasteStyles"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
@@ -136,7 +144,7 @@ export type PanelComponentProps = {
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
name: string;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
@@ -158,6 +166,7 @@ export interface Action {
|
||||
appState: AppState,
|
||||
appProps: ExcalidrawProps,
|
||||
app: AppClassProperties,
|
||||
data?: Record<string, any>,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
trackEvent:
|
||||
|
||||
+2
-9
@@ -99,12 +99,6 @@ export const getDefaultAppState = (): Omit<
|
||||
pendingImageElementId: null,
|
||||
showHyperlinkPopup: false,
|
||||
selectedLinearElement: null,
|
||||
snapLines: [],
|
||||
originSnapOffset: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
objectsSnapModeEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -152,6 +146,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
activeSubtypes: { browser: true, export: false, server: false },
|
||||
customData: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
@@ -212,9 +208,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
selectedLinearElement: { browser: true, export: false, server: false },
|
||||
snapLines: { browser: false, export: false, server: false },
|
||||
originSnapOffset: { browser: false, export: false, server: false },
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
+21
-1
@@ -11,6 +11,8 @@ import {
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
import { AppState } from "./types";
|
||||
import { selectSubtype } from "./element/subtypes";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
@@ -23,6 +25,8 @@ export interface Spreadsheet {
|
||||
title: string | null;
|
||||
labels: string[] | null;
|
||||
values: number[];
|
||||
activeSubtypes?: AppState["activeSubtypes"];
|
||||
customData?: AppState["customData"];
|
||||
}
|
||||
|
||||
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
||||
@@ -193,13 +197,17 @@ const chartXLabels = (
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const custom = selectSubtype(spreadsheet, "text");
|
||||
return (
|
||||
spreadsheet.labels?.map((label, index) => {
|
||||
return newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
||||
text:
|
||||
label.length > 8 && custom.subtype === undefined
|
||||
? `${label.slice(0, 5)}...`
|
||||
: label,
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||
y: y + BAR_GAP / 2,
|
||||
width: BAR_WIDTH,
|
||||
@@ -207,6 +215,7 @@ const chartXLabels = (
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
...custom,
|
||||
});
|
||||
}) || []
|
||||
);
|
||||
@@ -227,6 +236,7 @@ const chartYLabels = (
|
||||
y: y - BAR_GAP,
|
||||
text: "0",
|
||||
textAlign: "right",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
});
|
||||
|
||||
const maxYLabel = newTextElement({
|
||||
@@ -237,6 +247,7 @@ const chartYLabels = (
|
||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
||||
text: Math.max(...spreadsheet.values).toLocaleString(),
|
||||
textAlign: "right",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
});
|
||||
|
||||
return [minYLabel, maxYLabel];
|
||||
@@ -264,6 +275,7 @@ const chartLines = (
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
@@ -280,6 +292,7 @@ const chartLines = (
|
||||
[0, 0],
|
||||
[0, -chartHeight],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
@@ -298,6 +311,7 @@ const chartLines = (
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
@@ -324,6 +338,7 @@ const chartBaseElements = (
|
||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||
roundness: null,
|
||||
textAlign: "center",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -340,6 +355,7 @@ const chartBaseElements = (
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
fillStyle: "solid",
|
||||
opacity: 6,
|
||||
...selectSubtype(spreadsheet, "rectangle"),
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -372,6 +388,7 @@ const chartTypeBar = (
|
||||
y: y - barHeight - BAR_GAP,
|
||||
width: BAR_WIDTH,
|
||||
height: barHeight,
|
||||
...selectSubtype(spreadsheet, "rectangle"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -424,6 +441,7 @@ const chartTypeLine = (
|
||||
width: maxX - minX,
|
||||
strokeWidth: 2,
|
||||
points: points as any,
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const dots = spreadsheet.values.map((value, index) => {
|
||||
@@ -440,6 +458,7 @@ const chartTypeLine = (
|
||||
y: y + cy - BAR_GAP * 2,
|
||||
width: BAR_GAP,
|
||||
height: BAR_GAP,
|
||||
...selectSubtype(spreadsheet, "ellipse"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -462,6 +481,7 @@ const chartTypeLine = (
|
||||
[0, 0],
|
||||
[0, cy],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+6
-1
@@ -2,7 +2,7 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { BinaryFiles } from "./types";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
@@ -167,6 +167,7 @@ export const getSystemClipboard = async (
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
isPlainPaste = false,
|
||||
appState?: AppState,
|
||||
): Promise<ClipboardData> => {
|
||||
const systemClipboard = await getSystemClipboard(event);
|
||||
|
||||
@@ -186,6 +187,10 @@ export const parseClipboard = async (
|
||||
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
|
||||
|
||||
if (spreadsheetResult) {
|
||||
if ("spreadsheet" in spreadsheetResult) {
|
||||
spreadsheetResult.spreadsheet.activeSubtypes = appState?.activeSubtypes;
|
||||
spreadsheetResult.spreadsheet.customData = appState?.customData;
|
||||
}
|
||||
return spreadsheetResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
.undo-redo-buttons {
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
}
|
||||
|
||||
.zoom-button,
|
||||
.undo-redo-buttons button {
|
||||
border: 1px solid var(--default-border-color) !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: var(--color-surface-low) !important;
|
||||
background-color: transparent !important;
|
||||
font-size: 0.875rem !important;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
+73
-47
@@ -14,10 +14,16 @@ import {
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { AppClassProperties, UIAppState, Zoom } from "../types";
|
||||
import { capitalizeString, isTransparent } from "../utils";
|
||||
import { UIAppState, Zoom } from "../types";
|
||||
import {
|
||||
capitalizeString,
|
||||
isTransparent,
|
||||
updateActiveTool,
|
||||
setCursorForShape,
|
||||
} from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { SubtypeShapeActions, SubtypeToggles } from "./Subtypes";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
@@ -31,12 +37,7 @@ import {
|
||||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import {
|
||||
EmbedIcon,
|
||||
extraToolsIcon,
|
||||
frameToolIcon,
|
||||
laserPointerToolIcon,
|
||||
} from "./icons";
|
||||
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
@@ -100,6 +101,7 @@ export const SelectedShapeActions = ({
|
||||
{showChangeBackgroundIcons && (
|
||||
<div>{renderAction("changeBackgroundColor")}</div>
|
||||
)}
|
||||
<SubtypeShapeActions elements={targetElements} />
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||
@@ -215,23 +217,18 @@ export const SelectedShapeActions = ({
|
||||
export const ShapesSwitcher = ({
|
||||
interactiveCanvas,
|
||||
activeTool,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
app,
|
||||
}: {
|
||||
interactiveCanvas: HTMLCanvasElement | null;
|
||||
activeTool: UIAppState["activeTool"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: UIAppState;
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
const device = useDevice();
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
@@ -256,14 +253,29 @@ export const ShapesSwitcher = ({
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
app.setActiveTool({ type: value });
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
activeEmbeddable: null,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
@@ -290,14 +302,24 @@ export const ShapesSwitcher = ({
|
||||
data-testid={`toolbar-frame`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
app.setActiveTool({ type: "frame" });
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}}
|
||||
selected={activeTool.type === "frame"}
|
||||
/>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
@@ -310,28 +332,30 @@ export const ShapesSwitcher = ({
|
||||
data-testid={`toolbar-embeddable`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "embeddable", "ui");
|
||||
app.setActiveTool({ type: "embeddable" });
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}}
|
||||
selected={activeTool.type === "embeddable"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className={clsx("App-toolbar__extra-tools-trigger", {
|
||||
"App-toolbar__extra-tools-trigger--selected":
|
||||
frameToolSelected ||
|
||||
embeddableToolSelected ||
|
||||
// in collab we're already highlighting the laser button
|
||||
// outside toolbar, so let's not highlight extra-tools button
|
||||
// on top of it
|
||||
(laserToolSelected && !app.props.isCollaborating),
|
||||
})}
|
||||
className="App-toolbar__extra-tools-trigger"
|
||||
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
||||
title={t("toolBar.extraTools")}
|
||||
>
|
||||
@@ -344,39 +368,41 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
app.setActiveTool({ type: "frame" });
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
app.setActiveTool({ type: "embeddable" });
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
app.setActiveTool({ type: "laser" });
|
||||
}}
|
||||
icon={laserPointerToolIcon}
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<SubtypeToggles />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+145
-356
@@ -35,7 +35,6 @@ import {
|
||||
actionLink,
|
||||
actionToggleElementLock,
|
||||
actionToggleLinearEditor,
|
||||
actionToggleObjectsSnapMode,
|
||||
} from "../actions";
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
@@ -211,7 +210,7 @@ import {
|
||||
import Scene from "../scene/Scene";
|
||||
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { findShapeByKey } from "../shapes";
|
||||
import { findShapeByKey, SHAPES } from "../shapes";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppProps,
|
||||
@@ -229,9 +228,6 @@ import {
|
||||
FrameNameBoundsCache,
|
||||
SidebarName,
|
||||
SidebarTabName,
|
||||
KeyboardModifiersObject,
|
||||
CollaboratorPointer,
|
||||
ToolType,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@@ -241,14 +237,18 @@ import {
|
||||
isInputLike,
|
||||
isToolIcon,
|
||||
isWritableElement,
|
||||
resetCursor,
|
||||
resolvablePromise,
|
||||
sceneCoordsToViewportCoords,
|
||||
setCursor,
|
||||
setCursorForShape,
|
||||
tupleToCoors,
|
||||
viewportCoordsToSceneCoords,
|
||||
withBatchedUpdates,
|
||||
wrapEvent,
|
||||
withBatchedUpdatesThrottled,
|
||||
updateObject,
|
||||
setEraserCursor,
|
||||
updateActiveTool,
|
||||
getShortcutKey,
|
||||
isTransparent,
|
||||
@@ -270,6 +270,16 @@ import {
|
||||
import LayerUI from "./LayerUI";
|
||||
import { Toast } from "./Toast";
|
||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||
import {
|
||||
SubtypeLoadedCb,
|
||||
SubtypeRecord,
|
||||
SubtypePrepFn,
|
||||
checkRefreshOnSubtypeLoad,
|
||||
isSubtypeAction,
|
||||
prepareSubtype,
|
||||
selectSubtype,
|
||||
subtypeActionPredicate,
|
||||
} from "../element/subtypes";
|
||||
import {
|
||||
dataURLToFile,
|
||||
generateIdFromFile,
|
||||
@@ -342,17 +352,6 @@ import {
|
||||
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import {
|
||||
getSnapLinesAtPointer,
|
||||
snapDraggedElements,
|
||||
isActiveToolNonLinearSnappable,
|
||||
snapNewElement,
|
||||
snapResizingElements,
|
||||
isSnappingEnabled,
|
||||
getVisibleGaps,
|
||||
getReferenceSnapPoints,
|
||||
SnapCache,
|
||||
} from "../snapping";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
import { activeEyeDropperAtom } from "./EyeDropper";
|
||||
@@ -365,14 +364,6 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||
import { Renderer } from "../scene/Renderer";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { LaserToolOverlay } from "./LaserTool/LaserTool";
|
||||
import { LaserPathManager } from "./LaserTool/LaserPathManager";
|
||||
import {
|
||||
setEraserCursor,
|
||||
setCursor,
|
||||
resetCursor,
|
||||
setCursorForShape,
|
||||
} from "../cursor";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -497,13 +488,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
|
||||
|
||||
hitLinkElement?: NonDeletedExcalidrawElement;
|
||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
|
||||
null;
|
||||
lastPointerDown: React.PointerEvent<HTMLElement> | null = null;
|
||||
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
|
||||
lastViewportPosition = { x: 0, y: 0 };
|
||||
|
||||
laserPathManager: LaserPathManager = new LaserPathManager(this);
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
@@ -512,7 +500,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
viewModeEnabled = false,
|
||||
zenModeEnabled = false,
|
||||
gridModeEnabled = false,
|
||||
objectsSnapModeEnabled = false,
|
||||
theme = defaultAppState.theme,
|
||||
name = defaultAppState.name,
|
||||
} = props;
|
||||
@@ -523,7 +510,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...this.getCanvasOffsets(),
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
objectsSnapModeEnabled,
|
||||
gridSize: gridModeEnabled ? GRID_SIZE : null,
|
||||
name,
|
||||
width: window.innerWidth,
|
||||
@@ -533,6 +519,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.id = nanoid();
|
||||
|
||||
this.library = new Library(this);
|
||||
this.actionManager = new ActionManager(
|
||||
this.syncActionResult,
|
||||
() => this.state,
|
||||
() => this.scene.getElementsIncludingDeleted(),
|
||||
this,
|
||||
);
|
||||
this.scene = new Scene();
|
||||
|
||||
this.canvas = document.createElement("canvas");
|
||||
@@ -559,6 +551,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
getSceneElements: this.getSceneElements,
|
||||
getAppState: () => this.state,
|
||||
getFiles: () => this.files,
|
||||
actionManager: this.actionManager,
|
||||
addSubtype: this.addSubtype,
|
||||
refresh: this.refresh,
|
||||
setToast: this.setToast,
|
||||
id: this.id,
|
||||
@@ -586,16 +580,27 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onSceneUpdated: this.onSceneUpdated,
|
||||
});
|
||||
this.history = new History();
|
||||
this.actionManager = new ActionManager(
|
||||
this.syncActionResult,
|
||||
() => this.state,
|
||||
() => this.scene.getElementsIncludingDeleted(),
|
||||
this,
|
||||
);
|
||||
this.actionManager.registerAll(actions);
|
||||
|
||||
this.actionManager.registerAction(createUndoAction(this.history));
|
||||
this.actionManager.registerAction(createRedoAction(this.history));
|
||||
this.actionManager.registerActionPredicate(subtypeActionPredicate);
|
||||
}
|
||||
|
||||
private addSubtype(record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) {
|
||||
const subtypeLoadedCb: SubtypeLoadedCb = (hasSubtype) => {
|
||||
const elements = this.getSceneElementsIncludingDeleted();
|
||||
// If there are any elements of the just-registered subtype,
|
||||
// refresh the scene to re-render each such element.
|
||||
if (checkRefreshOnSubtypeLoad(hasSubtype, elements)) {
|
||||
this.refresh();
|
||||
}
|
||||
};
|
||||
const prep = prepareSubtype(record, subtypePrepFn, subtypeLoadedCb);
|
||||
if (prep.actions) {
|
||||
this.actionManager.registerAll(prep.actions);
|
||||
}
|
||||
return prep;
|
||||
}
|
||||
|
||||
private onWindowMessage(event: MessageEvent) {
|
||||
@@ -1110,7 +1115,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
cursor: CURSOR_TYPE.MOVE,
|
||||
pointerEvents: this.state.viewModeEnabled
|
||||
? POINTER_EVENTS.disabled
|
||||
: POINTER_EVENTS.enabled,
|
||||
: POINTER_EVENTS.inheritFromUI,
|
||||
}}
|
||||
onPointerDown={(event) => this.handleCanvasPointerDown(event)}
|
||||
onWheel={(event) => this.handleWheel(event)}
|
||||
@@ -1157,9 +1162,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.selectionElement ||
|
||||
this.state.draggingElement ||
|
||||
this.state.resizingElement ||
|
||||
(this.state.activeTool.type === "laser" &&
|
||||
// technically we can just test on this once we make it more safe
|
||||
this.state.cursorButton === "down") ||
|
||||
(this.state.editingElement &&
|
||||
!isTextElement(this.state.editingElement))
|
||||
? POINTER_EVENTS.disabled
|
||||
@@ -1215,14 +1217,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
app={this}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<LaserToolOverlay manager={this.laserPathManager} />
|
||||
{selectedElements.length === 1 &&
|
||||
!this.state.contextMenu &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
@@ -1750,9 +1750,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.removeEventListeners();
|
||||
this.scene.destroy();
|
||||
this.library.destroy();
|
||||
this.laserPathManager.destroy();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
isSomeElementSelected.clearCache();
|
||||
selectGroupsForSelectedElements.clearCache();
|
||||
@@ -2190,7 +2188,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// (something something security)
|
||||
let file = event?.clipboardData?.files[0];
|
||||
|
||||
const data = await parseClipboard(event, isPlainPaste);
|
||||
const data = await parseClipboard(event, isPlainPaste, this.state);
|
||||
if (!file && data.text && !isPlainPaste) {
|
||||
const string = data.text.trim();
|
||||
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
||||
@@ -2420,6 +2418,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
textAlign: this.state.currentItemTextAlign,
|
||||
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||
...selectSubtype(this.state, "text"),
|
||||
locked: false,
|
||||
};
|
||||
|
||||
@@ -2567,11 +2566,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
togglePenMode = (force?: boolean) => {
|
||||
togglePenMode = () => {
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
penMode: force ?? !prevState.penMode,
|
||||
penDetected: true,
|
||||
penMode: !prevState.penMode,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -3065,15 +3063,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||
if (this.state.activeTool.type === "laser") {
|
||||
this.setActiveTool({ type: "selection" });
|
||||
} else {
|
||||
this.setActiveTool({ type: "laser" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||
@@ -3133,10 +3122,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
});
|
||||
|
||||
setActiveTool = (
|
||||
private setActiveTool = (
|
||||
tool:
|
||||
| {
|
||||
type: ToolType;
|
||||
type:
|
||||
| typeof SHAPES[number]["value"]
|
||||
| "eraser"
|
||||
| "hand"
|
||||
| "frame"
|
||||
| "embeddable";
|
||||
}
|
||||
| { type: "custom"; customType: string },
|
||||
) => {
|
||||
@@ -3155,30 +3149,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (nextActiveTool.type === "image") {
|
||||
this.onImageAction();
|
||||
}
|
||||
|
||||
this.setState((prevState) => {
|
||||
const commonResets = {
|
||||
snapLines: prevState.snapLines.length ? [] : prevState.snapLines,
|
||||
originSnapOffset: null,
|
||||
activeEmbeddable: null,
|
||||
} as const;
|
||||
if (nextActiveTool.type !== "selection") {
|
||||
return {
|
||||
...prevState,
|
||||
activeTool: nextActiveTool,
|
||||
selectedElementIds: makeNextSelectedElementIds({}, prevState),
|
||||
selectedGroupIds: makeNextSelectedElementIds({}, prevState),
|
||||
editingGroupId: null,
|
||||
multiElement: null,
|
||||
...commonResets,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prevState,
|
||||
if (nextActiveTool.type !== "selection") {
|
||||
this.setState({
|
||||
activeTool: nextActiveTool,
|
||||
...commonResets,
|
||||
};
|
||||
});
|
||||
selectedElementIds: makeNextSelectedElementIds({}, this.state),
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
} else {
|
||||
this.setState({ activeTool: nextActiveTool, activeEmbeddable: null });
|
||||
}
|
||||
};
|
||||
|
||||
private setCursor = (cursor: string) => {
|
||||
@@ -3574,6 +3555,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
verticalAlign: parentCenterPosition
|
||||
? VERTICAL_ALIGN.MIDDLE
|
||||
: DEFAULT_VERTICAL_ALIGN,
|
||||
...selectSubtype(this.state, "text"),
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
lineHeight,
|
||||
@@ -3755,10 +3737,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isTouchScreen: boolean,
|
||||
) => {
|
||||
const draggedDistance = distance2d(
|
||||
this.lastPointerDownEvent!.clientX,
|
||||
this.lastPointerDownEvent!.clientY,
|
||||
this.lastPointerUpEvent!.clientX,
|
||||
this.lastPointerUpEvent!.clientY,
|
||||
this.lastPointerDown!.clientX,
|
||||
this.lastPointerDown!.clientY,
|
||||
this.lastPointerUp!.clientX,
|
||||
this.lastPointerUp!.clientY,
|
||||
);
|
||||
if (
|
||||
!this.hitLinkElement ||
|
||||
@@ -3769,7 +3751,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
const lastPointerDownCoords = viewportCoordsToSceneCoords(
|
||||
this.lastPointerDownEvent!,
|
||||
this.lastPointerDown!,
|
||||
this.state,
|
||||
);
|
||||
const lastPointerDownHittingLinkIcon = isPointHittingLink(
|
||||
@@ -3779,7 +3761,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.device.isMobile,
|
||||
);
|
||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||
this.lastPointerUpEvent!,
|
||||
this.lastPointerUp!,
|
||||
this.state,
|
||||
);
|
||||
const lastPointerUpHittingLinkIcon = isPointHittingLink(
|
||||
@@ -3914,30 +3896,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const scenePointer = viewportCoordsToSceneCoords(event, this.state);
|
||||
const { x: scenePointerX, y: scenePointerY } = scenePointer;
|
||||
|
||||
if (
|
||||
!this.state.draggingElement &&
|
||||
isActiveToolNonLinearSnappable(this.state.activeTool.type)
|
||||
) {
|
||||
const { originOffset, snapLines } = getSnapLinesAtPointer(
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.state,
|
||||
{
|
||||
x: scenePointerX,
|
||||
y: scenePointerY,
|
||||
},
|
||||
event,
|
||||
);
|
||||
|
||||
this.setState({
|
||||
snapLines,
|
||||
originSnapOffset: originOffset,
|
||||
});
|
||||
} else if (!this.state.draggingElement) {
|
||||
this.setState({
|
||||
snapLines: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.editingLinearElement &&
|
||||
!this.state.editingLinearElement.isDragging
|
||||
@@ -4408,10 +4366,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({ contextMenu: null });
|
||||
}
|
||||
|
||||
if (this.state.snapLines) {
|
||||
this.setAppState({ snapLines: [] });
|
||||
}
|
||||
|
||||
this.updateGestureOnPointerDown(event);
|
||||
|
||||
// if dragging element is freedraw and another pointerdown event occurs
|
||||
@@ -4484,21 +4438,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPointerDownEvent = event;
|
||||
|
||||
// we must exit before we set `cursorButton` state and `savePointer`
|
||||
// else it will send pointer state & laser pointer events in collab when
|
||||
// panning
|
||||
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastPointerDown = event;
|
||||
this.setState({
|
||||
lastPointerDownWith: event.pointerType,
|
||||
cursorButton: "down",
|
||||
});
|
||||
this.savePointer(event.clientX, event.clientY, "down");
|
||||
|
||||
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// only handle left mouse button or touch
|
||||
if (
|
||||
event.button !== POINTER_BUTTON.MAIN &&
|
||||
@@ -4589,11 +4539,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||
} else if (this.state.activeTool.type === "frame") {
|
||||
this.createFrameElementOnPointerDown(pointerDownState);
|
||||
} else if (this.state.activeTool.type === "laser") {
|
||||
this.laserPathManager.startPath(
|
||||
pointerDownState.lastCoords.x,
|
||||
pointerDownState.lastCoords.y,
|
||||
);
|
||||
} else if (
|
||||
this.state.activeTool.type !== "eraser" &&
|
||||
this.state.activeTool.type !== "hand"
|
||||
@@ -4617,7 +4562,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
lastPointerUp = onPointerUp;
|
||||
|
||||
if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
|
||||
if (!this.state.viewModeEnabled) {
|
||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
||||
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
||||
@@ -4633,14 +4578,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
this.removePointer(event);
|
||||
this.lastPointerUpEvent = event;
|
||||
this.lastPointerUp = event;
|
||||
|
||||
const scenePointer = viewportCoordsToSceneCoords(
|
||||
{ clientX: event.clientX, clientY: event.clientY },
|
||||
this.state,
|
||||
);
|
||||
const clicklength =
|
||||
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
|
||||
event.timeStamp - (this.lastPointerDown?.timeStamp ?? 0);
|
||||
if (this.device.isMobile && clicklength < 300) {
|
||||
const hitElement = this.getElementAtPosition(
|
||||
scenePointer.x,
|
||||
@@ -5394,9 +5339,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
sceneX,
|
||||
sceneY,
|
||||
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
const embedLink = getEmbedLink(link);
|
||||
@@ -5446,9 +5389,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
sceneX,
|
||||
sceneY,
|
||||
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||
@@ -5468,6 +5409,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
roughness: this.state.currentItemRoughness,
|
||||
roundness: null,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
...selectSubtype(this.state, "image"),
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
@@ -5568,6 +5510,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
...selectSubtype(this.state, elementType),
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
@@ -5625,9 +5568,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||
@@ -5646,6 +5587,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness: this.getCurrentItemRoundness(elementType),
|
||||
...selectSubtype(this.state, elementType),
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
} as const;
|
||||
@@ -5685,9 +5627,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
const frame = newFrameElement({
|
||||
@@ -5710,52 +5650,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
private maybeCacheReferenceSnapPoints(
|
||||
event: KeyboardModifiersObject,
|
||||
selectedElements: ExcalidrawElement[],
|
||||
recomputeAnyways: boolean = false,
|
||||
) {
|
||||
if (
|
||||
isSnappingEnabled({
|
||||
event,
|
||||
appState: this.state,
|
||||
selectedElements,
|
||||
}) &&
|
||||
(recomputeAnyways || !SnapCache.getReferenceSnapPoints())
|
||||
) {
|
||||
SnapCache.setReferenceSnapPoints(
|
||||
getReferenceSnapPoints(
|
||||
this.scene.getNonDeletedElements(),
|
||||
selectedElements,
|
||||
this.state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private maybeCacheVisibleGaps(
|
||||
event: KeyboardModifiersObject,
|
||||
selectedElements: ExcalidrawElement[],
|
||||
recomputeAnyways: boolean = false,
|
||||
) {
|
||||
if (
|
||||
isSnappingEnabled({
|
||||
event,
|
||||
appState: this.state,
|
||||
selectedElements,
|
||||
}) &&
|
||||
(recomputeAnyways || !SnapCache.getVisibleGaps())
|
||||
) {
|
||||
SnapCache.setVisibleGaps(
|
||||
getVisibleGaps(
|
||||
this.scene.getNonDeletedElements(),
|
||||
selectedElements,
|
||||
this.state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyDownFromPointerDownHandler(
|
||||
pointerDownState: PointerDownState,
|
||||
): (event: KeyboardEvent) => void {
|
||||
@@ -5813,10 +5707,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.activeTool.type === "laser") {
|
||||
this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||
}
|
||||
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
@@ -5989,62 +5879,33 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.state.editingElement &&
|
||||
this.state.activeEmbeddable?.state !== "active"
|
||||
) {
|
||||
const dragOffset = {
|
||||
x: pointerCoords.x - pointerDownState.origin.x,
|
||||
y: pointerCoords.y - pointerDownState.origin.y,
|
||||
};
|
||||
const [dragX, dragY] = getGridPoint(
|
||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
const originalElements = [
|
||||
...pointerDownState.originalElements.values(),
|
||||
const [dragDistanceX, dragDistanceY] = [
|
||||
Math.abs(pointerCoords.x - pointerDownState.origin.x),
|
||||
Math.abs(pointerCoords.y - pointerDownState.origin.y),
|
||||
];
|
||||
|
||||
// We only drag in one direction if shift is pressed
|
||||
const lockDirection = event.shiftKey;
|
||||
|
||||
if (lockDirection) {
|
||||
const distanceX = Math.abs(dragOffset.x);
|
||||
const distanceY = Math.abs(dragOffset.y);
|
||||
|
||||
const lockX = lockDirection && distanceX < distanceY;
|
||||
const lockY = lockDirection && distanceX > distanceY;
|
||||
|
||||
if (lockX) {
|
||||
dragOffset.x = 0;
|
||||
}
|
||||
|
||||
if (lockY) {
|
||||
dragOffset.y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Snap cache *must* be synchronously popuplated before initial drag,
|
||||
// otherwise the first drag even will not snap, causing a jump before
|
||||
// it snaps to its position if previously snapped already.
|
||||
this.maybeCacheVisibleGaps(event, selectedElements);
|
||||
this.maybeCacheReferenceSnapPoints(event, selectedElements);
|
||||
|
||||
const { snapOffset, snapLines } = snapDraggedElements(
|
||||
getSelectedElements(originalElements, this.state),
|
||||
dragOffset,
|
||||
this.state,
|
||||
event,
|
||||
);
|
||||
|
||||
this.setState({ snapLines });
|
||||
|
||||
// when we're editing the name of a frame, we want the user to be
|
||||
// able to select and interact with the text input
|
||||
!this.state.editingFrame &&
|
||||
dragSelectedElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
dragOffset,
|
||||
dragX,
|
||||
dragY,
|
||||
lockDirection,
|
||||
dragDistanceX,
|
||||
dragDistanceY,
|
||||
this.state,
|
||||
this.scene,
|
||||
snapOffset,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
|
||||
// We duplicate the selected element if alt is pressed on pointer move
|
||||
@@ -6085,21 +5946,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
groupIdMap,
|
||||
element,
|
||||
);
|
||||
const origElement = pointerDownState.originalElements.get(
|
||||
element.id,
|
||||
)!;
|
||||
mutateElement(duplicatedElement, {
|
||||
x: origElement.x,
|
||||
y: origElement.y,
|
||||
});
|
||||
|
||||
// put duplicated element to pointerDownState.originalElements
|
||||
// so that we can snap to the duplicated element without releasing
|
||||
pointerDownState.originalElements.set(
|
||||
duplicatedElement.id,
|
||||
duplicatedElement,
|
||||
const [originDragX, originDragY] = getGridPoint(
|
||||
pointerDownState.origin.x - pointerDownState.drag.offset.x,
|
||||
pointerDownState.origin.y - pointerDownState.drag.offset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
mutateElement(duplicatedElement, {
|
||||
x: duplicatedElement.x + (originDragX - dragX),
|
||||
y: duplicatedElement.y + (originDragY - dragY),
|
||||
});
|
||||
nextElements.push(duplicatedElement);
|
||||
elementsToAppend.push(element);
|
||||
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
|
||||
@@ -6125,8 +5980,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
this.scene.replaceAllElements(nextSceneElements);
|
||||
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
||||
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6343,7 +6196,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isResizing,
|
||||
isRotating,
|
||||
} = this.state;
|
||||
|
||||
this.setState({
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
@@ -6358,14 +6210,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
multiElement || isTextElement(this.state.editingElement)
|
||||
? this.state.editingElement
|
||||
: null,
|
||||
snapLines: [],
|
||||
|
||||
originSnapOffset: null,
|
||||
});
|
||||
|
||||
SnapCache.setReferenceSnapPoints(null);
|
||||
SnapCache.setVisibleGaps(null);
|
||||
|
||||
this.savePointer(childEvent.clientX, childEvent.clientY, "up");
|
||||
|
||||
this.setState({
|
||||
@@ -6589,9 +6435,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
// remove invisible element which was added in onPointerDown
|
||||
this.scene.replaceAllElements(
|
||||
this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.filter((el) => el.id !== draggingElement.id),
|
||||
this.scene.getElementsIncludingDeleted().slice(0, -1),
|
||||
);
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
@@ -6813,17 +6657,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
if (isEraserActive(this.state)) {
|
||||
const draggedDistance = distance2d(
|
||||
this.lastPointerDownEvent!.clientX,
|
||||
this.lastPointerDownEvent!.clientY,
|
||||
this.lastPointerUpEvent!.clientX,
|
||||
this.lastPointerUpEvent!.clientY,
|
||||
this.lastPointerDown!.clientX,
|
||||
this.lastPointerDown!.clientY,
|
||||
this.lastPointerUp!.clientX,
|
||||
this.lastPointerUp!.clientY,
|
||||
);
|
||||
|
||||
if (draggedDistance === 0) {
|
||||
const scenePointer = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: this.lastPointerUpEvent!.clientX,
|
||||
clientY: this.lastPointerUpEvent!.clientY,
|
||||
clientX: this.lastPointerUp!.clientX,
|
||||
clientY: this.lastPointerUp!.clientY,
|
||||
},
|
||||
this.state,
|
||||
);
|
||||
@@ -7063,11 +6907,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: unbindLinearElements)(this.scene.getSelectedElements(this.state));
|
||||
}
|
||||
|
||||
if (activeTool.type === "laser") {
|
||||
this.laserPathManager.endPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeTool.locked && activeTool.type !== "freedraw") {
|
||||
resetCursor(this.interactiveCanvas);
|
||||
this.setState({
|
||||
@@ -7084,16 +6923,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (
|
||||
hitElement &&
|
||||
this.lastPointerUpEvent &&
|
||||
this.lastPointerDownEvent &&
|
||||
this.lastPointerUpEvent.timeStamp -
|
||||
this.lastPointerDownEvent.timeStamp <
|
||||
300 &&
|
||||
this.lastPointerUp &&
|
||||
this.lastPointerDown &&
|
||||
this.lastPointerUp.timeStamp - this.lastPointerDown.timeStamp < 300 &&
|
||||
gesture.pointers.size <= 1 &&
|
||||
isEmbeddableElement(hitElement) &&
|
||||
this.isEmbeddableCenter(
|
||||
hitElement,
|
||||
this.lastPointerUpEvent,
|
||||
this.lastPointerUp,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
)
|
||||
@@ -7902,7 +7739,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
shouldResizeFromCenter(event),
|
||||
);
|
||||
} else {
|
||||
let [gridX, gridY] = getGridPoint(
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
@@ -7916,33 +7753,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? image.width / image.height
|
||||
: null;
|
||||
|
||||
this.maybeCacheReferenceSnapPoints(event, [draggingElement]);
|
||||
|
||||
const { snapOffset, snapLines } = snapNewElement(
|
||||
draggingElement,
|
||||
this.state,
|
||||
event,
|
||||
{
|
||||
x:
|
||||
pointerDownState.originInGrid.x +
|
||||
(this.state.originSnapOffset?.x ?? 0),
|
||||
y:
|
||||
pointerDownState.originInGrid.y +
|
||||
(this.state.originSnapOffset?.y ?? 0),
|
||||
},
|
||||
{
|
||||
x: gridX - pointerDownState.originInGrid.x,
|
||||
y: gridY - pointerDownState.originInGrid.y,
|
||||
},
|
||||
);
|
||||
|
||||
gridX += snapOffset.x;
|
||||
gridY += snapOffset.y;
|
||||
|
||||
this.setState({
|
||||
snapLines,
|
||||
});
|
||||
|
||||
dragNewElement(
|
||||
draggingElement,
|
||||
this.state.activeTool.type,
|
||||
@@ -7957,7 +7767,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: shouldMaintainAspectRatio(event),
|
||||
shouldResizeFromCenter(event),
|
||||
aspectRatio,
|
||||
this.state.originSnapOffset,
|
||||
);
|
||||
|
||||
this.maybeSuggestBindingForAll([draggingElement]);
|
||||
@@ -7999,7 +7808,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
const pointerCoords = pointerDownState.lastCoords;
|
||||
let [resizeX, resizeY] = getGridPoint(
|
||||
const [resizeX, resizeY] = getGridPoint(
|
||||
pointerCoords.x - pointerDownState.resize.offset.x,
|
||||
pointerCoords.y - pointerDownState.resize.offset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
@@ -8027,41 +7836,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
});
|
||||
|
||||
// check needed for avoiding flickering when a key gets pressed
|
||||
// during dragging
|
||||
if (!this.state.selectedElementsAreBeingDragged) {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
const dragOffset = {
|
||||
x: gridX - pointerDownState.originInGrid.x,
|
||||
y: gridY - pointerDownState.originInGrid.y,
|
||||
};
|
||||
|
||||
const originalElements = [...pointerDownState.originalElements.values()];
|
||||
|
||||
this.maybeCacheReferenceSnapPoints(event, selectedElements);
|
||||
|
||||
const { snapOffset, snapLines } = snapResizingElements(
|
||||
selectedElements,
|
||||
getSelectedElements(originalElements, this.state),
|
||||
this.state,
|
||||
event,
|
||||
dragOffset,
|
||||
transformHandleType,
|
||||
);
|
||||
|
||||
resizeX += snapOffset.x;
|
||||
resizeY += snapOffset.y;
|
||||
|
||||
this.setState({
|
||||
snapLines,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
transformElements(
|
||||
pointerDownState,
|
||||
@@ -8077,7 +7851,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
resizeY,
|
||||
pointerDownState.resize.center.x,
|
||||
pointerDownState.resize.center.y,
|
||||
this.state,
|
||||
)
|
||||
) {
|
||||
this.maybeSuggestBindingForAll(selectedElements);
|
||||
@@ -8135,6 +7908,29 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private getContextMenuItems = (
|
||||
type: "canvas" | "element",
|
||||
): ContextMenuItems => {
|
||||
const subtype: ContextMenuItems = [];
|
||||
this.actionManager
|
||||
.filterActions(isSubtypeAction)
|
||||
.forEach(
|
||||
(action) =>
|
||||
this.actionManager.isActionEnabled(action, { data: {} }) &&
|
||||
subtype.push(action),
|
||||
);
|
||||
if (subtype.length > 0) {
|
||||
subtype.push(CONTEXT_MENU_SEPARATOR);
|
||||
}
|
||||
const standard: ContextMenuItems = this._getContextMenuItems(type).filter(
|
||||
(item) =>
|
||||
!item ||
|
||||
item === CONTEXT_MENU_SEPARATOR ||
|
||||
this.actionManager.isActionEnabled(item, { noPredicates: true }),
|
||||
);
|
||||
return [...subtype, ...standard];
|
||||
};
|
||||
|
||||
private _getContextMenuItems = (
|
||||
type: "canvas" | "element",
|
||||
): ContextMenuItems => {
|
||||
const options: ContextMenuItems = [];
|
||||
|
||||
@@ -8165,7 +7961,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionUnlockAllElements,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionToggleGridMode,
|
||||
actionToggleObjectsSnapMode,
|
||||
actionToggleZenMode,
|
||||
actionToggleViewMode,
|
||||
actionToggleStats,
|
||||
@@ -8312,21 +8107,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!x || !y) {
|
||||
return;
|
||||
}
|
||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
const pointer = viewportCoordsToSceneCoords(
|
||||
{ clientX: x, clientY: y },
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (isNaN(sceneX) || isNaN(sceneY)) {
|
||||
if (isNaN(pointer.x) || isNaN(pointer.y)) {
|
||||
// sometimes the pointer goes off screen
|
||||
}
|
||||
|
||||
const pointer: CollaboratorPointer = {
|
||||
x: sceneX,
|
||||
y: sceneY,
|
||||
tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
|
||||
};
|
||||
|
||||
this.props.onPointerUpdate?.({
|
||||
pointer,
|
||||
button,
|
||||
|
||||
@@ -12,32 +12,32 @@
|
||||
|
||||
&--color-primary {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-surface-lowest);
|
||||
--text-color: var(--input-bg-color);
|
||||
--back-color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-brand-hover);
|
||||
--back-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-brand-active);
|
||||
--back-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-primary);
|
||||
--border-color: var(--color-border-outline);
|
||||
--back-color: transparent;
|
||||
--border-color: var(--color-primary);
|
||||
--back-color: var(--input-bg-color);
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-brand-hover);
|
||||
--border-color: var(--color-brand-hover);
|
||||
--text-color: var(--color-primary-darker);
|
||||
--border-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-brand-active);
|
||||
--border-color: var(--color-brand-active);
|
||||
--text-color: var(--color-primary-darkest);
|
||||
--border-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,35 +19,20 @@
|
||||
}
|
||||
|
||||
&__btn {
|
||||
--background: var(--color-surface-mid);
|
||||
|
||||
display: flex;
|
||||
column-gap: 0.5rem;
|
||||
align-items: center;
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--default-border-color);
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--background);
|
||||
border-radius: var(--border-radius-lg);
|
||||
color: var(--text-primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
--background: var(--color-surface-high);
|
||||
&:hover {
|
||||
--background: #363541;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--background: var(--color-surface-high);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__link-icon {
|
||||
|
||||
@@ -165,7 +165,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
|
||||
<Shortcut
|
||||
label={t("labels.eyeDropper")}
|
||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||
@@ -259,10 +258,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("buttons.zenMode")}
|
||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.objectsSnapMode")}
|
||||
shortcuts={[getShortcutKey("Alt+S")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showGrid")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import { sceneCoordsToViewportCoords } from "../../utils";
|
||||
import App from "../App";
|
||||
import { getClientColor } from "../../clients";
|
||||
|
||||
// decay time in milliseconds
|
||||
const DECAY_TIME = 1000;
|
||||
// length of line in points before it starts decaying
|
||||
const DECAY_LENGTH = 50;
|
||||
|
||||
const average = (a: number, b: number) => (a + b) / 2;
|
||||
function getSvgPathFromStroke(points: number[][], closed = true) {
|
||||
const len = points.length;
|
||||
|
||||
if (len < 4) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
let a = points[0];
|
||||
let b = points[1];
|
||||
const c = points[2];
|
||||
|
||||
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
|
||||
2,
|
||||
)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
|
||||
b[1],
|
||||
c[1],
|
||||
).toFixed(2)} T`;
|
||||
|
||||
for (let i = 2, max = len - 1; i < max; i++) {
|
||||
a = points[i];
|
||||
b = points[i + 1];
|
||||
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
|
||||
2,
|
||||
)} `;
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
result += "Z";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
LPM: LaserPathManager;
|
||||
}
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function instantiateCollabolatorState(): CollabolatorState {
|
||||
return {
|
||||
currentPath: undefined,
|
||||
finishedPaths: [],
|
||||
lastPoint: [-10000, -10000],
|
||||
svg: document.createElementNS("http://www.w3.org/2000/svg", "path"),
|
||||
};
|
||||
}
|
||||
|
||||
function instantiatePath() {
|
||||
LaserPointer.constants.cornerDetectionMaxAngle = 70;
|
||||
|
||||
return new LaserPointer({
|
||||
simplify: 0,
|
||||
streamline: 0.4,
|
||||
sizeMapping: (c) => {
|
||||
const pt = DECAY_TIME;
|
||||
const pl = DECAY_LENGTH;
|
||||
const t = Math.max(0, 1 - (performance.now() - c.pressure) / pt);
|
||||
const l = (pl - Math.min(pl, c.totalLength - c.currentIndex)) / pl;
|
||||
|
||||
return Math.min(easeOutCubic(l), easeOutCubic(t));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type CollabolatorState = {
|
||||
currentPath: LaserPointer | undefined;
|
||||
finishedPaths: LaserPointer[];
|
||||
lastPoint: [number, number];
|
||||
svg: SVGPathElement;
|
||||
};
|
||||
|
||||
export class LaserPathManager {
|
||||
private ownState: CollabolatorState;
|
||||
private collaboratorsState: Map<string, CollabolatorState> = new Map();
|
||||
|
||||
private rafId: number | undefined;
|
||||
private isDrawing = false;
|
||||
private container: SVGSVGElement | undefined;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.ownState = instantiateCollabolatorState();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.isDrawing = false;
|
||||
this.ownState = instantiateCollabolatorState();
|
||||
this.collaboratorsState = new Map();
|
||||
}
|
||||
|
||||
startPath(x: number, y: number) {
|
||||
this.ownState.currentPath = instantiatePath();
|
||||
this.ownState.currentPath.addPoint([x, y, performance.now()]);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
|
||||
addPointToPath(x: number, y: number) {
|
||||
if (this.ownState.currentPath) {
|
||||
this.ownState.currentPath?.addPoint([x, y, performance.now()]);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
}
|
||||
|
||||
endPath() {
|
||||
if (this.ownState.currentPath) {
|
||||
this.ownState.currentPath.close();
|
||||
this.ownState.finishedPaths.push(this.ownState.currentPath);
|
||||
this.updatePath(this.ownState);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePath(state: CollabolatorState) {
|
||||
this.isDrawing = true;
|
||||
|
||||
if (!this.isRunning) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
private isRunning = false;
|
||||
|
||||
start(svg?: SVGSVGElement) {
|
||||
if (svg) {
|
||||
this.container = svg;
|
||||
this.container.appendChild(this.ownState.svg);
|
||||
}
|
||||
|
||||
this.stop();
|
||||
this.isRunning = true;
|
||||
this.loop();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
this.rafId = undefined;
|
||||
}
|
||||
|
||||
loop() {
|
||||
this.rafId = requestAnimationFrame(this.loop.bind(this));
|
||||
|
||||
this.updateCollabolatorsState();
|
||||
|
||||
if (this.isDrawing) {
|
||||
this.update();
|
||||
} else {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
draw(path: LaserPointer) {
|
||||
const stroke = path
|
||||
.getStrokeOutline(path.options.size / this.app.state.zoom.value)
|
||||
.map(([x, y]) => {
|
||||
const result = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x, sceneY: y },
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
return [result.x, result.y];
|
||||
});
|
||||
|
||||
return getSvgPathFromStroke(stroke, true);
|
||||
}
|
||||
|
||||
updateCollabolatorsState() {
|
||||
if (!this.container || !this.app.state.collaborators.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
|
||||
if (!this.collaboratorsState.has(key)) {
|
||||
const state = instantiateCollabolatorState();
|
||||
this.container.appendChild(state.svg);
|
||||
this.collaboratorsState.set(key, state);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
|
||||
const state = this.collaboratorsState.get(key)!;
|
||||
|
||||
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
|
||||
if (collabolator.button === "down" && state.currentPath === undefined) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath = instantiatePath();
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
|
||||
if (collabolator.button === "down" && state.currentPath !== undefined) {
|
||||
if (
|
||||
collabolator.pointer.x !== state.lastPoint[0] ||
|
||||
collabolator.pointer.y !== state.lastPoint[1]
|
||||
) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
}
|
||||
|
||||
if (collabolator.button === "up" && state.currentPath !== undefined) {
|
||||
state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y];
|
||||
state.currentPath.addPoint([
|
||||
collabolator.pointer.x,
|
||||
collabolator.pointer.y,
|
||||
performance.now(),
|
||||
]);
|
||||
state.currentPath.close();
|
||||
|
||||
state.finishedPaths.push(state.currentPath);
|
||||
state.currentPath = undefined;
|
||||
|
||||
this.updatePath(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let somePathsExist = false;
|
||||
|
||||
for (const [key, state] of this.collaboratorsState.entries()) {
|
||||
if (!this.app.state.collaborators.has(key)) {
|
||||
state.svg.remove();
|
||||
this.collaboratorsState.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
state.finishedPaths = state.finishedPaths.filter((path) => {
|
||||
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
|
||||
|
||||
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
|
||||
});
|
||||
|
||||
let paths = state.finishedPaths.map((path) => this.draw(path)).join(" ");
|
||||
|
||||
if (state.currentPath) {
|
||||
paths += ` ${this.draw(state.currentPath)}`;
|
||||
}
|
||||
|
||||
if (paths.trim()) {
|
||||
somePathsExist = true;
|
||||
}
|
||||
|
||||
state.svg.setAttribute("d", paths);
|
||||
state.svg.setAttribute("fill", getClientColor(key));
|
||||
}
|
||||
|
||||
this.ownState.finishedPaths = this.ownState.finishedPaths.filter((path) => {
|
||||
const lastPoint = path.originalPoints[path.originalPoints.length - 1];
|
||||
|
||||
return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME);
|
||||
});
|
||||
|
||||
let paths = this.ownState.finishedPaths
|
||||
.map((path) => this.draw(path))
|
||||
.join(" ");
|
||||
|
||||
if (this.ownState.currentPath) {
|
||||
paths += ` ${this.draw(this.ownState.currentPath)}`;
|
||||
}
|
||||
|
||||
paths = paths.trim();
|
||||
|
||||
if (paths) {
|
||||
somePathsExist = true;
|
||||
}
|
||||
|
||||
this.ownState.svg.setAttribute("d", paths);
|
||||
this.ownState.svg.setAttribute("fill", "red");
|
||||
|
||||
if (!somePathsExist) {
|
||||
this.isDrawing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import "../ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "../ToolButton";
|
||||
import { laserPointerToolIcon } from "../icons";
|
||||
|
||||
type LaserPointerIconProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "small";
|
||||
|
||||
export const LaserPointerButton = (props: LaserPointerIconProps) => {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__LaserPointer",
|
||||
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||
{
|
||||
"is-mobile": props.isMobile,
|
||||
},
|
||||
)}
|
||||
title={`${props.title}`}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={props.title}
|
||||
data-testid="toolbar-LaserPointer"
|
||||
/>
|
||||
<div className="ToolIcon__icon">{laserPointerToolIcon}</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { LaserPathManager } from "./LaserPathManager";
|
||||
import "./LaserToolOverlay.scss";
|
||||
|
||||
type LaserToolOverlayProps = {
|
||||
manager: LaserPathManager;
|
||||
};
|
||||
|
||||
export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current) {
|
||||
manager.start(svgRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
manager.stop();
|
||||
};
|
||||
}, [manager]);
|
||||
|
||||
return (
|
||||
<div className="LaserToolOverlay">
|
||||
<svg ref={svgRef} className="LaserToolOverlayCanvas" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
.excalidraw {
|
||||
.LaserToolOverlay {
|
||||
pointer-events: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
z-index: 2;
|
||||
|
||||
.LaserToolOverlayCanvas {
|
||||
image-rendering: auto;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ import "./Toolbar.scss";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -78,7 +77,6 @@ interface LayerUIProps {
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
app: AppClassProperties;
|
||||
isCollaborating: boolean;
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
@@ -136,7 +134,6 @@ const LayerUI = ({
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
app,
|
||||
isCollaborating,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
@@ -282,7 +279,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
app={app}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
@@ -291,24 +288,6 @@ const LayerUI = ({
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{isCollaborating && (
|
||||
<Island
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
alignSelf: "center",
|
||||
height: "fit-content",
|
||||
}}
|
||||
>
|
||||
<LaserPointerButton
|
||||
title={t("toolBar.laser")}
|
||||
checked={appState.activeTool.type === "laser"}
|
||||
onChange={() =>
|
||||
app.setActiveTool({ type: "laser" })
|
||||
}
|
||||
isMobile
|
||||
/>
|
||||
</Island>
|
||||
)}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
|
||||
@@ -99,10 +99,10 @@
|
||||
font-size: 0.75rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-hover);
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--color-brand-active);
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
interactiveCanvas={interactiveCanvas}
|
||||
activeTool={appState.activeTool}
|
||||
app={app}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
|
||||
@@ -10,6 +10,12 @@ import { useApp } from "./App";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
import "./PasteChartDialog.scss";
|
||||
import { ensureSubtypesLoaded } from "../element/subtypes";
|
||||
import { isTextElement } from "../element";
|
||||
import {
|
||||
getContainerElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
|
||||
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
|
||||
|
||||
@@ -25,41 +31,54 @@ const ChartPreviewBtn = (props: {
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!props.spreadsheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = renderSpreadsheet(
|
||||
props.chartType,
|
||||
props.spreadsheet,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
setChartElements(elements);
|
||||
let svg: SVGSVGElement;
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
(async () => {
|
||||
svg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
previewNode.replaceChildren();
|
||||
previewNode.appendChild(svg);
|
||||
(async () => {
|
||||
let elements: ChartElements;
|
||||
await ensureSubtypesLoaded(
|
||||
props.spreadsheet?.activeSubtypes ?? [],
|
||||
() => {
|
||||
if (!props.spreadsheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
elements = renderSpreadsheet(
|
||||
props.chartType,
|
||||
props.spreadsheet,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
elements.forEach(
|
||||
(el) =>
|
||||
isTextElement(el) &&
|
||||
redrawTextBoundingBox(el, getContainerElement(el)),
|
||||
);
|
||||
setChartElements(elements);
|
||||
},
|
||||
).then(async () => {
|
||||
svg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
previewNode.replaceChildren();
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.replaceChildren();
|
||||
};
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.replaceChildren();
|
||||
};
|
||||
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
--RadioGroup-background: var(--island-bg-color);
|
||||
--RadioGroup-border: var(--color-surface-high);
|
||||
--RadioGroup-background: #ffffff;
|
||||
--RadioGroup-border: var(--color-gray-30);
|
||||
|
||||
--RadioGroup-choice-color-off: var(--color-primary);
|
||||
--RadioGroup-choice-color-off-hover: var(--color-brand-hover);
|
||||
--RadioGroup-choice-background-off: var(--island-bg-color);
|
||||
--RadioGroup-choice-background-off-active: var(--color-surface-high);
|
||||
--RadioGroup-choice-color-off-hover: var(--color-primary-darkest);
|
||||
--RadioGroup-choice-background-off: white;
|
||||
--RadioGroup-choice-background-off-active: var(--color-gray-20);
|
||||
|
||||
--RadioGroup-choice-color-on: var(--color-surface-lowest);
|
||||
--RadioGroup-choice-color-on: white;
|
||||
--RadioGroup-choice-background-on: var(--color-primary);
|
||||
--RadioGroup-choice-background-on-hover: var(--color-brand-hover);
|
||||
--RadioGroup-choice-background-on-active: var(--color-brand-active);
|
||||
--RadioGroup-choice-background-on-hover: var(--color-primary-darker);
|
||||
--RadioGroup-choice-background-on-active: var(--color-primary-darkest);
|
||||
|
||||
&.theme--dark {
|
||||
--RadioGroup-background: var(--color-gray-85);
|
||||
--RadioGroup-border: var(--color-gray-70);
|
||||
|
||||
--RadioGroup-choice-background-off: var(--color-gray-85);
|
||||
--RadioGroup-choice-background-off-active: var(--color-gray-70);
|
||||
--RadioGroup-choice-color-on: var(--color-gray-85);
|
||||
}
|
||||
|
||||
.RadioGroup {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
.excalidraw {
|
||||
.sidebar-trigger {
|
||||
@include outlineButtonStyles;
|
||||
@include filledButtonOnCanvas;
|
||||
|
||||
background-color: var(--island-bg-color);
|
||||
|
||||
width: auto;
|
||||
height: var(--lg-button-size);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { t } from "../i18n";
|
||||
import { Action } from "../actions/types";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Subtype,
|
||||
getSubtypeNames,
|
||||
hasAlwaysEnabledActions,
|
||||
isSubtypeAction,
|
||||
isValidSubtype,
|
||||
subtypeCollides,
|
||||
} from "../element/subtypes";
|
||||
import { ExcalidrawElement, Theme } from "../element/types";
|
||||
import {
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawContainer,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import { ContextMenuItems } from "./ContextMenu";
|
||||
|
||||
export const SubtypeButton = (
|
||||
subtype: Subtype,
|
||||
parentType: ExcalidrawElement["type"],
|
||||
icon: ({ theme }: { theme: Theme }) => JSX.Element,
|
||||
key?: string,
|
||||
) => {
|
||||
const title = key !== undefined ? ` - ${getShortcutKey(key)}` : "";
|
||||
const keyTest: Action["keyTest"] =
|
||||
key !== undefined ? (event) => event.code === `Key${key}` : undefined;
|
||||
const subtypeAction: Action = {
|
||||
name: subtype,
|
||||
trackEvent: false,
|
||||
predicate: (...rest) => rest[4]?.subtype === subtype,
|
||||
perform: (elements, appState) => {
|
||||
const inactive = !appState.activeSubtypes?.includes(subtype) ?? true;
|
||||
const activeSubtypes: Subtype[] = [];
|
||||
if (appState.activeSubtypes) {
|
||||
activeSubtypes.push(...appState.activeSubtypes);
|
||||
}
|
||||
let activated = false;
|
||||
if (inactive) {
|
||||
// Ensure `element.subtype` is well-defined
|
||||
if (!subtypeCollides(subtype, activeSubtypes)) {
|
||||
activeSubtypes.push(subtype);
|
||||
activated = true;
|
||||
}
|
||||
} else {
|
||||
// Can only be active if appState.activeSubtypes is defined
|
||||
// and contains subtype.
|
||||
activeSubtypes.splice(activeSubtypes.indexOf(subtype), 1);
|
||||
}
|
||||
const type =
|
||||
appState.activeTool.type !== "custom" &&
|
||||
isValidSubtype(subtype, appState.activeTool.type)
|
||||
? appState.activeTool.type
|
||||
: parentType;
|
||||
const activeTool = !inactive
|
||||
? appState.activeTool
|
||||
: updateActiveTool(appState, { type });
|
||||
const selectedElementIds = activated ? {} : appState.selectedElementIds;
|
||||
const selectedGroupIds = activated ? {} : appState.selectedGroupIds;
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
activeSubtypes,
|
||||
selectedElementIds,
|
||||
selectedGroupIds,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest,
|
||||
PanelComponent: ({ elements, appState, updateData, data }) => (
|
||||
<button
|
||||
className={clsx("ToolIcon_type_button", "ToolIcon_type_button--show", {
|
||||
ToolIcon: true,
|
||||
"ToolIcon--selected":
|
||||
appState.activeSubtypes !== undefined &&
|
||||
appState.activeSubtypes.includes(subtype),
|
||||
"ToolIcon--plain": true,
|
||||
})}
|
||||
title={`${t(`toolBar.${subtype}`)}${title}`}
|
||||
aria-label={t(`toolBar.${subtype}`)}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
onContextMenu={
|
||||
data && "onContextMenu" in data
|
||||
? (event: React.MouseEvent) => {
|
||||
if (
|
||||
appState.activeSubtypes === undefined ||
|
||||
(appState.activeSubtypes !== undefined &&
|
||||
!appState.activeSubtypes.includes(subtype))
|
||||
) {
|
||||
updateData(null);
|
||||
}
|
||||
data.onContextMenu(event, subtype);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
{icon.call(this, { theme: appState.theme })}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
),
|
||||
};
|
||||
if (key === "") {
|
||||
delete subtypeAction.keyTest;
|
||||
}
|
||||
return subtypeAction;
|
||||
};
|
||||
|
||||
export const SubtypeToggles = () => {
|
||||
const am = useExcalidrawActionManager();
|
||||
const { container } = useExcalidrawContainer();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const onContextMenu = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
subtype: string,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
const { top: offsetTop, left: offsetLeft } =
|
||||
container!.getBoundingClientRect();
|
||||
const left = event.clientX - offsetLeft;
|
||||
const top = event.clientY - offsetTop;
|
||||
|
||||
const items: ContextMenuItems = [];
|
||||
am.filterActions(isSubtypeAction).forEach(
|
||||
(action) =>
|
||||
am.isActionEnabled(action, { data: { subtype } }) && items.push(action),
|
||||
);
|
||||
setAppState({}, () => {
|
||||
setAppState({
|
||||
contextMenu: { top, left, items },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{getSubtypeNames().map((subtype) =>
|
||||
am.renderAction(
|
||||
subtype,
|
||||
hasAlwaysEnabledActions(subtype) ? { onContextMenu } : {},
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SubtypeToggles.displayName = "SubtypeToggles";
|
||||
|
||||
export const SubtypeShapeActions = (props: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
}) => {
|
||||
const am = useExcalidrawActionManager();
|
||||
return (
|
||||
<>
|
||||
{am
|
||||
.filterActions(isSubtypeAction, { elements: props.elements })
|
||||
.map((action) => am.renderAction(action.name))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
SubtypeShapeActions.displayName = "SubtypeShapeActions";
|
||||
+14
-16
@@ -1,13 +1,15 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
--Switch-disabled-color: var(--color-border-outline);
|
||||
--Switch-disabled-toggled-background: var(--color-border-outline-variant);
|
||||
--Switch-disabled-border: var(--color-border-outline-variant);
|
||||
--Switch-track-background: var(--island-bg-color);
|
||||
--Switch-thumb-background: var(--color-on-surface);
|
||||
--Switch-hover-background: var(--color-brand-hover);
|
||||
--Switch-active-background: var(--color-brand-active);
|
||||
--Switch-disabled-color: #d6d6d6;
|
||||
--Switch-track-background: white;
|
||||
--Switch-thumb-background: #3d3d3d;
|
||||
|
||||
&.theme--dark {
|
||||
--Switch-disabled-color: #5c5c5c;
|
||||
--Switch-track-background: #242424;
|
||||
--Switch-thumb-background: #b8b8b8;
|
||||
}
|
||||
|
||||
.Switch {
|
||||
position: relative;
|
||||
@@ -26,11 +28,7 @@
|
||||
|
||||
&:hover {
|
||||
background: var(--Switch-track-background);
|
||||
border: 1px solid var(--Switch-hover-background);
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 1px solid var(--Switch-active-background);
|
||||
border: 1px solid #999999;
|
||||
}
|
||||
|
||||
&.toggled {
|
||||
@@ -45,11 +43,11 @@
|
||||
|
||||
&.disabled {
|
||||
background: var(--Switch-track-background);
|
||||
border: 1px solid var(--Switch-disabled-border);
|
||||
border: 1px solid var(--Switch-disabled-color);
|
||||
|
||||
&.toggled {
|
||||
background: var(--Switch-disabled-toggled-background);
|
||||
border: 1px solid var(--Switch-disabled-toggled-background);
|
||||
background: var(--Switch-disabled-color);
|
||||
border: 1px solid var(--Switch-disabled-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +92,7 @@
|
||||
}
|
||||
|
||||
&.disabled.toggled:before {
|
||||
background: var(--Switch-disabled-color);
|
||||
background: var(--color-gray-50);
|
||||
}
|
||||
|
||||
& input {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
--ExcTextField--color: var(--color-on-surface);
|
||||
--ExcTextField--label-color: var(--color-on-surface);
|
||||
--ExcTextField--background: transparent;
|
||||
--ExcTextField--readonly--background: var(--color-surface-high);
|
||||
--ExcTextField--readonly--color: var(--color-on-surface);
|
||||
--ExcTextField--border: var(--color-border-outline);
|
||||
--ExcTextField--readonly--border: var(--color-border-outline-variant);
|
||||
--ExcTextField--border-hover: var(--color-brand-hover);
|
||||
--ExcTextField--border-active: var(--color-brand-active);
|
||||
--ExcTextField--placeholder: var(--color-border-outline-variant);
|
||||
--ExcTextField--color: var(--color-gray-80);
|
||||
--ExcTextField--label-color: var(--color-gray-80);
|
||||
--ExcTextField--background: white;
|
||||
--ExcTextField--readonly--background: var(--color-gray-10);
|
||||
--ExcTextField--readonly--color: var(--color-gray-80);
|
||||
--ExcTextField--border: var(--color-gray-40);
|
||||
--ExcTextField--border-hover: var(--color-gray-50);
|
||||
--ExcTextField--placeholder: var(--color-gray-40);
|
||||
|
||||
&.theme--dark {
|
||||
--ExcTextField--color: var(--color-gray-10);
|
||||
--ExcTextField--label-color: var(--color-gray-20);
|
||||
--ExcTextField--background: var(--color-gray-85);
|
||||
--ExcTextField--readonly--background: var(--color-gray-80);
|
||||
--ExcTextField--readonly--color: var(--color-gray-40);
|
||||
--ExcTextField--border: var(--color-gray-70);
|
||||
--ExcTextField--border-hover: var(--color-gray-60);
|
||||
--ExcTextField--placeholder: var(--color-gray-80);
|
||||
}
|
||||
|
||||
.ExcTextField {
|
||||
&--fullWidth {
|
||||
@@ -52,7 +61,7 @@
|
||||
|
||||
&:active,
|
||||
&:focus-within {
|
||||
border-color: var(--ExcTextField--border-active);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +107,7 @@
|
||||
|
||||
&--readonly {
|
||||
background: var(--ExcTextField--readonly--background);
|
||||
border-color: var(--ExcTextField--readonly--border);
|
||||
border-color: transparent;
|
||||
|
||||
& input {
|
||||
color: var(--ExcTextField--readonly--color);
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// &:hover {
|
||||
// background-color: var(--button-gray-2);
|
||||
// }
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-gray-3);
|
||||
}
|
||||
@@ -106,6 +110,7 @@
|
||||
}
|
||||
|
||||
&--hide {
|
||||
// visibility: hidden;
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -165,10 +170,5 @@
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__LaserPointer .ToolIcon__icon {
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,19 +22,12 @@
|
||||
.App-toolbar__extra-tools-trigger {
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-hover-bg);
|
||||
box-shadow: 0 0 0 1px
|
||||
var(--button-active-border, var(--color-primary-darkest)) inset;
|
||||
}
|
||||
|
||||
&--selected,
|
||||
&--selected:hover {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar__extra-tools-dropdown {
|
||||
|
||||
@@ -193,8 +193,6 @@ const getRelevantAppStateProps = (
|
||||
showHyperlinkPopup: appState.showHyperlinkPopup,
|
||||
collaborators: appState.collaborators, // Necessary for collab. sessions
|
||||
activeEmbeddable: appState.activeEmbeddable,
|
||||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
||||
@@ -114,13 +114,11 @@ const areEqual = (
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the StaticCanvas-relevant props
|
||||
getRelevantAppStateProps(prevProps.appState as AppState),
|
||||
getRelevantAppStateProps(nextProps.appState as AppState),
|
||||
) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig)
|
||||
return isShallowEqual(
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the StaticCanvas-relevant props
|
||||
getRelevantAppStateProps(prevProps.appState as AppState),
|
||||
getRelevantAppStateProps(nextProps.appState as AppState),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.dropdown-menu-container {
|
||||
padding: 8px 8px;
|
||||
box-sizing: border-box;
|
||||
// background-color: var(--island-bg-color);
|
||||
background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: var(--island-bg-color);
|
||||
background-color: #fff !important;
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
--gap: 2;
|
||||
@@ -40,7 +40,7 @@
|
||||
padding: 0 0.625rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-on-surface);
|
||||
color: var(--color-gray-100);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
.dropdown-menu-item {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border: 0;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
@@ -59,11 +59,6 @@
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-primary-light);
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
@@ -80,11 +75,6 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-hover-bg);
|
||||
border-color: var(--color-brand-active);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
@@ -103,33 +93,22 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
&.theme--dark {
|
||||
.dropdown-menu-item {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: var(--color-gray-90) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-button {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--island-bg-color);
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
--background: var(--color-surface-mid);
|
||||
|
||||
background-color: var(--background);
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
--background: var(--color-surface-high);
|
||||
&:hover {
|
||||
--background: #363541;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--background: var(--color-surface-high);
|
||||
background-color: var(--background);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
|
||||
@@ -11,14 +11,12 @@ const DropdownMenuItem = ({
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
onSelect: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
@@ -28,7 +26,7 @@ const DropdownMenuItem = ({
|
||||
{...rest}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
className={getDropdownMenuItemClassName(className, selected)}
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
|
||||
@@ -3,19 +3,15 @@ import React from "react";
|
||||
const DropdownMenuItemCustom = ({
|
||||
children,
|
||||
className = "",
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className} ${
|
||||
selected ? `dropdown-menu-item--selected` : ``
|
||||
}`.trim()}
|
||||
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ const DropdownMenuItemLink = ({
|
||||
children,
|
||||
onSelect,
|
||||
className = "",
|
||||
selected,
|
||||
...rest
|
||||
}: {
|
||||
href: string;
|
||||
@@ -20,7 +19,6 @@ const DropdownMenuItemLink = ({
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
onSelect?: (event: Event) => void;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
@@ -31,7 +29,7 @@ const DropdownMenuItemLink = ({
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={getDropdownMenuItemClassName(className, selected)}
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
||||
@@ -6,13 +6,8 @@ export const DropdownMenuContentPropsContext = React.createContext<{
|
||||
onSelect?: (event: Event) => void;
|
||||
}>({});
|
||||
|
||||
export const getDropdownMenuItemClassName = (
|
||||
className = "",
|
||||
selected = false,
|
||||
) => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
|
||||
selected ? "dropdown-menu-item--selected" : ""
|
||||
}`.trim();
|
||||
export const getDropdownMenuItemClassName = (className = "") => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
|
||||
};
|
||||
|
||||
export const useHandleDropdownMenuItemClick = (
|
||||
|
||||
@@ -1653,22 +1653,3 @@ export const frameToolIcon = createIcon(
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const laserPointerToolIcon = createIcon(
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transform="rotate(90 10 10)"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="m9.644 13.69 7.774-7.773a2.357 2.357 0 0 0-3.334-3.334l-7.773 7.774L8 12l1.643 1.69Z"
|
||||
/>
|
||||
<path d="m13.25 3.417 3.333 3.333M10 10l2-2M5 15l3-3M2.156 17.894l1-1M5.453 19.029l-.144-1.407M2.377 11.887l.866 1.118M8.354 17.273l-1.194-.758M.953 14.652l1.408.13" />
|
||||
</g>,
|
||||
|
||||
20,
|
||||
);
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
--button-active-bg: var(--color-primary-darker);
|
||||
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
// double .active to force specificity
|
||||
|
||||
@@ -43,7 +43,6 @@ const MainMenu = Object.assign(
|
||||
});
|
||||
}}
|
||||
data-testid="main-menu-trigger"
|
||||
className="main-menu-trigger"
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
justify-content: space-between;
|
||||
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border: none;
|
||||
|
||||
padding: 0.75rem;
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
|
||||
.welcome-screen-menu-item:hover {
|
||||
text-decoration: none;
|
||||
background: var(--button-hover-bg);
|
||||
background: var(--color-gray-10);
|
||||
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
@@ -216,8 +216,7 @@
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:active {
|
||||
background: var(--button-hover-bg);
|
||||
border-color: var(--color-brand-active);
|
||||
background: var(--color-gray-20);
|
||||
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
@@ -248,7 +247,8 @@
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:hover {
|
||||
background-color: var(--color-surface-low);
|
||||
background: var(--color-gray-85);
|
||||
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
@@ -259,6 +259,7 @@
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:active {
|
||||
background-color: var(--color-gray-90);
|
||||
.welcome-screen-menu-item__text {
|
||||
color: var(--color-gray-10);
|
||||
}
|
||||
|
||||
+2
-17
@@ -444,14 +444,13 @@
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 1px solid var(--button-active-border);
|
||||
border: 1px solid var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
@include outlineButtonStyles;
|
||||
@include filledButtonOnCanvas;
|
||||
|
||||
background-color: var(--island-bg-color);
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
@@ -622,20 +621,6 @@
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.main-menu-trigger {
|
||||
@include filledButtonOnCanvas;
|
||||
}
|
||||
|
||||
.App-menu__left {
|
||||
--button-border: transparent;
|
||||
--button-bg: var(--color-surface-mid);
|
||||
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
--button-hover-bg: #363541;
|
||||
--button-bg: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorSplash.excalidraw {
|
||||
|
||||
+23
-45
@@ -12,30 +12,27 @@
|
||||
--dialog-border-color: var(--color-gray-20);
|
||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||
--focus-highlight-color: #{$oc-blue-2};
|
||||
--icon-fill-color: var(--color-on-surface);
|
||||
--icon-fill-color: var(--color-gray-80);
|
||||
--icon-green-fill-color: #{$oc-green-9};
|
||||
--default-bg-color: #{$oc-white};
|
||||
--input-bg-color: #{$oc-white};
|
||||
--input-border-color: #{$oc-gray-4};
|
||||
--input-hover-bg-color: #{$oc-gray-1};
|
||||
--input-label-color: #{$oc-gray-7};
|
||||
--island-bg-color: #ffffff;
|
||||
--island-bg-color: rgba(255, 255, 255, 0.96);
|
||||
--keybinding-color: var(--color-gray-40);
|
||||
--link-color: #{$oc-blue-7};
|
||||
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
|
||||
--popup-bg-color: var(--island-bg-color);
|
||||
--popup-bg-color: #{$oc-white};
|
||||
--popup-secondary-bg-color: #{$oc-gray-1};
|
||||
--popup-text-color: #{$oc-black};
|
||||
--popup-text-inverted-color: #{$oc-white};
|
||||
--select-highlight-color: #{$oc-blue-5};
|
||||
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--button-hover-bg: var(--color-surface-high);
|
||||
--button-active-bg: var(--color-surface-high);
|
||||
--button-active-border: var(--color-brand-active);
|
||||
--default-border-color: var(--color-surface-high);
|
||||
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
|
||||
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
|
||||
0px 0px 0.931014px rgba(0, 0, 0, 0.1702);
|
||||
--button-hover-bg: var(--color-gray-10);
|
||||
--default-border-color: var(--color-gray-30);
|
||||
|
||||
--default-button-size: 2rem;
|
||||
--default-icon-size: 1rem;
|
||||
@@ -66,14 +63,14 @@
|
||||
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
|
||||
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
|
||||
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
|
||||
--sidebar-border-color: var(--color-surface-high);
|
||||
--sidebar-bg-color: var(--island-bg-color);
|
||||
--sidebar-border-color: var(--color-gray-20);
|
||||
--sidebar-bg-color: #fff;
|
||||
--library-dropdown-shadow: 0px 15px 6px rgba(0, 0, 0, 0.01),
|
||||
0px 8px 5px rgba(0, 0, 0, 0.05), 0px 4px 4px rgba(0, 0, 0, 0.09),
|
||||
0px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--space-factor: 0.25rem;
|
||||
--text-primary-color: var(--color-on-surface);
|
||||
--text-primary-color: var(--color-gray-80);
|
||||
|
||||
--color-selection: #6965db;
|
||||
|
||||
@@ -135,19 +132,6 @@
|
||||
--border-radius-md: 0.375rem;
|
||||
--border-radius-lg: 0.5rem;
|
||||
|
||||
--color-surface-high: hsl(244, 100%, 97%);
|
||||
--color-surface-mid: hsl(240 25% 96%);
|
||||
--color-surface-low: hsl(240 25% 94%);
|
||||
--color-surface-lowest: #ffffff;
|
||||
--color-on-surface: #1b1b1f;
|
||||
--color-brand-hover: #5753d0;
|
||||
--color-on-primary-container: #030064;
|
||||
--color-surface-primary-container: #e0dfff;
|
||||
--color-brand-active: #4440bf;
|
||||
--color-border-outline: #767680;
|
||||
--color-border-outline-variant: #c5c5d0;
|
||||
--color-surface-primary-container: #e0dfff;
|
||||
|
||||
&.theme--dark {
|
||||
&.theme--dark-background-none {
|
||||
background: none;
|
||||
@@ -166,24 +150,29 @@
|
||||
--dialog-border-color: var(--color-gray-80);
|
||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||
--focus-highlight-color: #{$oc-blue-6};
|
||||
--icon-fill-color: var(--color-gray-40);
|
||||
--icon-green-fill-color: #{$oc-green-4};
|
||||
--default-bg-color: #121212;
|
||||
--input-bg-color: #121212;
|
||||
--input-border-color: #2e2e2e;
|
||||
--input-hover-bg-color: #181818;
|
||||
--input-label-color: #{$oc-gray-2};
|
||||
--island-bg-color: #232329;
|
||||
--island-bg-color: #262627;
|
||||
--keybinding-color: var(--color-gray-60);
|
||||
--link-color: #{$oc-blue-4};
|
||||
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
||||
--popup-bg-color: #2c2c2c;
|
||||
--popup-secondary-bg-color: #222;
|
||||
--popup-text-color: #{$oc-gray-4};
|
||||
--popup-text-inverted-color: #2c2c2c;
|
||||
--select-highlight-color: #{$oc-blue-4};
|
||||
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
|
||||
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
|
||||
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
|
||||
|
||||
--text-primary-color: var(--color-gray-40);
|
||||
--button-hover-bg: var(--color-gray-80);
|
||||
--default-border-color: var(--color-gray-80);
|
||||
--shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
|
||||
0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),
|
||||
0px 1.13px 4.13211px rgba(0, 0, 0, 0.035),
|
||||
0px 0.769896px 1.4945px rgba(0, 0, 0, 0.0243888);
|
||||
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
|
||||
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
||||
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
|
||||
@@ -191,6 +180,8 @@
|
||||
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
|
||||
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
|
||||
--avatar-border-color: var(--color-gray-85);
|
||||
--sidebar-border-color: var(--color-gray-85);
|
||||
--sidebar-bg-color: #191919;
|
||||
|
||||
--scrollbar-thumb: #{$oc-gray-8};
|
||||
--scrollbar-thumb-hover: #{$oc-gray-7};
|
||||
@@ -233,18 +224,5 @@
|
||||
--color-promo: #d297ff;
|
||||
|
||||
--color-logo-text: #e2dfff;
|
||||
|
||||
--color-surface-high: hsl(245, 10%, 21%);
|
||||
--color-surface-low: hsl(240, 8%, 15%);
|
||||
--color-surface-mid: hsl(240 6% 10%);
|
||||
--color-surface-lowest: hsl(0, 0%, 7%);
|
||||
--color-on-surface: #e3e3e8;
|
||||
--color-brand-hover: #bbb8ff;
|
||||
--color-on-primary-container: #e0dfff;
|
||||
--color-surface-primary-container: #403e6a;
|
||||
--color-brand-active: #d0ccff;
|
||||
--color-border-outline: #8e8d9c;
|
||||
--color-border-outline-variant: #46464f;
|
||||
--color-surface-primary-container: #403e6a;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
.ToolIcon_type_radio,
|
||||
.ToolIcon_type_checkbox {
|
||||
&:checked + .ToolIcon__icon {
|
||||
--icon-fill-color: var(--color-on-primary-container);
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
|
||||
svg {
|
||||
fill: var(--icon-fill-color);
|
||||
@@ -23,11 +23,11 @@
|
||||
.ToolIcon_type_radio,
|
||||
.ToolIcon_type_checkbox {
|
||||
&:checked + .ToolIcon__icon {
|
||||
background: var(--color-surface-primary-container);
|
||||
--keybinding-color: var(--color-on-primary-container);
|
||||
background: var(--color-primary-light);
|
||||
--keybinding-color: var(--color-gray-60);
|
||||
|
||||
svg {
|
||||
color: var(--color-on-primary-container);
|
||||
color: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,7 @@
|
||||
|
||||
&:active {
|
||||
background: var(--button-hover-bg);
|
||||
border: 1px solid var(--button-active-border);
|
||||
|
||||
svg {
|
||||
color: var(--color-on-primary-container);
|
||||
}
|
||||
border: 1px solid var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +63,7 @@
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
background-color: var(--button-bg, var(--island-bg-color));
|
||||
color: var(--button-color, var(--color-on-surface));
|
||||
color: var(--button-color, var(--text-primary-color));
|
||||
|
||||
svg {
|
||||
width: var(--button-width, var(--lg-icon-size));
|
||||
@@ -92,38 +88,22 @@
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(
|
||||
--button-selected-bg,
|
||||
var(--color-surface-primary-container)
|
||||
);
|
||||
border-color: var(
|
||||
--button-selected-border,
|
||||
var(--color-surface-primary-container)
|
||||
);
|
||||
background-color: var(--button-selected-bg, var(--color-primary-light));
|
||||
border-color: var(--button-selected-border, var(--color-primary-light));
|
||||
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--button-selected-hover-bg,
|
||||
var(--color-surface-primary-container)
|
||||
var(--color-primary-light)
|
||||
);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--button-color, var(--color-on-primary-container));
|
||||
color: var(--button-color, var(--color-primary-darker));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin filledButtonOnCanvas {
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
background-color: var(--color-surface-low);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
|
||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||
$right-sidebar-width: "302px";
|
||||
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
import { CURSOR_TYPE, MIME_TYPES, THEME } from "./constants";
|
||||
import OpenColor from "open-color";
|
||||
import { AppState, DataURL } from "./types";
|
||||
import { isHandToolActive, isEraserActive } from "./appState";
|
||||
|
||||
const laserPointerCursorSVG_tag = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
|
||||
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
|
||||
const laserPointerCursorIconSVG = `<path stroke="#1b1b1f" fill="#fff" d="m7.868 11.113 7.773 7.774a2.359 2.359 0 0 0 1.667.691 2.368 2.368 0 0 0 2.357-2.358c0-.625-.248-1.225-.69-1.667L11.201 7.78 9.558 9.469l-1.69 1.643v.001Zm10.273 3.606-3.333 3.333m-3.25-6.583 2 2m-7-7 3 3M3.664 3.625l1 1M2.529 6.922l1.407-.144m5.735-2.932-1.118.866M4.285 9.823l.758-1.194m1.863-6.207-.13 1.408"/>`;
|
||||
|
||||
const laserPointerCursorDataURL_lightMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
const laserPointerCursorDataURL_darkMode = `data:${
|
||||
MIME_TYPES.svg
|
||||
},${encodeURIComponent(
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
|
||||
export const resetCursor = (interactiveCanvas: HTMLCanvasElement | null) => {
|
||||
if (interactiveCanvas) {
|
||||
interactiveCanvas.style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
export const setCursor = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
cursor: string,
|
||||
) => {
|
||||
if (interactiveCanvas) {
|
||||
interactiveCanvas.style.cursor = cursor;
|
||||
}
|
||||
};
|
||||
|
||||
let eraserCanvasCache: any;
|
||||
let previewDataURL: string;
|
||||
export const setEraserCursor = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
theme: AppState["theme"],
|
||||
) => {
|
||||
const cursorImageSizePx = 20;
|
||||
|
||||
const drawCanvas = () => {
|
||||
const isDarkTheme = theme === THEME.DARK;
|
||||
eraserCanvasCache = document.createElement("canvas");
|
||||
eraserCanvasCache.theme = theme;
|
||||
eraserCanvasCache.height = cursorImageSizePx;
|
||||
eraserCanvasCache.width = cursorImageSizePx;
|
||||
const context = eraserCanvasCache.getContext("2d")!;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
eraserCanvasCache.width / 2,
|
||||
eraserCanvasCache.height / 2,
|
||||
5,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
);
|
||||
context.fillStyle = isDarkTheme ? OpenColor.black : OpenColor.white;
|
||||
context.fill();
|
||||
context.strokeStyle = isDarkTheme ? OpenColor.white : OpenColor.black;
|
||||
context.stroke();
|
||||
previewDataURL = eraserCanvasCache.toDataURL(MIME_TYPES.svg) as DataURL;
|
||||
};
|
||||
if (!eraserCanvasCache || eraserCanvasCache.theme !== theme) {
|
||||
drawCanvas();
|
||||
}
|
||||
|
||||
setCursor(
|
||||
interactiveCanvas,
|
||||
`url(${previewDataURL}) ${cursorImageSizePx / 2} ${
|
||||
cursorImageSizePx / 2
|
||||
}, auto`,
|
||||
);
|
||||
};
|
||||
|
||||
export const setCursorForShape = (
|
||||
interactiveCanvas: HTMLCanvasElement | null,
|
||||
appState: Pick<AppState, "activeTool" | "theme">,
|
||||
) => {
|
||||
if (!interactiveCanvas) {
|
||||
return;
|
||||
}
|
||||
if (appState.activeTool.type === "selection") {
|
||||
resetCursor(interactiveCanvas);
|
||||
} else if (isHandToolActive(appState)) {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.GRAB;
|
||||
} else if (isEraserActive(appState)) {
|
||||
setEraserCursor(interactiveCanvas, appState.theme);
|
||||
// do nothing if image tool is selected which suggests there's
|
||||
// a image-preview set as the cursor
|
||||
// Ignore custom type as well and let host decide
|
||||
} else if (appState.activeTool.type === "laser") {
|
||||
const url =
|
||||
appState.theme === THEME.LIGHT
|
||||
? laserPointerCursorDataURL_lightMode
|
||||
: laserPointerCursorDataURL_darkMode;
|
||||
interactiveCanvas.style.cursor = `url(${url}), auto`;
|
||||
} else if (!["image", "custom"].includes(appState.activeTool.type)) {
|
||||
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||
}
|
||||
};
|
||||
+9
-12
@@ -36,6 +36,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { bumpVersion } from "../element/mutateElement";
|
||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isValidSubtype } from "../element/subtypes";
|
||||
import { MarkOptional, Mutable } from "../utility-types";
|
||||
import {
|
||||
detectLineHeight,
|
||||
@@ -67,7 +68,6 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
frame: true,
|
||||
embeddable: true,
|
||||
hand: true,
|
||||
laser: false,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
@@ -160,7 +160,7 @@ const restoreElementWithProperties = <
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
if ("subtype" in element) {
|
||||
if ("subtype" in element && isValidSubtype(element.subtype, base.type)) {
|
||||
base.subtype = element.subtype;
|
||||
}
|
||||
if ("customData" in element) {
|
||||
@@ -193,7 +193,7 @@ const restoreElement = (
|
||||
fontSize = parseFloat(fontPx);
|
||||
fontFamily = getFontFamilyByName(_fontFamily);
|
||||
}
|
||||
const text = (typeof element.text === "string" && element.text) || "";
|
||||
const text = element.text ?? "";
|
||||
|
||||
// line-height might not be specified either when creating elements
|
||||
// programmatically, or when importing old diagrams.
|
||||
@@ -222,17 +222,9 @@ const restoreElement = (
|
||||
baseline,
|
||||
});
|
||||
|
||||
// if empty text, mark as deleted. We keep in array
|
||||
// for data integrity purposes (collab etc.)
|
||||
if (!text && !element.isDeleted) {
|
||||
element = { ...element, originalText: text, isDeleted: true };
|
||||
element = bumpVersion(element);
|
||||
}
|
||||
|
||||
if (refreshDimensions) {
|
||||
element = { ...element, ...refreshTextDimensions(element) };
|
||||
}
|
||||
|
||||
return element;
|
||||
case "freedraw": {
|
||||
return restoreElementWithProperties(element, {
|
||||
@@ -307,7 +299,6 @@ const restoreElement = (
|
||||
// We also don't want to throw, but instead return void so we filter
|
||||
// out these unsupported elements from the restored array.
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -538,6 +529,12 @@ export const restoreAppState = (
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
if ("activeSubtypes" in appState) {
|
||||
nextAppState.activeSubtypes = appState.activeSubtypes;
|
||||
}
|
||||
if ("customData" in appState) {
|
||||
nextAppState.customData = appState.customData;
|
||||
}
|
||||
return {
|
||||
...nextAppState,
|
||||
cursorButton: localAppState?.cursorButton || "up",
|
||||
|
||||
+1
-14
@@ -158,7 +158,7 @@ export const getElementAbsoluteCoords = (
|
||||
];
|
||||
};
|
||||
|
||||
/*
|
||||
/**
|
||||
* for a given element, `getElementLineSegments` returns line segments
|
||||
* that can be used for visual collision detection (useful for frames)
|
||||
* as opposed to bounding box collision detection
|
||||
@@ -674,19 +674,6 @@ export const getCommonBounds = (
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
export const getDraggedElementsBounds = (
|
||||
elements: ExcalidrawElement[],
|
||||
dragOffset: { x: number; y: number },
|
||||
) => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return [
|
||||
minX + dragOffset.x,
|
||||
minY + dragOffset.y,
|
||||
maxX + dragOffset.x,
|
||||
maxY + dragOffset.y,
|
||||
];
|
||||
};
|
||||
|
||||
export const getResizedElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
nextWidth: number,
|
||||
|
||||
+33
-43
@@ -6,22 +6,23 @@ import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
import { getGridPoint } from "../math";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isFrameElement } from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
offset: { x: number; y: number },
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
lockDirection: boolean = false,
|
||||
distanceX: number = 0,
|
||||
distanceY: number = 0,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
snapOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
gridSize: AppState["gridSize"],
|
||||
) => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
// in the frame twice, hence the use of set
|
||||
@@ -43,11 +44,12 @@ export const dragSelectedElements = (
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
distanceY,
|
||||
pointerDownState,
|
||||
element,
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
// update coords of bound text only if we're dragging the container directly
|
||||
// (we don't drag the group that it's part of)
|
||||
@@ -67,11 +69,12 @@ export const dragSelectedElements = (
|
||||
(!textElement.frameId || !frames.includes(textElement.frameId))
|
||||
) {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
distanceY,
|
||||
pointerDownState,
|
||||
textElement,
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,40 +85,31 @@ export const dragSelectedElements = (
|
||||
};
|
||||
|
||||
const updateElementCoords = (
|
||||
lockDirection: boolean,
|
||||
distanceX: number,
|
||||
distanceY: number,
|
||||
pointerDownState: PointerDownState,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
dragOffset: { x: number; y: number },
|
||||
snapOffset: { x: number; y: number },
|
||||
gridSize: AppState["gridSize"],
|
||||
offset: { x: number; y: number },
|
||||
) => {
|
||||
const originalElement =
|
||||
pointerDownState.originalElements.get(element.id) ?? element;
|
||||
|
||||
let nextX = originalElement.x + dragOffset.x + snapOffset.x;
|
||||
let nextY = originalElement.y + dragOffset.y + snapOffset.y;
|
||||
|
||||
if (snapOffset.x === 0 || snapOffset.y === 0) {
|
||||
const [nextGridX, nextGridY] = getGridPoint(
|
||||
originalElement.x + dragOffset.x,
|
||||
originalElement.y + dragOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
if (snapOffset.x === 0) {
|
||||
nextX = nextGridX;
|
||||
}
|
||||
|
||||
if (snapOffset.y === 0) {
|
||||
nextY = nextGridY;
|
||||
}
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (lockDirection) {
|
||||
const lockX = lockDirection && distanceX < distanceY;
|
||||
const lockY = lockDirection && distanceX > distanceY;
|
||||
const original = pointerDownState.originalElements.get(element.id);
|
||||
x = lockX && original ? original.x : element.x + offset.x;
|
||||
y = lockY && original ? original.y : element.y + offset.y;
|
||||
} else {
|
||||
x = element.x + offset.x;
|
||||
y = element.y + offset.y;
|
||||
}
|
||||
|
||||
mutateElement(element, {
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDragOffsetXY = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
x: number,
|
||||
@@ -139,10 +133,6 @@ export const dragNewElement = (
|
||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||
true */
|
||||
widthAspectRatio?: number | null,
|
||||
originOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null = null,
|
||||
) => {
|
||||
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
|
||||
if (widthAspectRatio) {
|
||||
@@ -183,8 +173,8 @@ export const dragNewElement = (
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
mutateElement(draggingElement, {
|
||||
x: newX + (originOffset?.x ?? 0),
|
||||
y: newY + (originOffset?.y ?? 0),
|
||||
x: newX,
|
||||
y: newY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
@@ -2,8 +2,7 @@ import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { ExcalidrawProps } from "../types";
|
||||
import { getFontString, updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { newTextElement } from "./newElement";
|
||||
import { getContainerElement, wrapText } from "./textElement";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Point } from "../types";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { maybeGetSubtypeProps } from "./newElement";
|
||||
import { getSubtypeMethods } from "./subtypes";
|
||||
|
||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
@@ -17,7 +18,8 @@ const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
): ElementUpdate<TElement> => {
|
||||
const map = getSubtypeMethods(element.subtype);
|
||||
const subtype = maybeGetSubtypeProps(element, element.type).subtype;
|
||||
const map = getSubtypeMethods(subtype);
|
||||
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
|
||||
};
|
||||
|
||||
@@ -161,8 +163,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
*
|
||||
* NOTE: does not trigger re-render.
|
||||
*/
|
||||
export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
|
||||
element: T,
|
||||
export const bumpVersion = (
|
||||
element: Mutable<ExcalidrawElement>,
|
||||
version?: ExcalidrawElement["version"],
|
||||
) => {
|
||||
element.version = (version ?? element.version) + 1;
|
||||
|
||||
@@ -40,19 +40,28 @@ import {
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import { getSubtypeMethods } from "./subtypes";
|
||||
import { getSubtypeMethods, isValidSubtype } from "./subtypes";
|
||||
|
||||
export const maybeGetSubtypeProps = (obj: {
|
||||
subtype?: ExcalidrawElement["subtype"];
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
}) => {
|
||||
export const maybeGetSubtypeProps = (
|
||||
obj: {
|
||||
subtype?: ExcalidrawElement["subtype"];
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
},
|
||||
type: ExcalidrawElement["type"],
|
||||
) => {
|
||||
const data: typeof obj = {};
|
||||
if ("subtype" in obj && obj.subtype !== undefined) {
|
||||
if ("subtype" in obj) {
|
||||
data.subtype = obj.subtype;
|
||||
}
|
||||
if ("customData" in obj && obj.customData !== undefined) {
|
||||
if ("customData" in obj) {
|
||||
data.customData = obj.customData;
|
||||
}
|
||||
if ("subtype" in data && !isValidSubtype(data.subtype, type)) {
|
||||
delete data.subtype;
|
||||
}
|
||||
if (!("subtype" in data) && "customData" in data) {
|
||||
delete data.customData;
|
||||
}
|
||||
return data as typeof obj;
|
||||
};
|
||||
|
||||
@@ -108,7 +117,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
const { subtype, customData } = rest;
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||
...maybeGetSubtypeProps({ subtype, customData }),
|
||||
...maybeGetSubtypeProps({ subtype, customData }, type),
|
||||
id: rest.id || randomId(),
|
||||
type,
|
||||
x,
|
||||
@@ -364,6 +373,8 @@ export const newFreeDrawElement = (
|
||||
simulatePressure: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||
const map = getSubtypeMethods(opts?.subtype);
|
||||
map?.clean && map.clean(opts);
|
||||
return {
|
||||
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
@@ -381,6 +392,8 @@ export const newLinearElement = (
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
const map = getSubtypeMethods(opts?.subtype);
|
||||
map?.clean && map.clean(opts);
|
||||
return {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
@@ -400,6 +413,8 @@ export const newImageElement = (
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
const map = getSubtypeMethods(opts?.subtype);
|
||||
map?.clean && map.clean(opts);
|
||||
return {
|
||||
..._newElementBase<ExcalidrawImageElement>("image", opts),
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
MaybeTransformHandleType,
|
||||
TransformHandleDirection,
|
||||
} from "./transformHandles";
|
||||
import { AppState, Point, PointerDownState } from "../types";
|
||||
import { Point, PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineWidth,
|
||||
@@ -79,7 +79,6 @@ export const transformElements = (
|
||||
pointerY: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
appState: AppState,
|
||||
) => {
|
||||
if (selectedElements.length === 1) {
|
||||
const [element] = selectedElements;
|
||||
@@ -463,8 +462,8 @@ export const resizeSingleElement = (
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
eleNewWidth = Math.max(eleNewWidth, minWidth);
|
||||
eleNewHeight = Math.max(eleNewHeight, minHeight);
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,11 +504,8 @@ export const resizeSingleElement = (
|
||||
}
|
||||
}
|
||||
|
||||
const flipX = eleNewWidth < 0;
|
||||
const flipY = eleNewHeight < 0;
|
||||
|
||||
// Flip horizontally
|
||||
if (flipX) {
|
||||
if (eleNewWidth < 0) {
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
newTopLeft[0] -= Math.abs(newBoundsWidth);
|
||||
}
|
||||
@@ -517,9 +513,8 @@ export const resizeSingleElement = (
|
||||
newTopLeft[0] += Math.abs(newBoundsWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// Flip vertically
|
||||
if (flipY) {
|
||||
if (eleNewHeight < 0) {
|
||||
if (transformHandleDirection.includes("s")) {
|
||||
newTopLeft[1] -= Math.abs(newBoundsHeight);
|
||||
}
|
||||
@@ -543,20 +538,10 @@ export const resizeSingleElement = (
|
||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
|
||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||
// So we need to readjust (x,y) to be where the first point should be
|
||||
const newOrigin = [...newTopLeft];
|
||||
const linearElementXOffset = stateAtResizeStart.x - newBoundsX1;
|
||||
const linearElementYOffset = stateAtResizeStart.y - newBoundsY1;
|
||||
newOrigin[0] += linearElementXOffset;
|
||||
newOrigin[1] += linearElementYOffset;
|
||||
|
||||
const nextX = newOrigin[0];
|
||||
const nextY = newOrigin[1];
|
||||
|
||||
// Readjust points for linear elements
|
||||
let rescaledElementPointsY;
|
||||
let rescaledPoints;
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
rescaledElementPointsY = rescalePoints(
|
||||
1,
|
||||
@@ -573,11 +558,16 @@ export const resizeSingleElement = (
|
||||
);
|
||||
}
|
||||
|
||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||
// So we need to readjust (x,y) to be where the first point should be
|
||||
const newOrigin = [...newTopLeft];
|
||||
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
|
||||
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
|
||||
const resizedElement = {
|
||||
width: Math.abs(eleNewWidth),
|
||||
height: Math.abs(eleNewHeight),
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
x: newOrigin[0],
|
||||
y: newOrigin[1],
|
||||
points: rescaledPoints,
|
||||
};
|
||||
|
||||
@@ -686,10 +676,6 @@ export const resizeMultipleElements = (
|
||||
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
||||
targetElements.map(({ orig }) => orig).concat(boundTextElements),
|
||||
);
|
||||
|
||||
// const originalHeight = maxY - minY;
|
||||
// const originalWidth = maxX - minX;
|
||||
|
||||
const direction = transformHandleType;
|
||||
|
||||
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
|
||||
|
||||
@@ -12,7 +12,6 @@ export const showSelectedShapeActions = (
|
||||
(appState.editingElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand" &&
|
||||
appState.activeTool.type !== "laser"))) ||
|
||||
appState.activeTool.type !== "hand"))) ||
|
||||
getSelectedElements(elements, appState).length),
|
||||
);
|
||||
|
||||
+282
-14
@@ -1,8 +1,22 @@
|
||||
import { useEffect } from "react";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement, NonDeleted } from "../types";
|
||||
import { getNonDeletedElements } from "../";
|
||||
import { getSelectedElements } from "../../scene";
|
||||
import { AppState, ExcalidrawImperativeAPI } from "../../types";
|
||||
import { registerAuxLangData } from "../../i18n";
|
||||
|
||||
import { isTextElement } from "../typeChecks";
|
||||
import { getContainerElement, redrawTextBoundingBox } from "../textElement";
|
||||
import { Action, ActionName, ActionPredicateFn } from "../../actions/types";
|
||||
import {
|
||||
CustomShortcutName,
|
||||
registerCustomShortcuts,
|
||||
} from "../../actions/shortcuts";
|
||||
import { register } from "../../actions/register";
|
||||
import { hasBoundTextElement, isTextElement } from "../typeChecks";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../textElement";
|
||||
import { ShapeCache } from "../../scene/ShapeCache";
|
||||
import Scene from "../../scene/Scene";
|
||||
|
||||
@@ -12,10 +26,26 @@ let parentTypeMap: readonly {
|
||||
subtype: Subtype;
|
||||
parentType: ExcalidrawElement["type"];
|
||||
}[] = [];
|
||||
let subtypeActionMap: readonly {
|
||||
subtype: Subtype;
|
||||
actions: readonly SubtypeActionName[];
|
||||
}[] = [];
|
||||
let disabledActionMap: readonly {
|
||||
subtype: Subtype;
|
||||
actions: readonly DisabledActionName[];
|
||||
}[] = [];
|
||||
let alwaysEnabledMap: readonly {
|
||||
subtype: Subtype;
|
||||
actions: readonly SubtypeActionName[];
|
||||
}[] = [];
|
||||
|
||||
export type SubtypeRecord = Readonly<{
|
||||
subtype: Subtype;
|
||||
parents: readonly ExcalidrawElement["type"][];
|
||||
actionNames?: readonly SubtypeActionName[];
|
||||
disabledNames?: readonly DisabledActionName[];
|
||||
shortcutMap?: Record<CustomShortcutName, string[]>;
|
||||
alwaysEnabledNames?: readonly SubtypeActionName[];
|
||||
}>;
|
||||
|
||||
// Subtype Names
|
||||
@@ -23,6 +53,167 @@ export type Subtype = Required<ExcalidrawElement>["subtype"];
|
||||
export const getSubtypeNames = (): readonly Subtype[] => {
|
||||
return subtypeNames;
|
||||
};
|
||||
export const isValidSubtype = (s: any, t: any): s is Subtype =>
|
||||
parentTypeMap.find(
|
||||
(val) => (val.subtype as any) === s && (val.parentType as any) === t,
|
||||
) !== undefined;
|
||||
const isSubtypeName = (s: any): s is Subtype => subtypeNames.includes(s);
|
||||
|
||||
// Subtype Actions
|
||||
|
||||
// Used for context menus in the shape chooser
|
||||
export const hasAlwaysEnabledActions = (s: any): boolean => {
|
||||
if (!isSubtypeName(s)) {
|
||||
return false;
|
||||
}
|
||||
return alwaysEnabledMap.some((value) => value.subtype === s);
|
||||
};
|
||||
|
||||
type SubtypeActionName = string;
|
||||
|
||||
const isSubtypeActionName = (s: any): s is SubtypeActionName =>
|
||||
subtypeActionMap.some((val) => val.actions.includes(s));
|
||||
|
||||
const addSubtypeAction = (action: Action) => {
|
||||
if (isSubtypeActionName(action.name) || isSubtypeName(action.name)) {
|
||||
register(action);
|
||||
}
|
||||
};
|
||||
|
||||
// Standard actions disabled by subtypes
|
||||
type DisabledActionName = ActionName;
|
||||
|
||||
const isDisabledActionName = (s: any): s is DisabledActionName =>
|
||||
disabledActionMap.some((val) => val.actions.includes(s));
|
||||
|
||||
// Is the `actionName` one of the subtype actions for `subtype`
|
||||
// (if `isAdded` is true) or one of the standard actions disabled
|
||||
// by `subtype` (if `isAdded` is false)?
|
||||
const isForSubtype = (
|
||||
subtype: ExcalidrawElement["subtype"],
|
||||
actionName: ActionName | SubtypeActionName,
|
||||
isAdded: boolean,
|
||||
) => {
|
||||
const actions = isAdded ? subtypeActionMap : disabledActionMap;
|
||||
const map = actions.find((value) => value.subtype === subtype);
|
||||
if (map) {
|
||||
return map.actions.includes(actionName);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isSubtypeAction: ActionPredicateFn = function (action) {
|
||||
return isSubtypeActionName(action.name) && !isSubtypeName(action.name);
|
||||
};
|
||||
|
||||
export const subtypeActionPredicate: ActionPredicateFn = function (
|
||||
action,
|
||||
elements,
|
||||
appState,
|
||||
) {
|
||||
// We always enable subtype actions. Also let through standard actions
|
||||
// which no subtypes might have disabled.
|
||||
if (
|
||||
isSubtypeName(action.name) ||
|
||||
(!isSubtypeActionName(action.name) && !isDisabledActionName(action.name))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const chosen = appState.editingElement
|
||||
? [appState.editingElement, ...selectedElements]
|
||||
: selectedElements;
|
||||
// Now handle actions added by subtypes
|
||||
if (isSubtypeActionName(action.name)) {
|
||||
// Has any ExcalidrawElement enabled this actionName through having
|
||||
// its subtype?
|
||||
return (
|
||||
chosen.some((el) => {
|
||||
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
|
||||
return isForSubtype(e.subtype, action.name, true);
|
||||
}) ||
|
||||
// Or has any active subtype enabled this actionName?
|
||||
(appState.activeSubtypes !== undefined &&
|
||||
appState.activeSubtypes?.some((subtype) => {
|
||||
if (!isValidSubtype(subtype, appState.activeTool.type)) {
|
||||
return false;
|
||||
}
|
||||
return isForSubtype(subtype, action.name, true);
|
||||
})) ||
|
||||
alwaysEnabledMap.some((value) => {
|
||||
return value.actions.includes(action.name);
|
||||
})
|
||||
);
|
||||
}
|
||||
// Now handle standard actions disabled by subtypes
|
||||
if (isDisabledActionName(action.name)) {
|
||||
return (
|
||||
// Has every ExcalidrawElement not disabled this actionName?
|
||||
(chosen.every((el) => {
|
||||
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
|
||||
return !isForSubtype(e.subtype, action.name, false);
|
||||
}) &&
|
||||
// And has every active subtype not disabled this actionName?
|
||||
(appState.activeSubtypes === undefined ||
|
||||
appState.activeSubtypes?.every((subtype) => {
|
||||
if (!isValidSubtype(subtype, appState.activeTool.type)) {
|
||||
return true;
|
||||
}
|
||||
return !isForSubtype(subtype, action.name, false);
|
||||
}))) ||
|
||||
// Or can we find an ExcalidrawElement without a valid subtype
|
||||
// which would disable this action if it had a valid subtype?
|
||||
chosen.some((el) => {
|
||||
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
|
||||
return parentTypeMap.some(
|
||||
(value) =>
|
||||
value.parentType === e.type &&
|
||||
!isValidSubtype(e.subtype, e.type) &&
|
||||
isForSubtype(value.subtype, action.name, false),
|
||||
);
|
||||
}) ||
|
||||
chosen.some((el) => {
|
||||
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
|
||||
return (
|
||||
// Would the subtype of e by inself disable this action?
|
||||
isForSubtype(e.subtype, action.name, false) &&
|
||||
// Can we find an ExcalidrawElement which could have the same subtype
|
||||
// as e but whose subtype does not disable this action?
|
||||
chosen.some((el) => {
|
||||
const e2 = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
|
||||
return (
|
||||
// Does e have a valid subtype whose parent types include the
|
||||
// type of e2, and does the subtype of e2 not disable this action?
|
||||
parentTypeMap
|
||||
.filter((val) => val.subtype === e.subtype)
|
||||
.some((val) => val.parentType === e2.type) &&
|
||||
!isForSubtype(e2.subtype, action.name, false)
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
// Shouldn't happen
|
||||
return true;
|
||||
};
|
||||
|
||||
// Are any of the parent types of `subtype` shared by any subtype
|
||||
// in the array?
|
||||
export const subtypeCollides = (subtype: Subtype, subtypeArray: Subtype[]) => {
|
||||
const subtypeParents = parentTypeMap
|
||||
.filter((value) => value.subtype === subtype)
|
||||
.map((value) => value.parentType);
|
||||
const subtypeArrayParents = subtypeArray.flatMap((s) =>
|
||||
parentTypeMap
|
||||
.filter((value) => value.subtype === s)
|
||||
.map((value) => value.parentType),
|
||||
);
|
||||
return subtypeParents.some((t) => subtypeArrayParents.includes(t));
|
||||
};
|
||||
|
||||
// Subtype Methods
|
||||
export type SubtypeMethods = {
|
||||
@@ -32,8 +223,8 @@ export type SubtypeMethods = {
|
||||
"id" | "version" | "versionNonce"
|
||||
>,
|
||||
) => Omit<Partial<ExcalidrawElement>, "id" | "version" | "versionNonce">;
|
||||
ensureLoaded: (callback?: () => void) => Promise<void>;
|
||||
getEditorStyle: (element: ExcalidrawTextElement) => Record<string, any>;
|
||||
ensureLoaded: (callback?: () => void) => Promise<void>;
|
||||
measureText: (
|
||||
element: Pick<
|
||||
ExcalidrawTextElement,
|
||||
@@ -94,35 +285,71 @@ export const addSubtypeMethods = (
|
||||
subtype: Subtype,
|
||||
methods: Partial<SubtypeMethods>,
|
||||
) => {
|
||||
if (!subtypeNames.includes(subtype)) {
|
||||
return;
|
||||
}
|
||||
if (!methodMaps.find((method) => method.subtype === subtype)) {
|
||||
methodMaps.push({ subtype, methods });
|
||||
}
|
||||
};
|
||||
|
||||
// For a given `ExcalidrawElement` type, return the active subtype
|
||||
// and associated customData (if any) from the AppState. Assume
|
||||
// only one subtype is active for a given `ExcalidrawElement` type
|
||||
// at any given time.
|
||||
export const selectSubtype = (
|
||||
appState: {
|
||||
activeSubtypes?: AppState["activeSubtypes"];
|
||||
customData?: AppState["customData"];
|
||||
},
|
||||
type: ExcalidrawElement["type"],
|
||||
): {
|
||||
subtype?: ExcalidrawElement["subtype"];
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
} => {
|
||||
if (appState.activeSubtypes === undefined) {
|
||||
return {};
|
||||
}
|
||||
const subtype = appState.activeSubtypes.find((subtype) =>
|
||||
isValidSubtype(subtype, type),
|
||||
);
|
||||
if (subtype === undefined) {
|
||||
return {};
|
||||
}
|
||||
if (appState.customData === undefined || !(subtype in appState.customData)) {
|
||||
return { subtype };
|
||||
}
|
||||
const customData = appState.customData[subtype];
|
||||
return { subtype, customData };
|
||||
};
|
||||
|
||||
// Callback to re-render subtyped `ExcalidrawElement`s after completing
|
||||
// async loading of the subtype.
|
||||
export type SubtypeLoadedCb = (hasSubtype: SubtypeCheckFn) => void;
|
||||
export type SubtypeCheckFn = (element: ExcalidrawElement) => boolean;
|
||||
|
||||
// Functions to prepare subtypes for use
|
||||
export type SubtypePrepFn = (onSubtypeLoaded?: SubtypeLoadedCb) => {
|
||||
export type SubtypePrepFn = (
|
||||
addSubtypeAction: (action: Action) => void,
|
||||
addLangData: (
|
||||
fallbackLangData: Object,
|
||||
setLanguageAux: (langCode: string) => Promise<Object | undefined>,
|
||||
) => void,
|
||||
onSubtypeLoaded?: SubtypeLoadedCb,
|
||||
) => {
|
||||
actions: Action[];
|
||||
methods: Partial<SubtypeMethods>;
|
||||
};
|
||||
|
||||
// This is the main method to set up the subtype. The optional
|
||||
// `onSubtypeLoaded` callback may be used to re-render subtyped
|
||||
// `ExcalidrawElement`s after the subtype has finished async loading.
|
||||
// See the MathJax extension in `@excalidraw/extensions` for example.
|
||||
export const prepareSubtype = (
|
||||
record: SubtypeRecord,
|
||||
subtypePrepFn: SubtypePrepFn,
|
||||
onSubtypeLoaded?: SubtypeLoadedCb,
|
||||
): { methods: Partial<SubtypeMethods> } => {
|
||||
): { actions: Action[] | null; methods: Partial<SubtypeMethods> } => {
|
||||
const map = getSubtypeMethods(record.subtype);
|
||||
if (map) {
|
||||
return { methods: map };
|
||||
return { actions: null, methods: map };
|
||||
}
|
||||
|
||||
// Check for undefined/null subtypes and parentTypes
|
||||
@@ -132,7 +359,7 @@ export const prepareSubtype = (
|
||||
record.parents === undefined ||
|
||||
record.parents.length === 0
|
||||
) {
|
||||
return { methods: {} };
|
||||
return { actions: null, methods: {} };
|
||||
}
|
||||
|
||||
// Register the types
|
||||
@@ -141,17 +368,43 @@ export const prepareSubtype = (
|
||||
record.parents.forEach((parentType) => {
|
||||
parentTypeMap = [...parentTypeMap, { subtype, parentType }];
|
||||
});
|
||||
if (record.actionNames) {
|
||||
subtypeActionMap = [
|
||||
...subtypeActionMap,
|
||||
{ subtype, actions: record.actionNames },
|
||||
];
|
||||
}
|
||||
if (record.disabledNames) {
|
||||
disabledActionMap = [
|
||||
...disabledActionMap,
|
||||
{ subtype, actions: record.disabledNames },
|
||||
];
|
||||
}
|
||||
if (record.alwaysEnabledNames) {
|
||||
alwaysEnabledMap = [
|
||||
...alwaysEnabledMap,
|
||||
{ subtype, actions: record.alwaysEnabledNames },
|
||||
];
|
||||
}
|
||||
if (record.shortcutMap) {
|
||||
registerCustomShortcuts(record.shortcutMap);
|
||||
}
|
||||
|
||||
// Prepare the subtype
|
||||
const { methods } = subtypePrepFn(onSubtypeLoaded);
|
||||
const { actions, methods } = subtypePrepFn(
|
||||
addSubtypeAction,
|
||||
registerAuxLangData,
|
||||
onSubtypeLoaded,
|
||||
);
|
||||
|
||||
// Register the subtype's methods
|
||||
addSubtypeMethods(record.subtype, methods);
|
||||
return { methods };
|
||||
return { actions, methods };
|
||||
};
|
||||
|
||||
// Ensure all subtypes are loaded before continuing, eg to
|
||||
// redraw text element bounding boxes correctly.
|
||||
// render SVG previews of new charts. Chart-relevant subtypes
|
||||
// include math equations in titles or non hand-drawn line styles.
|
||||
export const ensureSubtypesLoadedForElements = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
callback?: () => void,
|
||||
@@ -163,7 +416,7 @@ export const ensureSubtypesLoadedForElements = async (
|
||||
elements.forEach((el) => {
|
||||
if (
|
||||
"subtype" in el &&
|
||||
el.subtype !== undefined &&
|
||||
isValidSubtype(el.subtype, el.type) &&
|
||||
!subtypesUsed.includes(el.subtype)
|
||||
) {
|
||||
subtypesUsed.push(el.subtype);
|
||||
@@ -220,3 +473,18 @@ export const checkRefreshOnSubtypeLoad = (
|
||||
scenes.forEach((scene) => scene.informMutation());
|
||||
return refreshNeeded;
|
||||
};
|
||||
|
||||
export const useSubtype = (
|
||||
api: ExcalidrawImperativeAPI | null,
|
||||
record: SubtypeRecord,
|
||||
subtypePrepFn: SubtypePrepFn,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (api) {
|
||||
const prep = api.addSubtype(record, subtypePrepFn);
|
||||
if (prep) {
|
||||
addSubtypeMethods(record.subtype, prep.methods);
|
||||
}
|
||||
}
|
||||
}, [api, record, subtypePrepFn]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Theme } from "../../../element/types";
|
||||
import { createIcon, iconFillColor } from "../../../components/icons";
|
||||
|
||||
// We inline font-awesome icons in order to save on js size rather than including the font awesome react library
|
||||
export const mathSubtypeIcon = ({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
// fa-square-root-variable-solid
|
||||
d="M289 24.2C292.5 10 305.3 0 320 0H544c17.7 0 32 14.3 32 32s-14.3 32-32 32H345L239 487.8c-3.2 13-14.2 22.6-27.6 24s-26.1-5.5-32.1-17.5L76.2 288H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H96c12.1 0 23.2 6.8 28.6 17.7l73.3 146.6L289 24.2zM393.4 233.4c12.5-12.5 32.8-12.5 45.3 0L480 274.7l41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L525.3 320l41.4 41.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L480 365.3l-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L434.7 320l-41.4-41.4c-12.5-12.5-12.5-32.8 0-45.3z"
|
||||
/>,
|
||||
{ width: 576, height: 512, mirror: true, strokeWidth: 1.25 },
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
import { ExcalidrawImperativeAPI } from "../../../types";
|
||||
import { useSubtype } from "../";
|
||||
import { getMathSubtypeRecord } from "./types";
|
||||
import { prepareMathSubtype } from "./implementation";
|
||||
|
||||
declare global {
|
||||
module SREfeature {
|
||||
function custom(locale: string): Promise<string>;
|
||||
}
|
||||
}
|
||||
|
||||
// The main hook to use the MathJax subtype
|
||||
export const useMathSubtype = (api: ExcalidrawImperativeAPI | null) => {
|
||||
useSubtype(api, getMathSubtypeRecord(), prepareMathSubtype);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"labels": {
|
||||
"changeMathOnly": "Math display",
|
||||
"mathOnlyTrue": "Math only",
|
||||
"mathOnlyFalse": "Mixed text",
|
||||
"resetUseTex": "Reset math input type",
|
||||
"useTexTrueActive": "✔ Standard input",
|
||||
"useTexTrueInactive": "Standard input",
|
||||
"useTexFalseActive": "✔ Simplified input",
|
||||
"useTexFalseInactive": "Simplified input"
|
||||
},
|
||||
"toolBar": {
|
||||
"math": "Math"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { vi } from "vitest";
|
||||
import { render } from "../../../../tests/test-utils";
|
||||
import { API } from "../../../../tests/helpers/api";
|
||||
import { Excalidraw } from "../../../../packages/excalidraw/index";
|
||||
|
||||
import { measureTextElement } from "../../../textElement";
|
||||
import { ensureSubtypesLoaded } from "../../";
|
||||
import { getMathSubtypeRecord } from "../types";
|
||||
import { prepareMathSubtype } from "../implementation";
|
||||
|
||||
describe("mathjax loaded", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
API.addSubtype(getMathSubtypeRecord(), prepareMathSubtype);
|
||||
await ensureSubtypesLoaded(["math"]);
|
||||
});
|
||||
it("text-only measurements match", async () => {
|
||||
const text = "A quick brown fox jumps over the lazy dog.";
|
||||
const elements = [
|
||||
API.createElement({ type: "text", id: "A", text, subtype: "math" }),
|
||||
API.createElement({ type: "text", id: "B", text }),
|
||||
];
|
||||
const metrics1 = measureTextElement(elements[0]);
|
||||
const metrics2 = measureTextElement(elements[1]);
|
||||
expect(metrics1).toStrictEqual(metrics2);
|
||||
});
|
||||
it("minimum height remains", async () => {
|
||||
const elements = [
|
||||
API.createElement({ type: "text", id: "A", text: "a" }),
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "B",
|
||||
text: "\\(\\alpha\\)",
|
||||
subtype: "math",
|
||||
customData: { useTex: true },
|
||||
}),
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "C",
|
||||
text: "`beta`",
|
||||
subtype: "math",
|
||||
customData: { useTex: false },
|
||||
}),
|
||||
];
|
||||
const height = measureTextElement(elements[0]).height;
|
||||
const height1 = measureTextElement(elements[1]).height;
|
||||
const height2 = measureTextElement(elements[2]).height;
|
||||
expect(height).toEqual(height1);
|
||||
expect(height).toEqual(height2);
|
||||
});
|
||||
it("converts math to svgs", async () => {
|
||||
const svgDim = 42;
|
||||
vi.spyOn(SVGElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
() => new DOMRect(0, 0, svgDim, svgDim),
|
||||
);
|
||||
const elements = [];
|
||||
const type = "text";
|
||||
const subtype = "math";
|
||||
let text = "Math ";
|
||||
elements.push(API.createElement({ type, text }));
|
||||
text = "Math \\(\\alpha\\)";
|
||||
elements.push(
|
||||
API.createElement({ type, subtype, text, customData: { useTex: true } }),
|
||||
);
|
||||
text = "Math `beta`";
|
||||
elements.push(
|
||||
API.createElement({ type, subtype, text, customData: { useTex: false } }),
|
||||
);
|
||||
const metrics = {
|
||||
width: measureTextElement(elements[0]).width + svgDim,
|
||||
height: svgDim,
|
||||
baseline: 0,
|
||||
};
|
||||
expect(measureTextElement(elements[1])).toStrictEqual(metrics);
|
||||
expect(measureTextElement(elements[2])).toStrictEqual(metrics);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getShortcutKey } from "../../../utils";
|
||||
import { SubtypeRecord } from "../";
|
||||
|
||||
// Exports
|
||||
export const getMathSubtypeRecord = () => mathSubtype;
|
||||
|
||||
// Use `getMathSubtype` so we don't have to export this
|
||||
const mathSubtype: SubtypeRecord = {
|
||||
subtype: "math",
|
||||
parents: ["text"],
|
||||
actionNames: ["useTexTrue", "useTexFalse", "resetUseTex", "changeMathOnly"],
|
||||
disabledNames: ["changeFontFamily"],
|
||||
shortcutMap: {
|
||||
resetUseTex: [getShortcutKey("Shift+R")],
|
||||
},
|
||||
alwaysEnabledNames: ["useTexTrue", "useTexFalse"],
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./types";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { resize } from "../tests/utils";
|
||||
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
@@ -952,11 +953,11 @@ describe("textWysiwyg", () => {
|
||||
editor.blur();
|
||||
|
||||
// should center align horizontally and vertically by default
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
85,
|
||||
4.999999999999986,
|
||||
4.5,
|
||||
]
|
||||
`);
|
||||
|
||||
@@ -976,7 +977,7 @@ describe("textWysiwyg", () => {
|
||||
editor.blur();
|
||||
|
||||
// should left align horizontally and bottom vertically after resize
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
15,
|
||||
@@ -998,11 +999,11 @@ describe("textWysiwyg", () => {
|
||||
editor.blur();
|
||||
|
||||
// should right align horizontally and top vertically after resize
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
374.99999999999994,
|
||||
-535.0000000000001,
|
||||
375,
|
||||
-539,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1048,7 +1049,7 @@ describe("textWysiwyg", () => {
|
||||
expect(rectangle.height).toBe(75);
|
||||
expect(textElement.fontSize).toBe(20);
|
||||
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
|
||||
shift: true,
|
||||
});
|
||||
expect(rectangle.width).toBe(200);
|
||||
@@ -1188,8 +1189,8 @@ describe("textWysiwyg", () => {
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect(rectangle.height).toBeCloseTo(155, 8);
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect(rectangle.height).toBe(156);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
|
||||
mouse.select(rectangle);
|
||||
@@ -1199,12 +1200,9 @@ describe("textWysiwyg", () => {
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBeCloseTo(155, 8);
|
||||
expect(rectangle.height).toBe(156);
|
||||
// cache updated again
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
|
||||
155,
|
||||
8,
|
||||
);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(156);
|
||||
});
|
||||
|
||||
it("should reset the container height cache when font properties updated", async () => {
|
||||
@@ -1512,16 +1510,28 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should bump the version of a labeled arrow when the label is updated", async () => {
|
||||
it("should bump the version of labelled arrow when label updated", async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
const arrow = UI.createElement("arrow", {
|
||||
width: 300,
|
||||
height: 0,
|
||||
});
|
||||
await UI.editText(arrow, "Hello");
|
||||
|
||||
mouse.select(arrow);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
const { version } = arrow;
|
||||
|
||||
await UI.editText(arrow, "Hello\nworld!");
|
||||
mouse.select(arrow);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello\nworld!");
|
||||
editor.blur();
|
||||
|
||||
expect(arrow.version).toEqual(version + 1);
|
||||
});
|
||||
|
||||
@@ -634,7 +634,7 @@ export const textWysiwyg = ({
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("pointerup", bindBlurEvent);
|
||||
window.removeEventListener("blur", handleSubmit);
|
||||
window.removeEventListener("beforeunload", handleSubmit);
|
||||
|
||||
unbindUpdate();
|
||||
|
||||
editable.remove();
|
||||
@@ -751,7 +751,6 @@ export const textWysiwyg = ({
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
window.addEventListener("beforeunload", handleSubmit);
|
||||
excalidrawContainer
|
||||
?.querySelector(".excalidraw-textEditorContainer")!
|
||||
.appendChild(editable);
|
||||
|
||||
@@ -436,121 +436,5 @@ describe("adding elements to frames", () => {
|
||||
expect(rect2.frameId).toBe(null);
|
||||
expectEqualIds([rect2_copy, frame, rect2]);
|
||||
});
|
||||
|
||||
it("random order 01", () => {
|
||||
const frame1 = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const frame2 = API.createElement({
|
||||
type: "frame",
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const frame3 = API.createElement({
|
||||
type: "frame",
|
||||
x: 300,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 25,
|
||||
y: 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
frameId: frame1.id,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 225,
|
||||
y: 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
frameId: frame2.id,
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 325,
|
||||
y: 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
frameId: frame3.id,
|
||||
});
|
||||
const rectangle4 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 350,
|
||||
y: 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
frameId: frame3.id,
|
||||
});
|
||||
|
||||
h.elements = [
|
||||
frame1,
|
||||
rectangle4,
|
||||
rectangle1,
|
||||
rectangle3,
|
||||
frame3,
|
||||
rectangle2,
|
||||
frame2,
|
||||
];
|
||||
|
||||
API.setSelectedElements([rectangle2]);
|
||||
|
||||
const origSize = h.elements.length;
|
||||
|
||||
expect(h.elements.length).toBe(origSize);
|
||||
dragElementIntoFrame(frame3, rectangle2);
|
||||
expect(h.elements.length).toBe(origSize);
|
||||
});
|
||||
|
||||
it("random order 02", () => {
|
||||
const frame1 = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const frame2 = API.createElement({
|
||||
type: "frame",
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 25,
|
||||
y: 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
frameId: frame1.id,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 225,
|
||||
y: 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
frameId: frame2.id,
|
||||
});
|
||||
|
||||
h.elements = [rectangle1, rectangle2, frame1, frame2];
|
||||
|
||||
API.setSelectedElements([rectangle2]);
|
||||
|
||||
expect(h.elements.length).toBe(4);
|
||||
dragElementIntoFrame(frame2, rectangle1);
|
||||
expect(h.elements.length).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+64
-110
@@ -14,7 +14,7 @@ import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "./element/textElement";
|
||||
import { arrayToMap } from "./utils";
|
||||
import { arrayToMap, findIndex } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./scene";
|
||||
@@ -452,125 +452,90 @@ export const getContainingFrame = (
|
||||
};
|
||||
|
||||
// --------------------------- Frame Operations -------------------------------
|
||||
|
||||
/**
|
||||
* Retains (or repairs for target frame) the ordering invriant where children
|
||||
* elements come right before the parent frame:
|
||||
* [el, el, child, child, frame, el]
|
||||
*/
|
||||
export const addElementsToFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const { allElementsIndexMap, currTargetFrameChildrenMap } =
|
||||
allElements.reduce(
|
||||
(acc, element, index) => {
|
||||
acc.allElementsIndexMap.set(element.id, index);
|
||||
if (element.frameId === frame.id) {
|
||||
acc.currTargetFrameChildrenMap.set(element.id, true);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
allElementsIndexMap: new Map<ExcalidrawElement["id"], number>(),
|
||||
currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
|
||||
},
|
||||
);
|
||||
const _elementsToAdd: ExcalidrawElement[] = [];
|
||||
|
||||
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
|
||||
|
||||
const finalElementsToAdd: ExcalidrawElement[] = [];
|
||||
|
||||
// - add bound text elements if not already in the array
|
||||
// - filter out elements that are already in the frame
|
||||
for (const element of omitGroupsContainingFrames(
|
||||
allElements,
|
||||
elementsToAdd,
|
||||
)) {
|
||||
if (!currTargetFrameChildrenMap.has(element.id)) {
|
||||
finalElementsToAdd.push(element);
|
||||
}
|
||||
for (const element of elementsToAdd) {
|
||||
_elementsToAdd.push(element);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (
|
||||
boundTextElement &&
|
||||
!suppliedElementsToAddSet.has(boundTextElement.id) &&
|
||||
!currTargetFrameChildrenMap.has(boundTextElement.id)
|
||||
) {
|
||||
finalElementsToAdd.push(boundTextElement);
|
||||
if (boundTextElement) {
|
||||
_elementsToAdd.push(boundTextElement);
|
||||
}
|
||||
}
|
||||
|
||||
const finalElementsToAddSet = new Set(finalElementsToAdd.map((el) => el.id));
|
||||
const allElementsIndex = allElements.reduce(
|
||||
(acc: Record<string, number>, element, index) => {
|
||||
acc[element.id] = index;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const nextElements: ExcalidrawElement[] = [];
|
||||
const frameIndex = allElementsIndex[frame.id];
|
||||
// need to be calculated before the mutation below occurs
|
||||
const leftFrameBoundaryIndex = findIndex(
|
||||
allElements,
|
||||
(e) => e.frameId === frame.id,
|
||||
);
|
||||
|
||||
const processedElements = new Set<ExcalidrawElement["id"]>();
|
||||
const existingFrameChildren = allElements.filter(
|
||||
(element) => element.frameId === frame.id,
|
||||
);
|
||||
|
||||
for (const element of allElements) {
|
||||
if (processedElements.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
const addedFrameChildren_left: ExcalidrawElement[] = [];
|
||||
const addedFrameChildren_right: ExcalidrawElement[] = [];
|
||||
|
||||
processedElements.add(element.id);
|
||||
for (const element of omitGroupsContainingFrames(
|
||||
allElements,
|
||||
_elementsToAdd,
|
||||
)) {
|
||||
if (element.frameId !== frame.id && !isFrameElement(element)) {
|
||||
if (allElementsIndex[element.id] > frameIndex) {
|
||||
addedFrameChildren_right.push(element);
|
||||
} else {
|
||||
addedFrameChildren_left.push(element);
|
||||
}
|
||||
|
||||
if (
|
||||
finalElementsToAddSet.has(element.id) ||
|
||||
(element.frameId && element.frameId === frame.id)
|
||||
) {
|
||||
// will be added in bulk once we process target frame
|
||||
continue;
|
||||
}
|
||||
|
||||
// target frame
|
||||
if (element.id === frame.id) {
|
||||
const currFrameChildren = getFrameElements(allElements, frame.id);
|
||||
currFrameChildren.forEach((child) => {
|
||||
processedElements.add(child.id);
|
||||
});
|
||||
|
||||
// if not found, add all children on top by assigning the lowest index
|
||||
const targetFrameIndex = allElementsIndexMap.get(frame.id) ?? -1;
|
||||
|
||||
const { newChildren_left, newChildren_right } = finalElementsToAdd.reduce(
|
||||
(acc, element) => {
|
||||
// if index not found, add on top of current frame children
|
||||
const elementIndex = allElementsIndexMap.get(element.id) ?? Infinity;
|
||||
if (elementIndex < targetFrameIndex) {
|
||||
acc.newChildren_left.push(element);
|
||||
} else {
|
||||
acc.newChildren_right.push(element);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
newChildren_left: [] as ExcalidrawElement[],
|
||||
newChildren_right: [] as ExcalidrawElement[],
|
||||
},
|
||||
);
|
||||
|
||||
nextElements.push(
|
||||
...newChildren_left,
|
||||
...currFrameChildren,
|
||||
...newChildren_right,
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: frame.id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
nextElements.push(element);
|
||||
}
|
||||
|
||||
for (const element of finalElementsToAdd) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: frame.id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
const frameElement = allElements[frameIndex];
|
||||
const nextFrameChildren = addedFrameChildren_left
|
||||
.concat(existingFrameChildren)
|
||||
.concat(addedFrameChildren_right);
|
||||
|
||||
const nextFrameChildrenMap = nextFrameChildren.reduce(
|
||||
(acc: Record<string, boolean>, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const nextOtherElements_left = allElements
|
||||
.slice(0, leftFrameBoundaryIndex >= 0 ? leftFrameBoundaryIndex : frameIndex)
|
||||
.filter((element) => !nextFrameChildrenMap[element.id]);
|
||||
|
||||
const nextOtherElement_right = allElements
|
||||
.slice(frameIndex + 1)
|
||||
.filter((element) => !nextFrameChildrenMap[element.id]);
|
||||
|
||||
const nextElements = nextOtherElements_left
|
||||
.concat(nextFrameChildren)
|
||||
.concat([frameElement])
|
||||
.concat(nextOtherElement_right);
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
@@ -740,17 +705,6 @@ export const isElementInFrame = (
|
||||
: element;
|
||||
|
||||
if (frame) {
|
||||
// Perf improvement:
|
||||
// For an element that's already in a frame, if it's not being dragged
|
||||
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
|
||||
// It has to be in its containing frame.
|
||||
if (
|
||||
!appState.selectedElementIds[element.id] ||
|
||||
!appState.selectedElementsAreBeingDragged
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_element.groupIds.length === 0) {
|
||||
return elementOverlapsWithFrame(_element, frame);
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -116,3 +116,5 @@ declare namespace jest {
|
||||
toBeNonNaNNumber(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "mathjax-full/mjs/input/asciimath/legacy/MathJax";
|
||||
|
||||
+37
-1
@@ -87,6 +87,22 @@ if (import.meta.env.DEV) {
|
||||
let currentLang: Language = defaultLang;
|
||||
let currentLangData = {};
|
||||
|
||||
const auxCurrentLangData = Array<Object>();
|
||||
const auxFallbackLangData = Array<Object>();
|
||||
const auxSetLanguageFuncs =
|
||||
Array<(langCode: string) => Promise<Object | undefined>>();
|
||||
|
||||
export const registerAuxLangData = (
|
||||
fallbackLangData: Object,
|
||||
setLanguageAux: (langCode: string) => Promise<Object | undefined>,
|
||||
) => {
|
||||
if (auxFallbackLangData.includes(fallbackLangData)) {
|
||||
return;
|
||||
}
|
||||
auxFallbackLangData.push(fallbackLangData);
|
||||
auxSetLanguageFuncs.push(setLanguageAux);
|
||||
};
|
||||
|
||||
export const setLanguage = async (lang: Language) => {
|
||||
currentLang = lang;
|
||||
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
||||
@@ -99,6 +115,17 @@ export const setLanguage = async (lang: Language) => {
|
||||
currentLangData = await import(
|
||||
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json`
|
||||
);
|
||||
// Empty the auxCurrentLangData array
|
||||
while (auxCurrentLangData.length > 0) {
|
||||
auxCurrentLangData.pop();
|
||||
}
|
||||
// Fill the auxCurrentLangData array with each locale file found in auxLangDataRoots for this language
|
||||
auxSetLanguageFuncs.forEach(async (setLanguageFn) => {
|
||||
const condData = await setLanguageFn(currentLang.code);
|
||||
if (condData) {
|
||||
auxCurrentLangData.push(condData);
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to load language ${lang.code}:`, error.message);
|
||||
currentLangData = fallbackLangData;
|
||||
@@ -125,7 +152,9 @@ const findPartsForData = (data: any, parts: string[]) => {
|
||||
};
|
||||
|
||||
export const t = (
|
||||
path: NestedKeyOf<typeof fallbackLangData>,
|
||||
path:
|
||||
| NestedKeyOf<typeof fallbackLangData>
|
||||
| `${NestedKeyOf<typeof fallbackLangData>}.${string}`,
|
||||
replacement?: { [key: string]: string | number } | null,
|
||||
fallback?: string,
|
||||
) => {
|
||||
@@ -141,6 +170,13 @@ export const t = (
|
||||
findPartsForData(currentLangData, parts) ||
|
||||
findPartsForData(fallbackLangData, parts) ||
|
||||
fallback;
|
||||
const auxData = Array<Object>().concat(
|
||||
auxCurrentLangData,
|
||||
auxFallbackLangData,
|
||||
);
|
||||
for (let i = 0; i < auxData.length; i++) {
|
||||
translation = translation || findPartsForData(auxData[i], parts);
|
||||
}
|
||||
if (translation === undefined) {
|
||||
const errorMessage = `Can't find translation for ${path}`;
|
||||
// in production, don't blow up the app on a missing translation key
|
||||
|
||||
@@ -21,7 +21,6 @@ export const CODES = {
|
||||
V: "KeyV",
|
||||
Z: "KeyZ",
|
||||
R: "KeyR",
|
||||
S: "KeyS",
|
||||
} as const;
|
||||
|
||||
export const KEYS = {
|
||||
|
||||
@@ -164,7 +164,6 @@
|
||||
"darkMode": "Dark mode",
|
||||
"lightMode": "Light mode",
|
||||
"zenMode": "Zen mode",
|
||||
"objectsSnapMode": "Snap to objects",
|
||||
"exitZenMode": "Exit zen mode",
|
||||
"cancel": "Cancel",
|
||||
"clear": "Clear",
|
||||
@@ -236,7 +235,6 @@
|
||||
"eraser": "Eraser",
|
||||
"frame": "Frame tool",
|
||||
"embeddable": "Web Embed",
|
||||
"laser": "Laser pointer",
|
||||
"hand": "Hand (panning tool)",
|
||||
"extraTools": "More tools"
|
||||
},
|
||||
|
||||
+1
-41
@@ -1,4 +1,4 @@
|
||||
import { rangeIntersection, rangesOverlap, rotate } from "./math";
|
||||
import { rotate } from "./math";
|
||||
|
||||
describe("rotate", () => {
|
||||
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
|
||||
@@ -13,43 +13,3 @@ describe("rotate", () => {
|
||||
expect(res2).toEqual([x1, x2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("range overlap", () => {
|
||||
it("should overlap when range a contains range b", () => {
|
||||
expect(rangesOverlap([1, 4], [2, 3])).toBe(true);
|
||||
expect(rangesOverlap([1, 4], [1, 4])).toBe(true);
|
||||
expect(rangesOverlap([1, 4], [1, 3])).toBe(true);
|
||||
expect(rangesOverlap([1, 4], [2, 4])).toBe(true);
|
||||
});
|
||||
|
||||
it("should overlap when range b contains range a", () => {
|
||||
expect(rangesOverlap([2, 3], [1, 4])).toBe(true);
|
||||
expect(rangesOverlap([1, 3], [1, 4])).toBe(true);
|
||||
expect(rangesOverlap([2, 4], [1, 4])).toBe(true);
|
||||
});
|
||||
|
||||
it("should overlap when range a and b intersect", () => {
|
||||
expect(rangesOverlap([1, 4], [2, 5])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("range intersection", () => {
|
||||
it("should intersect completely with itself", () => {
|
||||
expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]);
|
||||
});
|
||||
|
||||
it("should intersect irrespective of order", () => {
|
||||
expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]);
|
||||
expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]);
|
||||
expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]);
|
||||
expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it("should intersect at the edge", () => {
|
||||
expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]);
|
||||
});
|
||||
|
||||
it("should not intersect", () => {
|
||||
expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
-33
@@ -472,36 +472,3 @@ export const isRightAngle = (angle: number) => {
|
||||
// angle, which we can check with modulo after rounding.
|
||||
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
|
||||
};
|
||||
|
||||
// Given two ranges, return if the two ranges overlap with each other
|
||||
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
|
||||
export const rangesOverlap = (
|
||||
[a0, a1]: [number, number],
|
||||
[b0, b1]: [number, number],
|
||||
) => {
|
||||
if (a0 <= b0) {
|
||||
return a1 >= b0;
|
||||
}
|
||||
|
||||
if (a0 >= b0) {
|
||||
return b1 >= a0;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Given two ranges,return ther intersection of the two ranges if any
|
||||
// e.g. the intersection of [1, 3] and [2, 4] is [2, 3]
|
||||
export const rangeIntersection = (
|
||||
rangeA: [number, number],
|
||||
rangeB: [number, number],
|
||||
): [number, number] | null => {
|
||||
const rangeStart = Math.max(rangeA[0], rangeB[0]);
|
||||
const rangeEnd = Math.min(rangeA[1], rangeB[1]);
|
||||
|
||||
if (rangeStart <= rangeEnd) {
|
||||
return [rangeStart, rangeEnd];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -11,30 +11,9 @@ The change should be grouped under one of the below section and must contain PR
|
||||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [7078](https://github.com/excalidraw/excalidraw/pull/7078)
|
||||
|
||||
## 0.16.1 (2023-09-21)
|
||||
|
||||
## Excalidraw Library
|
||||
|
||||
**_This section lists the updates made to the excalidraw library and will not affect the integration._**
|
||||
|
||||
### Fixes
|
||||
|
||||
- More eye-droper fixes [#7019](https://github.com/excalidraw/excalidraw/pull/7019)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Move excalidraw-app outside src [#6987](https://github.com/excalidraw/excalidraw/pull/6987)
|
||||
|
||||
---
|
||||
|
||||
## 0.16.0 (2023-09-19)
|
||||
|
||||
- Add a `subtype` attribute to `ExcalidrawElement` to allow self-contained extensions of any `ExcalidrawElement` type. Implement MathJax support on stem.excalidraw.com as a `math` subtype of `ExcalidrawTextElement`. Both standard Latex input and simplified AsciiMath input are supported. [#6037](https://github.com/excalidraw/excalidraw/pull/6037).
|
||||
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
|
||||
- Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691)
|
||||
- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateEmbeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/excalidraw",
|
||||
"version": "0.16.1",
|
||||
"version": "0.16.0",
|
||||
"main": "main.js",
|
||||
"types": "types/packages/excalidraw/index.d.ts",
|
||||
"files": [
|
||||
|
||||
@@ -22,12 +22,5 @@ const polyfill = () => {
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Element.prototype.replaceChildren) {
|
||||
Element.prototype.replaceChildren = function (...nodes) {
|
||||
this.innerHTML = "";
|
||||
this.append(...nodes);
|
||||
};
|
||||
}
|
||||
};
|
||||
export default polyfill;
|
||||
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
EXTERNAL_LINK_IMG,
|
||||
getLinkHandleFromCoords,
|
||||
} from "../element/Hyperlink";
|
||||
import { renderSnaps } from "./renderSnaps";
|
||||
import {
|
||||
isEmbeddableElement,
|
||||
isFrameElement,
|
||||
@@ -721,8 +720,6 @@ const _renderInteractiveScene = ({
|
||||
context.restore();
|
||||
}
|
||||
|
||||
renderSnaps(context, appState);
|
||||
|
||||
// Reset zoom
|
||||
context.restore();
|
||||
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { PointSnapLine, PointerSnapLine } from "../snapping";
|
||||
import { InteractiveCanvasAppState, Point } from "../types";
|
||||
|
||||
const SNAP_COLOR_LIGHT = "#ff6b6b";
|
||||
const SNAP_COLOR_DARK = "#ff0000";
|
||||
const SNAP_WIDTH = 1;
|
||||
const SNAP_CROSS_SIZE = 2;
|
||||
|
||||
export const renderSnaps = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (!appState.snapLines.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// in dark mode, we need to adjust the color to account for color inversion.
|
||||
// Don't change if zen mode, because we draw only crosses, we want the
|
||||
// colors to be more visible
|
||||
const snapColor =
|
||||
appState.theme === "light" || appState.zenModeEnabled
|
||||
? SNAP_COLOR_LIGHT
|
||||
: SNAP_COLOR_DARK;
|
||||
// in zen mode make the cross more visible since we don't draw the lines
|
||||
const snapWidth =
|
||||
(appState.zenModeEnabled ? SNAP_WIDTH * 1.5 : SNAP_WIDTH) /
|
||||
appState.zoom.value;
|
||||
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
|
||||
for (const snapLine of appState.snapLines) {
|
||||
if (snapLine.type === "pointer") {
|
||||
context.lineWidth = snapWidth;
|
||||
context.strokeStyle = snapColor;
|
||||
|
||||
drawPointerSnapLine(snapLine, context, appState);
|
||||
} else if (snapLine.type === "gap") {
|
||||
context.lineWidth = snapWidth;
|
||||
context.strokeStyle = snapColor;
|
||||
|
||||
drawGapLine(
|
||||
snapLine.points[0],
|
||||
snapLine.points[1],
|
||||
snapLine.direction,
|
||||
appState,
|
||||
context,
|
||||
);
|
||||
} else if (snapLine.type === "points") {
|
||||
context.lineWidth = snapWidth;
|
||||
context.strokeStyle = snapColor;
|
||||
drawPointsSnapLine(snapLine, context, appState);
|
||||
}
|
||||
}
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const drawPointsSnapLine = (
|
||||
pointSnapLine: PointSnapLine,
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (!appState.zenModeEnabled) {
|
||||
const firstPoint = pointSnapLine.points[0];
|
||||
const lastPoint = pointSnapLine.points[pointSnapLine.points.length - 1];
|
||||
|
||||
drawLine(firstPoint, lastPoint, context);
|
||||
}
|
||||
|
||||
for (const point of pointSnapLine.points) {
|
||||
drawCross(point, appState, context);
|
||||
}
|
||||
};
|
||||
|
||||
const drawPointerSnapLine = (
|
||||
pointerSnapLine: PointerSnapLine,
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
drawCross(pointerSnapLine.points[0], appState, context);
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine(pointerSnapLine.points[0], pointerSnapLine.points[1], context);
|
||||
}
|
||||
};
|
||||
|
||||
const drawCross = (
|
||||
[x, y]: Point,
|
||||
appState: InteractiveCanvasAppState,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => {
|
||||
context.save();
|
||||
const size =
|
||||
(appState.zenModeEnabled ? SNAP_CROSS_SIZE * 1.5 : SNAP_CROSS_SIZE) /
|
||||
appState.zoom.value;
|
||||
context.beginPath();
|
||||
|
||||
context.moveTo(x - size, y - size);
|
||||
context.lineTo(x + size, y + size);
|
||||
|
||||
context.moveTo(x + size, y - size);
|
||||
context.lineTo(x - size, y + size);
|
||||
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const drawLine = (
|
||||
from: Point,
|
||||
to: Point,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.lineTo(...from);
|
||||
context.lineTo(...to);
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
const drawGapLine = (
|
||||
from: Point,
|
||||
to: Point,
|
||||
direction: "horizontal" | "vertical",
|
||||
appState: InteractiveCanvasAppState,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => {
|
||||
// a horizontal gap snap line
|
||||
// |–––––––||–––––––|
|
||||
// ^ ^ ^ ^
|
||||
// \ \ \ \
|
||||
// (1) (2) (3) (4)
|
||||
|
||||
const FULL = 8 / appState.zoom.value;
|
||||
const HALF = FULL / 2;
|
||||
const QUARTER = FULL / 4;
|
||||
|
||||
if (direction === "horizontal") {
|
||||
const halfPoint = [(from[0] + to[0]) / 2, from[1]];
|
||||
// (1)
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context);
|
||||
}
|
||||
|
||||
// (3)
|
||||
drawLine(
|
||||
[halfPoint[0] - QUARTER, halfPoint[1] - HALF],
|
||||
[halfPoint[0] - QUARTER, halfPoint[1] + HALF],
|
||||
context,
|
||||
);
|
||||
drawLine(
|
||||
[halfPoint[0] + QUARTER, halfPoint[1] - HALF],
|
||||
[halfPoint[0] + QUARTER, halfPoint[1] + HALF],
|
||||
context,
|
||||
);
|
||||
|
||||
if (!appState.zenModeEnabled) {
|
||||
// (4)
|
||||
drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context);
|
||||
|
||||
// (2)
|
||||
drawLine(from, to, context);
|
||||
}
|
||||
} else {
|
||||
const halfPoint = [from[0], (from[1] + to[1]) / 2];
|
||||
// (1)
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context);
|
||||
}
|
||||
|
||||
// (3)
|
||||
drawLine(
|
||||
[halfPoint[0] - HALF, halfPoint[1] - QUARTER],
|
||||
[halfPoint[0] + HALF, halfPoint[1] - QUARTER],
|
||||
context,
|
||||
);
|
||||
drawLine(
|
||||
[halfPoint[0] - HALF, halfPoint[1] + QUARTER],
|
||||
[halfPoint[0] + HALF, halfPoint[1] + QUARTER],
|
||||
context,
|
||||
);
|
||||
|
||||
if (!appState.zenModeEnabled) {
|
||||
// (4)
|
||||
drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context);
|
||||
|
||||
// (2)
|
||||
drawLine(from, to, context);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getFrameElements,
|
||||
} from "../frame";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { isElementInViewport } from "../element/sizeHelpers";
|
||||
|
||||
/**
|
||||
* Frames and their containing elements are not to be selected at the same time.
|
||||
@@ -90,26 +89,6 @@ export const getElementsWithinSelection = (
|
||||
return elementsInSelection;
|
||||
};
|
||||
|
||||
export const getVisibleAndNonSelectedElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElementsSet = new Set(
|
||||
selectedElements.map((element) => element.id),
|
||||
);
|
||||
return elements.filter((element) => {
|
||||
const isVisible = isElementInViewport(
|
||||
element,
|
||||
appState.width,
|
||||
appState.height,
|
||||
appState,
|
||||
);
|
||||
|
||||
return !selectedElementsSet.has(element.id) && isVisible;
|
||||
});
|
||||
};
|
||||
|
||||
// FIXME move this into the editor instance to keep utility methods stateless
|
||||
export const isSomeElementSelected = (function () {
|
||||
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
|
||||
|
||||
-1361
File diff suppressed because it is too large
Load Diff
@@ -331,17 +331,12 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -368,7 +363,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -530,14 +524,12 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -561,7 +553,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -675,7 +666,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
|
||||
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `6`;
|
||||
exports[`contextMenu element > selecting 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `7`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -732,14 +723,12 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -763,7 +752,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -1051,7 +1039,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of renders 1`] = `12`;
|
||||
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] number of renders 1`] = `13`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -1108,14 +1096,12 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -1139,7 +1125,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -1427,7 +1412,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `12`;
|
||||
exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `13`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -1484,14 +1469,12 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -1515,7 +1498,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -1629,7 +1611,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `6`;
|
||||
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `7`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -1686,14 +1668,12 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -1715,7 +1695,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -1868,7 +1847,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `7`;
|
||||
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `8`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -1925,14 +1904,12 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -1956,7 +1933,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -2172,7 +2148,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `7`;
|
||||
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `8`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -2229,14 +2205,12 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -2265,7 +2239,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -2564,7 +2537,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `12`;
|
||||
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `13`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -2621,14 +2594,12 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -2652,7 +2623,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -3446,7 +3416,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `19`;
|
||||
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `20`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -3503,14 +3473,12 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -3534,7 +3502,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -3822,7 +3789,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of renders 1`] = `11`;
|
||||
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] number of renders 1`] = `12`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -3879,14 +3846,12 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -3910,7 +3875,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -4198,7 +4162,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `11`;
|
||||
exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `12`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -4255,14 +4219,12 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -4289,7 +4251,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -4657,7 +4618,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `13`;
|
||||
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `14`;
|
||||
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -4990,14 +4951,12 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -5024,7 +4983,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -5240,7 +5198,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `12`;
|
||||
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `13`;
|
||||
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -5573,14 +5531,12 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -5609,7 +5565,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -5908,7 +5863,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of elements 1`] = `2`;
|
||||
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `13`;
|
||||
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `14`;
|
||||
|
||||
exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = `
|
||||
{
|
||||
@@ -5995,19 +5950,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
},
|
||||
"viewMode": true,
|
||||
},
|
||||
{
|
||||
"checked": [Function],
|
||||
"contextItemLabel": "buttons.objectsSnapMode",
|
||||
"keyTest": [Function],
|
||||
"name": "objectsSnapMode",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "canvas",
|
||||
"predicate": [Function],
|
||||
},
|
||||
"viewMode": true,
|
||||
},
|
||||
{
|
||||
"checked": [Function],
|
||||
"contextItemLabel": "buttons.zenMode",
|
||||
@@ -6093,17 +6035,12 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -6125,7 +6062,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -6495,14 +6431,12 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": null,
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -6526,7 +6460,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -6872,17 +6805,12 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"lastPointerDownWith": "mouse",
|
||||
"multiElement": null,
|
||||
"name": "Untitled-201933152653",
|
||||
"objectsSnapModeEnabled": false,
|
||||
"offsetLeft": 20,
|
||||
"offsetTop": 10,
|
||||
"openDialog": null,
|
||||
"openMenu": null,
|
||||
"openPopup": null,
|
||||
"openSidebar": null,
|
||||
"originSnapOffset": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"pasteDialog": {
|
||||
"data": null,
|
||||
"shown": false,
|
||||
@@ -6906,7 +6834,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"showHyperlinkPopup": false,
|
||||
"showStats": false,
|
||||
"showWelcomeScreen": true,
|
||||
"snapLines": [],
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
@@ -7104,6 +7031,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] nu
|
||||
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] number of elements 2`] = `2`;
|
||||
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 1`] = `6`;
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 1`] = `7`;
|
||||
|
||||
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 2`] = `6`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,6 @@ describe("contextMenu element", () => {
|
||||
"gridMode",
|
||||
"zenMode",
|
||||
"viewMode",
|
||||
"objectsSnapMode",
|
||||
"stats",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { API } from "./helpers/api";
|
||||
import { render } from "./test-utils";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import {
|
||||
CustomShortcutName,
|
||||
getShortcutFromShortcutName,
|
||||
registerCustomShortcuts,
|
||||
} from "../actions/shortcuts";
|
||||
import { Action, ActionPredicateFn, ActionResult } from "../actions/types";
|
||||
import {
|
||||
actionChangeFontFamily,
|
||||
actionChangeFontSize,
|
||||
} from "../actions/actionProperties";
|
||||
import { isTextElement } from "../element";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("regression tests", () => {
|
||||
it("should retrieve custom shortcuts", () => {
|
||||
const shortcuts: Record<CustomShortcutName, string[]> = {
|
||||
test: [getShortcutKey("CtrlOrCmd+1"), getShortcutKey("CtrlOrCmd+2")],
|
||||
};
|
||||
registerCustomShortcuts(shortcuts);
|
||||
expect(getShortcutFromShortcutName("test")).toBe("Ctrl+1");
|
||||
});
|
||||
|
||||
it("should apply universal action predicates", async () => {
|
||||
await render(<Excalidraw />);
|
||||
// Create the test elements
|
||||
const el1 = API.createElement({ type: "rectangle", id: "A", y: 0 });
|
||||
const el2 = API.createElement({ type: "rectangle", id: "B", y: 30 });
|
||||
const el3 = API.createElement({ type: "text", id: "C", y: 60 });
|
||||
const el12: ExcalidrawElement[] = [el1, el2];
|
||||
const el13: ExcalidrawElement[] = [el1, el3];
|
||||
const el23: ExcalidrawElement[] = [el2, el3];
|
||||
const el123: ExcalidrawElement[] = [el1, el2, el3];
|
||||
// Set up the custom Action enablers
|
||||
const enableName = "custom" as Action["name"];
|
||||
const enableAction: Action = {
|
||||
name: enableName,
|
||||
perform: (): ActionResult => {
|
||||
return {} as ActionResult;
|
||||
},
|
||||
trackEvent: false,
|
||||
};
|
||||
const enabler: ActionPredicateFn = function (action, elements) {
|
||||
if (action.name !== enableName || elements.some((el) => el.y === 30)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// Set up the standard Action disablers
|
||||
const disabled1 = actionChangeFontFamily;
|
||||
const disabled2 = actionChangeFontSize;
|
||||
const disabler: ActionPredicateFn = function (action, elements) {
|
||||
if (
|
||||
action.name === disabled2.name &&
|
||||
elements.some((el) => el.y === 0 || isTextElement(el))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// Test the custom Action enablers
|
||||
const am = h.app.actionManager;
|
||||
am.registerActionPredicate(enabler);
|
||||
expect(am.isActionEnabled(enableAction, { elements: el12 })).toBe(true);
|
||||
expect(am.isActionEnabled(enableAction, { elements: el13 })).toBe(false);
|
||||
expect(am.isActionEnabled(enableAction, { elements: el23 })).toBe(true);
|
||||
expect(am.isActionEnabled(disabled1, { elements: el12 })).toBe(true);
|
||||
expect(am.isActionEnabled(disabled1, { elements: el13 })).toBe(true);
|
||||
expect(am.isActionEnabled(disabled1, { elements: el23 })).toBe(true);
|
||||
// Test the standard Action disablers
|
||||
am.registerActionPredicate(disabler);
|
||||
expect(am.isActionEnabled(disabled1, { elements: el123 })).toBe(true);
|
||||
expect(am.isActionEnabled(disabled2, { elements: [el1] })).toBe(false);
|
||||
expect(am.isActionEnabled(disabled2, { elements: [el2] })).toBe(true);
|
||||
expect(am.isActionEnabled(disabled2, { elements: [el3] })).toBe(false);
|
||||
expect(am.isActionEnabled(disabled2, { elements: el12 })).toBe(false);
|
||||
expect(am.isActionEnabled(disabled2, { elements: el23 })).toBe(false);
|
||||
expect(am.isActionEnabled(disabled2, { elements: el13 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -340,12 +340,12 @@ exports[`restoreElements > should restore text element correctly with unknown fo
|
||||
"groupIds": [],
|
||||
"height": 100,
|
||||
"id": "id-text01",
|
||||
"isDeleted": true,
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
"originalText": "",
|
||||
"originalText": "test",
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
@@ -358,8 +358,8 @@ exports[`restoreElements > should restore text element correctly with unknown fo
|
||||
"textAlign": "left",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"version": 1,
|
||||
"versionNonce": 0,
|
||||
"verticalAlign": "top",
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user