Compare commits

..

8 Commits

Author SHA1 Message Date
Zsolt Viczian
09ae07ed7f Added font color picker 2022-03-14 20:56:12 +01:00
Aakansha Doshi
6d45430344 fix: undo when erasing elements by clicking (#4921)
* fix: undo when erasing elements by clicking

* newline remove
2022-03-14 14:59:55 +05:30
dependabot[bot]
3aa0c5ebc0 chore(deps-dev): bump css-loader in /src/packages/excalidraw (#4911)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 6.6.0 to 6.7.1.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v6.6.0...v6.7.1)

---
updated-dependencies:
- dependency-name: css-loader
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-14 14:47:01 +05:30
dependabot[bot]
e940993e0e chore(deps-dev): bump css-loader in /src/packages/utils (#4914)
Bumps [css-loader](https://github.com/webpack-contrib/css-loader) from 6.5.1 to 6.7.1.
- [Release notes](https://github.com/webpack-contrib/css-loader/releases)
- [Changelog](https://github.com/webpack-contrib/css-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/css-loader/compare/v6.5.1...v6.7.1)

---
updated-dependencies:
- dependency-name: css-loader
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-14 14:45:33 +05:30
dependabot[bot]
8f90aeb8d5 chore(deps-dev): bump ts-loader in /src/packages/utils (#4913)
Bumps [ts-loader](https://github.com/TypeStrong/ts-loader) from 9.2.6 to 9.2.8.
- [Release notes](https://github.com/TypeStrong/ts-loader/releases)
- [Changelog](https://github.com/TypeStrong/ts-loader/blob/main/CHANGELOG.md)
- [Commits](https://github.com/TypeStrong/ts-loader/compare/v9.2.6...v9.2.8)

---
updated-dependencies:
- dependency-name: ts-loader
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-14 14:45:02 +05:30
Aakansha Doshi
e92d133973 fix: undo when erasing (#4900) 2022-03-11 20:44:17 +05:30
David Luzar
b682d88167 fix: incorrectly erasing on mobile (#4899)
* fix: incorrectly erasing on mobile

* reintroduce fix for erasing on single-point click

* fix snaps
2022-03-11 15:45:59 +01:00
Aakansha Doshi
7daf1a7944 feat: Add Eraser 🎉 (#4887)
* feat: Add Eraser 🎉

* Eraser working

* remove unused state

* fix

* toggle eraser

* Support deselect with Alt/Option

* rename actionDelete -> actionErase

* Add util isEraserActive

* show eraser in mobile

* render eraser conditionally in mobile

* use selection if eraser in local storage state

* Add sampling to erase accurately

* use pointerDownState

* set eraser to false in AllowedExcalidrawElementTypes

* rename/reword fixes

* don't use updateScene

* handle bound text when erasing

* fix hover state in mobile

* consider all hitElements instead of a single

* code improvements

* revert to select if eraser active and elements selected

* show eraser in zenmode

* erase element when clicked on element while eraser active

* set groupIds to empty when eraser active

* fix test

* remove dragged distance
2022-03-11 19:53:42 +05:30
30 changed files with 444 additions and 146 deletions

View File

@@ -1,5 +1,5 @@
import { ColorPicker } from "../components/ColorPicker";
import { zoomIn, zoomOut } from "../components/icons";
import { eraser, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { THEME, ZOOM_STEP } from "../constants";
@@ -15,8 +15,9 @@ import { getShortcutKey } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
import { getDefaultAppState } from "../appState";
import { getDefaultAppState, isEraserActive } from "../appState";
import ClearCanvas from "../components/ClearCanvas";
import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -289,3 +290,31 @@ export const actionToggleTheme = register({
),
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
});
export const actionErase = register({
name: "eraser",
perform: (elements, appState) => {
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
elementType: isEraserActive(appState) ? "selection" : "eraser",
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, data }) => (
<ToolButton
type="button"
icon={eraser}
className={clsx("eraser", { active: isEraserActive(appState) })}
title={t("toolBar.eraser")}
aria-label={t("toolBar.eraser")}
onClick={() => {
updateData(null);
}}
size={data?.size || "medium"}
></ToolButton>
),
});

View File

@@ -51,6 +51,7 @@ import {
getContainerElement,
} from "../element/textElement";
import {
hasBoundTextElement,
isBoundToContainer,
isLinearElement,
isLinearElementType,
@@ -106,6 +107,7 @@ const getFormValue = function <T>(
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
defaultValue?: T,
onlyBoundTextElements: boolean = false,
): T | null {
const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements);
@@ -116,6 +118,7 @@ const getFormValue = function <T>(
nonDeletedElements,
appState,
getAttribute,
onlyBoundTextElements,
)
: defaultValue) ??
null
@@ -196,8 +199,8 @@ const changeFontSize = (
// -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
export const actionChangeFontColor = register({
name: "changeFontColor",
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
@@ -205,7 +208,7 @@ export const actionChangeStrokeColor = register({
elements,
appState,
(el) => {
return hasStrokeColor(el.type)
return isTextElement(el)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
@@ -221,28 +224,107 @@ export const actionChangeStrokeColor = register({
commitToHistory: !!value.currentItemStrokeColor,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
PanelComponent: ({ elements, appState, updateData }) => {
return (
<>
<h3 aria-hidden="true">{t("labels.fontColor")}</h3>
<ColorPicker
type="elementFontColor"
label={t("labels.fontColor")}
color={getFormValue(
elements,
appState,
(element) => element.strokeColor,
appState.currentItemStrokeColor,
true,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "fontColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "fontColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
);
},
});
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const hasOnlyContainersWithBoundText =
targetElements.length > 1 &&
targetElements.every(
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
);
return {
...(value.currentItemStrokeColor && {
elements: changeProperty(
elements,
appState,
(element) => element.strokeColor,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
),
(el) => {
return (hasStrokeColor(el.type) &&
!hasOnlyContainersWithBoundText) ||
!isBoundToContainer(el)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
: el;
},
true,
),
}),
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const hasOnlyContainersWithBoundText = targetElements.every(
(element) => hasBoundTextElement(element) || isBoundToContainer(element),
);
return (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
hasOnlyContainersWithBoundText
? elements.filter((element) => !isTextElement(element))
: elements,
appState,
(element) => element.strokeColor,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
);
},
});
export const actionChangeBackgroundColor = register({

View File

@@ -8,6 +8,7 @@ export {
export { actionSelectAll } from "./actionSelectAll";
export { actionDuplicateSelection } from "./actionDuplicateSelection";
export {
actionChangeFontColor,
actionChangeStrokeColor,
actionChangeBackgroundColor,
actionChangeStrokeWidth,

View File

@@ -49,6 +49,7 @@ export type ActionName =
| "gridMode"
| "zenMode"
| "stats"
| "changeFontColor"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeFillStyle"
@@ -106,7 +107,8 @@ export type ActionName =
| "increaseFontSize"
| "decreaseFontSize"
| "unbindText"
| "hyperlink";
| "hyperlink"
| "eraser";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];

View File

@@ -213,3 +213,9 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "server");
};
export const isEraserActive = ({
elementType,
}: {
elementType: AppState["elementType"];
}) => elementType === "eraser";

View File

@@ -30,7 +30,7 @@ export const SelectedShapeActions = ({
appState: AppState;
elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
elementType: ExcalidrawElement["type"];
elementType: AppState["elementType"];
}) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@@ -68,8 +68,15 @@ export const SelectedShapeActions = ({
}
}
const hasOnlyContainersWithBoundText =
targetElements.length > 1 &&
targetElements.every(
(element) => hasBoundTextElement(element) || isBoundToContainer(element),
);
return (
<div className="panelColumn">
{hasOnlyContainersWithBoundText && renderAction("changeFontColor")}
{((hasStrokeColor(elementType) &&
elementType !== "image" &&
commonSelectedType !== "image") ||
@@ -187,7 +194,7 @@ export const ShapesSwitcher = ({
onImageAction,
}: {
canvas: HTMLCanvasElement | null;
elementType: ExcalidrawElement["type"];
elementType: AppState["elementType"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
}) => (

View File

@@ -35,7 +35,7 @@ import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { ActionResult } from "../actions/types";
import { trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import { getDefaultAppState, isEraserActive } from "../appState";
import {
copyToClipboard,
parseClipboard,
@@ -314,6 +314,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerDown: React.PointerEvent<HTMLCanvasElement> | null = null;
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
contextMenuOpen: boolean = false;
lastScenePointer: { x: number; y: number } | null = null;
constructor(props: AppProps) {
super(props);
@@ -1044,6 +1045,12 @@ class App extends React.Component<AppProps, AppState> {
}
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (
Object.keys(this.state.selectedElementIds).length &&
isEraserActive(this.state)
) {
this.setState({ elementType: "selection" });
}
// Hide hyperlink popup if shown when element type is not selection
if (
prevState.elementType === "selection" &&
@@ -1833,7 +1840,11 @@ class App extends React.Component<AppProps, AppState> {
event.preventDefault();
}
if (event.key === KEYS.G || event.key === KEYS.S) {
if (
event.key === KEYS.G ||
event.key === KEYS.S ||
event.key === KEYS.C
) {
const selectedElements = getSelectedElements(
this.scene.getElements(),
this.state,
@@ -1855,6 +1866,9 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.S) {
this.setState({ openPopup: "strokeColorPicker" });
}
if (event.key === KEYS.C) {
this.setState({ openPopup: "fontColorPicker" });
}
}
},
);
@@ -2450,7 +2464,6 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
) => {
this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
if (gesture.pointers.has(event.pointerId)) {
gesture.pointers.set(event.pointerId, {
x: event.clientX,
@@ -2624,7 +2637,8 @@ class App extends React.Component<AppProps, AppState> {
if (
hasDeselectedButton ||
(this.state.elementType !== "selection" &&
this.state.elementType !== "text")
this.state.elementType !== "text" &&
this.state.elementType !== "eraser")
) {
return;
}
@@ -2699,8 +2713,9 @@ class App extends React.Component<AppProps, AppState> {
!this.state.showHyperlinkPopup
) {
this.setState({ showHyperlinkPopup: "info" });
}
if (this.state.elementType === "text") {
} else if (isEraserActive(this.state)) {
setCursor(this.canvas, CURSOR_TYPE.AUTO);
} else if (this.state.elementType === "text") {
setCursor(
this.canvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
@@ -2741,6 +2756,80 @@ class App extends React.Component<AppProps, AppState> {
}
};
private handleEraser = (
event: PointerEvent,
pointerDownState: PointerDownState,
scenePointer: { x: number; y: number },
) => {
const updateElementIds = (elements: ExcalidrawElement[]) => {
elements.forEach((element) => {
idsToUpdate.push(element.id);
if (event.altKey) {
if (pointerDownState.elementIdsToErase[element.id]) {
pointerDownState.elementIdsToErase[element.id] = false;
}
} else {
pointerDownState.elementIdsToErase[element.id] = true;
}
});
};
const idsToUpdate: Array<string> = [];
const distance = distance2d(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
scenePointer.x,
scenePointer.y,
);
const threshold = 10 / this.state.zoom.value;
const point = { ...pointerDownState.lastCoords };
let samplingInterval = 0;
while (samplingInterval <= distance) {
const hitElements = this.getElementsAtPosition(point.x, point.y);
updateElementIds(hitElements);
// Exit since we reached current point
if (samplingInterval === distance) {
break;
}
// Calculate next point in the line at a distance of sampling interval
samplingInterval = Math.min(samplingInterval + threshold, distance);
const distanceRatio = samplingInterval / distance;
const nextX =
(1 - distanceRatio) * point.x + distanceRatio * scenePointer.x;
const nextY =
(1 - distanceRatio) * point.y + distanceRatio * scenePointer.y;
point.x = nextX;
point.y = nextY;
}
const elements = this.scene.getElements().map((ele) => {
const id =
isBoundToContainer(ele) && idsToUpdate.includes(ele.containerId)
? ele.containerId
: ele.id;
if (idsToUpdate.includes(id)) {
if (event.altKey) {
if (pointerDownState.elementIdsToErase[id] === false) {
return newElementWith(ele, {
opacity: this.state.currentItemOpacity,
});
}
} else {
return newElementWith(ele, { opacity: 20 });
}
}
return ele;
});
this.scene.replaceAllElements(elements);
pointerDownState.lastCoords.x = scenePointer.x;
pointerDownState.lastCoords.y = scenePointer.y;
};
// set touch moving for mobile context menu
private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
invalidateContextMenu = true;
@@ -2773,6 +2862,7 @@ class App extends React.Component<AppProps, AppState> {
if (isPanning) {
return;
}
this.lastPointerDown = event;
this.setState({
lastPointerDownWith: event.pointerType,
@@ -2865,7 +2955,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.elementType,
pointerDownState,
);
} else {
} else if (this.state.elementType !== "eraser") {
this.createGenericElementOnPointerDown(
this.state.elementType,
pointerDownState,
@@ -2900,6 +2990,7 @@ class App extends React.Component<AppProps, AppState> {
) => {
this.lastPointerUp = event;
const isTouchScreen = ["pen", "touch"].includes(event.pointerType);
if (isTouchScreen) {
const scenePointer = viewportCoordsToSceneCoords(
{ clientX: event.clientX, clientY: event.clientY },
@@ -2914,6 +3005,8 @@ class App extends React.Component<AppProps, AppState> {
hitElement,
);
}
if (isEraserActive(this.state)) {
}
if (
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
@@ -3139,6 +3232,7 @@ class App extends React.Component<AppProps, AppState> {
boxSelection: {
hasOccurred: false,
},
elementIdsToErase: {},
};
}
@@ -3727,7 +3821,6 @@ class App extends React.Component<AppProps, AppState> {
),
);
}
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
@@ -3738,6 +3831,12 @@ class App extends React.Component<AppProps, AppState> {
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (isEraserActive(this.state)) {
this.handleEraser(event, pointerDownState, pointerCoords);
return;
}
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
@@ -4090,7 +4189,6 @@ class App extends React.Component<AppProps, AppState> {
isResizing,
isRotating,
} = this.state;
this.setState({
isResizing: false,
isRotating: false,
@@ -4311,6 +4409,33 @@ class App extends React.Component<AppProps, AppState> {
// Code below handles selection when element(s) weren't
// drag or added to selection on pointer down phase.
const hitElement = pointerDownState.hit.element;
if (isEraserActive(this.state)) {
const draggedDistance = distance2d(
this.lastPointerDown!.clientX,
this.lastPointerDown!.clientY,
this.lastPointerUp!.clientX,
this.lastPointerUp!.clientY,
);
if (draggedDistance === 0) {
const scenePointer = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUp!.clientX,
clientY: this.lastPointerUp!.clientY,
},
this.state,
);
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
);
pointerDownState.hit.element = hitElement;
}
this.eraseElements(pointerDownState);
return;
}
if (
hitElement &&
!pointerDownState.drag.hasOccurred &&
@@ -4450,6 +4575,27 @@ class App extends React.Component<AppProps, AppState> {
});
}
private eraseElements = (pointerDownState: PointerDownState) => {
const hitElement = pointerDownState.hit.element;
const elements = this.scene.getElements().map((ele) => {
if (pointerDownState.elementIdsToErase[ele.id]) {
return newElementWith(ele, { isDeleted: true });
} else if (hitElement && ele.id === hitElement.id) {
return newElementWith(ele, { isDeleted: true });
} else if (
isBoundToContainer(ele) &&
(pointerDownState.elementIdsToErase[ele.containerId] ||
(hitElement && ele.containerId === hitElement.id))
) {
return newElementWith(ele, { isDeleted: true });
}
return ele;
});
this.history.resumeRecording();
this.scene.replaceAllElements(elements);
};
private initializeImage = async ({
imageFile,
imageElement: _imageElement,

View File

@@ -255,7 +255,8 @@
color: #aaa;
}
.color-picker-type-elementStroke .color-picker-keybinding {
.color-picker-type-elementStroke .color-picker-keybinding,
.color-picker-type-elementFontColor .color-picker-keybinding {
color: #d4d4d4;
}

View File

@@ -101,19 +101,24 @@ const Picker = ({
onClose: () => void;
label: string;
showInput: boolean;
type: "canvasBackground" | "elementBackground" | "elementStroke";
type:
| "canvasBackground"
| "elementBackground"
| "elementStroke"
| "elementFontColor";
elements: readonly ExcalidrawElement[];
}) => {
const firstItem = React.useRef<HTMLButtonElement>();
const activeItem = React.useRef<HTMLButtonElement>();
const gallery = React.useRef<HTMLDivElement>();
const colorInput = React.useRef<HTMLInputElement>();
const colorType = type === "elementFontColor" ? "elementStroke" : type;
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
if (colorType === "canvasBackground") {
return [];
}
return getCustomColors(elements, type);
return getCustomColors(elements, colorType);
});
React.useEffect(() => {
@@ -356,7 +361,11 @@ export const ColorPicker = ({
elements,
appState,
}: {
type: "canvasBackground" | "elementBackground" | "elementStroke";
type:
| "canvasBackground"
| "elementBackground"
| "elementStroke"
| "elementFontColor";
color: string | null;
onChange: (color: string) => void;
label: string;
@@ -366,7 +375,7 @@ export const ColorPicker = ({
appState: AppState;
}) => {
const pickerButton = React.useRef<HTMLButtonElement>(null);
const colorType = type === "elementFontColor" ? "elementStroke" : type;
return (
<div>
<div className="color-picker-control-container">
@@ -393,7 +402,7 @@ export const ColorPicker = ({
}
>
<Picker
colors={colors[type]}
colors={colors[colorType]}
color={color || null}
onChange={(changedColor) => {
onChange(changedColor);

View File

@@ -11,6 +11,7 @@ import {
isTextElement,
} from "../element/typeChecks";
import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState";
interface HintViewerProps {
appState: AppState;
@@ -22,6 +23,9 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (isEraserActive(appState)) {
return t("hints.eraserRevert");
}
if (elementType === "arrow" || elementType === "line") {
if (!multiMode) {
return t("hints.linearElement");

View File

@@ -428,6 +428,14 @@ const LayerUI = ({
{actionManager.renderAction("redo", { size: "small" })}
</div>
)}
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</Section>
</Stack.Col>
</div>

View File

@@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { FixedSideContainer } from "./FixedSideContainer";
import { Island } from "./Island";
import { HintViewer } from "./HintViewer";
import { calculateScrollCenter } from "../scene";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import CollabButton from "./CollabButton";
@@ -113,6 +113,12 @@ export const MobileMenu = ({
};
const renderAppToolbar = () => {
// Render eraser conditionally in mobile
const showEraser =
!appState.viewModeEnabled &&
!appState.editingElement &&
getSelectedElements(elements, appState).length === 0;
if (viewModeEnabled) {
return (
<div className="App-toolbar-content">
@@ -120,12 +126,16 @@ export const MobileMenu = ({
</div>
);
}
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{showEraser && actionManager.renderAction("eraser")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}

View File

@@ -934,3 +934,7 @@ export const editIcon = createIcon(
></path>,
{ width: 640, height: 512 },
);
export const eraser = createIcon(
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
);

View File

@@ -63,8 +63,6 @@ export const ENV = {
export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
SHAPE_ACTIONS_MOBILE_MENU: "App-mobile-menu",
MOBILE_TOOLBAR: "App-toolbar-content",
};
// 1-based in case we ever do `if(element.fontFamily)`

View File

@@ -290,6 +290,16 @@
width: 100%;
box-sizing: border-box;
.eraser {
&.ToolIcon:hover {
--icon-fill-color: #fff;
--keybinding-color: #fff;
}
&.active {
background-color: var(--color-primary);
}
}
}
.App-toolbar-content {
@@ -467,7 +477,8 @@
font-family: var(--ui-font);
}
.undo-redo-buttons {
.undo-redo-buttons,
.eraser-buttons {
display: grid;
grid-auto-flow: column;
gap: 0.4em;

View File

@@ -31,8 +31,8 @@ type RestoredAppState = Omit<
>;
export const AllowedExcalidrawElementTypes: Record<
ExcalidrawElement["type"],
true
AppState["elementType"],
boolean
> = {
selection: true,
text: true,
@@ -43,6 +43,7 @@ export const AllowedExcalidrawElementTypes: Record<
image: true,
arrow: true,
freedraw: true,
eraser: false,
};
export type RestoredDataState = {

View File

@@ -1,4 +1,3 @@
import { SHAPES } from "../shapes";
import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
@@ -93,7 +92,7 @@ export const getDragOffsetXY = (
export const dragNewElement = (
draggingElement: NonDeletedExcalidrawElement,
elementType: typeof SHAPES[number]["value"],
elementType: AppState["elementType"],
originX: number,
originY: number,
x: number,

View File

@@ -10,5 +10,6 @@ export const showSelectedShapeActions = (
!appState.viewModeEnabled &&
(appState.editingElement ||
getSelectedElements(elements, appState).length ||
appState.elementType !== "selection"),
(appState.elementType !== "selection" &&
appState.elementType !== "eraser")),
);

View File

@@ -542,29 +542,9 @@ export const textWysiwyg = ({
target.closest(".color-picker-input") &&
isWritableElement(target);
const isShapeActionsPanel =
(target instanceof HTMLElement || target instanceof SVGElement) &&
(target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
target.closest(`.${CLASSES.SHAPE_ACTIONS_MOBILE_MENU}`) ||
target.closest(`.${CLASSES.MOBILE_TOOLBAR}`));
setTimeout(() => {
editable.onblur = () => {
app.setState({
toastMessage:
target instanceof HTMLElement
? target.tagName ?? "no tagName"
: "not an HTMLElement",
});
if (isShapeActionsPanel) {
return;
}
app.setState({
toastMessage: "debug: onblur",
});
handleSubmit();
};
if (target && (isTargetColorPicker || isShapeActionsPanel)) {
editable.onblur = handleSubmit;
if (target && isTargetColorPicker) {
target.onblur = () => {
editable.focus();
};
@@ -582,22 +562,13 @@ export const textWysiwyg = ({
event.target instanceof HTMLInputElement &&
event.target.closest(".color-picker-input") &&
isWritableElement(event.target);
const isShapeActionsPanel =
(event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
(event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MOBILE_MENU}`) ||
event.target.closest(`.${CLASSES.MOBILE_TOOLBAR}`));
if (
((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
isShapeActionsPanel &&
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
!isWritableElement(event.target)) ||
isTargetColorPicker
) {
app.setState({
toastMessage: "debug: onPointerDown",
});
editable.onblur = null;
window.addEventListener("pointerup", bindBlurEvent);
// handle edge-case where pointerup doesn't fire e.g. due to user

View File

@@ -1,3 +1,4 @@
import { AppState } from "../types";
import {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -60,7 +61,7 @@ export const isLinearElement = (
};
export const isLinearElementType = (
elementType: ExcalidrawElement["type"],
elementType: AppState["elementType"],
): boolean => {
return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
@@ -74,7 +75,7 @@ export const isBindingElement = (
};
export const isBindingElementType = (
elementType: ExcalidrawElement["type"],
elementType: AppState["elementType"],
): boolean => {
return elementType === "arrow";
};

View File

@@ -47,6 +47,7 @@ export const KEYS = {
COMMA: ",",
A: "a",
C: "c",
D: "d",
E: "e",
G: "g",

View File

@@ -16,6 +16,7 @@
"delete": "Delete",
"copyStyles": "Copy styles",
"pasteStyles": "Paste styles",
"fontColor": "Font color",
"stroke": "Stroke",
"background": "Background",
"fill": "Fill",
@@ -196,7 +197,8 @@
"library": "Library",
"lock": "Keep selected tool active after drawing",
"penMode": "Prevent pinch-zoom and accept freedraw input only from pen",
"link": "Add/ Update link for a selected shape"
"link": "Add/ Update link for a selected shape",
"eraser": "Eraser"
},
"headings": {
"canvasActions": "Canvas actions",
@@ -221,7 +223,8 @@
"placeImage": "Click to place the image, or click and drag to set its size manually",
"publishLibrary": "Publish your own library",
"bindTextToElement": "Press enter to add text",
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging"
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
"eraserRevert": "Hold Alt to revert the elements marked for deletion"
},
"canvasError": {
"cannotShowPreview": "Cannot show preview",

View File

@@ -56,7 +56,7 @@
"babel-loader": "8.2.3",
"babel-plugin-transform-class-properties": "6.24.1",
"cross-env": "7.0.3",
"css-loader": "6.6.0",
"css-loader": "6.7.1",
"mini-css-extract-plugin": "2.4.6",
"postcss-loader": "6.2.1",
"sass-loader": "12.4.0",

View File

@@ -2069,13 +2069,13 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"
css-loader@6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.6.0.tgz#c792ad5510bd1712618b49381bd0310574fafbd3"
integrity sha512-FK7H2lisOixPT406s5gZM1S3l8GrfhEBT3ZiL2UX1Ng1XWs0y2GPllz/OTyvbaHe12VgQrIXIzuEGVlbUhodqg==
css-loader@6.7.1:
version "6.7.1"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.7.1.tgz#e98106f154f6e1baf3fc3bc455cb9981c1d5fd2e"
integrity sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==
dependencies:
icss-utils "^5.1.0"
postcss "^8.4.5"
postcss "^8.4.7"
postcss-modules-extract-imports "^3.0.0"
postcss-modules-local-by-default "^4.0.0"
postcss-modules-scope "^3.0.0"
@@ -3150,10 +3150,10 @@ multicast-dns@^6.0.1:
dns-packet "^1.3.1"
thunky "^1.0.2"
nanoid@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==
nanoid@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35"
integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==
negotiator@0.6.2:
version "0.6.2"
@@ -3429,12 +3429,12 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.5:
version "8.4.6"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.6.tgz#c5ff3c3c457a23864f32cb45ac9b741498a09ae1"
integrity sha512-OovjwIzs9Te46vlEx7+uXB0PLijpwjXGKXjVGGPIGubGpq7uh5Xgf6D6FiJ/SzJMBosHDp6a2hiXOS97iBXcaA==
postcss@^8.4.7:
version "8.4.8"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.8.tgz#dad963a76e82c081a0657d3a2f3602ce10c2e032"
integrity sha512-2tXEqGxrjvAO6U+CJzDL2Fk2kPHTv1jQsYkSoMeOis2SsYaXRO2COxTdQp99cYvif9JTXaAk9lYGc3VhJt7JPQ==
dependencies:
nanoid "^3.2.0"
nanoid "^3.3.1"
picocolors "^1.0.0"
source-map-js "^1.0.2"

View File

@@ -44,10 +44,10 @@
"babel-loader": "8.2.3",
"babel-plugin-transform-class-properties": "6.24.1",
"cross-env": "7.0.3",
"css-loader": "6.5.1",
"css-loader": "6.7.1",
"file-loader": "6.2.0",
"sass-loader": "12.4.0",
"ts-loader": "9.2.6",
"ts-loader": "9.2.8",
"webpack": "5.66.0",
"webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.9.2"

View File

@@ -1565,11 +1565,6 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
colorette@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
colorette@^2.0.14:
version "2.0.16"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da"
@@ -1634,18 +1629,18 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"
css-loader@6.5.1:
version "6.5.1"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.5.1.tgz#0c43d4fbe0d97f699c91e9818cb585759091d1b1"
integrity sha512-gEy2w9AnJNnD9Kuo4XAP9VflW/ujKoS9c/syO+uWMlm5igc7LysKzPXaDoR2vroROkSwsTS2tGr1yGGEbZOYZQ==
css-loader@6.7.1:
version "6.7.1"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.7.1.tgz#e98106f154f6e1baf3fc3bc455cb9981c1d5fd2e"
integrity sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==
dependencies:
icss-utils "^5.1.0"
postcss "^8.2.15"
postcss "^8.4.7"
postcss-modules-extract-imports "^3.0.0"
postcss-modules-local-by-default "^4.0.0"
postcss-modules-scope "^3.0.0"
postcss-modules-values "^4.0.0"
postcss-value-parser "^4.1.0"
postcss-value-parser "^4.2.0"
semver "^7.3.5"
cssesc@^3.0.0:
@@ -2142,10 +2137,10 @@ ms@2.1.2:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
nanoid@^3.1.23:
version "3.2.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==
nanoid@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35"
integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==
neo-async@^2.6.2:
version "2.6.2"
@@ -2292,19 +2287,19 @@ postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4:
uniq "^1.0.1"
util-deprecate "^1.0.2"
postcss-value-parser@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==
postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.2.15:
version "8.3.0"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.0.tgz#b1a713f6172ca427e3f05ef1303de8b65683325f"
integrity sha512-+ogXpdAjWGa+fdYY5BQ96V/6tAo+TdSSIMP5huJBIygdWwKtVoB5JWZ7yUd4xZ8r+8Kvvx4nyg/PQ071H4UtcQ==
postcss@^8.4.7:
version "8.4.8"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.8.tgz#dad963a76e82c081a0657d3a2f3602ce10c2e032"
integrity sha512-2tXEqGxrjvAO6U+CJzDL2Fk2kPHTv1jQsYkSoMeOis2SsYaXRO2COxTdQp99cYvif9JTXaAk9lYGc3VhJt7JPQ==
dependencies:
colorette "^1.2.2"
nanoid "^3.1.23"
source-map-js "^0.6.2"
nanoid "^3.3.1"
picocolors "^1.0.0"
source-map-js "^1.0.2"
punycode@^2.1.0:
version "2.1.1"
@@ -2491,10 +2486,10 @@ sirv@^1.0.7:
mime "^2.3.1"
totalist "^1.0.0"
source-map-js@^0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e"
integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug==
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
source-map-support@~0.5.19:
version "0.5.19"
@@ -2605,10 +2600,10 @@ totalist@^1.0.0:
resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df"
integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==
ts-loader@9.2.6:
version "9.2.6"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.2.6.tgz#9937c4dd0a1e3dbbb5e433f8102a6601c6615d74"
integrity sha512-QMTC4UFzHmu9wU2VHZEmWWE9cUajjfcdcws+Gh7FhiO+Dy0RnR1bNz0YCHqhI0yRowCE9arVnNxYHqELOy9Hjw==
ts-loader@9.2.8:
version "9.2.8"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.2.8.tgz#e89aa32fa829c5cad0a1d023d6b3adecd51d5a48"
integrity sha512-gxSak7IHUuRtwKf3FIPSW1VpZcqF9+MBrHOvBp9cjHh+525SjtCIJKVGjRKIAfxBwDGDGCFF00rTfzB1quxdSw==
dependencies:
chalk "^4.1.0"
enhanced-resolve "^5.0.0"

View File

@@ -4,7 +4,7 @@ import {
} from "../element/types";
import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import { isBoundToContainer, isTextElement } from "../element/typeChecks";
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -41,12 +41,15 @@ export const getCommonAttributeOfSelectedElements = <T>(
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
onlyBoundTextElements: boolean = false,
): T | null => {
const attributes = Array.from(
new Set(
getSelectedElements(elements, appState).map((element) =>
getAttribute(element),
),
getSelectedElements(elements, appState, onlyBoundTextElements)
.filter((element) =>
onlyBoundTextElements ? isTextElement(element) : true,
)
.map((element) => getAttribute(element)),
),
);
return attributes.length === 1 ? attributes[0] : null;

View File

@@ -17592,4 +17592,4 @@ Object {
exports[`regression tests zoom hotkeys: [end of test] number of elements 1`] = `0`;
exports[`regression tests zoom hotkeys: [end of test] number of renders 1`] = `6`;
exports[`regression tests zoom hotkeys: [end of test] number of renders 1`] = `6`;

View File

@@ -77,7 +77,7 @@ export type AppState = {
// (e.g. text element when typing into the input)
editingElement: NonDeletedExcalidrawElement | null;
editingLinearElement: LinearElementEditor | null;
elementType: typeof SHAPES[number]["value"];
elementType: typeof SHAPES[number]["value"] | "eraser";
elementLocked: boolean;
penMode: boolean;
penDetected: boolean;
@@ -113,6 +113,7 @@ export type AppState = {
| "canvasColorPicker"
| "backgroundColorPicker"
| "strokeColorPicker"
| "fontColorPicker"
| null;
lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean };
@@ -384,6 +385,7 @@ export type PointerDownState = Readonly<{
boxSelection: {
hasOccurred: boolean;
};
elementIdsToErase: { [key: ExcalidrawElement["id"]]: boolean };
}>;
export type ExcalidrawImperativeAPI = {

View File

@@ -224,6 +224,9 @@ export const setCursorForShape = (
}
if (shape === "selection") {
resetCursor(canvas);
} else if (shape === "eraser") {
resetCursor(canvas);
// do nothing if image tool is selected which suggests there's
// a image-preview set as the cursor
} else if (shape !== "image") {