Compare commits

...
49 changed files with 2230 additions and 1006 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
### Does this package support collaboration ? ### Does this package support collaboration ?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same. No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/excalidraw-app/index.tsx). Here is a [detailed answer](https://github.com/excalidraw/excalidraw/discussions/3879#discussioncomment-1110524) on how you can achieve the same.
### Turning off Aggressive Anti-Fingerprinting in Brave browser ### Turning off Aggressive Anti-Fingerprinting in Brave browser
@@ -18,7 +18,7 @@ We strongly recommend turning it off. You can follow the steps below on how to d
2. Once opened, look for **Aggressively Block Fingerprinting** 2. Once opened, look for **Aggressively Block Fingerprinting**
![Aggresive block fingerprinting](../../assets/aggressive-block-fingerprint.png) ![Aggressive block fingerprinting](../../assets/aggressive-block-fingerprint.png)
3. Switch to **Block Fingerprinting** 3. Switch to **Block Fingerprinting**
+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"], 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", type: "category",
label: "@excalidraw/excalidraw", label: "@excalidraw/excalidraw",
+2 -1
View File
@@ -10,7 +10,7 @@ import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types"; import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, setCursor, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
@@ -21,6 +21,7 @@ import {
} from "../appState"; } from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds"; import { Bounds } from "../element/bounds";
import { setCursor } from "../cursor";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
+6 -2
View File
@@ -1,6 +1,6 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element"; import { isInvisiblySmallElement } from "../element";
import { updateActiveTool, resetCursor } from "../utils"; import { updateActiveTool } from "../utils";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons"; import { done } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -15,6 +15,7 @@ import {
} from "../element/binding"; } from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types"; import { AppState } from "../types";
import { resetCursor } from "../cursor";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
@@ -169,6 +170,7 @@ export const actionFinalize = register({
: activeTool, : activeTool,
activeEmbeddable: null, activeEmbeddable: null,
draggingElement: null, draggingElement: null,
selectionElement: null,
multiElement: null, multiElement: null,
editingElement: null, editingElement: null,
startBoundElement: null, startBoundElement: null,
@@ -195,7 +197,9 @@ export const actionFinalize = register({
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null || (appState.editingLinearElement !== null ||
(!appState.draggingElement && appState.multiElement === null))) || (!appState.selectionElement &&
!appState.draggingElement &&
appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
+2 -1
View File
@@ -4,7 +4,8 @@ import { removeAllElementsFromFrame } from "../frame";
import { getFrameElements } from "../frame"; import { getFrameElements } from "../frame";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { setCursorForShape, updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register"; import { register } from "./register";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
+1
View File
@@ -21,6 +21,7 @@ const writeData = (
!appState.multiElement && !appState.multiElement &&
!appState.resizingElement && !appState.resizingElement &&
!appState.editingElement && !appState.editingElement &&
!appState.selectionElement &&
!appState.draggingElement !appState.draggingElement
) { ) {
const data = updater(); const data = updater();
+2 -2
View File
@@ -2,13 +2,13 @@
.undo-redo-buttons { .undo-redo-buttons {
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
} }
.zoom-button, .zoom-button,
.undo-redo-buttons button { .undo-redo-buttons button {
border: 1px solid var(--default-border-color) !important;
border-radius: 0 !important; border-radius: 0 !important;
background-color: transparent !important; background-color: var(--color-surface-low) !important;
font-size: 0.875rem !important; font-size: 0.875rem !important;
width: var(--lg-button-size); width: var(--lg-button-size);
height: var(--lg-button-size); height: var(--lg-button-size);
+344 -302
View File
@@ -241,18 +241,14 @@ import {
isInputLike, isInputLike,
isToolIcon, isToolIcon,
isWritableElement, isWritableElement,
resetCursor,
resolvablePromise, resolvablePromise,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
setCursor,
setCursorForShape,
tupleToCoors, tupleToCoors,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
withBatchedUpdates, withBatchedUpdates,
wrapEvent, wrapEvent,
withBatchedUpdatesThrottled, withBatchedUpdatesThrottled,
updateObject, updateObject,
setEraserCursor,
updateActiveTool, updateActiveTool,
getShortcutKey, getShortcutKey,
isTransparent, isTransparent,
@@ -371,6 +367,12 @@ import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { LaserToolOverlay } from "./LaserTool/LaserTool"; import { LaserToolOverlay } from "./LaserTool/LaserTool";
import { LaserPathManager } from "./LaserTool/LaserPathManager"; import { LaserPathManager } from "./LaserTool/LaserPathManager";
import {
setEraserCursor,
setCursor,
resetCursor,
setCursorForShape,
} from "../cursor";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -1155,6 +1157,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement || this.state.selectionElement ||
this.state.draggingElement || this.state.draggingElement ||
this.state.resizingElement || this.state.resizingElement ||
(this.state.activeTool.type === "laser" &&
// technically we can just test on this once we make it more safe
this.state.cursorButton === "down") ||
(this.state.editingElement && (this.state.editingElement &&
!isTextElement(this.state.editingElement)) !isTextElement(this.state.editingElement))
? POINTER_EVENTS.disabled ? POINTER_EVENTS.disabled
@@ -3009,7 +3014,8 @@ class App extends React.Component<AppProps, AppState> {
!event.ctrlKey && !event.ctrlKey &&
!event.altKey && !event.altKey &&
!event.metaKey && !event.metaKey &&
this.state.draggingElement === null !this.state.draggingElement &&
!this.state.selectionElement
) { ) {
const shape = findShapeByKey(event.key); const shape = findShapeByKey(event.key);
if (shape) { if (shape) {
@@ -3338,6 +3344,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
draggingElement: null, draggingElement: null,
selectionElement: null,
editingElement: null, editingElement: null,
}); });
if (this.state.activeTool.locked) { if (this.state.activeTool.locked) {
@@ -4416,8 +4423,7 @@ class App extends React.Component<AppProps, AppState> {
// finger is lifted // finger is lifted
if ( if (
event.pointerType === "touch" && event.pointerType === "touch" &&
this.state.draggingElement && this.state.draggingElement?.type === "freedraw"
this.state.draggingElement.type === "freedraw"
) { ) {
const element = this.state.draggingElement as ExcalidrawFreeDrawElement; const element = this.state.draggingElement as ExcalidrawFreeDrawElement;
this.updateScene({ this.updateScene({
@@ -4429,6 +4435,7 @@ class App extends React.Component<AppProps, AppState> {
} }
: {}), : {}),
appState: { appState: {
selectionElement: null,
draggingElement: null, draggingElement: null,
editingElement: null, editingElement: null,
startBoundElement: null, startBoundElement: null,
@@ -4479,12 +4486,15 @@ class App extends React.Component<AppProps, AppState> {
return; 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)) { if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
return; return;
} }
this.lastPointerDownEvent = event;
this.setState({ this.setState({
lastPointerDownWith: event.pointerType, lastPointerDownWith: event.pointerType,
cursorButton: "down", cursorButton: "down",
@@ -4553,13 +4563,16 @@ class App extends React.Component<AppProps, AppState> {
// retrieve the latest element as the state may be stale // retrieve the latest element as the state may be stale
const pendingImageElement = const pendingImageElement =
this.state.pendingImageElementId && this.state.pendingImageElementId &&
this.scene.getElement(this.state.pendingImageElementId); this.scene.getElement<ExcalidrawImageElement>(
this.state.pendingImageElementId,
);
if (!pendingImageElement) { if (!pendingImageElement) {
return; return;
} }
this.setState({ this.setState({
selectionElement: null,
draggingElement: pendingImageElement, draggingElement: pendingImageElement,
editingElement: pendingImageElement, editingElement: pendingImageElement,
pendingImageElementId: null, pendingImageElementId: null,
@@ -5366,6 +5379,7 @@ class App extends React.Component<AppProps, AppState> {
); );
this.scene.addNewElement(element); this.scene.addNewElement(element);
this.setState({ this.setState({
selectionElement: null,
draggingElement: element, draggingElement: element,
editingElement: element, editingElement: element,
startBoundElement: boundElement, startBoundElement: boundElement,
@@ -5585,6 +5599,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.addNewElement(element); this.scene.addNewElement(element);
this.setState({ this.setState({
selectionElement: null,
draggingElement: element, draggingElement: element,
editingElement: element, editingElement: element,
startBoundElement: boundElement, startBoundElement: boundElement,
@@ -5659,12 +5674,13 @@ class App extends React.Component<AppProps, AppState> {
if (element.type === "selection") { if (element.type === "selection") {
this.setState({ this.setState({
selectionElement: element, selectionElement: element,
draggingElement: element, draggingElement: null,
}); });
} else { } else {
this.scene.addNewElement(element); this.scene.addNewElement(element);
this.setState({ this.setState({
multiElement: null, multiElement: null,
selectionElement: null,
draggingElement: element, draggingElement: element,
editingElement: element, editingElement: element,
}); });
@@ -5697,6 +5713,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
multiElement: null, multiElement: null,
selectionElement: null,
draggingElement: frame, draggingElement: frame,
editingElement: frame, editingElement: frame,
}); });
@@ -5755,7 +5772,9 @@ class App extends React.Component<AppProps, AppState> {
if (this.maybeHandleResize(pointerDownState, event)) { if (this.maybeHandleResize(pointerDownState, event)) {
return; 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)) { if (this.maybeHandleResize(pointerDownState, event)) {
return; 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, // It is very important to read this.state within each move event,
// otherwise we would read a stale one! // otherwise we would read a stale one!
const draggingElement = this.state.draggingElement; const draggingElement = this.state.draggingElement;
@@ -6191,105 +6219,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event); 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); resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({ this.setState((prevState) => ({
draggingElement: null, draggingElement: null,
selectionElement: null,
activeTool: updateActiveTool(this.state, { activeTool: updateActiveTool(this.state, {
type: "selection", type: "selection",
}), }),
@@ -6568,6 +6498,7 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
this.setState((prevState) => ({ this.setState((prevState) => ({
draggingElement: null, draggingElement: null,
selectionElement: null,
})); }));
} }
} }
@@ -6587,140 +6518,137 @@ class App extends React.Component<AppProps, AppState> {
); );
this.setState({ this.setState({
draggingElement: null, draggingElement: null,
selectionElement: null,
}); });
return; return;
} }
if (draggingElement) { if (pointerDownState.drag.hasOccurred) {
if (pointerDownState.drag.hasOccurred) { const sceneCoords = viewportCoordsToSceneCoords(childEvent, this.state);
const sceneCoords = viewportCoordsToSceneCoords(
childEvent, // when editing the points of a linear element, we check if the
this.state, // 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 if (linearElement?.frameId) {
// linear element still is in the frame afterwards const frame = getContainingFrame(linearElement);
// 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) { if (frame && linearElement) {
const frame = getContainingFrame(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) { this.scene.replaceAllElements(
if (!elementOverlapsWithFrame(linearElement, frame)) { removeElementsFromFrame(
// remove the linear element from all groups this.scene.getElementsIncludingDeleted(),
// before removing it from the frame as well [linearElement],
mutateElement(linearElement, { this.state,
groupIds: [], ),
}); );
this.scene.replaceAllElements(
removeElementsFromFrame(
this.scene.getElementsIncludingDeleted(),
[linearElement],
this.state,
),
);
}
} }
} }
} else { }
// update the relationships between selected elements and frames } else {
const topLayerFrame = // update the relationships between selected elements and frames
this.getTopLayerFrameAtSceneCoords(sceneCoords); const topLayerFrame = this.getTopLayerFrameAtSceneCoords(sceneCoords);
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
let nextElements = this.scene.getElementsIncludingDeleted(); let nextElements = this.scene.getElementsIncludingDeleted();
const updateGroupIdsAfterEditingGroup = ( const updateGroupIdsAfterEditingGroup = (
elements: ExcalidrawElement[], elements: ExcalidrawElement[],
) => { ) => {
if (elements.length > 0) { if (elements.length > 0) {
for (const element of elements) { for (const element of elements) {
const index = element.groupIds.indexOf( const index = element.groupIds.indexOf(
this.state.editingGroupId!, 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( mutateElement(
element, element,
{ {
groupIds: element.groupIds.slice(0, index), groupIds: [],
}, },
false, false,
); );
} }
});
nextElements.forEach((element) => { this.setState({
if ( editingGroupId: null,
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);
}
} }
};
nextElements = updateFrameMembershipOfSelectedElements( if (
nextElements, topLayerFrame &&
this.state, !this.state.selectedElementIds[topLayerFrame.id]
this, ) {
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") { if (draggingElement.type === "frame") {
const elementsInsideFrame = getElementsInNewFrame( const elementsInsideFrame = getElementsInNewFrame(
this.scene.getElementsIncludingDeleted(), this.scene.getElementsIncludingDeleted(),
@@ -7024,8 +6952,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
!activeTool.locked && !activeTool.locked &&
activeTool.type !== "freedraw" && activeTool.type !== "freedraw" &&
draggingElement && draggingElement
draggingElement.type !== "selection"
) { ) {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
@@ -7064,12 +6991,14 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
this.setState({ this.setState({
draggingElement: null, draggingElement: null,
selectionElement: null,
suggestedBindings: [], suggestedBindings: [],
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, { type: "selection" }),
}); });
} else { } else {
this.setState({ this.setState({
draggingElement: null, draggingElement: null,
selectionElement: null,
suggestedBindings: [], 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 = ( private maybeDragNewGenericElement = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
event: MouseEvent | KeyboardEvent, event: MouseEvent | KeyboardEvent,
@@ -7877,93 +7939,73 @@ class App extends React.Component<AppProps, AppState> {
if (!draggingElement) { if (!draggingElement) {
return; return;
} }
if ( let [gridX, gridY] = getGridPoint(
draggingElement.type === "selection" && pointerCoords.x,
this.state.activeTool.type !== "eraser" pointerCoords.y,
) { event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
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,
);
const image = const image =
isInitializedImageElement(draggingElement) && isInitializedImageElement(draggingElement) &&
this.imageCache.get(draggingElement.fileId)?.image; this.imageCache.get(draggingElement.fileId)?.image;
const aspectRatio = const aspectRatio =
image && !(image instanceof Promise) image && !(image instanceof Promise) ? image.width / image.height : null;
? image.width / image.height
: null;
this.maybeCacheReferenceSnapPoints(event, [draggingElement]); this.maybeCacheReferenceSnapPoints(event, [draggingElement]);
const { snapOffset, snapLines } = snapNewElement( const { snapOffset, snapLines } = snapNewElement(
draggingElement, draggingElement,
this.state, this.state,
event, event,
{ {
x: x:
pointerDownState.originInGrid.x + pointerDownState.originInGrid.x +
(this.state.originSnapOffset?.x ?? 0), (this.state.originSnapOffset?.x ?? 0),
y: y:
pointerDownState.originInGrid.y + pointerDownState.originInGrid.y +
(this.state.originSnapOffset?.y ?? 0), (this.state.originSnapOffset?.y ?? 0),
}, },
{ {
x: gridX - pointerDownState.originInGrid.x, x: gridX - pointerDownState.originInGrid.x,
y: gridY - pointerDownState.originInGrid.y, y: gridY - pointerDownState.originInGrid.y,
}, },
); );
gridX += snapOffset.x; gridX += snapOffset.x;
gridY += snapOffset.y; 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({ 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 { &--color-primary {
&.ExcButton--variant-filled { &.ExcButton--variant-filled {
--text-color: var(--input-bg-color); --text-color: var(--color-surface-lowest);
--back-color: var(--color-primary); --back-color: var(--color-primary);
&:hover { &:hover {
--back-color: var(--color-primary-darker); --back-color: var(--color-brand-hover);
} }
&:active { &:active {
--back-color: var(--color-primary-darkest); --back-color: var(--color-brand-active);
} }
} }
&.ExcButton--variant-outlined, &.ExcButton--variant-outlined,
&.ExcButton--variant-icon { &.ExcButton--variant-icon {
--text-color: var(--color-primary); --text-color: var(--color-primary);
--border-color: var(--color-primary); --border-color: var(--color-border-outline);
--back-color: var(--input-bg-color); --back-color: transparent;
&:hover { &:hover {
--text-color: var(--color-primary-darker); --text-color: var(--color-brand-hover);
--border-color: var(--color-primary-darker); --border-color: var(--color-brand-hover);
} }
&:active { &:active {
--text-color: var(--color-primary-darkest); --text-color: var(--color-brand-active);
--border-color: var(--color-primary-darkest); --border-color: var(--color-brand-active);
} }
} }
} }
+16 -1
View File
@@ -19,20 +19,35 @@
} }
&__btn { &__btn {
--background: var(--color-surface-mid);
display: flex; display: flex;
column-gap: 0.5rem; column-gap: 0.5rem;
align-items: center; align-items: center;
border: 1px solid var(--default-border-color); background-color: var(--background);
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
border: 1px solid var(--background);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
color: var(--text-primary-color); color: var(--text-primary-color);
font-weight: 600; font-weight: 600;
font-size: 0.75rem; font-size: 0.75rem;
letter-spacing: 0.4px; letter-spacing: 0.4px;
@at-root .excalidraw.theme--dark#{&} {
--background: var(--color-surface-high);
&:hover {
--background: #363541;
}
}
&:hover { &:hover {
--background: var(--color-surface-high);
text-decoration: none; text-decoration: none;
} }
&:active {
border-color: var(--color-primary);
}
} }
&__link-icon { &__link-icon {
+2 -1
View File
@@ -82,8 +82,9 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
if (activeTool.type === "selection") { if (activeTool.type === "selection") {
if ( if (
appState.draggingElement?.type === "selection" && appState.selectionElement &&
!selectedElements.length && !selectedElements.length &&
!appState.draggingElement &&
!appState.editingElement && !appState.editingElement &&
!appState.editingLinearElement !appState.editingLinearElement
) { ) {
+2 -2
View File
@@ -99,10 +99,10 @@
font-size: 0.75rem; font-size: 0.75rem;
&:hover { &:hover {
background-color: var(--color-primary-darker); background-color: var(--color-brand-hover);
} }
&:active { &: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"; @import "../css/variables.module";
.excalidraw { .excalidraw {
--RadioGroup-background: #ffffff; --RadioGroup-background: var(--island-bg-color);
--RadioGroup-border: var(--color-gray-30); --RadioGroup-border: var(--color-surface-high);
--RadioGroup-choice-color-off: var(--color-primary); --RadioGroup-choice-color-off: var(--color-primary);
--RadioGroup-choice-color-off-hover: var(--color-primary-darkest); --RadioGroup-choice-color-off-hover: var(--color-brand-hover);
--RadioGroup-choice-background-off: white; --RadioGroup-choice-background-off: var(--island-bg-color);
--RadioGroup-choice-background-off-active: var(--color-gray-20); --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: var(--color-primary);
--RadioGroup-choice-background-on-hover: var(--color-primary-darker); --RadioGroup-choice-background-on-hover: var(--color-brand-hover);
--RadioGroup-choice-background-on-active: var(--color-primary-darkest); --RadioGroup-choice-background-on-active: var(--color-brand-active);
&.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 { .RadioGroup {
box-sizing: border-box; box-sizing: border-box;
+1 -2
View File
@@ -3,8 +3,7 @@
.excalidraw { .excalidraw {
.sidebar-trigger { .sidebar-trigger {
@include outlineButtonStyles; @include outlineButtonStyles;
@include filledButtonOnCanvas;
background-color: var(--island-bg-color);
width: auto; width: auto;
height: var(--lg-button-size); height: var(--lg-button-size);
+16 -14
View File
@@ -1,15 +1,13 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
--Switch-disabled-color: #d6d6d6; --Switch-disabled-color: var(--color-border-outline);
--Switch-track-background: white; --Switch-disabled-toggled-background: var(--color-border-outline-variant);
--Switch-thumb-background: #3d3d3d; --Switch-disabled-border: var(--color-border-outline-variant);
--Switch-track-background: var(--island-bg-color);
&.theme--dark { --Switch-thumb-background: var(--color-on-surface);
--Switch-disabled-color: #5c5c5c; --Switch-hover-background: var(--color-brand-hover);
--Switch-track-background: #242424; --Switch-active-background: var(--color-brand-active);
--Switch-thumb-background: #b8b8b8;
}
.Switch { .Switch {
position: relative; position: relative;
@@ -28,7 +26,11 @@
&:hover { &:hover {
background: var(--Switch-track-background); 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 { &.toggled {
@@ -43,11 +45,11 @@
&.disabled { &.disabled {
background: var(--Switch-track-background); background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-color); border: 1px solid var(--Switch-disabled-border);
&.toggled { &.toggled {
background: var(--Switch-disabled-color); background: var(--Switch-disabled-toggled-background);
border: 1px solid var(--Switch-disabled-color); border: 1px solid var(--Switch-disabled-toggled-background);
} }
} }
@@ -92,7 +94,7 @@
} }
&.disabled.toggled:before { &.disabled.toggled:before {
background: var(--color-gray-50); background: var(--Switch-disabled-color);
} }
& input { & input {
+12 -21
View File
@@ -1,25 +1,16 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .excalidraw {
--ExcTextField--color: var(--color-gray-80); --ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-color: var(--color-gray-80); --ExcTextField--label-color: var(--color-on-surface);
--ExcTextField--background: white; --ExcTextField--background: transparent;
--ExcTextField--readonly--background: var(--color-gray-10); --ExcTextField--readonly--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-gray-80); --ExcTextField--readonly--color: var(--color-on-surface);
--ExcTextField--border: var(--color-gray-40); --ExcTextField--border: var(--color-border-outline);
--ExcTextField--border-hover: var(--color-gray-50); --ExcTextField--readonly--border: var(--color-border-outline-variant);
--ExcTextField--placeholder: var(--color-gray-40); --ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active);
&.theme--dark { --ExcTextField--placeholder: var(--color-border-outline-variant);
--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 { .ExcTextField {
&--fullWidth { &--fullWidth {
@@ -61,7 +52,7 @@
&:active, &:active,
&:focus-within { &:focus-within {
border-color: var(--color-primary); border-color: var(--ExcTextField--border-active);
} }
} }
@@ -107,7 +98,7 @@
&--readonly { &--readonly {
background: var(--ExcTextField--readonly--background); background: var(--ExcTextField--readonly--background);
border-color: transparent; border-color: var(--ExcTextField--readonly--border);
& input { & input {
color: var(--ExcTextField--readonly--color); color: var(--ExcTextField--readonly--color);
-5
View File
@@ -97,10 +97,6 @@
} }
} }
// &:hover {
// background-color: var(--button-gray-2);
// }
&:active { &:active {
background-color: var(--button-gray-3); background-color: var(--button-gray-3);
} }
@@ -110,7 +106,6 @@
} }
&--hide { &--hide {
// visibility: hidden;
display: none !important; display: none !important;
} }
} }
+1
View File
@@ -22,6 +22,7 @@
.App-toolbar__extra-tools-trigger { .App-toolbar__extra-tools-trigger {
box-shadow: none; box-shadow: none;
border: 0; border: 0;
background-color: transparent;
&:active { &:active {
background-color: var(--button-hover-bg); background-color: var(--button-hover-bg);
+7 -5
View File
@@ -114,11 +114,13 @@ const areEqual = (
return false; return false;
} }
return isShallowEqual( return (
// asserting AppState because we're being passed the whole AppState isShallowEqual(
// but resolve to only the StaticCanvas-relevant props // asserting AppState because we're being passed the whole AppState
getRelevantAppStateProps(prevProps.appState as AppState), // but resolve to only the StaticCanvas-relevant props
getRelevantAppStateProps(nextProps.appState as AppState), getRelevantAppStateProps(prevProps.appState as AppState),
getRelevantAppStateProps(nextProps.appState as AppState),
) && isShallowEqual(prevProps.renderConfig, nextProps.renderConfig)
); );
}; };
+30 -14
View File
@@ -16,7 +16,7 @@
.dropdown-menu-container { .dropdown-menu-container {
padding: 8px 8px; padding: 8px 8px;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--island-bg-color); // background-color: var(--island-bg-color);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
position: relative; position: relative;
@@ -29,7 +29,7 @@
} }
.dropdown-menu-container { .dropdown-menu-container {
background-color: #fff !important; background-color: var(--island-bg-color);
max-height: calc(100vh - 150px); max-height: calc(100vh - 150px);
overflow-y: auto; overflow-y: auto;
--gap: 2; --gap: 2;
@@ -40,7 +40,7 @@
padding: 0 0.625rem; padding: 0 0.625rem;
column-gap: 0.625rem; column-gap: 0.625rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-gray-100); color: var(--color-on-surface);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
font-weight: normal; font-weight: normal;
@@ -49,7 +49,7 @@
.dropdown-menu-item { .dropdown-menu-item {
background-color: transparent; background-color: transparent;
border: 0; border: 1px solid transparent;
align-items: center; align-items: center;
height: 2rem; height: 2rem;
cursor: pointer; cursor: pointer;
@@ -80,6 +80,11 @@
text-decoration: none; text-decoration: none;
} }
&:active {
background-color: var(--button-hover-bg);
border-color: var(--color-brand-active);
}
svg { svg {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
@@ -98,22 +103,33 @@
font-weight: 500; 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 { .dropdown-menu-button {
@include outlineButtonStyles; @include outlineButtonStyles;
background-color: var(--island-bg-color);
width: var(--lg-button-size); width: var(--lg-button-size);
height: 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 { svg {
width: var(--lg-icon-size); width: var(--lg-icon-size);
height: var(--lg-icon-size); height: var(--lg-icon-size);
@@ -14,6 +14,8 @@
--button-active-bg: var(--color-primary-darker); --button-active-bg: var(--color-primary-darker);
box-shadow: 0 0 0 1px var(--color-surface-lowest);
flex-shrink: 0; flex-shrink: 0;
// double .active to force specificity // double .active to force specificity
+1
View File
@@ -43,6 +43,7 @@ const MainMenu = Object.assign(
}); });
}} }}
data-testid="main-menu-trigger" data-testid="main-menu-trigger"
className="main-menu-trigger"
> >
{HamburgerMenuIcon} {HamburgerMenuIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -174,7 +174,7 @@
justify-content: space-between; justify-content: space-between;
background: none; background: none;
border: none; border: 1px solid transparent;
padding: 0.75rem; padding: 0.75rem;
@@ -204,7 +204,7 @@
.welcome-screen-menu-item:hover { .welcome-screen-menu-item:hover {
text-decoration: none; text-decoration: none;
background: var(--color-gray-10); background: var(--button-hover-bg);
.welcome-screen-menu-item__shortcut { .welcome-screen-menu-item__shortcut {
color: var(--color-gray-50); color: var(--color-gray-50);
@@ -216,7 +216,8 @@
} }
.welcome-screen-menu-item:active { .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 { .welcome-screen-menu-item__shortcut {
color: var(--color-gray-50); color: var(--color-gray-50);
@@ -247,8 +248,7 @@
} }
.welcome-screen-menu-item:hover { .welcome-screen-menu-item:hover {
background: var(--color-gray-85); background-color: var(--color-surface-low);
.welcome-screen-menu-item__shortcut { .welcome-screen-menu-item__shortcut {
color: var(--color-gray-50); color: var(--color-gray-50);
} }
@@ -259,7 +259,6 @@
} }
.welcome-screen-menu-item:active { .welcome-screen-menu-item:active {
background-color: var(--color-gray-90);
.welcome-screen-menu-item__text { .welcome-screen-menu-item__text {
color: var(--color-gray-10); color: var(--color-gray-10);
} }
+17 -2
View File
@@ -444,13 +444,14 @@
} }
&:active { &:active {
border: 1px solid var(--color-primary-darkest); border: 1px solid var(--button-active-border);
} }
} }
.help-icon { .help-icon {
@include outlineButtonStyles; @include outlineButtonStyles;
background-color: var(--island-bg-color); @include filledButtonOnCanvas;
width: var(--lg-button-size); width: var(--lg-button-size);
height: var(--lg-button-size); height: var(--lg-button-size);
@@ -621,6 +622,20 @@
padding: 0; 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 { .ErrorSplash.excalidraw {
+45 -23
View File
@@ -12,27 +12,30 @@
--dialog-border-color: var(--color-gray-20); --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>'); --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}; --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}; --icon-green-fill-color: #{$oc-green-9};
--default-bg-color: #{$oc-white}; --default-bg-color: #{$oc-white};
--input-bg-color: #{$oc-white}; --input-bg-color: #{$oc-white};
--input-border-color: #{$oc-gray-4}; --input-border-color: #{$oc-gray-4};
--input-hover-bg-color: #{$oc-gray-1}; --input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7}; --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); --keybinding-color: var(--color-gray-40);
--link-color: #{$oc-blue-7}; --link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)}; --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-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black}; --popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white}; --popup-text-inverted-color: #{$oc-white};
--select-highlight-color: #{$oc-blue-5}; --select-highlight-color: #{$oc-blue-5};
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05), --shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 0px 0.931014px rgba(0, 0, 0, 0.1702); 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--button-hover-bg: var(--color-gray-10);
--default-border-color: var(--color-gray-30); --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-button-size: 2rem;
--default-icon-size: 1rem; --default-icon-size: 1rem;
@@ -63,14 +66,14 @@
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035), 0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725), 0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802); 0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--sidebar-border-color: var(--color-gray-20); --sidebar-border-color: var(--color-surface-high);
--sidebar-bg-color: #fff; --sidebar-bg-color: var(--island-bg-color);
--library-dropdown-shadow: 0px 15px 6px rgba(0, 0, 0, 0.01), --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 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); 0px 1px 2px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(0, 0, 0, 0.1);
--space-factor: 0.25rem; --space-factor: 0.25rem;
--text-primary-color: var(--color-gray-80); --text-primary-color: var(--color-on-surface);
--color-selection: #6965db; --color-selection: #6965db;
@@ -132,6 +135,19 @@
--border-radius-md: 0.375rem; --border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem; --border-radius-lg: 0.5rem;
--color-surface-high: hsl(244, 100%, 97%);
--color-surface-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 {
&.theme--dark-background-none { &.theme--dark-background-none {
background: none; background: none;
@@ -150,29 +166,24 @@
--dialog-border-color: var(--color-gray-80); --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>'); --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}; --focus-highlight-color: #{$oc-blue-6};
--icon-fill-color: var(--color-gray-40);
--icon-green-fill-color: #{$oc-green-4}; --icon-green-fill-color: #{$oc-green-4};
--default-bg-color: #121212; --default-bg-color: #121212;
--input-bg-color: #121212; --input-bg-color: #121212;
--input-border-color: #2e2e2e; --input-border-color: #2e2e2e;
--input-hover-bg-color: #181818; --input-hover-bg-color: #181818;
--input-label-color: #{$oc-gray-2}; --input-label-color: #{$oc-gray-2};
--island-bg-color: #262627; --island-bg-color: #232329;
--keybinding-color: var(--color-gray-60); --keybinding-color: var(--color-gray-60);
--link-color: #{$oc-blue-4}; --link-color: #{$oc-blue-4};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)}; --overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c;
--popup-secondary-bg-color: #222; --popup-secondary-bg-color: #222;
--popup-text-color: #{$oc-gray-4}; --popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c; --popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$oc-blue-4}; --select-highlight-color: #{$oc-blue-4};
--text-primary-color: var(--color-gray-40); --shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
--button-hover-bg: var(--color-gray-80); 0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
--default-border-color: var(--color-gray-80); 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),
0px 1.13px 4.13211px rgba(0, 0, 0, 0.035),
0px 0.769896px 1.4945px rgba(0, 0, 0, 0.0243888);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07), --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198), 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275), 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 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802); 0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
--avatar-border-color: var(--color-gray-85); --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: #{$oc-gray-8};
--scrollbar-thumb-hover: #{$oc-gray-7}; --scrollbar-thumb-hover: #{$oc-gray-7};
@@ -224,5 +233,18 @@
--color-promo: #d297ff; --color-promo: #d297ff;
--color-logo-text: #e2dfff; --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_radio,
.ToolIcon_type_checkbox { .ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon { &:checked + .ToolIcon__icon {
--icon-fill-color: var(--color-primary-darker); --icon-fill-color: var(--color-on-primary-container);
svg { svg {
fill: var(--icon-fill-color); fill: var(--icon-fill-color);
@@ -23,11 +23,11 @@
.ToolIcon_type_radio, .ToolIcon_type_radio,
.ToolIcon_type_checkbox { .ToolIcon_type_checkbox {
&:checked + .ToolIcon__icon { &:checked + .ToolIcon__icon {
background: var(--color-primary-light); background: var(--color-surface-primary-container);
--keybinding-color: var(--color-gray-60); --keybinding-color: var(--color-on-primary-container);
svg { svg {
color: var(--color-primary-darker); color: var(--color-on-primary-container);
} }
} }
} }
@@ -44,7 +44,11 @@
&:active { &:active {
background: var(--button-hover-bg); 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); border-radius: var(--border-radius-lg);
cursor: pointer; cursor: pointer;
background-color: var(--button-bg, var(--island-bg-color)); 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 { svg {
width: var(--button-width, var(--lg-icon-size)); width: var(--button-width, var(--lg-icon-size));
@@ -88,22 +92,38 @@
} }
&.active { &.active {
background-color: var(--button-selected-bg, var(--color-primary-light)); background-color: var(
border-color: var(--button-selected-border, var(--color-primary-light)); --button-selected-bg,
var(--color-surface-primary-container)
);
border-color: var(
--button-selected-border,
var(--color-surface-primary-container)
);
&:hover { &:hover {
background-color: var( background-color: var(
--button-selected-hover-bg, --button-selected-hover-bg,
var(--color-primary-light) var(--color-surface-primary-container)
); );
} }
svg { 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)"; $theme-filter: "invert(93%) hue-rotate(180deg)";
$right-sidebar-width: "302px"; $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); fontSize = parseFloat(fontPx);
fontFamily = getFontFamilyByName(_fontFamily); 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 // line-height might not be specified either when creating elements
// programmatically, or when importing old diagrams. // programmatically, or when importing old diagrams.
@@ -222,9 +222,17 @@ const restoreElement = (
baseline, 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) { if (refreshDimensions) {
element = { ...element, ...refreshTextDimensions(element) }; element = { ...element, ...refreshTextDimensions(element) };
} }
return element; return element;
case "freedraw": { case "freedraw": {
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@@ -299,6 +307,7 @@ const restoreElement = (
// We also don't want to throw, but instead return void so we filter // We also don't want to throw, but instead return void so we filter
// out these unsupported elements from the restored array. // 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); const { x, y } = getCoordsForPopover(element, appState);
if ( if (
appState.selectionElement ||
appState.draggingElement || appState.draggingElement ||
appState.resizingElement || appState.resizingElement ||
appState.isRotating || appState.isRotating ||
+2 -1
View File
@@ -2,7 +2,8 @@ import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import { t } from "../i18n"; import { t } from "../i18n";
import { ExcalidrawProps } from "../types"; import { ExcalidrawProps } from "../types";
import { getFontString, setCursorForShape, updateActiveTool } from "../utils"; import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement"; import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks"; import { isEmbeddableElement } from "./typeChecks";
+2 -5
View File
@@ -134,10 +134,7 @@ export class LinearElementEditor {
appState: AppState, appState: AppState,
setState: React.Component<any, AppState>["setState"], setState: React.Component<any, AppState>["setState"],
) { ) {
if ( if (!appState.editingLinearElement || !appState.selectionElement) {
!appState.editingLinearElement ||
appState.draggingElement?.type !== "selection"
) {
return false; return false;
} }
const { editingLinearElement } = appState; const { editingLinearElement } = appState;
@@ -149,7 +146,7 @@ export class LinearElementEditor {
} }
const [selectionX1, selectionY1, selectionX2, selectionY2] = const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(appState.draggingElement); getElementAbsoluteCoords(appState.selectionElement);
const pointsSceneCoords = const pointsSceneCoords =
LinearElementEditor.getPointsGlobalCoordinates(element); LinearElementEditor.getPointsGlobalCoordinates(element);
+2 -2
View File
@@ -140,8 +140,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
* *
* NOTE: does not trigger re-render. * NOTE: does not trigger re-render.
*/ */
export const bumpVersion = ( export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
element: Mutable<ExcalidrawElement>, element: T,
version?: ExcalidrawElement["version"], version?: ExcalidrawElement["version"],
) => { ) => {
element.version = (version ?? element.version) + 1; element.version = (version ?? element.version) + 1;
+8 -21
View File
@@ -17,7 +17,6 @@ import {
} from "./types"; } from "./types";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { resize } from "../tests/utils";
import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
// Unmount ReactDOM from root // Unmount ReactDOM from root
@@ -953,7 +952,7 @@ describe("textWysiwyg", () => {
editor.blur(); editor.blur();
// should center align horizontally and vertically by default // 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(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[ [
85, 85,
@@ -977,7 +976,7 @@ describe("textWysiwyg", () => {
editor.blur(); editor.blur();
// should left align horizontally and bottom vertically after resize // 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(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[ [
15, 15,
@@ -999,7 +998,7 @@ describe("textWysiwyg", () => {
editor.blur(); editor.blur();
// should right align horizontally and top vertically after resize // 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(` expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
[ [
374.99999999999994, 374.99999999999994,
@@ -1049,7 +1048,7 @@ describe("textWysiwyg", () => {
expect(rectangle.height).toBe(75); expect(rectangle.height).toBe(75);
expect(textElement.fontSize).toBe(20); 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, shift: true,
}); });
expect(rectangle.width).toBe(200); expect(rectangle.width).toBe(200);
@@ -1189,7 +1188,7 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, "Hello"); updateTextEditor(editor, "Hello");
editor.blur(); 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(rectangle.height).toBeCloseTo(155, 8);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null); 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} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
width: 300, width: 300,
height: 0, height: 0,
}); });
await UI.editText(arrow, "Hello");
mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER);
let editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
editor.blur();
const { version } = arrow; const { version } = arrow;
mouse.select(arrow); await UI.editText(arrow, "Hello\nworld!");
Keyboard.keyPress(KEYS.ENTER);
editor = getTextEditor();
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello\nworld!");
editor.blur();
expect(arrow.version).toEqual(version + 1); expect(arrow.version).toEqual(version + 1);
}); });
+2 -1
View File
@@ -584,7 +584,7 @@ export const textWysiwyg = ({
window.removeEventListener("pointerdown", onPointerDown); window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", bindBlurEvent); window.removeEventListener("pointerup", bindBlurEvent);
window.removeEventListener("blur", handleSubmit); window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
unbindUpdate(); unbindUpdate();
editable.remove(); editable.remove();
@@ -701,6 +701,7 @@ export const textWysiwyg = ({
passive: false, passive: false,
capture: true, capture: true,
}); });
window.addEventListener("beforeunload", handleSubmit);
excalidrawContainer excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")! ?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable); .appendChild(editable);
+120 -4
View File
@@ -177,7 +177,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, frame]); expectEqualIds([rect2, frame]);
}); });
it.skip("should add elements", async () => { it("should add elements", async () => {
h.elements = [rect2, rect3, frame]; h.elements = [rect2, rect3, frame];
func(frame, rect2); func(frame, rect2);
@@ -188,7 +188,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect3, rect2, frame]); 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]; h.elements = [rect1, rect2, rect4, rect3, frame];
func(frame, rect2); func(frame, rect2);
@@ -199,7 +199,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect4, rect3, rect2, frame]); 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]; h.elements = [rect3, rect4, rect2, rect1, frame];
func(frame, rect2); func(frame, rect2);
@@ -234,7 +234,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]); 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]; h.elements = [rect3, rect4, frame, rect2, rect1];
func(frame, rect2); func(frame, rect2);
@@ -436,5 +436,121 @@ describe("adding elements to frames", () => {
expect(rect2.frameId).toBe(null); expect(rect2.frameId).toBe(null);
expectEqualIds([rect2_copy, frame, rect2]); 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 ------------------------------- // --------------------------- 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 = ( export const addElementsToFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => { ) => {
const currTargetFrameChildrenMap = new Map( const { allElementsIndexMap, currTargetFrameChildrenMap } =
allElements.reduce( allElements.reduce(
(acc: [ExcalidrawElement["id"], ExcalidrawElement][], element) => { (acc, element, index) => {
acc.allElementsIndexMap.set(element.id, index);
if (element.frameId === frame.id) { if (element.frameId === frame.id) {
acc.push([element.id, element]); acc.currTargetFrameChildrenMap.set(element.id, true);
} }
return acc; return acc;
}, },
[], {
), allElementsIndexMap: new Map<ExcalidrawElement["id"], number>(),
); currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
},
);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id)); const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
@@ -520,12 +529,36 @@ export const addElementsToFrame = (
currFrameChildren.forEach((child) => { currFrameChildren.forEach((child) => {
processedElements.add(child.id); 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; continue;
} }
// console.log("(2)", element.frameId);
nextElements.push(element); nextElements.push(element);
} }
@@ -707,6 +740,17 @@ export const isElementInFrame = (
: element; : element;
if (frame) { if (frame) {
// Perf improvement:
// For an element that's already in a frame, if it's not being dragged
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
// It has to be in its containing frame.
if (
!appState.selectedElementIds[element.id] ||
!appState.selectedElementsAreBeingDragged
) {
return true;
}
if (_element.groupIds.length === 0) { if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame); return elementOverlapsWithFrame(_element, frame);
} }
@@ -5152,35 +5152,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "down", "cursorButton": "down",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"draggingElement": { "draggingElement": null,
"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,
},
"editingElement": null, "editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
@@ -5449,35 +5421,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "up", "cursorButton": "up",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"draggingElement": { "draggingElement": null,
"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,
},
"editingElement": null, "editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
@@ -5718,35 +5662,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "down", "cursorButton": "down",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"draggingElement": { "draggingElement": null,
"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,
},
"editingElement": null, "editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
@@ -16262,35 +16178,7 @@ exports[`regression tests > switches from group of selected elements to another
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "down", "cursorButton": "down",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"draggingElement": { "draggingElement": null,
"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,
},
"editingElement": null, "editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
@@ -16662,35 +16550,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"currentItemTextAlign": "left", "currentItemTextAlign": "left",
"cursorButton": "down", "cursorButton": "down",
"defaultSidebarDockedPreference": false, "defaultSidebarDockedPreference": false,
"draggingElement": { "draggingElement": null,
"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,
},
"editingElement": null, "editingElement": null,
"editingFrame": null, "editingFrame": null,
"editingGroupId": null, "editingGroupId": null,
@@ -340,12 +340,12 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",
"isDeleted": false, "isDeleted": true,
"lineHeight": 1.25, "lineHeight": 1.25,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"originalText": "test", "originalText": "",
"roughness": 1, "roughness": 1,
"roundness": { "roundness": {
"type": 3, "type": 3,
@@ -358,8 +358,8 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"textAlign": "left", "textAlign": "left",
"type": "text", "type": "text",
"updated": 1, "updated": 1,
"version": 1, "version": 2,
"versionNonce": 0, "versionNonce": Any<Number>,
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 0, "x": 0,
+3
View File
@@ -86,12 +86,15 @@ describe("restoreElements", () => {
textElement.text = null; textElement.text = null;
textElement.font = "10 unknown"; textElement.font = "10 unknown";
expect(textElement.isDeleted).toBe(false);
const restoredText = restore.restoreElements( const restoredText = restore.restoreElements(
[textElement], [textElement],
null, null,
)[0] as ExcalidrawTextElement; )[0] as ExcalidrawTextElement;
expect(restoredText.isDeleted).toBe(true);
expect(restoredText).toMatchSnapshot({ expect(restoredText).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),
versionNonce: expect.any(Number),
}); });
}); });
+2 -1
View File
@@ -94,6 +94,7 @@ export class API {
angle?: number; angle?: number;
id?: string; id?: string;
isDeleted?: boolean; isDeleted?: boolean;
frameId?: ExcalidrawElement["id"];
groupIds?: string[]; groupIds?: string[];
// generic element props // generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"]; strokeColor?: ExcalidrawGenericElement["strokeColor"];
@@ -151,12 +152,12 @@ export class API {
| "versionNonce" | "versionNonce"
| "isDeleted" | "isDeleted"
| "groupIds" | "groupIds"
| "frameId"
| "link" | "link"
| "updated" | "updated"
> = { > = {
x, x,
y, y,
frameId: rest.frameId ?? null,
angle: rest.angle ?? 0, angle: rest.angle ?? 0,
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor, strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
backgroundColor: backgroundColor:
+237 -42
View File
@@ -1,13 +1,37 @@
import { import type { Point } from "../../types";
import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawArrowElement,
ExcalidrawRectangleElement,
ExcalidrawEllipseElement,
ExcalidrawDiamondElement,
ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer,
} from "../../element/types"; } 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 { KEYS } from "../../keys";
import { ToolName } from "../queries/toolQueries"; import { type ToolName } from "../queries/toolQueries";
import { fireEvent, GlobalTestState } from "../test-utils"; import { fireEvent, GlobalTestState, screen } from "../test-utils";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { API } from "./api"; 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; 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 { export class Pointer {
public clientX = 0; public clientX = 0;
public clientY = 0; public clientY = 0;
@@ -199,31 +246,120 @@ export class Pointer {
elements: ExcalidrawElement | ExcalidrawElement[], elements: ExcalidrawElement | ExcalidrawElement[],
) { ) {
API.clearSelection(); API.clearSelection();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
elements = Array.isArray(elements) ? elements : [elements]; elements = Array.isArray(elements) ? elements : [elements];
elements.forEach((element) => { elements.forEach((element) => {
this.reset(); this.reset();
this.click(element.x, element.y); this.click(...getElementPointForSelection(element));
}); });
}); });
this.reset(); this.reset();
} }
clickOn(element: ExcalidrawElement) { clickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.click(element.x, element.y); this.click(...getElementPointForSelection(element));
this.reset(); this.reset();
} }
doubleClickOn(element: ExcalidrawElement) { doubleClickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.doubleClick(element.x, element.y); this.doubleClick(...getElementPointForSelection(element));
this.reset(); this.reset();
} }
} }
const mouse = new Pointer("mouse"); 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 { export class UI {
static clickTool = (toolName: ToolName) => { static clickTool = (toolName: ToolName) => {
fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName)); fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
@@ -246,6 +382,10 @@ export class UI {
fireEvent.click(element); 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 * 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 * 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 * If you need to get the actual element, not the proxy, call `get()` method
* on the proxy object. * on the proxy object.
*/ */
static createElement<T extends ToolName>( static createElement<T extends DrawingToolName>(
type: T, type: T,
{ {
position = 0, position = 0,
x = position, x = position,
y = position, y = position,
size = 10, size = 10,
width = size, width: initialWidth = size,
height = width, height: initialHeight = initialWidth,
angle = 0, angle = 0,
points: initialPoints,
}: { }: {
position?: number; position?: number;
x?: number; x?: number;
@@ -273,25 +414,46 @@ export class UI {
width?: number; width?: number;
height?: number; height?: number;
angle?: number; angle?: number;
points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never;
} = {}, } = {},
): (T extends "arrow" | "line" | "freedraw" ): Element<T> & {
? ExcalidrawLinearElement
: T extends "text"
? ExcalidrawTextElement
: ExcalidrawElement) & {
/** Returns the actual, current element from the elements array, instead /** Returns the actual, current element from the elements array, instead
of the proxy */ of the proxy */
get(): T extends "arrow" | "line" | "freedraw" get(): Element<T>;
? ExcalidrawLinearElement
: T extends "text"
? ExcalidrawTextElement
: ExcalidrawElement;
} { } {
const width = initialWidth ?? initialHeight ?? size;
const height = initialHeight ?? size;
const points: Point[] = initialPoints ?? [
[0, 0],
[width, height],
];
UI.clickTool(type); UI.clickTool(type);
mouse.reset();
mouse.down(x, y); if (type === "text") {
mouse.reset(); mouse.reset();
mouse.up(x + (width ?? height ?? size), y + (height ?? size)); 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; const origElement = h.elements[h.elements.length - 1] as any;
@@ -299,25 +461,58 @@ export class UI {
mutateElement(origElement, { angle }); mutateElement(origElement, { angle });
} }
return new Proxy( return proxy(origElement);
{}, }
{
get(target, prop) { static async editText<
const currentElement = h.elements.find( T extends ExcalidrawTextElement | ExcalidrawTextContainer,
(element) => element.id === origElement.id, >(element: T, text: string) {
) as any; const openedEditor = document.querySelector<HTMLTextAreaElement>(
if (prop === "get") { ".excalidraw-textEditorContainer > textarea",
if (currentElement.hasOwnProperty("get")) { );
throw new Error(
"trying to get `get` test property, but ExcalidrawElement seems to define its own", if (!openedEditor) {
); mouse.select(element);
} Keyboard.keyPress(KEYS.ENTER);
return () => currentElement; }
}
return currentElement[prop]; const editor =
}, openedEditor ??
}, document.querySelector<HTMLTextAreaElement>(
) as any; ".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[]) { static group(elements: ExcalidrawElement[]) {
+5 -67
View File
@@ -16,7 +16,6 @@ import { Point } from "../types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { queryByTestId, queryByText } from "@testing-library/react"; import { queryByTestId, queryByText } from "@testing-library/react";
import { resize, rotate } from "./utils";
import { import {
getBoundTextElementPosition, getBoundTextElementPosition,
wrapText, wrapText,
@@ -939,71 +938,10 @@ describe("Test Linear Elements", () => {
expect(line.boundElements).toBeNull(); expect(line.boundElements).toBeNull();
}); });
it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => { // TODO fix #7029 and rewrite this test
createThreePointerLinearElement("arrow", { it.todo(
type: ROUNDNESS.PROPORTIONAL_RADIUS, "should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated",
}); );
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,
]
`);
});
it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => { it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => {
createThreePointerLinearElement("arrow", { 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 }) expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(` .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); expect(h.state.activeTool.locked).toBe(true);
for (const { value } of Object.values(SHAPES)) { for (const { value } of Object.values(SHAPES)) {
if (value !== "image" && value !== "selection") { if (value !== "image" && value !== "selection" && value !== "eraser") {
const element = UI.createElement(value); const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true); 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, StrokeRoundness,
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
ExcalidrawSelectionElement,
} from "./element/types"; } from "./element/types";
import { Point as RoughPoint } from "roughjs/bin/geometry"; import { Point as RoughPoint } from "roughjs/bin/geometry";
import { LinearElementEditor } from "./element/linearElementEditor"; import { LinearElementEditor } from "./element/linearElementEditor";
@@ -182,10 +183,25 @@ export type AppState = {
element: NonDeletedExcalidrawElement; element: NonDeletedExcalidrawElement;
state: "hover" | "active"; state: "hover" | "active";
} | null; } | 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; resizingElement: NonDeletedExcalidrawElement | null;
/** multi-point linear element when it's being created */
multiElement: NonDeleted<ExcalidrawLinearElement> | null; 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; isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null; startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBindings: SuggestedBinding[]; suggestedBindings: SuggestedBinding[];
@@ -198,9 +214,14 @@ export type AppState = {
}; };
editingFrame: string | null; editingFrame: string | null;
elementsToHighlight: NonDeleted<ExcalidrawElement>[] | 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; 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; editingLinearElement: LinearElementEditor | null;
activeTool: { activeTool: {
/** /**
+1 -84
View File
@@ -1,13 +1,9 @@
import oc from "open-color";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
import { import {
CURSOR_TYPE,
DEFAULT_VERSION, DEFAULT_VERSION,
EVENT, EVENT,
FONT_FAMILY, FONT_FAMILY,
isDarwin, isDarwin,
MIME_TYPES,
THEME,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
import { import {
@@ -15,9 +11,8 @@ import {
FontString, FontString,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } 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 { unstable_batchedUpdates } from "react-dom";
import { isEraserActive, isHandToolActive } from "./appState";
import { ResolutionType } from "./utility-types"; import { ResolutionType } from "./utility-types";
import React from "react"; 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 = () => export const isFullScreen = () =>
document.fullscreenElement?.nodeName === "HTML"; document.fullscreenElement?.nodeName === "HTML";