Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage

This commit is contained in:
Daniel J. Geiger 2023-10-14 13:05:07 -05:00
commit fc7ea757b2
48 changed files with 914 additions and 360 deletions

View File

@ -70,7 +70,7 @@ The Excalidraw editor (npm package) supports:
## Excalidraw.com ## 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/src/excalidraw-app) is part of this repository as well, and the app features: The app hosted at [excalidraw.com](https://excalidraw.com) is a minimal showcase of what you can build with Excalidraw. Its [source code](https://github.com/excalidraw/excalidraw/tree/master/excalidraw-app) is part of this repository as well, and the app features:
- 📡 PWA support (works offline). - 📡 PWA support (works offline).
- 🤼 Real-time collaboration. - 🤼 Real-time collaboration.

View File

@ -38,6 +38,7 @@ To render an item, its recommended to use `MainMenu.Item`.
| Prop | Type | Required | Default | Description | | Prop | Type | Required | Default | Description |
| --- | --- | :-: | :-: | --- | | --- | --- | :-: | :-: | --- |
| `onSelect` | `function` | Yes | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. | | `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 | | `children` | `React.ReactNode` | Yes | - | The content of the menu item |
| `icon` | `JSX.Element` | No | - | The icon used in 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 | | `shortcut` | `string` | No | - | The shortcut to be shown for the menu item |
@ -70,6 +71,7 @@ function App() {
| Prop | Type | Required | Default | Description | | Prop | Type | Required | Default | Description |
| --- | --- | :-: | :-: | --- | | --- | --- | :-: | :-: | --- |
| `onSelect` | `function` | No | - | Triggered when selected (via mouse). Calling `event.preventDefault()` will stop menu from closing. | | `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. | | `href` | `string` | Yes | - | The `href` attribute to be added to the `anchor` element. |
| `children` | `React.ReactNode` | Yes | - | The content of the menu item | | `children` | `React.ReactNode` | Yes | - | The content of the menu item |
| `icon` | `JSX.Element` | No | - | The icon used in the menu item | | `icon` | `JSX.Element` | No | - | The icon used in the menu item |

View File

@ -1,6 +1,6 @@
# Customizing Styles # Customizing Styles
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. 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.
Make sure the selector has higher specificity, e.g. by prefixing it with your app's selector: Make sure the selector has higher specificity, e.g. by prefixing it with your app's selector:

View File

@ -2,7 +2,7 @@
### Does this package support collaboration ? ### 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/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. 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.
### Turning off Aggressive Anti-Fingerprinting in Brave browser ### 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** 2. Once opened, look for **Aggressively Block Fingerprinting**
![Aggresive block fingerprinting](../../assets/aggressive-block-fingerprint.png) ![Aggressive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
3. Switch to **Block Fingerprinting** 3. Switch to **Block Fingerprinting**

View File

@ -34,7 +34,7 @@ function App() {
Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`. Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`.
The following worfklow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon. The following workflow shows one way how to render Excalidraw on Next.js. We'll add more detailed and alternative Next.js examples, soon.
```jsx showLineNumbers ```jsx showLineNumbers
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";

View File

@ -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. Run `yarn` to install dependencies
1. Create a branch for your PR with `git checkout -b your-branch-name` 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. To do this, run: > To keep `master` branch pointing to remote repository and make pull requests from branches on your fork, run:
> >
> ```bash > ```bash
> git remote add upstream https://github.com/excalidraw/excalidraw.git > git remote add upstream https://github.com/excalidraw/excalidraw.git

View File

@ -15,7 +15,7 @@ const FeatureList = [
Svg: require("@site/static/img/undraw_blank_canvas.svg").default, Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
description: ( description: (
<> <>
Want to build your own app powered by Excalidraw by don't know where to Want to build your own app powered by Excalidraw but don't know where to
start? start?
</> </>
), ),

View File

@ -107,7 +107,7 @@ export type SocketUpdateDataSource = {
type: "MOUSE_LOCATION"; type: "MOUSE_LOCATION";
payload: { payload: {
socketId: string; socketId: string;
pointer: { x: number; y: number }; pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up"; button: "down" | "up";
selectedElementIds: AppState["selectedElementIds"]; selectedElementIds: AppState["selectedElementIds"];
username: string; username: string;

View File

@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.2.0",
"@excalidraw/random-username": "1.0.0", "@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2", "@radix-ui/react-tabs": "1.0.2",

View File

@ -11,7 +11,7 @@
{ {
"src": "apple-touch-icon.png", "src": "apple-touch-icon.png",
"type": "image/png", "type": "image/png",
"sizes": "256x256" "sizes": "180x180"
} }
], ],
"start_url": "/", "start_url": "/",

View File

@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, setCursor, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
@ -21,6 +21,7 @@ import {
} from "../appState"; } from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds"; import { Bounds } from "../element/bounds";
import { setCursor } from "../cursor";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",

View File

@ -1,6 +1,6 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element"; import { isInvisiblySmallElement } from "../element";
import { updateActiveTool, resetCursor } from "../utils"; import { updateActiveTool } from "../utils";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons"; import { done } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
@ -15,6 +15,7 @@ import {
} from "../element/binding"; } from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types"; import { AppState } from "../types";
import { resetCursor } from "../cursor";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
@ -90,7 +91,9 @@ export const actionFinalize = register({
} }
} }
if (isInvisiblySmallElement(multiPointElement)) { if (isInvisiblySmallElement(multiPointElement)) {
newElements = newElements.slice(0, -1); newElements = newElements.filter(
(el) => el.id !== multiPointElement.id,
);
} }
// If the multi point line closes the loop, // If the multi point line closes the loop,

View File

@ -4,7 +4,8 @@ import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame"; import { getFrameElements } from "../frame";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register"; import { register } from "./register";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {

View File

@ -14,13 +14,8 @@ import {
hasText, hasText,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { UIAppState, Zoom } from "../types"; import { AppClassProperties, UIAppState, Zoom } from "../types";
import { import { capitalizeString, isTransparent } from "../utils";
capitalizeString,
isTransparent,
updateActiveTool,
setCursorForShape,
} from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { SubtypeShapeActions, SubtypeToggles } from "./Subtypes"; import { SubtypeShapeActions, SubtypeToggles } from "./Subtypes";
@ -37,7 +32,12 @@ import {
import "./Actions.scss"; import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu"; import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons"; import {
EmbedIcon,
extraToolsIcon,
frameToolIcon,
laserPointerToolIcon,
} from "./icons";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
@ -217,18 +217,23 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({ export const ShapesSwitcher = ({
interactiveCanvas, interactiveCanvas,
activeTool, activeTool,
setAppState,
onImageAction, onImageAction,
appState, appState,
app,
}: { }: {
interactiveCanvas: HTMLCanvasElement | null; interactiveCanvas: HTMLCanvasElement | null;
activeTool: UIAppState["activeTool"]; activeTool: UIAppState["activeTool"];
setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState; appState: UIAppState;
app: AppClassProperties;
}) => { }) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice(); const device = useDevice();
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
return ( return (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@ -253,29 +258,14 @@ export const ShapesSwitcher = ({
data-testid={`toolbar-${value}`} data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
setAppState({ app.togglePenMode(true);
penDetected: true,
penMode: true,
});
} }
}} }}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) { if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui"); trackEvent("toolbar", value, "ui");
} }
const nextActiveTool = updateActiveTool(appState, { app.setActiveTool({ type: value });
type: value,
});
setAppState({
activeTool: nextActiveTool,
activeEmbeddable: null,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(interactiveCanvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") { if (value === "image") {
onImageAction({ pointerType }); onImageAction({ pointerType });
} }
@ -302,24 +292,14 @@ export const ShapesSwitcher = ({
data-testid={`toolbar-frame`} data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
setAppState({ app.togglePenMode(true);
penDetected: true,
penMode: true,
});
} }
}} }}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
trackEvent("toolbar", "frame", "ui"); trackEvent("toolbar", "frame", "ui");
const nextActiveTool = updateActiveTool(appState, { app.setActiveTool({ type: "frame" });
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}} }}
selected={activeTool.type === "frame"}
/> />
<ToolButton <ToolButton
className={clsx("Shape", { fillable: false })} className={clsx("Shape", { fillable: false })}
@ -332,30 +312,28 @@ export const ShapesSwitcher = ({
data-testid={`toolbar-embeddable`} data-testid={`toolbar-embeddable`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
setAppState({ app.togglePenMode(true);
penDetected: true,
penMode: true,
});
} }
}} }}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
trackEvent("toolbar", "embeddable", "ui"); trackEvent("toolbar", "embeddable", "ui");
const nextActiveTool = updateActiveTool(appState, { app.setActiveTool({ type: "embeddable" });
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
activeEmbeddable: null,
});
}} }}
selected={activeTool.type === "embeddable"}
/> />
</> </>
) : ( ) : (
<DropdownMenu open={isExtraToolsMenuOpen}> <DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger <DropdownMenu.Trigger
className="App-toolbar__extra-tools-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),
})}
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)} onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")} title={t("toolBar.extraTools")}
> >
@ -368,37 +346,36 @@ export const ShapesSwitcher = ({
> >
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
const nextActiveTool = updateActiveTool(appState, { app.setActiveTool({ type: "frame" });
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}} }}
icon={frameToolIcon} icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()} shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame" data-testid="toolbar-frame"
selected={frameToolSelected}
> >
{t("toolBar.frame")} {t("toolBar.frame")}
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
const nextActiveTool = updateActiveTool(appState, { app.setActiveTool({ type: "embeddable" });
type: "embeddable",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}} }}
icon={EmbedIcon} icon={EmbedIcon}
data-testid="toolbar-embeddable" data-testid="toolbar-embeddable"
selected={embeddableToolSelected}
> >
{t("toolBar.embeddable")} {t("toolBar.embeddable")}
</DropdownMenu.Item> </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.Content>
</DropdownMenu> </DropdownMenu>
)} )}

View File

@ -211,7 +211,7 @@ import {
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types"; import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, SHAPES } from "../shapes"; import { findShapeByKey } from "../shapes";
import { import {
AppClassProperties, AppClassProperties,
AppProps, AppProps,
@ -230,6 +230,8 @@ import {
SidebarName, SidebarName,
SidebarTabName, SidebarTabName,
KeyboardModifiersObject, KeyboardModifiersObject,
CollaboratorPointer,
ToolType,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@ -239,18 +241,14 @@ import {
isInputLike, isInputLike,
isToolIcon, isToolIcon,
isWritableElement, isWritableElement,
resetCursor,
resolvablePromise, resolvablePromise,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
setCursor,
setCursorForShape,
tupleToCoors, tupleToCoors,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
withBatchedUpdates, withBatchedUpdates,
wrapEvent, wrapEvent,
withBatchedUpdatesThrottled, withBatchedUpdatesThrottled,
updateObject, updateObject,
setEraserCursor,
updateActiveTool, updateActiveTool,
getShortcutKey, getShortcutKey,
isTransparent, isTransparent,
@ -377,6 +375,14 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer"; import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache"; 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 AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@ -501,10 +507,13 @@ class App extends React.Component<AppProps, AppState> {
private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>(); private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
hitLinkElement?: NonDeletedExcalidrawElement; hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDown: React.PointerEvent<HTMLElement> | null = null; lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
laserPathManager: LaserPathManager = new LaserPathManager(this);
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
@ -1177,6 +1186,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement || this.state.selectionElement ||
this.state.draggingElement || this.state.draggingElement ||
this.state.resizingElement || 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 && (this.state.editingElement &&
!isTextElement(this.state.editingElement)) !isTextElement(this.state.editingElement))
? POINTER_EVENTS.disabled ? POINTER_EVENTS.disabled
@ -1232,12 +1244,14 @@ class App extends React.Component<AppProps, AppState> {
!this.scene.getElementsIncludingDeleted().length !this.scene.getElementsIncludingDeleted().length
} }
app={this} app={this}
isCollaborating={this.props.isCollaborating}
> >
{this.props.children} {this.props.children}
</LayerUI> </LayerUI>
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" /> <div className="excalidraw-eye-dropper-container" />
<LaserToolOverlay manager={this.laserPathManager} />
{selectedElements.length === 1 && {selectedElements.length === 1 &&
!this.state.contextMenu && !this.state.contextMenu &&
this.state.showHyperlinkPopup && ( this.state.showHyperlinkPopup && (
@ -1765,6 +1779,7 @@ class App extends React.Component<AppProps, AppState> {
this.removeEventListeners(); this.removeEventListeners();
this.scene.destroy(); this.scene.destroy();
this.library.destroy(); this.library.destroy();
this.laserPathManager.destroy();
ShapeCache.destroy(); ShapeCache.destroy();
SnapCache.destroy(); SnapCache.destroy();
clearTimeout(touchTimeout); clearTimeout(touchTimeout);
@ -2582,10 +2597,11 @@ class App extends React.Component<AppProps, AppState> {
}); });
}; };
togglePenMode = () => { togglePenMode = (force?: boolean) => {
this.setState((prevState) => { this.setState((prevState) => {
return { return {
penMode: !prevState.penMode, penMode: force ?? !prevState.penMode,
penDetected: true,
}; };
}); });
}; };
@ -3079,6 +3095,15 @@ 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 ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@ -3138,15 +3163,10 @@ class App extends React.Component<AppProps, AppState> {
} }
}); });
private setActiveTool = ( setActiveTool = (
tool: tool:
| { | {
type: type: ToolType;
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
} }
| { type: "custom"; customType: string }, | { type: "custom"; customType: string },
) => { ) => {
@ -3165,23 +3185,30 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type === "image") { if (nextActiveTool.type === "image") {
this.onImageAction(); this.onImageAction();
} }
if (nextActiveTool.type !== "selection") {
this.setState((prevState) => ({ this.setState((prevState) => {
activeTool: nextActiveTool, const commonResets = {
selectedElementIds: makeNextSelectedElementIds({}, this.state), snapLines: prevState.snapLines.length ? [] : prevState.snapLines,
selectedGroupIds: {},
editingGroupId: null,
snapLines: [],
originSnapOffset: null,
}));
} else {
this.setState({
activeTool: nextActiveTool,
snapLines: [],
originSnapOffset: null, originSnapOffset: null,
activeEmbeddable: 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,
activeTool: nextActiveTool,
...commonResets,
};
});
}; };
private setCursor = (cursor: string) => { private setCursor = (cursor: string) => {
@ -3759,10 +3786,10 @@ class App extends React.Component<AppProps, AppState> {
isTouchScreen: boolean, isTouchScreen: boolean,
) => { ) => {
const draggedDistance = distance2d( const draggedDistance = distance2d(
this.lastPointerDown!.clientX, this.lastPointerDownEvent!.clientX,
this.lastPointerDown!.clientY, this.lastPointerDownEvent!.clientY,
this.lastPointerUp!.clientX, this.lastPointerUpEvent!.clientX,
this.lastPointerUp!.clientY, this.lastPointerUpEvent!.clientY,
); );
if ( if (
!this.hitLinkElement || !this.hitLinkElement ||
@ -3773,7 +3800,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const lastPointerDownCoords = viewportCoordsToSceneCoords( const lastPointerDownCoords = viewportCoordsToSceneCoords(
this.lastPointerDown!, this.lastPointerDownEvent!,
this.state, this.state,
); );
const lastPointerDownHittingLinkIcon = isPointHittingLink( const lastPointerDownHittingLinkIcon = isPointHittingLink(
@ -3783,7 +3810,7 @@ class App extends React.Component<AppProps, AppState> {
this.device.isMobile, this.device.isMobile,
); );
const lastPointerUpCoords = viewportCoordsToSceneCoords( const lastPointerUpCoords = viewportCoordsToSceneCoords(
this.lastPointerUp!, this.lastPointerUpEvent!,
this.state, this.state,
); );
const lastPointerUpHittingLinkIcon = isPointHittingLink( const lastPointerUpHittingLinkIcon = isPointHittingLink(
@ -4488,17 +4515,21 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
this.lastPointerDown = event; 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.setState({ this.setState({
lastPointerDownWith: event.pointerType, lastPointerDownWith: event.pointerType,
cursorButton: "down", cursorButton: "down",
}); });
this.savePointer(event.clientX, event.clientY, "down"); this.savePointer(event.clientX, event.clientY, "down");
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return;
}
// only handle left mouse button or touch // only handle left mouse button or touch
if ( if (
event.button !== POINTER_BUTTON.MAIN && event.button !== POINTER_BUTTON.MAIN &&
@ -4589,6 +4620,11 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (this.state.activeTool.type === "frame") { } else if (this.state.activeTool.type === "frame") {
this.createFrameElementOnPointerDown(pointerDownState); this.createFrameElementOnPointerDown(pointerDownState);
} else if (this.state.activeTool.type === "laser") {
this.laserPathManager.startPath(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
);
} else if ( } else if (
this.state.activeTool.type !== "eraser" && this.state.activeTool.type !== "eraser" &&
this.state.activeTool.type !== "hand" this.state.activeTool.type !== "hand"
@ -4612,7 +4648,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUp = onPointerUp; lastPointerUp = onPointerUp;
if (!this.state.viewModeEnabled) { if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp); window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown); window.addEventListener(EVENT.KEYDOWN, onKeyDown);
@ -4628,14 +4664,14 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
) => { ) => {
this.removePointer(event); this.removePointer(event);
this.lastPointerUp = event; this.lastPointerUpEvent = event;
const scenePointer = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY }, { clientX: event.clientX, clientY: event.clientY },
this.state, this.state,
); );
const clicklength = const clicklength =
event.timeStamp - (this.lastPointerDown?.timeStamp ?? 0); event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.device.isMobile && clicklength < 300) { if (this.device.isMobile && clicklength < 300) {
const hitElement = this.getElementAtPosition( const hitElement = this.getElementAtPosition(
scenePointer.x, scenePointer.x,
@ -5389,7 +5425,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
sceneX, sceneX,
sceneY, sceneY,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
); );
const embedLink = getEmbedLink(link); const embedLink = getEmbedLink(link);
@ -5439,7 +5477,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
sceneX, sceneX,
sceneY, sceneY,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
); );
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@ -5618,7 +5658,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
); );
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
@ -5677,7 +5719,9 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
this.lastPointerDown?.[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize, this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
? null
: this.state.gridSize,
); );
const frame = newFrameElement({ const frame = newFrameElement({
@ -5803,6 +5847,10 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (this.state.activeTool.type === "laser") {
this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y);
}
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
@ -6575,7 +6623,9 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
// remove invisible element which was added in onPointerDown // remove invisible element which was added in onPointerDown
this.scene.replaceAllElements( this.scene.replaceAllElements(
this.scene.getElementsIncludingDeleted().slice(0, -1), this.scene
.getElementsIncludingDeleted()
.filter((el) => el.id !== draggingElement.id),
); );
this.setState({ this.setState({
draggingElement: null, draggingElement: null,
@ -6797,17 +6847,17 @@ class App extends React.Component<AppProps, AppState> {
} }
if (isEraserActive(this.state)) { if (isEraserActive(this.state)) {
const draggedDistance = distance2d( const draggedDistance = distance2d(
this.lastPointerDown!.clientX, this.lastPointerDownEvent!.clientX,
this.lastPointerDown!.clientY, this.lastPointerDownEvent!.clientY,
this.lastPointerUp!.clientX, this.lastPointerUpEvent!.clientX,
this.lastPointerUp!.clientY, this.lastPointerUpEvent!.clientY,
); );
if (draggedDistance === 0) { if (draggedDistance === 0) {
const scenePointer = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(
{ {
clientX: this.lastPointerUp!.clientX, clientX: this.lastPointerUpEvent!.clientX,
clientY: this.lastPointerUp!.clientY, clientY: this.lastPointerUpEvent!.clientY,
}, },
this.state, this.state,
); );
@ -7047,6 +7097,11 @@ class App extends React.Component<AppProps, AppState> {
: unbindLinearElements)(this.scene.getSelectedElements(this.state)); : unbindLinearElements)(this.scene.getSelectedElements(this.state));
} }
if (activeTool.type === "laser") {
this.laserPathManager.endPath();
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw") { if (!activeTool.locked && activeTool.type !== "freedraw") {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
this.setState({ this.setState({
@ -7063,14 +7118,16 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
hitElement && hitElement &&
this.lastPointerUp && this.lastPointerUpEvent &&
this.lastPointerDown && this.lastPointerDownEvent &&
this.lastPointerUp.timeStamp - this.lastPointerDown.timeStamp < 300 && this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 && gesture.pointers.size <= 1 &&
isEmbeddableElement(hitElement) && isEmbeddableElement(hitElement) &&
this.isEmbeddableCenter( this.isEmbeddableCenter(
hitElement, hitElement,
this.lastPointerUp, this.lastPointerUpEvent,
pointerDownState.origin.x, pointerDownState.origin.x,
pointerDownState.origin.y, pointerDownState.origin.y,
) )
@ -8312,15 +8369,21 @@ class App extends React.Component<AppProps, AppState> {
if (!x || !y) { if (!x || !y) {
return; return;
} }
const pointer = viewportCoordsToSceneCoords( const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX: x, clientY: y }, { clientX: x, clientY: y },
this.state, this.state,
); );
if (isNaN(pointer.x) || isNaN(pointer.y)) { if (isNaN(sceneX) || isNaN(sceneY)) {
// sometimes the pointer goes off screen // sometimes the pointer goes off screen
} }
const pointer: CollaboratorPointer = {
x: sceneX,
y: sceneY,
tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
};
this.props.onPointerUpdate?.({ this.props.onPointerUpdate?.({
pointer, pointer,
button, button,

View File

@ -165,6 +165,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[KEYS.E, KEYS["0"]]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} /> <Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut label={t("toolBar.laser")} shortcuts={[KEYS.K]} />
<Shortcut <Shortcut
label={t("labels.eyeDropper")} label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]} shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}

View File

@ -0,0 +1,309 @@
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;
}
}
}

View File

@ -0,0 +1,41 @@
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>
);
};

View File

@ -0,0 +1,27 @@
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>
);
};

View File

@ -0,0 +1,20 @@
.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;
}
}
}

View File

@ -55,6 +55,7 @@ import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@ -77,6 +78,7 @@ interface LayerUIProps {
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
children?: React.ReactNode; children?: React.ReactNode;
app: AppClassProperties; app: AppClassProperties;
isCollaborating: boolean;
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@ -134,6 +136,7 @@ const LayerUI = ({
renderWelcomeScreen, renderWelcomeScreen,
children, children,
app, app,
isCollaborating,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
@ -279,7 +282,7 @@ const LayerUI = ({
appState={appState} appState={appState}
interactiveCanvas={interactiveCanvas} interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool} activeTool={appState.activeTool}
setAppState={setAppState} app={app}
onImageAction={({ pointerType }) => { onImageAction={({ pointerType }) => {
onImageAction({ onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse", insertOnCanvasDirectly: pointerType !== "mouse",
@ -288,6 +291,24 @@ const LayerUI = ({
/> />
</Stack.Row> </Stack.Row>
</Island> </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.Row>
</Stack.Col> </Stack.Col>
</div> </div>

View File

@ -87,7 +87,7 @@ export const MobileMenu = ({
appState={appState} appState={appState}
interactiveCanvas={interactiveCanvas} interactiveCanvas={interactiveCanvas}
activeTool={appState.activeTool} activeTool={appState.activeTool}
setAppState={setAppState} app={app}
onImageAction={({ pointerType }) => { onImageAction={({ pointerType }) => {
onImageAction({ onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse", insertOnCanvasDirectly: pointerType !== "mouse",

View File

@ -170,5 +170,10 @@
height: var(--lg-icon-size); height: var(--lg-icon-size);
} }
} }
.ToolIcon__LaserPointer .ToolIcon__icon {
width: var(--default-button-size);
height: var(--default-button-size);
}
} }
} }

View File

@ -28,6 +28,12 @@
box-shadow: 0 0 0 1px box-shadow: 0 0 0 1px
var(--button-active-border, var(--color-primary-darkest)) inset; 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 { .App-toolbar__extra-tools-dropdown {

View File

@ -114,11 +114,13 @@ const areEqual = (
return false; return false;
} }
return isShallowEqual( return (
// asserting AppState because we're being passed the whole AppState isShallowEqual(
// but resolve to only the StaticCanvas-relevant props // asserting AppState because we're being passed the whole AppState
getRelevantAppStateProps(prevProps.appState as AppState), // but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(nextProps.appState as AppState), getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig)
); );
}; };

View File

@ -59,6 +59,11 @@
height: 2.25rem; height: 2.25rem;
} }
&--selected {
background: var(--color-primary-light);
--icon-fill-color: var(--color-primary-darker);
}
&__text { &__text {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;

View File

@ -11,12 +11,14 @@ const DropdownMenuItem = ({
children, children,
shortcut, shortcut,
className, className,
selected,
...rest ...rest
}: { }: {
icon?: JSX.Element; icon?: JSX.Element;
onSelect: (event: Event) => void; onSelect: (event: Event) => void;
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
selected?: boolean;
className?: string; className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => { } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
@ -26,7 +28,7 @@ const DropdownMenuItem = ({
{...rest} {...rest}
onClick={handleClick} onClick={handleClick}
type="button" type="button"
className={getDropdownMenuItemClassName(className)} className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}
> >
<MenuItemContent icon={icon} shortcut={shortcut}> <MenuItemContent icon={icon} shortcut={shortcut}>

View File

@ -3,15 +3,19 @@ import React from "react";
const DropdownMenuItemCustom = ({ const DropdownMenuItemCustom = ({
children, children,
className = "", className = "",
selected,
...rest ...rest
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
selected?: boolean;
} & React.HTMLAttributes<HTMLDivElement>) => { } & React.HTMLAttributes<HTMLDivElement>) => {
return ( return (
<div <div
{...rest} {...rest}
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()} className={`dropdown-menu-item-base dropdown-menu-item-custom ${className} ${
selected ? `dropdown-menu-item--selected` : ``
}`.trim()}
> >
{children} {children}
</div> </div>

View File

@ -12,6 +12,7 @@ const DropdownMenuItemLink = ({
children, children,
onSelect, onSelect,
className = "", className = "",
selected,
...rest ...rest
}: { }: {
href: string; href: string;
@ -19,6 +20,7 @@ const DropdownMenuItemLink = ({
children: React.ReactNode; children: React.ReactNode;
shortcut?: string; shortcut?: string;
className?: string; className?: string;
selected?: boolean;
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => { } & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
@ -29,7 +31,7 @@ const DropdownMenuItemLink = ({
href={href} href={href}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={getDropdownMenuItemClassName(className)} className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}
onClick={handleClick} onClick={handleClick}
> >

View File

@ -6,8 +6,13 @@ export const DropdownMenuContentPropsContext = React.createContext<{
onSelect?: (event: Event) => void; onSelect?: (event: Event) => void;
}>({}); }>({});
export const getDropdownMenuItemClassName = (className = "") => { export const getDropdownMenuItemClassName = (
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim(); className = "",
selected = false,
) => {
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
selected ? "dropdown-menu-item--selected" : ""
}`.trim();
}; };
export const useHandleDropdownMenuItemClick = ( export const useHandleDropdownMenuItemClick = (

View File

@ -1653,3 +1653,22 @@ export const frameToolIcon = createIcon(
</g>, </g>,
tablerIconProps, 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,
);

103
src/cursor.ts Normal file
View File

@ -0,0 +1,103 @@
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;
}
};

View File

@ -68,6 +68,7 @@ export const AllowedExcalidrawActiveTools: Record<
frame: true, frame: true,
embeddable: true, embeddable: true,
hand: true, hand: true,
laser: false,
}; };
export type RestoredDataState = { export type RestoredDataState = {

View File

@ -2,7 +2,8 @@ import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { ExcalidrawProps } from "../types"; import { ExcalidrawProps } from "../types";
import { getFontString, setCursorForShape, updateActiveTool } from "../utils"; import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement"; import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks"; import { isEmbeddableElement } from "./typeChecks";

View File

@ -12,6 +12,7 @@ export const showSelectedShapeActions = (
(appState.editingElement || (appState.editingElement ||
(appState.activeTool.type !== "selection" && (appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser" && appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand"))) || appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser"))) ||
getSelectedElements(elements, appState).length), getSelectedElements(elements, appState).length),
); );

View File

@ -707,6 +707,17 @@ export const isElementInFrame = (
: element; : element;
if (frame) { 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) { if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame); return elementOverlapsWithFrame(_element, frame);
} }

View File

@ -236,6 +236,7 @@
"eraser": "Eraser", "eraser": "Eraser",
"frame": "Frame tool", "frame": "Frame tool",
"embeddable": "Web Embed", "embeddable": "Web Embed",
"laser": "Laser pointer",
"hand": "Hand (panning tool)", "hand": "Hand (panning tool)",
"extraTools": "More tools" "extraTools": "More tools"
}, },

View File

@ -11,6 +11,12 @@ 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. 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) ## 0.16.1 (2023-09-21)
## Excalidraw Library ## Excalidraw Library

View File

@ -675,7 +675,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 elements 1`] = `1`;
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 'Add to library' in context menu adds element to library > [end of test] number of renders 1`] = `6`;
exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Bring forward' in context menu brings element forward > [end of test] appState 1`] = `
{ {
@ -1051,7 +1051,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 elements 1`] = `2`;
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 forward' in context menu brings element forward > [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] appState 1`] = ` exports[`contextMenu element > selecting 'Bring to front' in context menu brings element to front > [end of test] appState 1`] = `
{ {
@ -1427,7 +1427,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 elements 1`] = `2`;
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 'Bring to front' in context menu brings element to front > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] appState 1`] = `
{ {
@ -1629,7 +1629,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 elements 1`] = `1`;
exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `7`; exports[`contextMenu element > selecting 'Copy styles' in context menu copies styles > [end of test] number of renders 1`] = `6`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] appState 1`] = `
{ {
@ -1868,7 +1868,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 elements 1`] = `1`;
exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `8`; exports[`contextMenu element > selecting 'Delete' in context menu deletes element > [end of test] number of renders 1`] = `7`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] appState 1`] = `
{ {
@ -2172,7 +2172,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 elements 1`] = `2`;
exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `8`; exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates element > [end of test] number of renders 1`] = `7`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] appState 1`] = `
{ {
@ -2564,7 +2564,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 elements 1`] = `2`;
exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `13`; exports[`contextMenu element > selecting 'Group selection' in context menu groups selected elements > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] appState 1`] = `
{ {
@ -3446,7 +3446,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 elements 1`] = `2`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `20`; exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `19`;
exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Send backward' in context menu sends element backward > [end of test] appState 1`] = `
{ {
@ -3822,7 +3822,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 elements 1`] = `2`;
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 backward' in context menu sends element backward > [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] appState 1`] = ` exports[`contextMenu element > selecting 'Send to back' in context menu sends element to back > [end of test] appState 1`] = `
{ {
@ -4198,7 +4198,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 elements 1`] = `2`;
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 'Send to back' in context menu sends element to back > [end of test] number of renders 1`] = `11`;
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = ` exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] appState 1`] = `
{ {
@ -4657,7 +4657,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 elements 1`] = `2`;
exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `14`; exports[`contextMenu element > selecting 'Ungroup selection' in context menu ungroups selected group > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = ` exports[`contextMenu element > shows 'Group selection' in context menu for multiple selected elements > [end of test] appState 1`] = `
{ {
@ -5240,7 +5240,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 elements 1`] = `2`;
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 'Group selection' in context menu for multiple selected elements > [end of test] number of renders 1`] = `12`;
exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = ` exports[`contextMenu element > shows 'Ungroup selection' in context menu for group inside selected elements > [end of test] appState 1`] = `
{ {
@ -5908,7 +5908,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 elements 1`] = `2`;
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 'Ungroup selection' in context menu for group inside selected elements > [end of test] number of renders 1`] = `13`;
exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = ` exports[`contextMenu element > shows context menu for canvas > [end of test] appState 1`] = `
{ {
@ -7104,6 +7104,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 elements 2`] = `2`;
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 1`] = `6`;
exports[`contextMenu element > shows context menu for element > [end of test] number of renders 2`] = `6`; exports[`contextMenu element > shows context menu for element > [end of test] number of renders 2`] = `6`;

View File

@ -454,7 +454,7 @@ exports[`given element A and group of elements B and given both are selected whe
exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected > [end of test] number of elements 1`] = `0`; exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected > [end of test] number of elements 1`] = `0`;
exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected > [end of test] number of renders 1`] = `22`; exports[`given element A and group of elements B and given both are selected when user clicks on B, on pointer up only elements from B should be selected > [end of test] number of renders 1`] = `21`;
exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected > [end of test] appState 1`] = ` exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected > [end of test] appState 1`] = `
{ {
@ -912,7 +912,7 @@ exports[`given element A and group of elements B and given both are selected whe
exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected > [end of test] number of elements 1`] = `0`; exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected > [end of test] number of elements 1`] = `0`;
exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected > [end of test] number of renders 1`] = `20`; exports[`given element A and group of elements B and given both are selected when user shift-clicks on B, on pointer up only element A should be selected > [end of test] number of renders 1`] = `19`;
exports[`regression tests > Cmd/Ctrl-click exclusively select element under pointer > [end of test] appState 1`] = ` exports[`regression tests > Cmd/Ctrl-click exclusively select element under pointer > [end of test] appState 1`] = `
{ {
@ -1743,7 +1743,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
exports[`regression tests > Cmd/Ctrl-click exclusively select element under pointer > [end of test] number of elements 1`] = `0`; exports[`regression tests > Cmd/Ctrl-click exclusively select element under pointer > [end of test] number of elements 1`] = `0`;
exports[`regression tests > Cmd/Ctrl-click exclusively select element under pointer > [end of test] number of renders 1`] = `30`; exports[`regression tests > Cmd/Ctrl-click exclusively select element under pointer > [end of test] number of renders 1`] = `29`;
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] appState 1`] = ` exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] appState 1`] = `
{ {
@ -1956,7 +1956,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of elements 1`] = `0`; exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of elements 1`] = `0`;
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of renders 1`] = `10`; exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of renders 1`] = `9`;
exports[`regression tests > adjusts z order when grouping > [end of test] appState 1`] = ` exports[`regression tests > adjusts z order when grouping > [end of test] appState 1`] = `
{ {
@ -2410,7 +2410,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor
exports[`regression tests > adjusts z order when grouping > [end of test] number of elements 1`] = `0`; exports[`regression tests > adjusts z order when grouping > [end of test] number of elements 1`] = `0`;
exports[`regression tests > adjusts z order when grouping > [end of test] number of renders 1`] = `19`; exports[`regression tests > adjusts z order when grouping > [end of test] number of renders 1`] = `18`;
exports[`regression tests > alt-drag duplicates an element > [end of test] appState 1`] = ` exports[`regression tests > alt-drag duplicates an element > [end of test] appState 1`] = `
{ {
@ -2652,7 +2652,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] histo
exports[`regression tests > alt-drag duplicates an element > [end of test] number of elements 1`] = `0`; exports[`regression tests > alt-drag duplicates an element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > alt-drag duplicates an element > [end of test] number of renders 1`] = `10`; exports[`regression tests > alt-drag duplicates an element > [end of test] number of renders 1`] = `9`;
exports[`regression tests > arrow keys > [end of test] appState 1`] = ` exports[`regression tests > arrow keys > [end of test] appState 1`] = `
{ {
@ -2820,7 +2820,7 @@ exports[`regression tests > arrow keys > [end of test] history 1`] = `
exports[`regression tests > arrow keys > [end of test] number of elements 1`] = `0`; exports[`regression tests > arrow keys > [end of test] number of elements 1`] = `0`;
exports[`regression tests > arrow keys > [end of test] number of renders 1`] = `14`; exports[`regression tests > arrow keys > [end of test] number of renders 1`] = `13`;
exports[`regression tests > can drag element that covers another element, while another elem is selected > [end of test] appState 1`] = ` exports[`regression tests > can drag element that covers another element, while another elem is selected > [end of test] appState 1`] = `
{ {
@ -3264,7 +3264,7 @@ exports[`regression tests > can drag element that covers another element, while
exports[`regression tests > can drag element that covers another element, while another elem is selected > [end of test] number of elements 1`] = `0`; exports[`regression tests > can drag element that covers another element, while another elem is selected > [end of test] number of elements 1`] = `0`;
exports[`regression tests > can drag element that covers another element, while another elem is selected > [end of test] number of renders 1`] = `19`; exports[`regression tests > can drag element that covers another element, while another elem is selected > [end of test] number of renders 1`] = `18`;
exports[`regression tests > change the properties of a shape > [end of test] appState 1`] = ` exports[`regression tests > change the properties of a shape > [end of test] appState 1`] = `
{ {
@ -3561,7 +3561,7 @@ exports[`regression tests > change the properties of a shape > [end of test] his
exports[`regression tests > change the properties of a shape > [end of test] number of elements 1`] = `0`; exports[`regression tests > change the properties of a shape > [end of test] number of elements 1`] = `0`;
exports[`regression tests > change the properties of a shape > [end of test] number of renders 1`] = `11`; exports[`regression tests > change the properties of a shape > [end of test] number of renders 1`] = `10`;
exports[`regression tests > click on an element and drag it > [dragged] appState 1`] = ` exports[`regression tests > click on an element and drag it > [dragged] appState 1`] = `
{ {
@ -3806,7 +3806,7 @@ exports[`regression tests > click on an element and drag it > [dragged] history
exports[`regression tests > click on an element and drag it > [dragged] number of elements 1`] = `1`; exports[`regression tests > click on an element and drag it > [dragged] number of elements 1`] = `1`;
exports[`regression tests > click on an element and drag it > [dragged] number of renders 1`] = `10`; exports[`regression tests > click on an element and drag it > [dragged] number of renders 1`] = `9`;
exports[`regression tests > click on an element and drag it > [end of test] appState 1`] = ` exports[`regression tests > click on an element and drag it > [end of test] appState 1`] = `
{ {
@ -4062,7 +4062,7 @@ exports[`regression tests > click on an element and drag it > [end of test] hist
exports[`regression tests > click on an element and drag it > [end of test] number of elements 1`] = `0`; exports[`regression tests > click on an element and drag it > [end of test] number of elements 1`] = `0`;
exports[`regression tests > click on an element and drag it > [end of test] number of renders 1`] = `12`; exports[`regression tests > click on an element and drag it > [end of test] number of renders 1`] = `11`;
exports[`regression tests > click to select a shape > [end of test] appState 1`] = ` exports[`regression tests > click to select a shape > [end of test] appState 1`] = `
{ {
@ -4304,7 +4304,7 @@ exports[`regression tests > click to select a shape > [end of test] history 1`]
exports[`regression tests > click to select a shape > [end of test] number of elements 1`] = `0`; exports[`regression tests > click to select a shape > [end of test] number of elements 1`] = `0`;
exports[`regression tests > click to select a shape > [end of test] number of renders 1`] = `13`; exports[`regression tests > click to select a shape > [end of test] number of renders 1`] = `12`;
exports[`regression tests > click-drag to select a group > [end of test] appState 1`] = ` exports[`regression tests > click-drag to select a group > [end of test] appState 1`] = `
{ {
@ -4648,7 +4648,7 @@ exports[`regression tests > click-drag to select a group > [end of test] history
exports[`regression tests > click-drag to select a group > [end of test] number of elements 1`] = `0`; exports[`regression tests > click-drag to select a group > [end of test] number of elements 1`] = `0`;
exports[`regression tests > click-drag to select a group > [end of test] number of renders 1`] = `18`; exports[`regression tests > click-drag to select a group > [end of test] number of renders 1`] = `17`;
exports[`regression tests > deleting last but one element in editing group should unselect the group > [end of test] appState 1`] = ` exports[`regression tests > deleting last but one element in editing group should unselect the group > [end of test] appState 1`] = `
{ {
@ -5123,7 +5123,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
exports[`regression tests > deleting last but one element in editing group should unselect the group > [end of test] number of elements 1`] = `0`; exports[`regression tests > deleting last but one element in editing group should unselect the group > [end of test] number of elements 1`] = `0`;
exports[`regression tests > deleting last but one element in editing group should unselect the group > [end of test] number of renders 1`] = `20`; exports[`regression tests > deleting last but one element in editing group should unselect the group > [end of test] number of renders 1`] = `19`;
exports[`regression tests > deselects group of selected elements on pointer down when pointer doesn't hit any element > [end of test] appState 1`] = ` exports[`regression tests > deselects group of selected elements on pointer down when pointer doesn't hit any element > [end of test] appState 1`] = `
{ {
@ -5420,7 +5420,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
exports[`regression tests > deselects group of selected elements on pointer down when pointer doesn't hit any element > [end of test] number of elements 1`] = `0`; exports[`regression tests > deselects group of selected elements on pointer down when pointer doesn't hit any element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > deselects group of selected elements on pointer down when pointer doesn't hit any element > [end of test] number of renders 1`] = `14`; exports[`regression tests > deselects group of selected elements on pointer down when pointer doesn't hit any element > [end of test] number of renders 1`] = `13`;
exports[`regression tests > deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element > [end of test] appState 1`] = ` exports[`regression tests > deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element > [end of test] appState 1`] = `
{ {
@ -5689,7 +5689,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
exports[`regression tests > deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element > [end of test] number of elements 1`] = `0`; exports[`regression tests > deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element > [end of test] number of renders 1`] = `14`; exports[`regression tests > deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element > [end of test] number of renders 1`] = `13`;
exports[`regression tests > deselects selected element on pointer down when pointer doesn't hit any element > [end of test] appState 1`] = ` exports[`regression tests > deselects selected element on pointer down when pointer doesn't hit any element > [end of test] appState 1`] = `
{ {
@ -5913,7 +5913,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
exports[`regression tests > deselects selected element on pointer down when pointer doesn't hit any element > [end of test] number of elements 1`] = `0`; exports[`regression tests > deselects selected element on pointer down when pointer doesn't hit any element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > deselects selected element on pointer down when pointer doesn't hit any element > [end of test] number of renders 1`] = `9`; exports[`regression tests > deselects selected element on pointer down when pointer doesn't hit any element > [end of test] number of renders 1`] = `8`;
exports[`regression tests > deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element > [end of test] appState 1`] = ` exports[`regression tests > deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element > [end of test] appState 1`] = `
{ {
@ -6081,7 +6081,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
exports[`regression tests > deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element > [end of test] number of elements 1`] = `0`; exports[`regression tests > deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element > [end of test] number of renders 1`] = `9`; exports[`regression tests > deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element > [end of test] number of renders 1`] = `8`;
exports[`regression tests > double click to edit a group > [end of test] appState 1`] = ` exports[`regression tests > double click to edit a group > [end of test] appState 1`] = `
{ {
@ -6533,7 +6533,7 @@ exports[`regression tests > double click to edit a group > [end of test] history
exports[`regression tests > double click to edit a group > [end of test] number of elements 1`] = `0`; exports[`regression tests > double click to edit a group > [end of test] number of elements 1`] = `0`;
exports[`regression tests > double click to edit a group > [end of test] number of renders 1`] = `19`; exports[`regression tests > double click to edit a group > [end of test] number of renders 1`] = `18`;
exports[`regression tests > drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging > [end of test] appState 1`] = ` exports[`regression tests > drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging > [end of test] appState 1`] = `
{ {
@ -6850,7 +6850,7 @@ exports[`regression tests > drags selected elements from point inside common bou
exports[`regression tests > drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging > [end of test] number of elements 1`] = `0`; exports[`regression tests > drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging > [end of test] number of elements 1`] = `0`;
exports[`regression tests > drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging > [end of test] number of renders 1`] = `15`; exports[`regression tests > drags selected elements from point inside common bounding box that doesn't hit any element and keeps elements selected after dragging > [end of test] number of renders 1`] = `14`;
exports[`regression tests > draw every type of shape > [end of test] appState 1`] = ` exports[`regression tests > draw every type of shape > [end of test] appState 1`] = `
{ {
@ -8917,7 +8917,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`]
exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`; exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`;
exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `44`; exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `43`;
exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] appState 1`] = ` exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] appState 1`] = `
{ {
@ -9261,7 +9261,7 @@ exports[`regression tests > given a group of selected elements with an element t
exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] number of elements 1`] = `0`; exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] number of elements 1`] = `0`;
exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] number of renders 1`] = `18`; exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] number of renders 1`] = `17`;
exports[`regression tests > given a selected element A and a not selected element B with higher z-index than A and given B partially overlaps A when there's a shift-click on the overlapped section B is added to the selection > [end of test] appState 1`] = ` exports[`regression tests > given a selected element A and a not selected element B with higher z-index than A and given B partially overlaps A when there's a shift-click on the overlapped section B is added to the selection > [end of test] appState 1`] = `
{ {
@ -9504,7 +9504,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
exports[`regression tests > given a selected element A and a not selected element B with higher z-index than A and given B partially overlaps A when there's a shift-click on the overlapped section B is added to the selection > [end of test] number of elements 1`] = `0`; exports[`regression tests > given a selected element A and a not selected element B with higher z-index than A and given B partially overlaps A when there's a shift-click on the overlapped section B is added to the selection > [end of test] number of elements 1`] = `0`;
exports[`regression tests > given a selected element A and a not selected element B with higher z-index than A and given B partially overlaps A when there's a shift-click on the overlapped section B is added to the selection > [end of test] number of renders 1`] = `15`; exports[`regression tests > given a selected element A and a not selected element B with higher z-index than A and given B partially overlaps A when there's a shift-click on the overlapped section B is added to the selection > [end of test] number of renders 1`] = `14`;
exports[`regression tests > given selected element A with lower z-index than unselected element B and given B is partially over A when clicking intersection between A and B B should be selected on pointer up > [end of test] appState 1`] = ` exports[`regression tests > given selected element A with lower z-index than unselected element B and given B is partially over A when clicking intersection between A and B B should be selected on pointer up > [end of test] appState 1`] = `
{ {
@ -12842,7 +12842,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor
exports[`regression tests > make a group and duplicate it > [end of test] number of elements 1`] = `0`; exports[`regression tests > make a group and duplicate it > [end of test] number of elements 1`] = `0`;
exports[`regression tests > make a group and duplicate it > [end of test] number of renders 1`] = `21`; exports[`regression tests > make a group and duplicate it > [end of test] number of renders 1`] = `20`;
exports[`regression tests > noop interaction after undo shouldn't create history entry > [end of test] appState 1`] = ` exports[`regression tests > noop interaction after undo shouldn't create history entry > [end of test] appState 1`] = `
{ {
@ -13084,7 +13084,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
exports[`regression tests > noop interaction after undo shouldn't create history entry > [end of test] number of elements 1`] = `0`; exports[`regression tests > noop interaction after undo shouldn't create history entry > [end of test] number of elements 1`] = `0`;
exports[`regression tests > noop interaction after undo shouldn't create history entry > [end of test] number of renders 1`] = `17`; exports[`regression tests > noop interaction after undo shouldn't create history entry > [end of test] number of renders 1`] = `16`;
exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
{ {
@ -13375,7 +13375,7 @@ exports[`regression tests > shift click on selected element should deselect it o
exports[`regression tests > shift click on selected element should deselect it on pointer up > [end of test] number of elements 1`] = `0`; exports[`regression tests > shift click on selected element should deselect it on pointer up > [end of test] number of elements 1`] = `0`;
exports[`regression tests > shift click on selected element should deselect it on pointer up > [end of test] number of renders 1`] = `9`; exports[`regression tests > shift click on selected element should deselect it on pointer up > [end of test] number of renders 1`] = `8`;
exports[`regression tests > shift-click to multiselect, then drag > [end of test] appState 1`] = ` exports[`regression tests > shift-click to multiselect, then drag > [end of test] appState 1`] = `
{ {
@ -13692,7 +13692,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
exports[`regression tests > shift-click to multiselect, then drag > [end of test] number of elements 1`] = `0`; exports[`regression tests > shift-click to multiselect, then drag > [end of test] number of elements 1`] = `0`;
exports[`regression tests > shift-click to multiselect, then drag > [end of test] number of renders 1`] = `16`; exports[`regression tests > shift-click to multiselect, then drag > [end of test] number of renders 1`] = `15`;
exports[`regression tests > should group elements and ungroup them > [end of test] appState 1`] = ` exports[`regression tests > should group elements and ungroup them > [end of test] appState 1`] = `
{ {
@ -14253,7 +14253,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
exports[`regression tests > should group elements and ungroup them > [end of test] number of elements 1`] = `0`; exports[`regression tests > should group elements and ungroup them > [end of test] number of elements 1`] = `0`;
exports[`regression tests > should group elements and ungroup them > [end of test] number of renders 1`] = `22`; exports[`regression tests > should group elements and ungroup them > [end of test] number of renders 1`] = `21`;
exports[`regression tests > should show fill icons when element has non transparent background > [end of test] appState 1`] = ` exports[`regression tests > should show fill icons when element has non transparent background > [end of test] appState 1`] = `
{ {
@ -14464,7 +14464,7 @@ exports[`regression tests > should show fill icons when element has non transpar
exports[`regression tests > should show fill icons when element has non transparent background > [end of test] number of elements 1`] = `0`; exports[`regression tests > should show fill icons when element has non transparent background > [end of test] number of elements 1`] = `0`;
exports[`regression tests > should show fill icons when element has non transparent background > [end of test] number of renders 1`] = `10`; exports[`regression tests > should show fill icons when element has non transparent background > [end of test] number of renders 1`] = `9`;
exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] appState 1`] = ` exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] appState 1`] = `
{ {
@ -15320,7 +15320,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] number of elements 1`] = `0`; exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] number of elements 1`] = `0`;
exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] number of renders 1`] = `30`; exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] number of renders 1`] = `29`;
exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] appState 1`] = ` exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] appState 1`] = `
{ {
@ -16233,7 +16233,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] =
exports[`regression tests > supports nested groups > [end of test] number of elements 1`] = `0`; exports[`regression tests > supports nested groups > [end of test] number of elements 1`] = `0`;
exports[`regression tests > supports nested groups > [end of test] number of renders 1`] = `27`; exports[`regression tests > supports nested groups > [end of test] number of renders 1`] = `26`;
exports[`regression tests > switches from group of selected elements to another element on pointer down > [end of test] appState 1`] = ` exports[`regression tests > switches from group of selected elements to another element on pointer down > [end of test] appState 1`] = `
{ {
@ -16633,7 +16633,7 @@ exports[`regression tests > switches from group of selected elements to another
exports[`regression tests > switches from group of selected elements to another element on pointer down > [end of test] number of elements 1`] = `0`; exports[`regression tests > switches from group of selected elements to another element on pointer down > [end of test] number of elements 1`] = `0`;
exports[`regression tests > switches from group of selected elements to another element on pointer down > [end of test] number of renders 1`] = `18`; exports[`regression tests > switches from group of selected elements to another element on pointer down > [end of test] number of renders 1`] = `17`;
exports[`regression tests > switches selected element on pointer down > [end of test] appState 1`] = ` exports[`regression tests > switches selected element on pointer down > [end of test] appState 1`] = `
{ {
@ -16931,7 +16931,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
exports[`regression tests > switches selected element on pointer down > [end of test] number of elements 1`] = `0`; exports[`regression tests > switches selected element on pointer down > [end of test] number of elements 1`] = `0`;
exports[`regression tests > switches selected element on pointer down > [end of test] number of renders 1`] = `13`; exports[`regression tests > switches selected element on pointer down > [end of test] number of renders 1`] = `12`;
exports[`regression tests > two-finger scroll works > [end of test] appState 1`] = ` exports[`regression tests > two-finger scroll works > [end of test] appState 1`] = `
{ {
@ -17537,7 +17537,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history
exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`; exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `24`; exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `23`;
exports[`regression tests > updates fontSize & fontFamily appState > [end of test] appState 1`] = ` exports[`regression tests > updates fontSize & fontFamily appState > [end of test] appState 1`] = `
{ {
@ -17601,10 +17601,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": { "originSnapOffset": null,
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@ -17663,7 +17660,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
exports[`regression tests > updates fontSize & fontFamily appState > [end of test] number of elements 1`] = `0`; exports[`regression tests > updates fontSize & fontFamily appState > [end of test] number of elements 1`] = `0`;
exports[`regression tests > updates fontSize & fontFamily appState > [end of test] number of renders 1`] = `6`; exports[`regression tests > updates fontSize & fontFamily appState > [end of test] number of renders 1`] = `5`;
exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
{ {

View File

@ -47,7 +47,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@ -79,7 +79,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
@ -112,7 +112,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@ -144,7 +144,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@ -180,7 +180,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@ -221,7 +221,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@ -241,7 +241,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@ -261,7 +261,7 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@ -288,7 +288,7 @@ describe("Test dragCreate", () => {
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@ -315,7 +315,7 @@ describe("Test dragCreate", () => {
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });

View File

@ -43,7 +43,7 @@ describe("move element", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -85,7 +85,7 @@ describe("move element", () => {
new Pointer("mouse").clickOn(rectB); new Pointer("mouse").clickOn(rectB);
expect(renderInteractiveScene).toHaveBeenCalledTimes(24); expect(renderInteractiveScene).toHaveBeenCalledTimes(24);
expect(renderStaticScene).toHaveBeenCalledTimes(20); expect(renderStaticScene).toHaveBeenCalledTimes(19);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3); expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
@ -131,7 +131,7 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(6);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

View File

@ -48,7 +48,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@ -63,7 +63,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@ -78,7 +78,7 @@ describe("remove shape in non linear elements", () => {
fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(5);
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
}); });
@ -111,7 +111,7 @@ describe("multi point mode in linear elements", () => {
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(11); expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
expect(renderStaticScene).toHaveBeenCalledTimes(10); expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
@ -154,7 +154,7 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene).toHaveBeenCalledTimes(11); expect(renderInteractiveScene).toHaveBeenCalledTimes(11);
expect(renderStaticScene).toHaveBeenCalledTimes(10); expect(renderStaticScene).toHaveBeenCalledTimes(9);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;

View File

@ -310,7 +310,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -342,7 +342,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -374,7 +374,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -419,7 +419,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -463,7 +463,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

View File

@ -19,7 +19,6 @@ import {
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
} from "./element/types"; } from "./element/types";
import { Action } from "./actions/types"; import { Action } from "./actions/types";
import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry"; import { Point as RoughPoint } from "roughjs/bin/geometry";
import { LinearElementEditor } from "./element/linearElementEditor"; import { LinearElementEditor } from "./element/linearElementEditor";
import { SuggestedBinding } from "./element/binding"; import { SuggestedBinding } from "./element/binding";
@ -47,10 +46,7 @@ import { Merge, ForwardRef, ValueOf } from "./utility-types";
export type Point = Readonly<RoughPoint>; export type Point = Readonly<RoughPoint>;
export type Collaborator = { export type Collaborator = {
pointer?: { pointer?: CollaboratorPointer;
x: number;
y: number;
};
button?: "up" | "down"; button?: "up" | "down";
selectedElementIds?: AppState["selectedElementIds"]; selectedElementIds?: AppState["selectedElementIds"];
username?: string | null; username?: string | null;
@ -66,6 +62,12 @@ export type Collaborator = {
id?: string; id?: string;
}; };
export type CollaboratorPointer = {
x: number;
y: number;
tool: "pointer" | "laser";
};
export type DataURL = string & { _brand: "DataURL" }; export type DataURL = string & { _brand: "DataURL" };
export type BinaryFileData = { export type BinaryFileData = {
@ -93,21 +95,31 @@ export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>; export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
export type LastActiveTool = export type ToolType =
| "selection"
| "rectangle"
| "diamond"
| "ellipse"
| "arrow"
| "line"
| "freedraw"
| "text"
| "image"
| "eraser"
| "hand"
| "frame"
| "embeddable"
| "laser";
export type ActiveTool =
| { | {
type: type: ToolType;
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
customType: null; customType: null;
} }
| { | {
type: "custom"; type: "custom";
customType: string; customType: string;
} };
| null;
export type SidebarName = string; export type SidebarName = string;
export type SidebarTabName = string; export type SidebarTabName = string;
@ -206,23 +218,9 @@ export type AppState = {
* indicates a previous tool we should revert back to if we deselect the * indicates a previous tool we should revert back to if we deselect the
* currently active tool. At the moment applies to `eraser` and `hand` tool. * currently active tool. At the moment applies to `eraser` and `hand` tool.
*/ */
lastActiveTool: LastActiveTool; lastActiveTool: ActiveTool | null;
locked: boolean; locked: boolean;
} & ( } & ActiveTool;
| {
type:
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
customType: null;
}
| {
type: "custom";
customType: string;
}
);
penMode: boolean; penMode: boolean;
penDetected: boolean; penDetected: boolean;
exportBackground: boolean; exportBackground: boolean;
@ -303,7 +301,7 @@ export type AppState = {
showHyperlinkPopup: false | "info" | "editor"; showHyperlinkPopup: false | "info" | "editor";
selectedLinearElement: LinearElementEditor | null; selectedLinearElement: LinearElementEditor | null;
snapLines: SnapLine[]; snapLines: readonly SnapLine[];
originSnapOffset: { originSnapOffset: {
x: number; x: number;
y: number; y: number;
@ -406,7 +404,7 @@ export interface ExcalidrawProps {
excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>; excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
isCollaborating?: boolean; isCollaborating?: boolean;
onPointerUpdate?: (payload: { onPointerUpdate?: (payload: {
pointer: { x: number; y: number }; pointer: { x: number; y: number; tool: "pointer" | "laser" };
button: "down" | "up"; button: "down" | "up";
pointersMap: Gesture["pointers"]; pointersMap: Gesture["pointers"];
}) => void; }) => void;
@ -550,6 +548,8 @@ export type AppClassProperties = {
onInsertElements: App["onInsertElements"]; onInsertElements: App["onInsertElements"];
onExportImage: App["onExportImage"]; onExportImage: App["onExportImage"];
lastViewportPosition: App["lastViewportPosition"]; lastViewportPosition: App["lastViewportPosition"];
togglePenMode: App["togglePenMode"];
setActiveTool: App["setActiveTool"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{

View File

@ -1,13 +1,9 @@
import oc from "open-color";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
import { import {
CURSOR_TYPE,
DEFAULT_VERSION, DEFAULT_VERSION,
EVENT, EVENT,
FONT_FAMILY, FONT_FAMILY,
isDarwin, isDarwin,
MIME_TYPES,
THEME,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
import { import {
@ -15,10 +11,8 @@ import {
FontString, FontString,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { AppState, DataURL, LastActiveTool, Zoom } from "./types"; import { ActiveTool, AppState, ToolType, Zoom } from "./types";
import { unstable_batchedUpdates } from "react-dom"; import { unstable_batchedUpdates } from "react-dom";
import { SHAPES } from "./shapes";
import { isEraserActive, isHandToolActive } from "./appState";
import { ResolutionType } from "./utility-types"; import { ResolutionType } from "./utility-types";
import React from "react"; import React from "react";
@ -371,15 +365,10 @@ export const updateActiveTool = (
appState: Pick<AppState, "activeTool">, appState: Pick<AppState, "activeTool">,
data: ( data: (
| { | {
type: type: ToolType;
| typeof SHAPES[number]["value"]
| "eraser"
| "hand"
| "frame"
| "embeddable";
} }
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { lastActiveToolBeforeEraser?: LastActiveTool }, ) & { lastActiveToolBeforeEraser?: ActiveTool | null },
): AppState["activeTool"] => { ): AppState["activeTool"] => {
if (data.type === "custom") { if (data.type === "custom") {
return { return {
@ -400,84 +389,6 @@ export const updateActiveTool = (
}; };
}; };
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 ? oc.black : oc.white;
context.fill();
context.strokeStyle = isDarkTheme ? oc.white : oc.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 (!["image", "custom"].includes(appState.activeTool.type)) {
interactiveCanvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
}
};
export const isFullScreen = () => export const isFullScreen = () =>
document.fullscreenElement?.nodeName === "HTML"; document.fullscreenElement?.nodeName === "HTML";

View File

@ -111,7 +111,7 @@ export default defineConfig({
{ {
src: "apple-touch-icon.png", src: "apple-touch-icon.png",
type: "image/png", type: "image/png",
sizes: "256x256", sizes: "180x180",
}, },
], ],
start_url: "/", start_url: "/",

View File

@ -1522,6 +1522,11 @@
resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd" resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ== integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ==
"@excalidraw/laser-pointer@1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.2.0.tgz#cd34ea7d24b11743c726488cc1fcb28c161cacba"
integrity sha512-WjFFwLk9ahmKRKku7U0jqYpeM3fe9ZS1K43pfwPREHk4/FYU3iKDKVeS8m4tEAASnRlBt3hhLCBQLBF2uvgOnw==
"@excalidraw/prettier-config@1.0.2": "@excalidraw/prettier-config@1.0.2":
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65" resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"