Compare commits

...

11 Commits

Author SHA1 Message Date
dwelle add575a419 feat: stop storing selection element into appState.draggingElement 2023-10-15 23:01:19 +02:00
mazijian-pp 44d9d5fcac fix: wysiwyg left in undefined state on reload (#7123) 2023-10-13 14:29:54 +02:00
Alex Kim 89a3bbddb7 test: add more resizing tests (#7028)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-12 20:59:02 +02:00
David Luzar b86184a849 fix: ensure relative z-index of elements added to frame is retained (#7134) 2023-10-12 15:00:23 +02:00
Barnabás Molnár b552166924 feat: new dark mode theme & light theme tweaks (#7104)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-12 14:58:33 +02:00
David Luzar 26ff3993bb feat: better laser cursor for dark mode (#7132) 2023-10-11 11:17:27 +02:00
David Luzar 7ad02c359a fix: memoize static canvas on props.renderConfig (#7131) 2023-10-10 23:31:23 +02:00
David Luzar 2523fe82e3 feat: laser pointer improvements (#7128) 2023-10-10 13:55:55 +02:00
zsviczian 4ea079eb85 fix: regression from #6739 preventing redirect link in view mode (#7120)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-09 12:26:49 +02:00
Ryan Di f20ba90ffa perf: improve element in frame check (#7124) 2023-10-09 16:32:27 +08:00
Emmanuel Ferdman 03da9112cf fix: update links to excalidraw-app (#7072) 2023-10-08 19:37:17 -05:00
49 changed files with 2230 additions and 1006 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
### Does this package support collaboration ?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/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
@@ -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**
![Aggresive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
![Aggressive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
3. Switch to **Block Fingerprinting**
+22
View File
@@ -0,0 +1,22 @@
# Frames
## Ordering
Frames should be ordered where frame children come first, followed by the frame element itself:
```
[
other_element,
frame1_child1,
frame1_child2,
frame1,
other_element,
frame2_child1,
frame2_child2,
frame2,
other_element,
...
]
```
If not oredered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.
+5 -1
View File
@@ -23,7 +23,11 @@ const sidebars = {
},
items: ["introduction/development", "introduction/contributing"],
},
{ type: "category", label: "Codebase", items: ["codebase/json-schema"] },
{
type: "category",
label: "Codebase",
items: ["codebase/json-schema", "codebase/frames"],
},
{
type: "category",
label: "@excalidraw/excalidraw",
+2 -1
View File
@@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement";
@@ -21,6 +21,7 @@ import {
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds";
import { setCursor } from "../cursor";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
+6 -2
View File
@@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { updateActiveTool, resetCursor } from "../utils";
import { updateActiveTool } from "../utils";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@@ -15,6 +15,7 @@ import {
} from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import { resetCursor } from "../cursor";
export const actionFinalize = register({
name: "finalize",
@@ -169,6 +170,7 @@ export const actionFinalize = register({
: activeTool,
activeEmbeddable: null,
draggingElement: null,
selectionElement: null,
multiElement: null,
editingElement: null,
startBoundElement: null,
@@ -195,7 +197,9 @@ export const actionFinalize = register({
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null ||
(!appState.draggingElement && appState.multiElement === null))) ||
(!appState.selectionElement &&
!appState.draggingElement &&
appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
+2 -1
View File
@@ -4,7 +4,8 @@ import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils";
import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
+1
View File
@@ -21,6 +21,7 @@ const writeData = (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.selectionElement &&
!appState.draggingElement
) {
const data = updater();
+2 -2
View File
@@ -2,13 +2,13 @@
.undo-redo-buttons {
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
}
.zoom-button,
.undo-redo-buttons button {
border: 1px solid var(--default-border-color) !important;
border-radius: 0 !important;
background-color: transparent !important;
background-color: var(--color-surface-low) !important;
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
+344 -302
View File
@@ -241,18 +241,14 @@ import {
isInputLike,
isToolIcon,
isWritableElement,
resetCursor,
resolvablePromise,
sceneCoordsToViewportCoords,
setCursor,
setCursorForShape,
tupleToCoors,
viewportCoordsToSceneCoords,
withBatchedUpdates,
wrapEvent,
withBatchedUpdatesThrottled,
updateObject,
setEraserCursor,
updateActiveTool,
getShortcutKey,
isTransparent,
@@ -371,6 +367,12 @@ import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache";
import { LaserToolOverlay } from "./LaserTool/LaserTool";
import { LaserPathManager } from "./LaserTool/LaserPathManager";
import {
setEraserCursor,
setCursor,
resetCursor,
setCursorForShape,
} from "../cursor";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -1155,6 +1157,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement ||
this.state.draggingElement ||
this.state.resizingElement ||
(this.state.activeTool.type === "laser" &&
// technically we can just test on this once we make it more safe
this.state.cursorButton === "down") ||
(this.state.editingElement &&
!isTextElement(this.state.editingElement))
? POINTER_EVENTS.disabled
@@ -3009,7 +3014,8 @@ class App extends React.Component<AppProps, AppState> {
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
this.state.draggingElement === null
!this.state.draggingElement &&
!this.state.selectionElement
) {
const shape = findShapeByKey(event.key);
if (shape) {
@@ -3338,6 +3344,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
draggingElement: null,
selectionElement: null,
editingElement: null,
});
if (this.state.activeTool.locked) {
@@ -4416,8 +4423,7 @@ class App extends React.Component<AppProps, AppState> {
// finger is lifted
if (
event.pointerType === "touch" &&
this.state.draggingElement &&
this.state.draggingElement.type === "freedraw"
this.state.draggingElement?.type === "freedraw"
) {
const element = this.state.draggingElement as ExcalidrawFreeDrawElement;
this.updateScene({
@@ -4429,6 +4435,7 @@ class App extends React.Component<AppProps, AppState> {
}
: {}),
appState: {
selectionElement: null,
draggingElement: null,
editingElement: null,
startBoundElement: null,
@@ -4479,12 +4486,15 @@ class App extends React.Component<AppProps, AppState> {
return;
}
this.lastPointerDownEvent = event;
// we must exit before we set `cursorButton` state and `savePointer`
// else it will send pointer state & laser pointer events in collab when
// panning
if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return;
}
this.lastPointerDownEvent = event;
this.setState({
lastPointerDownWith: event.pointerType,
cursorButton: "down",
@@ -4553,13 +4563,16 @@ class App extends React.Component<AppProps, AppState> {
// retrieve the latest element as the state may be stale
const pendingImageElement =
this.state.pendingImageElementId &&
this.scene.getElement(this.state.pendingImageElementId);
this.scene.getElement<ExcalidrawImageElement>(
this.state.pendingImageElementId,
);
if (!pendingImageElement) {
return;
}
this.setState({
selectionElement: null,
draggingElement: pendingImageElement,
editingElement: pendingImageElement,
pendingImageElementId: null,
@@ -5366,6 +5379,7 @@ class App extends React.Component<AppProps, AppState> {
);
this.scene.addNewElement(element);
this.setState({
selectionElement: null,
draggingElement: element,
editingElement: element,
startBoundElement: boundElement,
@@ -5585,6 +5599,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.addNewElement(element);
this.setState({
selectionElement: null,
draggingElement: element,
editingElement: element,
startBoundElement: boundElement,
@@ -5659,12 +5674,13 @@ class App extends React.Component<AppProps, AppState> {
if (element.type === "selection") {
this.setState({
selectionElement: element,
draggingElement: element,
draggingElement: null,
});
} else {
this.scene.addNewElement(element);
this.setState({
multiElement: null,
selectionElement: null,
draggingElement: element,
editingElement: element,
});
@@ -5697,6 +5713,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
multiElement: null,
selectionElement: null,
draggingElement: frame,
editingElement: frame,
});
@@ -5755,7 +5772,9 @@ class App extends React.Component<AppProps, AppState> {
if (this.maybeHandleResize(pointerDownState, event)) {
return;
}
this.maybeDragNewGenericElement(pointerDownState, event);
if (!this.maybeUpdateSelectionElement(pointerDownState, event)) {
this.maybeDragNewGenericElement(pointerDownState, event);
}
});
}
@@ -5768,7 +5787,9 @@ class App extends React.Component<AppProps, AppState> {
if (this.maybeHandleResize(pointerDownState, event)) {
return;
}
this.maybeDragNewGenericElement(pointerDownState, event);
if (!this.maybeUpdateSelectionElement(pointerDownState, event)) {
this.maybeDragNewGenericElement(pointerDownState, event);
}
});
}
@@ -6124,6 +6145,13 @@ class App extends React.Component<AppProps, AppState> {
}
}
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
if (this.maybeHandleBoxSelection(pointerDownState, event)) {
return;
}
// It is very important to read this.state within each move event,
// otherwise we would read a stale one!
const draggingElement = this.state.draggingElement;
@@ -6191,105 +6219,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event);
}
if (this.state.activeTool.type === "selection") {
pointerDownState.boxSelection.hasOccurred = true;
const elements = this.scene.getNonDeletedElements();
// box-select line editor points
if (this.state.editingLinearElement) {
LinearElementEditor.handleBoxSelection(
event,
this.state,
this.setState.bind(this),
);
// regular box-select
} else {
let shouldReuseSelection = true;
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
if (
pointerDownState.withCmdOrCtrl &&
pointerDownState.hit.element
) {
this.setState((prevState) =>
selectGroupsForSelectedElements(
{
...prevState,
selectedElementIds: {
[pointerDownState.hit.element!.id]: true,
},
},
this.scene.getNonDeletedElements(),
prevState,
this,
),
);
} else {
shouldReuseSelection = false;
}
}
const elementsWithinSelection = getElementsWithinSelection(
elements,
draggingElement,
);
this.setState((prevState) => {
const nextSelectedElementIds = {
...(shouldReuseSelection && prevState.selectedElementIds),
...elementsWithinSelection.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
acc[element.id] = true;
return acc;
},
{},
),
};
if (pointerDownState.hit.element) {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
if (!elementsWithinSelection.length) {
nextSelectedElementIds[pointerDownState.hit.element.id] = true;
} else {
delete nextSelectedElementIds[pointerDownState.hit.element.id];
}
}
prevState = !shouldReuseSelection
? { ...prevState, selectedGroupIds: {}, editingGroupId: null }
: prevState;
return {
...selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: nextSelectedElementIds,
},
this.scene.getNonDeletedElements(),
prevState,
this,
),
// select linear element only when we haven't box-selected anything else
selectedLinearElement:
elementsWithinSelection.length === 1 &&
isLinearElement(elementsWithinSelection[0])
? new LinearElementEditor(
elementsWithinSelection[0],
this.scene,
)
: null,
showHyperlinkPopup:
elementsWithinSelection.length === 1 &&
(elementsWithinSelection[0].link ||
isEmbeddableElement(elementsWithinSelection[0]))
? "info"
: false,
};
});
}
}
});
}
@@ -6550,6 +6479,7 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
draggingElement: null,
selectionElement: null,
activeTool: updateActiveTool(this.state, {
type: "selection",
}),
@@ -6568,6 +6498,7 @@ class App extends React.Component<AppProps, AppState> {
} else {
this.setState((prevState) => ({
draggingElement: null,
selectionElement: null,
}));
}
}
@@ -6587,140 +6518,137 @@ class App extends React.Component<AppProps, AppState> {
);
this.setState({
draggingElement: null,
selectionElement: null,
});
return;
}
if (draggingElement) {
if (pointerDownState.drag.hasOccurred) {
const sceneCoords = viewportCoordsToSceneCoords(
childEvent,
this.state,
if (pointerDownState.drag.hasOccurred) {
const sceneCoords = viewportCoordsToSceneCoords(childEvent, this.state);
// when editing the points of a linear element, we check if the
// linear element still is in the frame afterwards
// if not, the linear element will be removed from its frame (if any)
if (
this.state.selectedLinearElement &&
this.state.selectedLinearElement.isDragging
) {
const linearElement = this.scene.getElement(
this.state.selectedLinearElement.elementId,
);
// when editing the points of a linear element, we check if the
// linear element still is in the frame afterwards
// if not, the linear element will be removed from its frame (if any)
if (
this.state.selectedLinearElement &&
this.state.selectedLinearElement.isDragging
) {
const linearElement = this.scene.getElement(
this.state.selectedLinearElement.elementId,
);
if (linearElement?.frameId) {
const frame = getContainingFrame(linearElement);
if (linearElement?.frameId) {
const frame = getContainingFrame(linearElement);
if (frame && linearElement) {
if (!elementOverlapsWithFrame(linearElement, frame)) {
// remove the linear element from all groups
// before removing it from the frame as well
mutateElement(linearElement, {
groupIds: [],
});
if (frame && linearElement) {
if (!elementOverlapsWithFrame(linearElement, frame)) {
// remove the linear element from all groups
// before removing it from the frame as well
mutateElement(linearElement, {
groupIds: [],
});
this.scene.replaceAllElements(
removeElementsFromFrame(
this.scene.getElementsIncludingDeleted(),
[linearElement],
this.state,
),
);
}
this.scene.replaceAllElements(
removeElementsFromFrame(
this.scene.getElementsIncludingDeleted(),
[linearElement],
this.state,
),
);
}
}
} else {
// update the relationships between selected elements and frames
const topLayerFrame =
this.getTopLayerFrameAtSceneCoords(sceneCoords);
}
} else {
// update the relationships between selected elements and frames
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(sceneCoords);
const selectedElements = this.scene.getSelectedElements(this.state);
let nextElements = this.scene.getElementsIncludingDeleted();
const selectedElements = this.scene.getSelectedElements(this.state);
let nextElements = this.scene.getElementsIncludingDeleted();
const updateGroupIdsAfterEditingGroup = (
elements: ExcalidrawElement[],
) => {
if (elements.length > 0) {
for (const element of elements) {
const index = element.groupIds.indexOf(
this.state.editingGroupId!,
);
const updateGroupIdsAfterEditingGroup = (
elements: ExcalidrawElement[],
) => {
if (elements.length > 0) {
for (const element of elements) {
const index = element.groupIds.indexOf(
this.state.editingGroupId!,
);
mutateElement(
element,
{
groupIds: element.groupIds.slice(0, index),
},
false,
);
}
nextElements.forEach((element) => {
if (
element.groupIds.length &&
getElementsInGroup(
nextElements,
element.groupIds[element.groupIds.length - 1],
).length < 2
) {
mutateElement(
element,
{
groupIds: element.groupIds.slice(0, index),
groupIds: [],
},
false,
);
}
});
nextElements.forEach((element) => {
if (
element.groupIds.length &&
getElementsInGroup(
nextElements,
element.groupIds[element.groupIds.length - 1],
).length < 2
) {
mutateElement(
element,
{
groupIds: [],
},
false,
);
}
});
this.setState({
editingGroupId: null,
});
}
};
if (
topLayerFrame &&
!this.state.selectedElementIds[topLayerFrame.id]
) {
const elementsToAdd = selectedElements.filter(
(element) =>
element.frameId !== topLayerFrame.id &&
isElementInFrame(element, nextElements, this.state),
);
if (this.state.editingGroupId) {
updateGroupIdsAfterEditingGroup(elementsToAdd);
}
nextElements = addElementsToFrame(
nextElements,
elementsToAdd,
topLayerFrame,
);
} else if (!topLayerFrame) {
if (this.state.editingGroupId) {
const elementsToRemove = selectedElements.filter(
(element) =>
element.frameId &&
!isElementInFrame(element, nextElements, this.state),
);
updateGroupIdsAfterEditingGroup(elementsToRemove);
}
this.setState({
editingGroupId: null,
});
}
};
nextElements = updateFrameMembershipOfSelectedElements(
nextElements,
this.state,
this,
if (
topLayerFrame &&
!this.state.selectedElementIds[topLayerFrame.id]
) {
const elementsToAdd = selectedElements.filter(
(element) =>
element.frameId !== topLayerFrame.id &&
isElementInFrame(element, nextElements, this.state),
);
this.scene.replaceAllElements(nextElements);
}
}
if (this.state.editingGroupId) {
updateGroupIdsAfterEditingGroup(elementsToAdd);
}
nextElements = addElementsToFrame(
nextElements,
elementsToAdd,
topLayerFrame,
);
} else if (!topLayerFrame) {
if (this.state.editingGroupId) {
const elementsToRemove = selectedElements.filter(
(element) =>
element.frameId &&
!isElementInFrame(element, nextElements, this.state),
);
updateGroupIdsAfterEditingGroup(elementsToRemove);
}
}
nextElements = updateFrameMembershipOfSelectedElements(
nextElements,
this.state,
this,
);
this.scene.replaceAllElements(nextElements);
}
}
if (draggingElement) {
if (draggingElement.type === "frame") {
const elementsInsideFrame = getElementsInNewFrame(
this.scene.getElementsIncludingDeleted(),
@@ -7024,8 +6952,7 @@ class App extends React.Component<AppProps, AppState> {
if (
!activeTool.locked &&
activeTool.type !== "freedraw" &&
draggingElement &&
draggingElement.type !== "selection"
draggingElement
) {
this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds(
@@ -7064,12 +6991,14 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas);
this.setState({
draggingElement: null,
selectionElement: null,
suggestedBindings: [],
activeTool: updateActiveTool(this.state, { type: "selection" }),
});
} else {
this.setState({
draggingElement: null,
selectionElement: null,
suggestedBindings: [],
});
}
@@ -7868,6 +7797,139 @@ class App extends React.Component<AppProps, AppState> {
);
};
private maybeUpdateSelectionElement = (
pointerDownState: PointerDownState,
event: PointerEvent | KeyboardEvent,
): boolean => {
const { selectionElement } = this.state;
if (!selectionElement || this.state.activeTool.type !== "selection") {
return false;
}
const pointerCoords = pointerDownState.lastCoords;
dragNewElement(
selectionElement,
this.state.activeTool.type,
pointerDownState.origin.x,
pointerDownState.origin.y,
pointerCoords.x,
pointerCoords.y,
distance(pointerDownState.origin.x, pointerCoords.x),
distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
);
return true;
};
private maybeHandleBoxSelection = (
pointerDownState: PointerDownState,
event: PointerEvent,
): boolean => {
const { selectionElement } = this.state;
if (!selectionElement || this.state.activeTool.type !== "selection") {
return false;
}
this.maybeUpdateSelectionElement(pointerDownState, event);
pointerDownState.boxSelection.hasOccurred = true;
const elements = this.scene.getNonDeletedElements();
// box-select line editor points
if (this.state.editingLinearElement) {
LinearElementEditor.handleBoxSelection(
event,
this.state,
this.setState.bind(this),
);
// regular box-select
} else {
let shouldReuseSelection = true;
if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
if (pointerDownState.withCmdOrCtrl && pointerDownState.hit.element) {
this.setState((prevState) =>
selectGroupsForSelectedElements(
{
...prevState,
selectedElementIds: {
[pointerDownState.hit.element!.id]: true,
},
},
this.scene.getNonDeletedElements(),
prevState,
this,
),
);
} else {
shouldReuseSelection = false;
}
}
const elementsWithinSelection = getElementsWithinSelection(
elements,
selectionElement,
);
this.setState((prevState) => {
const nextSelectedElementIds = {
...(shouldReuseSelection && prevState.selectedElementIds),
...elementsWithinSelection.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
acc[element.id] = true;
return acc;
},
{},
),
};
if (pointerDownState.hit.element) {
// if using ctrl/cmd, select the hitElement only if we
// haven't box-selected anything else
if (!elementsWithinSelection.length) {
nextSelectedElementIds[pointerDownState.hit.element.id] = true;
} else {
delete nextSelectedElementIds[pointerDownState.hit.element.id];
}
}
prevState = !shouldReuseSelection
? { ...prevState, selectedGroupIds: {}, editingGroupId: null }
: prevState;
return {
...selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: nextSelectedElementIds,
},
this.scene.getNonDeletedElements(),
prevState,
this,
),
// select linear element only when we haven't box-selected anything else
selectedLinearElement:
elementsWithinSelection.length === 1 &&
isLinearElement(elementsWithinSelection[0])
? new LinearElementEditor(elementsWithinSelection[0], this.scene)
: null,
showHyperlinkPopup:
elementsWithinSelection.length === 1 &&
(elementsWithinSelection[0].link ||
isEmbeddableElement(elementsWithinSelection[0]))
? "info"
: false,
};
});
}
return true;
};
private maybeDragNewGenericElement = (
pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent,
@@ -7877,93 +7939,73 @@ class App extends React.Component<AppProps, AppState> {
if (!draggingElement) {
return;
}
if (
draggingElement.type === "selection" &&
this.state.activeTool.type !== "eraser"
) {
dragNewElement(
draggingElement,
this.state.activeTool.type,
pointerDownState.origin.x,
pointerDownState.origin.y,
pointerCoords.x,
pointerCoords.y,
distance(pointerDownState.origin.x, pointerCoords.x),
distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
);
} else {
let [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
let [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
);
const image =
isInitializedImageElement(draggingElement) &&
this.imageCache.get(draggingElement.fileId)?.image;
const aspectRatio =
image && !(image instanceof Promise)
? image.width / image.height
: null;
const image =
isInitializedImageElement(draggingElement) &&
this.imageCache.get(draggingElement.fileId)?.image;
const aspectRatio =
image && !(image instanceof Promise) ? image.width / image.height : null;
this.maybeCacheReferenceSnapPoints(event, [draggingElement]);
this.maybeCacheReferenceSnapPoints(event, [draggingElement]);
const { snapOffset, snapLines } = snapNewElement(
draggingElement,
this.state,
event,
{
x:
pointerDownState.originInGrid.x +
(this.state.originSnapOffset?.x ?? 0),
y:
pointerDownState.originInGrid.y +
(this.state.originSnapOffset?.y ?? 0),
},
{
x: gridX - pointerDownState.originInGrid.x,
y: gridY - pointerDownState.originInGrid.y,
},
);
const { snapOffset, snapLines } = snapNewElement(
draggingElement,
this.state,
event,
{
x:
pointerDownState.originInGrid.x +
(this.state.originSnapOffset?.x ?? 0),
y:
pointerDownState.originInGrid.y +
(this.state.originSnapOffset?.y ?? 0),
},
{
x: gridX - pointerDownState.originInGrid.x,
y: gridY - pointerDownState.originInGrid.y,
},
);
gridX += snapOffset.x;
gridY += snapOffset.y;
gridX += snapOffset.x;
gridY += snapOffset.y;
this.setState({
snapLines,
});
dragNewElement(
draggingElement,
this.state.activeTool.type,
pointerDownState.originInGrid.x,
pointerDownState.originInGrid.y,
gridX,
gridY,
distance(pointerDownState.originInGrid.x, gridX),
distance(pointerDownState.originInGrid.y, gridY),
isImageElement(draggingElement)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
aspectRatio,
this.state.originSnapOffset,
);
this.maybeSuggestBindingForAll([draggingElement]);
// highlight elements that are to be added to frames on frames creation
if (this.state.activeTool.type === "frame") {
this.setState({
snapLines,
elementsToHighlight: getElementsInResizingFrame(
this.scene.getNonDeletedElements(),
draggingElement as ExcalidrawFrameElement,
this.state,
),
});
dragNewElement(
draggingElement,
this.state.activeTool.type,
pointerDownState.originInGrid.x,
pointerDownState.originInGrid.y,
gridX,
gridY,
distance(pointerDownState.originInGrid.x, gridX),
distance(pointerDownState.originInGrid.y, gridY),
isImageElement(draggingElement)
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
shouldResizeFromCenter(event),
aspectRatio,
this.state.originSnapOffset,
);
this.maybeSuggestBindingForAll([draggingElement]);
// highlight elements that are to be added to frames on frames creation
if (this.state.activeTool.type === "frame") {
this.setState({
elementsToHighlight: getElementsInResizingFrame(
this.scene.getNonDeletedElements(),
draggingElement as ExcalidrawFrameElement,
this.state,
),
});
}
}
};
+9 -9
View File
@@ -12,32 +12,32 @@
&--color-primary {
&.ExcButton--variant-filled {
--text-color: var(--input-bg-color);
--text-color: var(--color-surface-lowest);
--back-color: var(--color-primary);
&:hover {
--back-color: var(--color-primary-darker);
--back-color: var(--color-brand-hover);
}
&:active {
--back-color: var(--color-primary-darkest);
--back-color: var(--color-brand-active);
}
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-primary);
--border-color: var(--color-primary);
--back-color: var(--input-bg-color);
--border-color: var(--color-border-outline);
--back-color: transparent;
&:hover {
--text-color: var(--color-primary-darker);
--border-color: var(--color-primary-darker);
--text-color: var(--color-brand-hover);
--border-color: var(--color-brand-hover);
}
&:active {
--text-color: var(--color-primary-darkest);
--border-color: var(--color-primary-darkest);
--text-color: var(--color-brand-active);
--border-color: var(--color-brand-active);
}
}
}
+16 -1
View File
@@ -19,20 +19,35 @@
}
&__btn {
--background: var(--color-surface-mid);
display: flex;
column-gap: 0.5rem;
align-items: center;
border: 1px solid var(--default-border-color);
background-color: var(--background);
padding: 0.625rem 1rem;
border: 1px solid var(--background);
border-radius: var(--border-radius-lg);
color: var(--text-primary-color);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.4px;
@at-root .excalidraw.theme--dark#{&} {
--background: var(--color-surface-high);
&:hover {
--background: #363541;
}
}
&:hover {
--background: var(--color-surface-high);
text-decoration: none;
}
&:active {
border-color: var(--color-primary);
}
}
&__link-icon {
+2 -1
View File
@@ -82,8 +82,9 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
if (activeTool.type === "selection") {
if (
appState.draggingElement?.type === "selection" &&
appState.selectionElement &&
!selectedElements.length &&
!appState.draggingElement &&
!appState.editingElement &&
!appState.editingLinearElement
) {
+2 -2
View File
@@ -99,10 +99,10 @@
font-size: 0.75rem;
&:hover {
background-color: var(--color-primary-darker);
background-color: var(--color-brand-hover);
}
&:active {
background-color: var(--color-primary-darkest);
background-color: var(--color-brand-active);
}
}
+8 -17
View File
@@ -1,27 +1,18 @@
@import "../css/variables.module";
.excalidraw {
--RadioGroup-background: #ffffff;
--RadioGroup-border: var(--color-gray-30);
--RadioGroup-background: var(--island-bg-color);
--RadioGroup-border: var(--color-surface-high);
--RadioGroup-choice-color-off: var(--color-primary);
--RadioGroup-choice-color-off-hover: var(--color-primary-darkest);
--RadioGroup-choice-background-off: white;
--RadioGroup-choice-background-off-active: var(--color-gray-20);
--RadioGroup-choice-color-off-hover: var(--color-brand-hover);
--RadioGroup-choice-background-off: var(--island-bg-color);
--RadioGroup-choice-background-off-active: var(--color-surface-high);
--RadioGroup-choice-color-on: white;
--RadioGroup-choice-color-on: var(--color-surface-lowest);
--RadioGroup-choice-background-on: var(--color-primary);
--RadioGroup-choice-background-on-hover: var(--color-primary-darker);
--RadioGroup-choice-background-on-active: var(--color-primary-darkest);
&.theme--dark {
--RadioGroup-background: var(--color-gray-85);
--RadioGroup-border: var(--color-gray-70);
--RadioGroup-choice-background-off: var(--color-gray-85);
--RadioGroup-choice-background-off-active: var(--color-gray-70);
--RadioGroup-choice-color-on: var(--color-gray-85);
}
--RadioGroup-choice-background-on-hover: var(--color-brand-hover);
--RadioGroup-choice-background-on-active: var(--color-brand-active);
.RadioGroup {
box-sizing: border-box;
+1 -2
View File
@@ -3,8 +3,7 @@
.excalidraw {
.sidebar-trigger {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
@include filledButtonOnCanvas;
width: auto;
height: var(--lg-button-size);
+16 -14
View File
@@ -1,15 +1,13 @@
@import "../css/variables.module";
.excalidraw {
--Switch-disabled-color: #d6d6d6;
--Switch-track-background: white;
--Switch-thumb-background: #3d3d3d;
&.theme--dark {
--Switch-disabled-color: #5c5c5c;
--Switch-track-background: #242424;
--Switch-thumb-background: #b8b8b8;
}
--Switch-disabled-color: var(--color-border-outline);
--Switch-disabled-toggled-background: var(--color-border-outline-variant);
--Switch-disabled-border: var(--color-border-outline-variant);
--Switch-track-background: var(--island-bg-color);
--Switch-thumb-background: var(--color-on-surface);
--Switch-hover-background: var(--color-brand-hover);
--Switch-active-background: var(--color-brand-active);
.Switch {
position: relative;
@@ -28,7 +26,11 @@
&:hover {
background: var(--Switch-track-background);
border: 1px solid #999999;
border: 1px solid var(--Switch-hover-background);
}
&:active {
border: 1px solid var(--Switch-active-background);
}
&.toggled {
@@ -43,11 +45,11 @@
&.disabled {
background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-border);
&.toggled {
background: var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-color);
background: var(--Switch-disabled-toggled-background);
border: 1px solid var(--Switch-disabled-toggled-background);
}
}
@@ -92,7 +94,7 @@
}
&.disabled.toggled:before {
background: var(--color-gray-50);
background: var(--Switch-disabled-color);
}
& input {
+12 -21
View File
@@ -1,25 +1,16 @@
@import "../css/variables.module";
.excalidraw {
--ExcTextField--color: var(--color-gray-80);
--ExcTextField--label-color: var(--color-gray-80);
--ExcTextField--background: white;
--ExcTextField--readonly--background: var(--color-gray-10);
--ExcTextField--readonly--color: var(--color-gray-80);
--ExcTextField--border: var(--color-gray-40);
--ExcTextField--border-hover: var(--color-gray-50);
--ExcTextField--placeholder: var(--color-gray-40);
&.theme--dark {
--ExcTextField--color: var(--color-gray-10);
--ExcTextField--label-color: var(--color-gray-20);
--ExcTextField--background: var(--color-gray-85);
--ExcTextField--readonly--background: var(--color-gray-80);
--ExcTextField--readonly--color: var(--color-gray-40);
--ExcTextField--border: var(--color-gray-70);
--ExcTextField--border-hover: var(--color-gray-60);
--ExcTextField--placeholder: var(--color-gray-80);
}
--ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-color: var(--color-on-surface);
--ExcTextField--background: transparent;
--ExcTextField--readonly--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-on-surface);
--ExcTextField--border: var(--color-border-outline);
--ExcTextField--readonly--border: var(--color-border-outline-variant);
--ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant);
.ExcTextField {
&--fullWidth {
@@ -61,7 +52,7 @@
&:active,
&:focus-within {
border-color: var(--color-primary);
border-color: var(--ExcTextField--border-active);
}
}
@@ -107,7 +98,7 @@
&--readonly {
background: var(--ExcTextField--readonly--background);
border-color: transparent;
border-color: var(--ExcTextField--readonly--border);
& input {
color: var(--ExcTextField--readonly--color);
-5
View File
@@ -97,10 +97,6 @@
}
}
// &:hover {
// background-color: var(--button-gray-2);
// }
&:active {
background-color: var(--button-gray-3);
}
@@ -110,7 +106,6 @@
}
&--hide {
// visibility: hidden;
display: none !important;
}
}
+1
View File
@@ -22,6 +22,7 @@
.App-toolbar__extra-tools-trigger {
box-shadow: none;
border: 0;
background-color: transparent;
&:active {
background-color: var(--button-hover-bg);
+7 -5
View File
@@ -114,11 +114,13 @@ const areEqual = (
return false;
}
return isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
return (
isShallowEqual(
// asserting AppState because we're being passed the whole AppState
// but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig)
);
};
+30 -14
View File
@@ -16,7 +16,7 @@
.dropdown-menu-container {
padding: 8px 8px;
box-sizing: border-box;
background-color: var(--island-bg-color);
// background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
@@ -29,7 +29,7 @@
}
.dropdown-menu-container {
background-color: #fff !important;
background-color: var(--island-bg-color);
max-height: calc(100vh - 150px);
overflow-y: auto;
--gap: 2;
@@ -40,7 +40,7 @@
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-gray-100);
color: var(--color-on-surface);
width: 100%;
box-sizing: border-box;
font-weight: normal;
@@ -49,7 +49,7 @@
.dropdown-menu-item {
background-color: transparent;
border: 0;
border: 1px solid transparent;
align-items: center;
height: 2rem;
cursor: pointer;
@@ -80,6 +80,11 @@
text-decoration: none;
}
&:active {
background-color: var(--button-hover-bg);
border-color: var(--color-brand-active);
}
svg {
width: 1rem;
height: 1rem;
@@ -98,22 +103,33 @@
font-weight: 500;
}
}
&.theme--dark {
.dropdown-menu-item {
color: var(--color-gray-40);
}
.dropdown-menu-container {
background-color: var(--color-gray-90) !important;
}
}
.dropdown-menu-button {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size);
height: var(--lg-button-size);
--background: var(--color-surface-mid);
background-color: var(--background);
@at-root .excalidraw.theme--dark#{&} {
--background: var(--color-surface-high);
&:hover {
--background: #363541;
}
}
&:hover {
--background: var(--color-surface-high);
background-color: var(--background);
text-decoration: none;
}
&:active {
border-color: var(--color-primary);
}
svg {
width: var(--lg-icon-size);
height: var(--lg-icon-size);
@@ -14,6 +14,8 @@
--button-active-bg: var(--color-primary-darker);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
flex-shrink: 0;
// double .active to force specificity
+1
View File
@@ -43,6 +43,7 @@ const MainMenu = Object.assign(
});
}}
data-testid="main-menu-trigger"
className="main-menu-trigger"
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
@@ -174,7 +174,7 @@
justify-content: space-between;
background: none;
border: none;
border: 1px solid transparent;
padding: 0.75rem;
@@ -204,7 +204,7 @@
.welcome-screen-menu-item:hover {
text-decoration: none;
background: var(--color-gray-10);
background: var(--button-hover-bg);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
@@ -216,7 +216,8 @@
}
.welcome-screen-menu-item:active {
background: var(--color-gray-20);
background: var(--button-hover-bg);
border-color: var(--color-brand-active);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
@@ -247,8 +248,7 @@
}
.welcome-screen-menu-item:hover {
background: var(--color-gray-85);
background-color: var(--color-surface-low);
.welcome-screen-menu-item__shortcut {
color: var(--color-gray-50);
}
@@ -259,7 +259,6 @@
}
.welcome-screen-menu-item:active {
background-color: var(--color-gray-90);
.welcome-screen-menu-item__text {
color: var(--color-gray-10);
}
+17 -2
View File
@@ -444,13 +444,14 @@
}
&:active {
border: 1px solid var(--color-primary-darkest);
border: 1px solid var(--button-active-border);
}
}
.help-icon {
@include outlineButtonStyles;
background-color: var(--island-bg-color);
@include filledButtonOnCanvas;
width: var(--lg-button-size);
height: var(--lg-button-size);
@@ -621,6 +622,20 @@
padding: 0;
}
}
.main-menu-trigger {
@include filledButtonOnCanvas;
}
.App-menu__left {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
@at-root .excalidraw.theme--dark#{&} {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}
}
}
.ErrorSplash.excalidraw {
+45 -23
View File
@@ -12,27 +12,30 @@
--dialog-border-color: var(--color-gray-20);
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-2};
--icon-fill-color: var(--color-gray-80);
--icon-fill-color: var(--color-on-surface);
--icon-green-fill-color: #{$oc-green-9};
--default-bg-color: #{$oc-white};
--input-bg-color: #{$oc-white};
--input-border-color: #{$oc-gray-4};
--input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7};
--island-bg-color: rgba(255, 255, 255, 0.96);
--island-bg-color: #ffffff;
--keybinding-color: var(--color-gray-40);
--link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
--popup-bg-color: #{$oc-white};
--popup-bg-color: var(--island-bg-color);
--popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--select-highlight-color: #{$oc-blue-5};
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
0px 0px 0.931014px rgba(0, 0, 0, 0.1702);
--button-hover-bg: var(--color-gray-10);
--default-border-color: var(--color-gray-30);
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high);
--button-active-border: var(--color-brand-active);
--default-border-color: var(--color-surface-high);
--default-button-size: 2rem;
--default-icon-size: 1rem;
@@ -63,14 +66,14 @@
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--sidebar-border-color: var(--color-gray-20);
--sidebar-bg-color: #fff;
--sidebar-border-color: var(--color-surface-high);
--sidebar-bg-color: var(--island-bg-color);
--library-dropdown-shadow: 0px 15px 6px rgba(0, 0, 0, 0.01),
0px 8px 5px rgba(0, 0, 0, 0.05), 0px 4px 4px rgba(0, 0, 0, 0.09),
0px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(0, 0, 0, 0.1);
--space-factor: 0.25rem;
--text-primary-color: var(--color-gray-80);
--text-primary-color: var(--color-on-surface);
--color-selection: #6965db;
@@ -132,6 +135,19 @@
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--color-surface-high: hsl(244, 100%, 97%);
--color-surface-mid: hsl(240 25% 96%);
--color-surface-low: hsl(240 25% 94%);
--color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0;
--color-on-primary-container: #030064;
--color-surface-primary-container: #e0dfff;
--color-brand-active: #4440bf;
--color-border-outline: #767680;
--color-border-outline-variant: #c5c5d0;
--color-surface-primary-container: #e0dfff;
&.theme--dark {
&.theme--dark-background-none {
background: none;
@@ -150,29 +166,24 @@
--dialog-border-color: var(--color-gray-80);
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
--focus-highlight-color: #{$oc-blue-6};
--icon-fill-color: var(--color-gray-40);
--icon-green-fill-color: #{$oc-green-4};
--default-bg-color: #121212;
--input-bg-color: #121212;
--input-border-color: #2e2e2e;
--input-hover-bg-color: #181818;
--input-label-color: #{$oc-gray-2};
--island-bg-color: #262627;
--island-bg-color: #232329;
--keybinding-color: var(--color-gray-60);
--link-color: #{$oc-blue-4};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c;
--popup-secondary-bg-color: #222;
--popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$oc-blue-4};
--text-primary-color: var(--color-gray-40);
--button-hover-bg: var(--color-gray-80);
--default-border-color: var(--color-gray-80);
--shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),
0px 1.13px 4.13211px rgba(0, 0, 0, 0.035),
0px 0.769896px 1.4945px rgba(0, 0, 0, 0.0243888);
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
@@ -180,8 +191,6 @@
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--avatar-border-color: var(--color-gray-85);
--sidebar-border-color: var(--color-gray-85);
--sidebar-bg-color: #191919;
--scrollbar-thumb: #{$oc-gray-8};
--scrollbar-thumb-hover: #{$oc-gray-7};
@@ -224,5 +233,18 @@
--color-promo: #d297ff;
--color-logo-text: #e2dfff;
--color-surface-high: hsl(245, 10%, 21%);
--color-surface-low: hsl(240, 8%, 15%);
--color-surface-mid: hsl(240 6% 10%);
--color-surface-lowest: hsl(0, 0%, 7%);
--color-on-surface: #e3e3e8;
--color-brand-hover: #bbb8ff;
--color-on-primary-container: #e0dfff;
--color-surface-primary-container: #403e6a;
--color-brand-active: #d0ccff;
--color-border-outline: #8e8d9c;
--color-border-outline-variant: #46464f;
--color-surface-primary-container: #403e6a;
}
}
+30 -10
View File
@@ -11,7 +11,7 @@
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon {
--icon-fill-color: var(--color-primary-darker);
--icon-fill-color: var(--color-on-primary-container);
svg {
fill: var(--icon-fill-color);
@@ -23,11 +23,11 @@
.ToolIcon_type_radio,
.ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon {
background: var(--color-primary-light);
--keybinding-color: var(--color-gray-60);
background: var(--color-surface-primary-container);
--keybinding-color: var(--color-on-primary-container);
svg {
color: var(--color-primary-darker);
color: var(--color-on-primary-container);
}
}
}
@@ -44,7 +44,11 @@
&:active {
background: var(--button-hover-bg);
border: 1px solid var(--color-primary-darkest);
border: 1px solid var(--button-active-border);
svg {
color: var(--color-on-primary-container);
}
}
}
}
@@ -63,7 +67,7 @@
border-radius: var(--border-radius-lg);
cursor: pointer;
background-color: var(--button-bg, var(--island-bg-color));
color: var(--button-color, var(--text-primary-color));
color: var(--button-color, var(--color-on-surface));
svg {
width: var(--button-width, var(--lg-icon-size));
@@ -88,22 +92,38 @@
}
&.active {
background-color: var(--button-selected-bg, var(--color-primary-light));
border-color: var(--button-selected-border, var(--color-primary-light));
background-color: var(
--button-selected-bg,
var(--color-surface-primary-container)
);
border-color: var(
--button-selected-border,
var(--color-surface-primary-container)
);
&:hover {
background-color: var(
--button-selected-hover-bg,
var(--color-primary-light)
var(--color-surface-primary-container)
);
}
svg {
color: var(--button-color, var(--color-primary-darker));
color: var(--button-color, var(--color-on-primary-container));
}
}
}
@mixin filledButtonOnCanvas {
border: none;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
background-color: var(--color-surface-low);
&:active {
box-shadow: 0 0 0 1px var(--color-brand-active);
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)";
$right-sidebar-width: "302px";
+103
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;
}
};
+10 -1
View File
@@ -189,7 +189,7 @@ const restoreElement = (
fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily);
}
const text = element.text ?? "";
const text = (typeof element.text === "string" && element.text) || "";
// line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams.
@@ -222,9 +222,17 @@ const restoreElement = (
baseline,
});
// if empty text, mark as deleted. We keep in array
// for data integrity purposes (collab etc.)
if (!text && !element.isDeleted) {
element = { ...element, originalText: text, isDeleted: true };
element = bumpVersion(element);
}
if (refreshDimensions) {
element = { ...element, ...refreshTextDimensions(element) };
}
return element;
case "freedraw": {
return restoreElementWithProperties(element, {
@@ -299,6 +307,7 @@ const restoreElement = (
// We also don't want to throw, but instead return void so we filter
// out these unsupported elements from the restored array.
}
return null;
};
/**
+1
View File
@@ -210,6 +210,7 @@ export const Hyperlink = ({
};
const { x, y } = getCoordsForPopover(element, appState);
if (
appState.selectionElement ||
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
+2 -1
View File
@@ -2,7 +2,8 @@ import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import { t } from "../i18n";
import { ExcalidrawProps } from "../types";
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks";
+2 -5
View File
@@ -134,10 +134,7 @@ export class LinearElementEditor {
appState: AppState,
setState: React.Component<any, AppState>["setState"],
) {
if (
!appState.editingLinearElement ||
appState.draggingElement?.type !== "selection"
) {
if (!appState.editingLinearElement || !appState.selectionElement) {
return false;
}
const { editingLinearElement } = appState;
@@ -149,7 +146,7 @@ export class LinearElementEditor {
}
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(appState.draggingElement);
getElementAbsoluteCoords(appState.selectionElement);
const pointsSceneCoords =
LinearElementEditor.getPointsGlobalCoordinates(element);
+2 -2
View File
@@ -140,8 +140,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
*
* NOTE: does not trigger re-render.
*/
export const bumpVersion = (
element: Mutable<ExcalidrawElement>,
export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
element: T,
version?: ExcalidrawElement["version"],
) => {
element.version = (version ?? element.version) + 1;
+8 -21
View File
@@ -17,7 +17,6 @@ import {
} from "./types";
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
// Unmount ReactDOM from root
@@ -953,7 +952,7 @@ describe("textWysiwyg", () => {
editor.blur();
// should center align horizontally and vertically by default
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
85,
@@ -977,7 +976,7 @@ describe("textWysiwyg", () => {
editor.blur();
// should left align horizontally and bottom vertically after resize
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
15,
@@ -999,7 +998,7 @@ describe("textWysiwyg", () => {
editor.blur();
// should right align horizontally and top vertically after resize
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[
374.99999999999994,
@@ -1049,7 +1048,7 @@ describe("textWysiwyg", () => {
expect(rectangle.height).toBe(75);
expect(textElement.fontSize).toBe(20);
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
shift: true,
});
expect(rectangle.width).toBe(200);
@@ -1189,7 +1188,7 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, "Hello");
editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBeCloseTo(155, 8);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
@@ -1513,28 +1512,16 @@ describe("textWysiwyg", () => {
});
});
it("should bump the version of labelled arrow when label updated", async () => {
it("should bump the version of a labeled arrow when the label is updated", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const arrow = UI.createElement("arrow", {
width: 300,
height: 0,
});
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
await UI.editText(arrow, "Hello");
const { version } = arrow;
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello\nworld!");
editor.blur();
await UI.editText(arrow, "Hello\nworld!");
expect(arrow.version).toEqual(version + 1);
});
+2 -1
View File
@@ -584,7 +584,7 @@ export const textWysiwyg = ({
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", bindBlurEvent);
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
unbindUpdate();
editable.remove();
@@ -701,6 +701,7 @@ export const textWysiwyg = ({
passive: false,
capture: true,
});
window.addEventListener("beforeunload", handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);
+120 -4
View File
@@ -177,7 +177,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, frame]);
});
it.skip("should add elements", async () => {
it("should add elements", async () => {
h.elements = [rect2, rect3, frame];
func(frame, rect2);
@@ -188,7 +188,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect3, rect2, frame]);
});
it.skip("should add elements when there are other other elements in between", async () => {
it("should add elements when there are other other elements in between", async () => {
h.elements = [rect1, rect2, rect4, rect3, frame];
func(frame, rect2);
@@ -199,7 +199,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect4, rect3, rect2, frame]);
});
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
it("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [rect3, rect4, rect2, rect1, frame];
func(frame, rect2);
@@ -234,7 +234,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]);
});
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
it("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [rect3, rect4, frame, rect2, rect1];
func(frame, rect2);
@@ -436,5 +436,121 @@ describe("adding elements to frames", () => {
expect(rect2.frameId).toBe(null);
expectEqualIds([rect2_copy, frame, rect2]);
});
it("random order 01", () => {
const frame1 = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const frame2 = API.createElement({
type: "frame",
x: 200,
y: 0,
width: 100,
height: 100,
});
const frame3 = API.createElement({
type: "frame",
x: 300,
y: 0,
width: 100,
height: 100,
});
const rectangle1 = API.createElement({
type: "rectangle",
x: 25,
y: 25,
width: 50,
height: 50,
frameId: frame1.id,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 225,
y: 25,
width: 50,
height: 50,
frameId: frame2.id,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 325,
y: 25,
width: 50,
height: 50,
frameId: frame3.id,
});
const rectangle4 = API.createElement({
type: "rectangle",
x: 350,
y: 25,
width: 50,
height: 50,
frameId: frame3.id,
});
h.elements = [
frame1,
rectangle4,
rectangle1,
rectangle3,
frame3,
rectangle2,
frame2,
];
API.setSelectedElements([rectangle2]);
const origSize = h.elements.length;
expect(h.elements.length).toBe(origSize);
dragElementIntoFrame(frame3, rectangle2);
expect(h.elements.length).toBe(origSize);
});
it("random order 02", () => {
const frame1 = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const frame2 = API.createElement({
type: "frame",
x: 200,
y: 0,
width: 100,
height: 100,
});
const rectangle1 = API.createElement({
type: "rectangle",
x: 25,
y: 25,
width: 50,
height: 50,
frameId: frame1.id,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 225,
y: 25,
width: 50,
height: 50,
frameId: frame2.id,
});
h.elements = [rectangle1, rectangle2, frame1, frame2];
API.setSelectedElements([rectangle2]);
expect(h.elements.length).toBe(4);
dragElementIntoFrame(frame2, rectangle1);
expect(h.elements.length).toBe(4);
});
});
});
+53 -9
View File
@@ -452,22 +452,31 @@ export const getContainingFrame = (
};
// --------------------------- Frame Operations -------------------------------
/**
* Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame:
* [el, el, child, child, frame, el]
*/
export const addElementsToFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement,
) => {
const currTargetFrameChildrenMap = new Map(
const { allElementsIndexMap, currTargetFrameChildrenMap } =
allElements.reduce(
(acc: [ExcalidrawElement["id"], ExcalidrawElement][], element) => {
(acc, element, index) => {
acc.allElementsIndexMap.set(element.id, index);
if (element.frameId === frame.id) {
acc.push([element.id, element]);
acc.currTargetFrameChildrenMap.set(element.id, true);
}
return acc;
},
[],
),
);
{
allElementsIndexMap: new Map<ExcalidrawElement["id"], number>(),
currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
},
);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
@@ -520,12 +529,36 @@ export const addElementsToFrame = (
currFrameChildren.forEach((child) => {
processedElements.add(child.id);
});
// console.log(currFrameChildren, finalElementsToAdd, element);
nextElements.push(...currFrameChildren, ...finalElementsToAdd, element);
// if not found, add all children on top by assigning the lowest index
const targetFrameIndex = allElementsIndexMap.get(frame.id) ?? -1;
const { newChildren_left, newChildren_right } = finalElementsToAdd.reduce(
(acc, element) => {
// if index not found, add on top of current frame children
const elementIndex = allElementsIndexMap.get(element.id) ?? Infinity;
if (elementIndex < targetFrameIndex) {
acc.newChildren_left.push(element);
} else {
acc.newChildren_right.push(element);
}
return acc;
},
{
newChildren_left: [] as ExcalidrawElement[],
newChildren_right: [] as ExcalidrawElement[],
},
);
nextElements.push(
...newChildren_left,
...currFrameChildren,
...newChildren_right,
element,
);
continue;
}
// console.log("(2)", element.frameId);
nextElements.push(element);
}
@@ -707,6 +740,17 @@ export const isElementInFrame = (
: element;
if (frame) {
// Perf improvement:
// For an element that's already in a frame, if it's not being dragged
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
// It has to be in its containing frame.
if (
!appState.selectedElementIds[element.id] ||
!appState.selectedElementsAreBeingDragged
) {
return true;
}
if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame);
}
@@ -5152,35 +5152,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
"draggingElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "hachure",
"frameId": null,
"groupIds": [],
"height": 0,
"id": "id3",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 2,
},
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "selection",
"updated": 1,
"version": 1,
"versionNonce": 0,
"width": 0,
"x": 500,
"y": 500,
},
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -5449,35 +5421,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "hachure",
"frameId": null,
"groupIds": [],
"height": 0,
"id": "id3",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 2,
},
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "selection",
"updated": 1,
"version": 1,
"versionNonce": 0,
"width": 0,
"x": 50,
"y": 50,
},
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -5718,35 +5662,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
"draggingElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "hachure",
"frameId": null,
"groupIds": [],
"height": 0,
"id": "id1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 2,
},
"seed": 2019559783,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "selection",
"updated": 1,
"version": 1,
"versionNonce": 0,
"width": 0,
"x": 110,
"y": 110,
},
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -16262,35 +16178,7 @@ exports[`regression tests > switches from group of selected elements to another
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
"draggingElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "hachure",
"frameId": null,
"groupIds": [],
"height": 0,
"id": "id4",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 2,
},
"seed": 493213705,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "selection",
"updated": 1,
"version": 1,
"versionNonce": 0,
"width": 0,
"x": 0,
"y": 0,
},
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -16662,35 +16550,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
"draggingElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "hachure",
"frameId": null,
"groupIds": [],
"height": 0,
"id": "id2",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 2,
},
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "selection",
"updated": 1,
"version": 1,
"versionNonce": 0,
"width": 0,
"x": 0,
"y": 0,
},
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -340,12 +340,12 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"groupIds": [],
"height": 100,
"id": "id-text01",
"isDeleted": false,
"isDeleted": true,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
"originalText": "test",
"originalText": "",
"roughness": 1,
"roundness": {
"type": 3,
@@ -358,8 +358,8 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"textAlign": "left",
"type": "text",
"updated": 1,
"version": 1,
"versionNonce": 0,
"version": 2,
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 100,
"x": 0,
+3
View File
@@ -86,12 +86,15 @@ describe("restoreElements", () => {
textElement.text = null;
textElement.font = "10 unknown";
expect(textElement.isDeleted).toBe(false);
const restoredText = restore.restoreElements(
[textElement],
null,
)[0] as ExcalidrawTextElement;
expect(restoredText.isDeleted).toBe(true);
expect(restoredText).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
});
});
+2 -1
View File
@@ -94,6 +94,7 @@ export class API {
angle?: number;
id?: string;
isDeleted?: boolean;
frameId?: ExcalidrawElement["id"];
groupIds?: string[];
// generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"];
@@ -151,12 +152,12 @@ export class API {
| "versionNonce"
| "isDeleted"
| "groupIds"
| "frameId"
| "link"
| "updated"
> = {
x,
y,
frameId: rest.frameId ?? null,
angle: rest.angle ?? 0,
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
backgroundColor:
+237 -42
View File
@@ -1,13 +1,37 @@
import {
import type { Point } from "../../types";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
ExcalidrawArrowElement,
ExcalidrawRectangleElement,
ExcalidrawEllipseElement,
ExcalidrawDiamondElement,
ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer,
} from "../../element/types";
import {
getTransformHandles,
getTransformHandlesFromCoords,
OMIT_SIDES_FOR_FRAME,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
TransformHandleType,
type TransformHandle,
type TransformHandleDirection,
} from "../../element/transformHandles";
import { KEYS } from "../../keys";
import { ToolName } from "../queries/toolQueries";
import { fireEvent, GlobalTestState } from "../test-utils";
import { type ToolName } from "../queries/toolQueries";
import { fireEvent, GlobalTestState, screen } from "../test-utils";
import { mutateElement } from "../../element/mutateElement";
import { API } from "./api";
import {
isFrameElement,
isLinearElement,
isFreeDrawElement,
isTextElement,
} from "../../element/typeChecks";
import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
import { rotatePoint } from "../../math";
const { h } = window;
@@ -86,6 +110,29 @@ export class Keyboard {
};
}
const getElementPointForSelection = (element: ExcalidrawElement): Point => {
const { x, y, width, height, angle } = element;
const target: Point = [
x +
(isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
y,
];
let center: Point;
if (isLinearElement(element)) {
const bounds = getElementPointsCoords(element, element.points);
center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2];
} else {
center = [x + width / 2, y + height / 2];
}
if (isTextElement(element)) {
return center;
}
return rotatePoint(target, center, angle);
};
export class Pointer {
public clientX = 0;
public clientY = 0;
@@ -199,31 +246,120 @@ export class Pointer {
elements: ExcalidrawElement | ExcalidrawElement[],
) {
API.clearSelection();
Keyboard.withModifierKeys({ shift: true }, () => {
elements = Array.isArray(elements) ? elements : [elements];
elements.forEach((element) => {
this.reset();
this.click(element.x, element.y);
this.click(...getElementPointForSelection(element));
});
});
this.reset();
}
clickOn(element: ExcalidrawElement) {
this.reset();
this.click(element.x, element.y);
this.click(...getElementPointForSelection(element));
this.reset();
}
doubleClickOn(element: ExcalidrawElement) {
this.reset();
this.doubleClick(element.x, element.y);
this.doubleClick(...getElementPointForSelection(element));
this.reset();
}
}
const mouse = new Pointer("mouse");
const transform = (
element: ExcalidrawElement | ExcalidrawElement[],
handle: TransformHandleType,
mouseMove: [deltaX: number, deltaY: number],
keyboardModifiers: KeyboardModifiers = {},
) => {
const elements = Array.isArray(element) ? element : [element];
mouse.select(elements);
let handleCoords: TransformHandle | undefined;
if (elements.length === 1) {
handleCoords = getTransformHandles(elements[0], h.state.zoom, "mouse")[
handle
];
} else {
const [x1, y1, x2, y2] = getCommonBounds(elements);
const isFrameSelected = elements.some(isFrameElement);
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
h.state.zoom,
"mouse",
isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
);
handleCoords = transformHandles[handle];
}
if (!handleCoords) {
throw new Error(`There is no "${handle}" handle for this selection`);
}
const clientX = handleCoords[0] + handleCoords[2] / 2;
const clientY = handleCoords[1] + handleCoords[3] / 2;
Keyboard.withModifierKeys(keyboardModifiers, () => {
mouse.reset();
mouse.down(clientX, clientY);
mouse.move(mouseMove[0], mouseMove[1]);
mouse.up();
});
};
const proxy = <T extends ExcalidrawElement>(
element: T,
): typeof element & {
/** Returns the actual, current element from the elements array, instead of
the proxy */
get(): typeof element;
} => {
return new Proxy(
{},
{
get(target, prop) {
const currentElement = h.elements.find(
({ id }) => id === element.id,
) as any;
if (prop === "get") {
if (currentElement.hasOwnProperty("get")) {
throw new Error(
"trying to get `get` test property, but ExcalidrawElement seems to define its own",
);
}
return () => currentElement;
}
return currentElement[prop];
},
},
) as any;
};
/** Tools that can be used to draw shapes */
type DrawingToolName = Exclude<ToolName, "lock" | "selection" | "eraser">;
type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
? ExcalidrawLinearElement
: T extends "arrow"
? ExcalidrawArrowElement
: T extends "text"
? ExcalidrawTextElement
: T extends "rectangle"
? ExcalidrawRectangleElement
: T extends "ellipse"
? ExcalidrawEllipseElement
: T extends "diamond"
? ExcalidrawDiamondElement
: ExcalidrawElement;
export class UI {
static clickTool = (toolName: ToolName) => {
fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
@@ -246,6 +382,10 @@ export class UI {
fireEvent.click(element);
};
static clickByTitle = (title: string) => {
fireEvent.click(screen.getByTitle(title));
};
/**
* Creates an Excalidraw element, and returns a proxy that wraps it so that
* accessing props will return the latest ones from the object existing in
@@ -255,16 +395,17 @@ export class UI {
* If you need to get the actual element, not the proxy, call `get()` method
* on the proxy object.
*/
static createElement<T extends ToolName>(
static createElement<T extends DrawingToolName>(
type: T,
{
position = 0,
x = position,
y = position,
size = 10,
width = size,
height = width,
width: initialWidth = size,
height: initialHeight = initialWidth,
angle = 0,
points: initialPoints,
}: {
position?: number;
x?: number;
@@ -273,25 +414,46 @@ export class UI {
width?: number;
height?: number;
angle?: number;
points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never;
} = {},
): (T extends "arrow" | "line" | "freedraw"
? ExcalidrawLinearElement
: T extends "text"
? ExcalidrawTextElement
: ExcalidrawElement) & {
): Element<T> & {
/** Returns the actual, current element from the elements array, instead
of the proxy */
get(): T extends "arrow" | "line" | "freedraw"
? ExcalidrawLinearElement
: T extends "text"
? ExcalidrawTextElement
: ExcalidrawElement;
get(): Element<T>;
} {
const width = initialWidth ?? initialHeight ?? size;
const height = initialHeight ?? size;
const points: Point[] = initialPoints ?? [
[0, 0],
[width, height],
];
UI.clickTool(type);
mouse.reset();
mouse.down(x, y);
mouse.reset();
mouse.up(x + (width ?? height ?? size), y + (height ?? size));
if (type === "text") {
mouse.reset();
mouse.click(x, y);
} else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => {
mouse.reset();
mouse.click(x + point[0], y + point[1]);
});
Keyboard.keyPress(KEYS.ESCAPE);
} else if (type === "freedraw" && points.length > 2) {
const firstPoint = points[0];
mouse.reset();
mouse.down(x + firstPoint[0], y + firstPoint[1]);
points
.slice(1)
.forEach((point) => mouse.moveTo(x + point[0], y + point[1]));
mouse.upAt();
Keyboard.keyPress(KEYS.ESCAPE);
} else {
mouse.reset();
mouse.down(x, y);
mouse.reset();
mouse.up(x + width, y + height);
}
const origElement = h.elements[h.elements.length - 1] as any;
@@ -299,25 +461,58 @@ export class UI {
mutateElement(origElement, { angle });
}
return new Proxy(
{},
{
get(target, prop) {
const currentElement = h.elements.find(
(element) => element.id === origElement.id,
) as any;
if (prop === "get") {
if (currentElement.hasOwnProperty("get")) {
throw new Error(
"trying to get `get` test property, but ExcalidrawElement seems to define its own",
);
}
return () => currentElement;
}
return currentElement[prop];
},
},
) as any;
return proxy(origElement);
}
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const openedEditor = document.querySelector<HTMLTextAreaElement>(
".excalidraw-textEditorContainer > textarea",
);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor =
openedEditor ??
document.querySelector<HTMLTextAreaElement>(
".excalidraw-textEditorContainer > textarea",
);
if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom");
}
fireEvent.input(editor, { target: { value: text } });
await new Promise((resolve) => setTimeout(resolve, 0));
editor.blur();
return isTextElement(element)
? element
: proxy(
h.elements[
h.elements.length - 1
] as ExcalidrawTextElementWithContainer,
);
}
static resize(
element: ExcalidrawElement | ExcalidrawElement[],
handle: TransformHandleDirection,
mouseMove: [deltaX: number, deltaY: number],
keyboardModifiers: KeyboardModifiers = {},
) {
return transform(element, handle, mouseMove, keyboardModifiers);
}
static rotate(
element: ExcalidrawElement | ExcalidrawElement[],
mouseMove: [deltaX: number, deltaY: number],
keyboardModifiers: KeyboardModifiers = {},
) {
return transform(element, "rotation", mouseMove, keyboardModifiers);
}
static group(elements: ExcalidrawElement[]) {
+5 -67
View File
@@ -16,7 +16,6 @@ import { Point } from "../types";
import { KEYS } from "../keys";
import { LinearElementEditor } from "../element/linearElementEditor";
import { queryByTestId, queryByText } from "@testing-library/react";
import { resize, rotate } from "./utils";
import {
getBoundTextElementPosition,
wrapText,
@@ -939,71 +938,10 @@ describe("Test Linear Elements", () => {
expect(line.boundElements).toBeNull();
});
it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => {
createThreePointerLinearElement("arrow", {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
});
const arrow = h.elements[0] as ExcalidrawLinearElement;
const { textElement, container } = createBoundTextElement(
DEFAULT_TEXT,
arrow,
);
expect(container.angle).toBe(0);
expect(textElement.angle).toBe(0);
expect(getBoundTextElementPosition(arrow, textElement))
.toMatchInlineSnapshot(`
{
"x": 75,
"y": 60,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
[
20,
20,
105,
80,
55.45893770831013,
45,
]
`);
rotate(container, -35, 55);
expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`);
expect(textElement.angle).toBe(0);
expect(getBoundTextElementPosition(container, textElement))
.toMatchInlineSnapshot(`
{
"x": 21.73926141863671,
"y": 73.31003398390868,
}
`);
expect(textElement.text).toMatchInlineSnapshot(`
"Online whiteboard
collaboration made
easy"
`);
expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
.toMatchInlineSnapshot(`
[
20,
20,
102.41961302274555,
86.49012635273976,
55.45893770831013,
45,
]
`);
});
// TODO fix #7029 and rewrite this test
it.todo(
"should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated",
);
it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => {
createThreePointerLinearElement("arrow", {
@@ -1042,7 +980,7 @@ describe("Test Linear Elements", () => {
]
`);
resize(container, "ne", [300, 200]);
UI.resize(container, "ne", [300, 200]);
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
+973 -123
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
import ReactDOM from "react-dom";
import { render } from "./test-utils";
import { reseed } from "../random";
import { UI } from "./helpers/ui";
import { Excalidraw } from "../packages/excalidraw/index";
import { expect } from "vitest";
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
beforeEach(() => {
localStorage.clear();
reseed(7);
});
test("unselected bound arrow updates when rotating its target element", async () => {
await render(<Excalidraw />);
const rectangle = UI.createElement("rectangle", {
width: 200,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: -80,
y: 50,
width: 70,
height: 0,
});
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
UI.rotate(rectangle, [60, 36], { shift: true });
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(110.7, 1);
expect(arrow.height).toBeCloseTo(0);
});
test("unselected bound arrows update when rotating their target elements", async () => {
await render(<Excalidraw />);
const ellipse = UI.createElement("ellipse", {
x: 0,
y: 80,
width: 300,
height: 120,
});
const ellipseArrow = UI.createElement("arrow", {
position: 0,
width: 40,
height: 80,
});
const text = UI.createElement("text", {
position: 220,
});
await UI.editText(text, "test");
const textArrow = UI.createElement("arrow", {
x: 360,
y: 300,
width: -100,
height: -40,
});
expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id);
expect(textArrow.endBinding?.elementId).toEqual(text.id);
UI.rotate([ellipse, text], [-82, 23], { shift: true });
expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id);
expect(ellipseArrow.x).toEqual(0);
expect(ellipseArrow.y).toEqual(0);
expect(ellipseArrow.points[0]).toEqual([0, 0]);
expect(ellipseArrow.points[1][0]).toBeCloseTo(48.5, 1);
expect(ellipseArrow.points[1][1]).toBeCloseTo(126.5, 1);
expect(textArrow.endBinding?.elementId).toEqual(text.id);
expect(textArrow.x).toEqual(360);
expect(textArrow.y).toEqual(300);
expect(textArrow.points[0]).toEqual([0, 0]);
expect(textArrow.points[1][0]).toBeCloseTo(-94, 1);
expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 1);
});
+1 -1
View File
@@ -480,7 +480,7 @@ describe("tool locking & selection", () => {
expect(h.state.activeTool.locked).toBe(true);
for (const { value } of Object.values(SHAPES)) {
if (value !== "image" && value !== "selection") {
if (value !== "image" && value !== "selection" && value !== "eraser") {
const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true);
}
-48
View File
@@ -1,48 +0,0 @@
import {
getTransformHandles,
TransformHandleDirection,
} from "../element/transformHandles";
import { ExcalidrawElement } from "../element/types";
import { Keyboard, KeyboardModifiers, Pointer } from "./helpers/ui";
const mouse = new Pointer("mouse");
const { h } = window;
export const resize = (
element: ExcalidrawElement,
handleDir: TransformHandleDirection,
mouseMove: [number, number],
keyboardModifiers: KeyboardModifiers = {},
) => {
mouse.select(element);
const handle = getTransformHandles(element, h.state.zoom, "mouse")[
handleDir
]!;
const clientX = handle[0] + handle[2] / 2;
const clientY = handle[1] + handle[3] / 2;
Keyboard.withModifierKeys(keyboardModifiers, () => {
mouse.reset();
mouse.down(clientX, clientY);
mouse.move(mouseMove[0], mouseMove[1]);
mouse.up();
});
};
export const rotate = (
element: ExcalidrawElement,
deltaX: number,
deltaY: number,
keyboardModifiers: KeyboardModifiers = {},
) => {
mouse.select(element);
const handle = getTransformHandles(element, h.state.zoom, "mouse").rotation!;
const clientX = handle[0] + handle[2] / 2;
const clientY = handle[1] + handle[3] / 2;
Keyboard.withModifierKeys(keyboardModifiers, () => {
mouse.reset();
mouse.down(clientX, clientY);
mouse.move(clientX + deltaX, clientY + deltaY);
mouse.up();
});
};
+25 -4
View File
@@ -17,6 +17,7 @@ import {
StrokeRoundness,
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
ExcalidrawSelectionElement,
} from "./element/types";
import { Point as RoughPoint } from "roughjs/bin/geometry";
import { LinearElementEditor } from "./element/linearElementEditor";
@@ -182,10 +183,25 @@ export type AppState = {
element: NonDeletedExcalidrawElement;
state: "hover" | "active";
} | null;
draggingElement: NonDeletedExcalidrawElement | null;
/** element that's being dragged or created */
draggingElement: Exclude<
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement
> | null;
/**
* Element that's being resized.
* NOTE not set when resizing a group or linear element
*/
resizingElement: NonDeletedExcalidrawElement | null;
/** multi-point linear element when it's being created */
multiElement: NonDeleted<ExcalidrawLinearElement> | null;
selectionElement: NonDeletedExcalidrawElement | null;
/**
* The selection box (we currently use an excalidraw element).
*
* Checking for this attribute is a good way to determine whether the user is
* selecting.
*/
selectionElement: ExcalidrawSelectionElement | null;
isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBindings: SuggestedBinding[];
@@ -198,9 +214,14 @@ export type AppState = {
};
editingFrame: string | null;
elementsToHighlight: NonDeleted<ExcalidrawElement>[] | null;
// element being edited, but not necessarily added to elements array yet
// (e.g. text element when typing into the input)
/**
* Text that's being element, or new element being created.
*/
editingElement: NonDeletedExcalidrawElement | null;
/**
* Linear element that's being edited (when in the linear element editor).
* Not set when creating multi-point linear element.
*/
editingLinearElement: LinearElementEditor | null;
activeTool: {
/**
+1 -84
View File
@@ -1,13 +1,9 @@
import oc from "open-color";
import { COLOR_PALETTE } from "./colors";
import {
CURSOR_TYPE,
DEFAULT_VERSION,
EVENT,
FONT_FAMILY,
isDarwin,
MIME_TYPES,
THEME,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
import {
@@ -15,9 +11,8 @@ import {
FontString,
NonDeletedExcalidrawElement,
} from "./element/types";
import { ActiveTool, AppState, DataURL, ToolType, Zoom } from "./types";
import { ActiveTool, AppState, ToolType, Zoom } from "./types";
import { unstable_batchedUpdates } from "react-dom";
import { isEraserActive, isHandToolActive } from "./appState";
import { ResolutionType } from "./utility-types";
import React from "react";
@@ -394,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 = () =>
document.fullscreenElement?.nodeName === "HTML";