+
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
- {
-
- }
+ {renderGitHubCorner()}
{renderFooter()}
);
diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx
index 1500de55f..6c94dbd58 100644
--- a/src/components/MobileMenu.tsx
+++ b/src/components/MobileMenu.tsx
@@ -29,6 +29,7 @@ type MobileMenuProps = {
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
+ viewModeEnabled: boolean;
};
export const MobileMenu = ({
@@ -43,121 +44,164 @@ export const MobileMenu = ({
canvas,
isCollaborating,
renderCustomFooter,
-}: MobileMenuProps) => (
- <>
-
-
- {(heading) => (
-
-
-
- {heading}
-
-
-
-
-
-
- {libraryMenu}
-
- )}
-
-
-
-
-
- {appState.openMenu === "canvas" ? (
-
-
-
- {actionManager.renderAction("loadScene")}
- {actionManager.renderAction("saveScene")}
- {actionManager.renderAction("saveAsScene")}
- {exportButton}
- {actionManager.renderAction("clearCanvas")}
- {onCollabButtonClick && (
-
- )}
- {
+ const renderFixedSideContainer = () => {
+ return (
+
+
+ {(heading) => (
+
+
+
+ {heading}
+
+
+
+
+
- {renderCustomFooter?.(true)}
-
-
-
-
- ) : appState.openMenu === "shape" &&
- showSelectedShapeActions(appState, elements) ? (
-
- ) : null}
-
-
-
- >
-);
+
+
+
+ );
+ };
+
+ const renderAppToolbar = () => {
+ if (viewModeEnabled) {
+ return (
+
+ {actionManager.renderAction("toggleCanvasMenu")}
+
+ );
+ }
+ return (
+
+ {actionManager.renderAction("toggleCanvasMenu")}
+ {actionManager.renderAction("toggleEditMenu")}
+ {actionManager.renderAction("undo")}
+ {actionManager.renderAction("redo")}
+ {actionManager.renderAction(
+ appState.multiElement ? "finalize" : "duplicateSelection",
+ )}
+ {actionManager.renderAction("deleteSelectedElements")}
+
+ );
+ };
+
+ const renderCanvasActions = () => {
+ if (viewModeEnabled) {
+ return (
+ <>
+ {actionManager.renderAction("saveScene")}
+ {actionManager.renderAction("saveAsScene")}
+ {exportButton}
+ >
+ );
+ }
+ return (
+ <>
+ {actionManager.renderAction("loadScene")}
+ {actionManager.renderAction("saveScene")}
+ {actionManager.renderAction("saveAsScene")}
+ {exportButton}
+ {actionManager.renderAction("clearCanvas")}
+ {onCollabButtonClick && (
+
+ )}
+ {
+
+ }
+ >
+ );
+ };
+ return (
+ <>
+ {!viewModeEnabled && renderFixedSideContainer()}
+
+
+ {appState.openMenu === "canvas" ? (
+
+
+
+ {renderCanvasActions()}
+ {renderCustomFooter?.(true)}
+
+
+
+
+ ) : appState.openMenu === "shape" &&
+ !viewModeEnabled &&
+ showSelectedShapeActions(appState, elements) ? (
+
+ ) : null}
+
+
+
+ >
+ );
+};
diff --git a/src/components/Modal.scss b/src/components/Modal.scss
index 2b34500da..2666f3514 100644
--- a/src/components/Modal.scss
+++ b/src/components/Modal.scss
@@ -1,4 +1,4 @@
-@import "../css/_variables";
+@import "../css/variables.module";
.excalidraw {
.Modal {
diff --git a/src/components/PasteChartDialog.scss b/src/components/PasteChartDialog.scss
index 9d45fb2df..dc76306a8 100644
--- a/src/components/PasteChartDialog.scss
+++ b/src/components/PasteChartDialog.scss
@@ -1,4 +1,4 @@
-@import "../css/_variables";
+@import "../css/variables.module";
.excalidraw {
.PasteChartDialog {
diff --git a/src/components/PasteChartDialog.tsx b/src/components/PasteChartDialog.tsx
index 22381d8a4..43607b3ca 100644
--- a/src/components/PasteChartDialog.tsx
+++ b/src/components/PasteChartDialog.tsx
@@ -1,5 +1,6 @@
import oc from "open-color";
import React, { useLayoutEffect, useRef, useState } from "react";
+import { trackEvent } from "../analytics";
import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
import { ChartType } from "../element/types";
import { t } from "../i18n";
@@ -86,6 +87,7 @@ export const PasteChartDialog = ({
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertChart(elements);
+ trackEvent("magic", "chart", chartType);
setAppState({
currentChartType: chartType,
pasteDialog: {
diff --git a/src/components/ShortcutsDialog.tsx b/src/components/ShortcutsDialog.tsx
deleted file mode 100644
index ff9327d90..000000000
--- a/src/components/ShortcutsDialog.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-import React from "react";
-import { t } from "../i18n";
-import { isDarwin } from "../keys";
-import { Dialog } from "./Dialog";
-import { getShortcutKey } from "../utils";
-import "./ShortcutsDialog.scss";
-
-const Columns = (props: { children: React.ReactNode }) => (
-
- {props.children}
-
-);
-
-const Column = (props: { children: React.ReactNode }) => (
-
{props.children}
-);
-
-const ShortcutIsland = (props: {
- caption: string;
- children: React.ReactNode;
-}) => (
-
-
{props.caption}
- {props.children}
-
-);
-
-const Shortcut = (props: {
- label: string;
- shortcuts: string[];
- isOr: boolean;
-}) => {
- return (
-
-
-
- {props.label}
-
-
- {props.shortcuts.map((shortcut, index) => (
-
- {shortcut}
- {props.isOr &&
- index !== props.shortcuts.length - 1 &&
- t("shortcutsDialog.or")}
-
- ))}
-
-
-
- );
-};
-
-Shortcut.defaultProps = {
- isOr: true,
-};
-
-const ShortcutKey = (props: { children: React.ReactNode }) => (
-
-);
-
-const Footer = () => (
-
-);
-
-export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
- const handleClose = React.useCallback(() => {
- if (onClose) {
- onClose();
- }
- }, [onClose]);
-
- return (
- <>
-
- >
- );
-};
diff --git a/src/components/Stats.scss b/src/components/Stats.scss
index a6849f3bc..84864f933 100644
--- a/src/components/Stats.scss
+++ b/src/components/Stats.scss
@@ -1,4 +1,4 @@
-@import "../css/_variables";
+@import "../css/variables.module";
.Stats {
position: fixed;
diff --git a/src/components/TextInput.scss b/src/components/TextInput.scss
index 3ff96c87c..930372ff0 100644
--- a/src/components/TextInput.scss
+++ b/src/components/TextInput.scss
@@ -1,4 +1,4 @@
-@import "../css/_variables.scss";
+@import "../css/variables.module";
.excalidraw {
.TextInput {
diff --git a/src/components/Toast.scss b/src/components/Toast.scss
new file mode 100644
index 000000000..70cc80180
--- /dev/null
+++ b/src/components/Toast.scss
@@ -0,0 +1,32 @@
+@import "../css/variables.module";
+
+.excalidraw {
+ .Toast {
+ animation: fade-in 0.5s;
+ background-color: var(--button-gray-1);
+ border-radius: 4px;
+ bottom: 10px;
+ box-sizing: border-box;
+ cursor: default;
+ left: 50%;
+ margin-left: -150px;
+ padding: 4px 0;
+ position: fixed;
+ text-align: center;
+ width: 300px;
+ z-index: 999999;
+ }
+
+ .Toast__message {
+ color: var(--popup-text-color);
+ }
+
+ @keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+}
diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx
new file mode 100644
index 000000000..d7ae91407
--- /dev/null
+++ b/src/components/Toast.tsx
@@ -0,0 +1,34 @@
+import React, { useCallback, useEffect, useRef } from "react";
+import { TOAST_TIMEOUT } from "../constants";
+import "./Toast.scss";
+
+export const Toast = ({
+ message,
+ clearToast,
+}: {
+ message: string;
+ clearToast: () => void;
+}) => {
+ const timerRef = useRef
(0);
+
+ const scheduleTimeout = useCallback(
+ () =>
+ (timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
+ [clearToast],
+ );
+
+ useEffect(() => {
+ scheduleTimeout();
+ return () => clearTimeout(timerRef.current);
+ }, [scheduleTimeout, message]);
+
+ return (
+ clearTimeout(timerRef?.current)}
+ onMouseLeave={scheduleTimeout}
+ >
+
{message}
+
+ );
+};
diff --git a/src/components/ToolIcon.scss b/src/components/ToolIcon.scss
index 91e8e1bc5..df07ca025 100644
--- a/src/components/ToolIcon.scss
+++ b/src/components/ToolIcon.scss
@@ -1,5 +1,5 @@
@import "open-color/open-color.scss";
-@import "../css/variables";
+@import "../css/variables.module";
.excalidraw {
.ToolIcon {
diff --git a/src/components/Tooltip.scss b/src/components/Tooltip.scss
index 9fe048f6a..75b79bf56 100644
--- a/src/components/Tooltip.scss
+++ b/src/components/Tooltip.scss
@@ -1,4 +1,4 @@
-@import "../css/_variables";
+@import "../css/variables.module";
.excalidraw {
.Tooltip {
position: relative;
@@ -48,15 +48,7 @@
}
}
- // the following 3 rules ensure that the tooltip doesn't show (nor affect
- // the cursor) when you drag over when you draw on canvas, but at the same
- // time it still works when clicking on the link/shield
-
- body:active & .Tooltip:not(:hover) {
- pointer-events: none;
- }
-
- body:not(:active) & .Tooltip:hover .Tooltip__label {
+ .Tooltip:hover .Tooltip__label {
visibility: visible;
}
diff --git a/src/constants.ts b/src/constants.ts
index 4f22b35ec..b037eb235 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -89,3 +89,7 @@ export const STORAGE_KEYS = {
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
+export const TOAST_TIMEOUT = 5000;
+export const VERSION_TIMEOUT = 30000;
+
+export const ZOOM_STEP = 0.1;
diff --git a/src/createInverseContext.tsx b/src/createInverseContext.tsx
new file mode 100644
index 000000000..ac6cc223e
--- /dev/null
+++ b/src/createInverseContext.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+
+export const createInverseContext = (
+ initialValue: T,
+) => {
+ const Context = React.createContext(initialValue) as React.Context & {
+ _updateProviderValue?: (value: T) => void;
+ };
+
+ class InverseConsumer extends React.Component {
+ state = { value: initialValue };
+ constructor(props: any) {
+ super(props);
+ Context._updateProviderValue = (value: T) => this.setState({ value });
+ }
+ render() {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+ }
+
+ class InverseProvider extends React.Component<{ value: T }> {
+ componentDidMount() {
+ Context._updateProviderValue?.(this.props.value);
+ }
+ componentDidUpdate() {
+ Context._updateProviderValue?.(this.props.value);
+ }
+ render() {
+ return {() => this.props.children};
+ }
+ }
+
+ return {
+ Context,
+ Consumer: InverseConsumer,
+ Provider: InverseProvider,
+ };
+};
diff --git a/src/css/styles.scss b/src/css/styles.scss
index c2fcd527b..573dba8f5 100644
--- a/src/css/styles.scss
+++ b/src/css/styles.scss
@@ -1,4 +1,4 @@
-@import "./_variables";
+@import "./variables.module";
@import "./theme";
.excalidraw {
@@ -13,7 +13,7 @@
a {
font-weight: 500;
text-decoration: none;
- color: $oc-blue-7; /* OC Blue 7 */
+ color: var(--link-color);
&:hover {
text-decoration: underline;
@@ -282,7 +282,7 @@
pointer-events: none !important;
}
- .App-menu_top > * {
+ .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
pointer-events: all;
}
@@ -323,7 +323,7 @@
}
}
- .App-menu_bottom > * {
+ .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
pointer-events: all;
}
@@ -431,6 +431,7 @@
cursor: pointer;
fill: $oc-gray-6;
bottom: 14px;
+ width: 1.5rem;
:root[dir="ltr"] & {
right: 14px;
@@ -491,6 +492,13 @@
pointer-events: none !important;
}
+ &.excalidraw--view-mode {
+ .App-menu {
+ display: flex;
+ justify-content: space-between;
+ }
+ }
+
@media print {
.App-bottom-bar,
.FixedSideContainer,
diff --git a/src/css/theme.scss b/src/css/theme.scss
index 4de90d83d..ca88d8404 100644
--- a/src/css/theme.scss
+++ b/src/css/theme.scss
@@ -32,6 +32,7 @@
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--dialog-border: #{$oc-gray-6};
+ --link-color: #{$oc-blue-7};
}
.excalidraw {
diff --git a/src/css/_variables.scss b/src/css/variables.module.scss
similarity index 73%
rename from src/css/_variables.scss
rename to src/css/variables.module.scss
index 4e4ac861d..5b9ee7a8c 100644
--- a/src/css/_variables.scss
+++ b/src/css/variables.module.scss
@@ -2,3 +2,7 @@
// keep up to date with is-mobile.tsx
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
+
+:export {
+ isMobileQuery: unquote($is-mobile-query);
+}
diff --git a/src/data/index.ts b/src/data/index.ts
index 41de22a44..7dae58be7 100644
--- a/src/data/index.ts
+++ b/src/data/index.ts
@@ -1,4 +1,4 @@
-import { fileSave } from "browser-nativefs";
+import { fileSave } from "browser-fs-access";
import {
copyCanvasToClipboardAsPng,
copyTextToSystemClipboard,
@@ -36,7 +36,7 @@ export const exportCanvas = async (
},
) => {
if (elements.length === 0) {
- return window.alert(t("alerts.cannotExportEmptyCanvas"));
+ throw new Error(t("alerts.cannotExportEmptyCanvas"));
}
if (type === "svg" || type === "clipboard-svg") {
const tempSvg = exportToSvg(elements, {
diff --git a/src/data/json.ts b/src/data/json.ts
index d4d3205a1..65688c32e 100644
--- a/src/data/json.ts
+++ b/src/data/json.ts
@@ -1,4 +1,4 @@
-import { fileOpen, fileSave } from "browser-nativefs";
+import { fileOpen, fileSave } from "browser-fs-access";
import { cleanAppStateForExport } from "../appState";
import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
diff --git a/src/element/index.ts b/src/element/index.ts
index e49bc633e..63fdcfff4 100644
--- a/src/element/index.ts
+++ b/src/element/index.ts
@@ -34,7 +34,6 @@ export {
export {
resizeTest,
getCursorForResizingElement,
- normalizeTransformHandleType,
getElementWithTransformHandleType,
getTransformHandleTypeFromCoords,
} from "./resizeTest";
diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts
index 199e3a482..96c216b06 100644
--- a/src/element/resizeElements.ts
+++ b/src/element/resizeElements.ts
@@ -4,7 +4,6 @@ import { rescalePoints } from "../points";
import {
rotate,
adjustXYWithRotation,
- getFlipAdjustment,
centerPoint,
rotatePoint,
} from "../math";
@@ -13,21 +12,16 @@ import {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
NonDeleted,
- ExcalidrawGenericElement,
- ExcalidrawElement,
} from "./types";
import {
getElementAbsoluteCoords,
getCommonBounds,
getResizedElementAbsoluteCoords,
} from "./bounds";
-import { isGenericElement, isLinearElement, isTextElement } from "./typeChecks";
+import { isLinearElement, isTextElement } from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
-import {
- getCursorForResizingElement,
- normalizeTransformHandleType,
-} from "./resizeTest";
+import { getCursorForResizingElement } from "./resizeTest";
import { measureText, getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import {
@@ -49,7 +43,6 @@ const normalizeAngle = (angle: number): number => {
export const transformElements = (
pointerDownState: PointerDownState,
transformHandleType: MaybeTransformHandleType,
- setTransformHandle: (nextTransformHandle: MaybeTransformHandleType) => void,
selectedElements: readonly NonDeletedExcalidrawElement[],
resizeArrowDirection: "origin" | "end",
isRotateWithDiscreteAngle: boolean,
@@ -101,36 +94,15 @@ export const transformElements = (
);
updateBoundElements(element);
} else if (transformHandleType) {
- if (isGenericElement(element)) {
- resizeSingleGenericElement(
- pointerDownState.originalElements.get(element.id) as typeof element,
- shouldKeepSidesRatio,
- element,
- transformHandleType,
- isResizeCenterPoint,
- pointerX,
- pointerY,
- );
- } else {
- const keepSquareAspectRatio = shouldKeepSidesRatio;
- resizeSingleNonGenericElement(
- element,
- transformHandleType,
- isResizeCenterPoint,
- keepSquareAspectRatio,
- pointerX,
- pointerY,
- );
- setTransformHandle(
- normalizeTransformHandleType(element, transformHandleType),
- );
- if (element.width < 0) {
- mutateElement(element, { width: -element.width });
- }
- if (element.height < 0) {
- mutateElement(element, { height: -element.height });
- }
- }
+ resizeSingleElement(
+ pointerDownState.originalElements.get(element.id) as typeof element,
+ shouldKeepSidesRatio,
+ element,
+ transformHandleType,
+ isResizeCenterPoint,
+ pointerX,
+ pointerY,
+ );
}
// update cursor
@@ -414,8 +386,8 @@ const resizeSingleTextElement = (
}
};
-const resizeSingleGenericElement = (
- stateAtResizeStart: NonDeleted,
+const resizeSingleElement = (
+ stateAtResizeStart: NonDeletedExcalidrawElement,
shouldKeepSidesRatio: boolean,
element: NonDeletedExcalidrawElement,
transformHandleDirection: TransformHandleDirection,
@@ -423,251 +395,184 @@ const resizeSingleGenericElement = (
pointerX: number,
pointerY: number,
) => {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(stateAtResizeStart);
+ // Gets bounds corners
+ const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
+ stateAtResizeStart,
+ stateAtResizeStart.width,
+ stateAtResizeStart.height,
+ );
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position
- let newWidth = stateAtResizeStart.width;
- let newHeight = stateAtResizeStart.height;
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
startCenter,
-stateAtResizeStart.angle,
);
+
+ //Get bounds corners rendered on screen
+ const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
+ element,
+ element.width,
+ element.height,
+ );
+ const boundsCurrentWidth = esx2 - esx1;
+ const boundsCurrentHeight = esy2 - esy1;
+
+ // It's important we set the initial scale value based on the width and height at resize start,
+ // otherwise previous dimensions affected by modifiers will be taken into account.
+ const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
+ const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1];
+ let scaleX = atStartBoundsWidth / boundsCurrentWidth;
+ let scaleY = atStartBoundsHeight / boundsCurrentHeight;
+
if (transformHandleDirection.includes("e")) {
- newWidth = rotatedPointer[0] - startTopLeft[0];
+ scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
}
if (transformHandleDirection.includes("s")) {
- newHeight = rotatedPointer[1] - startTopLeft[1];
+ scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
}
if (transformHandleDirection.includes("w")) {
- newWidth = startBottomRight[0] - rotatedPointer[0];
+ scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
}
if (transformHandleDirection.includes("n")) {
- newHeight = startBottomRight[1] - rotatedPointer[1];
+ scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
}
+ // Linear elements dimensions differ from bounds dimensions
+ const eleInitialWidth = stateAtResizeStart.width;
+ const eleInitialHeight = stateAtResizeStart.height;
+ // We have to use dimensions of element on screen, otherwise the scaling of the
+ // dimensions won't match the cursor for linear elements.
+ let eleNewWidth = element.width * scaleX;
+ let eleNewHeight = element.height * scaleY;
// adjust dimensions for resizing from center
if (isResizeFromCenter) {
- newWidth = 2 * newWidth - stateAtResizeStart.width;
- newHeight = 2 * newHeight - stateAtResizeStart.height;
+ eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
+ eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
}
// adjust dimensions to keep sides ratio
if (shouldKeepSidesRatio) {
- const widthRatio = Math.abs(newWidth) / stateAtResizeStart.width;
- const heightRatio = Math.abs(newHeight) / stateAtResizeStart.height;
+ const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
+ const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
if (transformHandleDirection.length === 1) {
- newHeight *= widthRatio;
- newWidth *= heightRatio;
+ eleNewHeight *= widthRatio;
+ eleNewWidth *= heightRatio;
}
if (transformHandleDirection.length === 2) {
const ratio = Math.max(widthRatio, heightRatio);
- newWidth = stateAtResizeStart.width * ratio * Math.sign(newWidth);
- newHeight = stateAtResizeStart.height * ratio * Math.sign(newHeight);
+ eleNewWidth = eleInitialWidth * ratio * Math.sign(eleNewWidth);
+ eleNewHeight = eleInitialHeight * ratio * Math.sign(eleNewHeight);
}
}
+ const [
+ newBoundsX1,
+ newBoundsY1,
+ newBoundsX2,
+ newBoundsY2,
+ ] = getResizedElementAbsoluteCoords(
+ stateAtResizeStart,
+ eleNewWidth,
+ eleNewHeight,
+ );
+ const newBoundsWidth = newBoundsX2 - newBoundsX1;
+ const newBoundsHeight = newBoundsY2 - newBoundsY1;
+
// Calculate new topLeft based on fixed corner during resize
- let newTopLeft = startTopLeft as [number, number];
+ let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandleDirection)) {
newTopLeft = [
- startBottomRight[0] - Math.abs(newWidth),
- startBottomRight[1] - Math.abs(newHeight),
+ startBottomRight[0] - Math.abs(newBoundsWidth),
+ startBottomRight[1] - Math.abs(newBoundsHeight),
];
}
if (transformHandleDirection === "ne") {
- const bottomLeft = [
- stateAtResizeStart.x,
- stateAtResizeStart.y + stateAtResizeStart.height,
- ];
- newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newHeight)];
+ const bottomLeft = [startTopLeft[0], startBottomRight[1]];
+ newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
}
if (transformHandleDirection === "sw") {
- const topRight = [
- stateAtResizeStart.x + stateAtResizeStart.width,
- stateAtResizeStart.y,
- ];
- newTopLeft = [topRight[0] - Math.abs(newWidth), topRight[1]];
+ const topRight = [startBottomRight[0], startTopLeft[1]];
+ newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
}
// Keeps opposite handle fixed during resize
if (shouldKeepSidesRatio) {
if (["s", "n"].includes(transformHandleDirection)) {
- newTopLeft[0] = startCenter[0] - newWidth / 2;
+ newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
if (["e", "w"].includes(transformHandleDirection)) {
- newTopLeft[1] = startCenter[1] - newHeight / 2;
+ newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
}
}
// Flip horizontally
- if (newWidth < 0) {
+ if (eleNewWidth < 0) {
if (transformHandleDirection.includes("e")) {
- newTopLeft[0] -= Math.abs(newWidth);
+ newTopLeft[0] -= Math.abs(newBoundsWidth);
}
if (transformHandleDirection.includes("w")) {
- newTopLeft[0] += Math.abs(newWidth);
+ newTopLeft[0] += Math.abs(newBoundsWidth);
}
}
// Flip vertically
- if (newHeight < 0) {
+ if (eleNewHeight < 0) {
if (transformHandleDirection.includes("s")) {
- newTopLeft[1] -= Math.abs(newHeight);
+ newTopLeft[1] -= Math.abs(newBoundsHeight);
}
if (transformHandleDirection.includes("n")) {
- newTopLeft[1] += Math.abs(newHeight);
+ newTopLeft[1] += Math.abs(newBoundsHeight);
}
}
if (isResizeFromCenter) {
- newTopLeft[0] = startCenter[0] - Math.abs(newWidth) / 2;
- newTopLeft[1] = startCenter[1] - Math.abs(newHeight) / 2;
+ newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
+ newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
}
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
- newTopLeft[0] + Math.abs(newWidth) / 2,
- newTopLeft[1] + Math.abs(newHeight) / 2,
+ newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
+ newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
+ // Readjust points for linear elements
+ const rescaledPoints = rescalePointsInElement(
+ stateAtResizeStart,
+ eleNewWidth,
+ eleNewHeight,
+ );
+ // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
+ // So we need to readjust (x,y) to be where the first point should be
+ const newOrigin = [...newTopLeft];
+ newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
+ newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
+
const resizedElement = {
- width: Math.abs(newWidth),
- height: Math.abs(newHeight),
- x: newTopLeft[0],
- y: newTopLeft[1],
+ width: Math.abs(eleNewWidth),
+ height: Math.abs(eleNewHeight),
+ x: newOrigin[0],
+ y: newOrigin[1],
+ ...rescaledPoints,
};
- updateBoundElements(element, {
- newSize: { width: resizedElement.width, height: resizedElement.height },
- });
- mutateElement(element, resizedElement);
-};
-
-const resizeSingleNonGenericElement = (
- element: NonDeleted>,
- transformHandleType: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
- isResizeFromCenter: boolean,
- keepSquareAspectRatio: boolean,
- pointerX: number,
- pointerY: number,
-) => {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- const cx = (x1 + x2) / 2;
- const cy = (y1 + y2) / 2;
-
- // rotation pointer with reverse angle
- const [rotatedX, rotatedY] = rotate(
- pointerX,
- pointerY,
- cx,
- cy,
- -element.angle,
- );
-
- let scaleX = 1;
- let scaleY = 1;
- if (
- transformHandleType === "e" ||
- transformHandleType === "ne" ||
- transformHandleType === "se"
- ) {
- scaleX = (rotatedX - x1) / (x2 - x1);
- }
- if (
- transformHandleType === "s" ||
- transformHandleType === "sw" ||
- transformHandleType === "se"
- ) {
- scaleY = (rotatedY - y1) / (y2 - y1);
- }
- if (
- transformHandleType === "w" ||
- transformHandleType === "nw" ||
- transformHandleType === "sw"
- ) {
- scaleX = (x2 - rotatedX) / (x2 - x1);
- }
- if (
- transformHandleType === "n" ||
- transformHandleType === "nw" ||
- transformHandleType === "ne"
- ) {
- scaleY = (y2 - rotatedY) / (y2 - y1);
- }
- let nextWidth = element.width * scaleX;
- let nextHeight = element.height * scaleY;
- if (keepSquareAspectRatio) {
- nextWidth = nextHeight = Math.max(nextWidth, nextHeight);
- }
-
- const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
- element,
- nextWidth,
- nextHeight,
- );
- const deltaX1 = (x1 - nextX1) / 2;
- const deltaY1 = (y1 - nextY1) / 2;
- const deltaX2 = (x2 - nextX2) / 2;
- const deltaY2 = (y2 - nextY2) / 2;
-
- const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
-
- updateBoundElements(element, {
- newSize: { width: nextWidth, height: nextHeight },
- });
- const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords(
- {
- ...element,
- ...rescaledPoints,
- },
- Math.abs(nextWidth),
- Math.abs(nextHeight),
- );
- const [flipDiffX, flipDiffY] = getFlipAdjustment(
- transformHandleType,
- nextWidth,
- nextHeight,
- nextX1,
- nextY1,
- nextX2,
- nextY2,
- finalX1,
- finalY1,
- finalX2,
- finalY2,
- isLinearElement(element),
- element.angle,
- );
- const [nextElementX, nextElementY] = adjustXYWithRotation(
- getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
- element.x - flipDiffX,
- element.y - flipDiffY,
- element.angle,
- deltaX1,
- deltaY1,
- deltaX2,
- deltaY2,
- );
if (
- nextWidth !== 0 &&
- nextHeight !== 0 &&
- Number.isFinite(nextElementX) &&
- Number.isFinite(nextElementY)
+ resizedElement.width !== 0 &&
+ resizedElement.height !== 0 &&
+ Number.isFinite(resizedElement.x) &&
+ Number.isFinite(resizedElement.y)
) {
- mutateElement(element, {
- width: nextWidth,
- height: nextHeight,
- x: nextElementX,
- y: nextElementY,
- ...rescaledPoints,
+ updateBoundElements(element, {
+ newSize: { width: resizedElement.width, height: resizedElement.height },
});
+ mutateElement(element, resizedElement);
}
};
diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts
index 4471244dd..3a794e2c5 100644
--- a/src/element/resizeTest.ts
+++ b/src/element/resizeTest.ts
@@ -173,57 +173,3 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : "";
};
-
-export const normalizeTransformHandleType = (
- element: ExcalidrawElement,
- transformHandleType: TransformHandleType,
-): TransformHandleType => {
- if (element.width >= 0 && element.height >= 0) {
- return transformHandleType;
- }
-
- if (element.width < 0 && element.height < 0) {
- switch (transformHandleType) {
- case "nw":
- return "se";
- case "ne":
- return "sw";
- case "se":
- return "nw";
- case "sw":
- return "ne";
- }
- } else if (element.width < 0) {
- switch (transformHandleType) {
- case "nw":
- return "ne";
- case "ne":
- return "nw";
- case "se":
- return "sw";
- case "sw":
- return "se";
- case "e":
- return "w";
- case "w":
- return "e";
- }
- } else {
- switch (transformHandleType) {
- case "nw":
- return "sw";
- case "ne":
- return "se";
- case "se":
- return "ne";
- case "sw":
- return "nw";
- case "n":
- return "s";
- case "s":
- return "n";
- }
- }
-
- return transformHandleType;
-};
diff --git a/src/element/showSelectedShapeActions.ts b/src/element/showSelectedShapeActions.ts
index d0a7439c9..545289812 100644
--- a/src/element/showSelectedShapeActions.ts
+++ b/src/element/showSelectedShapeActions.ts
@@ -7,7 +7,8 @@ export const showSelectedShapeActions = (
elements: readonly NonDeletedExcalidrawElement[],
) =>
Boolean(
- appState.editingElement ||
- getSelectedElements(elements, appState).length ||
- appState.elementType !== "selection",
+ !appState.viewModeEnabled &&
+ (appState.editingElement ||
+ getSelectedElements(elements, appState).length ||
+ appState.elementType !== "selection"),
);
diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/CollabWrapper.tsx
index d27adb35d..c308a70d6 100644
--- a/src/excalidraw-app/collab/CollabWrapper.tsx
+++ b/src/excalidraw-app/collab/CollabWrapper.tsx
@@ -6,10 +6,11 @@ import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import {
+ getElementMap,
getSceneVersion,
getSyncableElements,
} from "../../packages/excalidraw/index";
-import { AppState, Collaborator, Gesture } from "../../types";
+import { Collaborator, Gesture } from "../../types";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
INITIAL_SCENE_UPDATE_TIMEOUT,
@@ -31,6 +32,7 @@ import {
} from "../data/localStorage";
import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
+import { createInverseContext } from "../../createInverseContext";
interface CollabState {
isCollaborating: boolean;
@@ -56,17 +58,21 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
};
interface Props {
- children: (collab: CollabAPI) => React.ReactNode;
- // NOTE not type-safe because the refObject may in fact not be initialized
- // with ExcalidrawImperativeAPI yet
- excalidrawRef: React.MutableRefObject;
+ excalidrawAPI: ExcalidrawImperativeAPI;
}
+const {
+ Context: CollabContext,
+ Consumer: CollabContextConsumer,
+ Provider: CollabContextProvider,
+} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
+
+export { CollabContext, CollabContextConsumer };
+
class CollabWrapper extends PureComponent {
portal: Portal;
+ excalidrawAPI: Props["excalidrawAPI"];
private socketInitializationTimer?: NodeJS.Timeout;
- private excalidrawRef: Props["excalidrawRef"];
- excalidrawAppState?: AppState;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map();
@@ -80,7 +86,7 @@ class CollabWrapper extends PureComponent {
activeRoomLink: "",
};
this.portal = new Portal(this);
- this.excalidrawRef = props.excalidrawRef;
+ this.excalidrawAPI = props.excalidrawAPI;
}
componentDidMount() {
@@ -142,7 +148,7 @@ class CollabWrapper extends PureComponent {
saveCollabRoomToFirebase = async (
syncableElements: ExcalidrawElement[] = getSyncableElements(
- this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
+ this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
) => {
try {
@@ -154,13 +160,13 @@ class CollabWrapper extends PureComponent {
openPortal = async () => {
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
- const elements = this.excalidrawRef.current!.getSceneElements();
+ const elements = this.excalidrawAPI.getSceneElements();
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
- this.excalidrawRef.current!.history.clear();
- this.excalidrawRef.current!.updateScene({
+ this.excalidrawAPI.history.clear();
+ this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
@@ -175,7 +181,7 @@ class CollabWrapper extends PureComponent {
private destroySocketClient = () => {
this.collaborators = new Map();
- this.excalidrawRef.current!.updateScene({
+ this.excalidrawAPI.updateScene({
collaborators: this.collaborators,
});
this.setState({
@@ -265,7 +271,7 @@ class CollabWrapper extends PureComponent {
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
- this.excalidrawRef.current!.updateScene({
+ this.excalidrawAPI.updateScene({
collaborators,
});
break;
@@ -300,7 +306,55 @@ class CollabWrapper extends PureComponent {
private reconcileElements = (
elements: readonly ExcalidrawElement[],
): ReconciledElements => {
- const newElements = this.portal.reconcileElements(elements);
+ const currentElements = this.getSceneElementsIncludingDeleted();
+ // create a map of ids so we don't have to iterate
+ // over the array more than once.
+ const localElementMap = getElementMap(currentElements);
+
+ const appState = this.excalidrawAPI.getAppState();
+
+ // Reconcile
+ const newElements: readonly ExcalidrawElement[] = elements
+ .reduce((elements, element) => {
+ // if the remote element references one that's currently
+ // edited on local, skip it (it'll be added in the next step)
+ if (
+ element.id === appState.editingElement?.id ||
+ element.id === appState.resizingElement?.id ||
+ element.id === appState.draggingElement?.id
+ ) {
+ return elements;
+ }
+
+ if (
+ localElementMap.hasOwnProperty(element.id) &&
+ localElementMap[element.id].version > element.version
+ ) {
+ elements.push(localElementMap[element.id]);
+ delete localElementMap[element.id];
+ } else if (
+ localElementMap.hasOwnProperty(element.id) &&
+ localElementMap[element.id].version === element.version &&
+ localElementMap[element.id].versionNonce !== element.versionNonce
+ ) {
+ // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
+ if (localElementMap[element.id].versionNonce < element.versionNonce) {
+ elements.push(localElementMap[element.id]);
+ } else {
+ // it should be highly unlikely that the two versionNonces are the same. if we are
+ // really worried about this, we can replace the versionNonce with the socket id.
+ elements.push(element);
+ }
+ delete localElementMap[element.id];
+ } else {
+ elements.push(element);
+ delete localElementMap[element.id];
+ }
+
+ return elements;
+ }, [] as Mutable)
+ // add local elements that weren't deleted or on remote
+ .concat(...Object.values(localElementMap));
// Avoid broadcasting to the rest of the collaborators the scene
// we just received!
@@ -319,10 +373,10 @@ class CollabWrapper extends PureComponent {
}: { init?: boolean; initFromSnapshot?: boolean } = {},
) => {
if (init || initFromSnapshot) {
- this.excalidrawRef.current!.setScrollToCenter(elements);
+ this.excalidrawAPI.setScrollToCenter(elements);
}
- this.excalidrawRef.current!.updateScene({
+ this.excalidrawAPI.updateScene({
elements,
commitToHistory: !!init,
});
@@ -331,7 +385,7 @@ class CollabWrapper extends PureComponent {
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
- this.excalidrawRef.current!.history.clear();
+ this.excalidrawAPI.history.clear();
};
setCollaborators(sockets: string[]) {
@@ -347,7 +401,7 @@ class CollabWrapper extends PureComponent {
}
}
this.collaborators = collaborators;
- this.excalidrawRef.current!.updateScene({ collaborators });
+ this.excalidrawAPI.updateScene({ collaborators });
});
}
@@ -360,7 +414,7 @@ class CollabWrapper extends PureComponent {
};
public getSceneElementsIncludingDeleted = () => {
- return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
+ return this.excalidrawAPI.getSceneElementsIncludingDeleted();
};
onPointerUpdate = (payload: {
@@ -373,11 +427,7 @@ class CollabWrapper extends PureComponent {
this.portal.broadcastMouseLocation(payload);
};
- broadcastElements = (
- elements: readonly ExcalidrawElement[],
- state: AppState,
- ) => {
- this.excalidrawAppState = state;
+ broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@@ -396,7 +446,7 @@ class CollabWrapper extends PureComponent {
this.portal.broadcastScene(
SCENE.UPDATE,
getSyncableElements(
- this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
+ this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
true,
);
@@ -425,8 +475,23 @@ class CollabWrapper extends PureComponent {
});
};
+ /** PRIVATE. Use `this.getContextValue()` instead. */
+ private contextValue: CollabAPI | null = null;
+
+ /** Getter of context value. Returned object is stable. */
+ getContextValue = (): CollabAPI => {
+ this.contextValue = this.contextValue || ({} as CollabAPI);
+
+ this.contextValue.isCollaborating = this.state.isCollaborating;
+ this.contextValue.username = this.state.username;
+ this.contextValue.onPointerUpdate = this.onPointerUpdate;
+ this.contextValue.initializeSocketClient = this.initializeSocketClient;
+ this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
+ this.contextValue.broadcastElements = this.broadcastElements;
+ return this.contextValue;
+ };
+
render() {
- const { children } = this.props;
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
return (
@@ -450,14 +515,11 @@ class CollabWrapper extends PureComponent {
onClose={() => this.setState({ errorMessage: "" })}
/>
)}
- {children({
- isCollaborating: this.state.isCollaborating,
- username: this.state.username,
- onPointerUpdate: this.onPointerUpdate,
- initializeSocketClient: this.initializeSocketClient,
- onCollabButtonClick: this.onCollabButtonClick,
- broadcastElements: this.broadcastElements,
- })}
+
>
);
}
diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx
index 2b66b0bd0..92086a27b 100644
--- a/src/excalidraw-app/collab/Portal.tsx
+++ b/src/excalidraw-app/collab/Portal.tsx
@@ -6,23 +6,20 @@ import {
import CollabWrapper from "./CollabWrapper";
-import {
- getElementMap,
- getSyncableElements,
-} from "../../packages/excalidraw/index";
+import { getSyncableElements } from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
class Portal {
- app: CollabWrapper;
+ collab: CollabWrapper;
socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map = new Map();
- constructor(app: CollabWrapper) {
- this.app = app;
+ constructor(collab: CollabWrapper) {
+ this.collab = collab;
}
open(socket: SocketIOClient.Socket, id: string, key: string) {
@@ -30,7 +27,7 @@ class Portal {
this.roomId = id;
this.roomKey = key;
- // Initialize socket listeners (moving from App)
+ // Initialize socket listeners
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
@@ -39,12 +36,12 @@ class Portal {
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
SCENE.INIT,
- getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
+ getSyncableElements(this.collab.getSceneElementsIncludingDeleted()),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: string[]) => {
- this.app.setCollaborators(clients);
+ this.collab.setCollaborators(clients);
});
}
@@ -125,10 +122,10 @@ class Portal {
data as SocketUpdateData,
);
- if (syncAll && this.app.state.isCollaborating) {
+ if (syncAll && this.collab.state.isCollaborating) {
await Promise.all([
broadcastPromise,
- this.app.saveCollabRoomToFirebase(syncableElements),
+ this.collab.saveCollabRoomToFirebase(syncableElements),
]);
} else {
await broadcastPromise;
@@ -146,9 +143,9 @@ class Portal {
socketId: this.socket.id,
pointer: payload.pointer,
button: payload.button || "up",
- selectedElementIds:
- this.app.excalidrawAppState?.selectedElementIds || {},
- username: this.app.state.username,
+ selectedElementIds: this.collab.excalidrawAPI.getAppState()
+ .selectedElementIds,
+ username: this.collab.state.username,
},
};
return this._broadcastSocketData(
@@ -157,62 +154,6 @@ class Portal {
);
}
};
-
- reconcileElements = (
- sceneElements: readonly ExcalidrawElement[],
- ): readonly ExcalidrawElement[] => {
- const currentElements = this.app.getSceneElementsIncludingDeleted();
- // create a map of ids so we don't have to iterate
- // over the array more than once.
- const localElementMap = getElementMap(currentElements);
-
- // Reconcile
- return (
- sceneElements
- .reduce((elements, element) => {
- // if the remote element references one that's currently
- // edited on local, skip it (it'll be added in the next step)
- if (
- element.id === this.app.excalidrawAppState?.editingElement?.id ||
- element.id === this.app.excalidrawAppState?.resizingElement?.id ||
- element.id === this.app.excalidrawAppState?.draggingElement?.id
- ) {
- return elements;
- }
-
- if (
- localElementMap.hasOwnProperty(element.id) &&
- localElementMap[element.id].version > element.version
- ) {
- elements.push(localElementMap[element.id]);
- delete localElementMap[element.id];
- } else if (
- localElementMap.hasOwnProperty(element.id) &&
- localElementMap[element.id].version === element.version &&
- localElementMap[element.id].versionNonce !== element.versionNonce
- ) {
- // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
- if (
- localElementMap[element.id].versionNonce < element.versionNonce
- ) {
- elements.push(localElementMap[element.id]);
- } else {
- // it should be highly unlikely that the two versionNonces are the same. if we are
- // really worried about this, we can replace the versionNonce with the socket id.
- elements.push(element);
- }
- delete localElementMap[element.id];
- } else {
- elements.push(element);
- delete localElementMap[element.id];
- }
-
- return elements;
- }, [] as Mutable)
- // add local elements that weren't deleted or on remote
- .concat(...Object.values(localElementMap))
- );
- };
}
export default Portal;
diff --git a/src/excalidraw-app/collab/RoomDialog.scss b/src/excalidraw-app/collab/RoomDialog.scss
index de784d93d..5a045136a 100644
--- a/src/excalidraw-app/collab/RoomDialog.scss
+++ b/src/excalidraw-app/collab/RoomDialog.scss
@@ -1,4 +1,4 @@
-@import "../../css/_variables";
+@import "../../css/variables.module";
.excalidraw {
.RoomDialog-linkContainer {
diff --git a/src/excalidraw-app/components/LanguageList.tsx b/src/excalidraw-app/components/LanguageList.tsx
index 4b707907e..18f38a320 100644
--- a/src/excalidraw-app/components/LanguageList.tsx
+++ b/src/excalidraw-app/components/LanguageList.tsx
@@ -25,7 +25,6 @@ export const LanguageList = ({
-
{languages.map((lang) => (