diff --git a/.npmrc b/.npmrc
index cffe8cdef1..1b78f1c6f2 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,2 @@
save-exact=true
+legacy-peer-deps=true
diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx
index 2626818955..cdd5ea5a49 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx
+++ b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx
@@ -16,7 +16,6 @@ function App() {
className="custom-footer"
onClick={() => alert("This is dummy footer")}
>
- {" "}
custom footer
diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx
index 8fbf228df1..2494df108f 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx
+++ b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/main-menu.mdx
@@ -14,8 +14,7 @@ function App() {
Item1
window.alert("Item2")}>
- {" "}
- Item 2{" "}
+ Item 2
@@ -93,7 +92,6 @@ function App() {
style={{ height: "2rem" }}
onClick={() => window.alert("custom menu item")}
>
- {" "}
custom item
diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx
index ca329e3e62..6531e29ecf 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx
+++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/render-props.mdx
@@ -3,7 +3,7 @@
## renderTopRightUI
- (isMobile: boolean, appState:{" "}
+ (isMobile: boolean, appState:
AppState
@@ -29,8 +29,7 @@ function App() {
}}
onClick={() => window.alert("This is dummy top right UI")}
>
- {" "}
- Click me{" "}
+ Click me
);
}}
@@ -55,8 +54,7 @@ function App() {
(
- {" "}
- Dummy stats will be shown here{" "}
+ Dummy stats will be shown here
)}
/>
@@ -105,8 +103,7 @@ function App() {
return (
{
+ PanelComponent: ({ elements, appState, updateData, appProps }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return (
-
- updateData({ viewBackgroundColor: color })}
- isActive={appState.openPopup === "canvasColorPicker"}
- setActive={(active) =>
- updateData({ openPopup: active ? "canvasColorPicker" : null })
- }
- data-testid="canvas-background-picker"
- elements={elements}
- appState={appState}
- />
-
+ updateData({ viewBackgroundColor: color })}
+ data-testid="canvas-background-picker"
+ elements={elements}
+ appState={appState}
+ updateData={updateData}
+ />
);
},
});
diff --git a/src/actions/actionElementLock.test.tsx b/src/actions/actionElementLock.test.tsx
new file mode 100644
index 0000000000..19db5e3259
--- /dev/null
+++ b/src/actions/actionElementLock.test.tsx
@@ -0,0 +1,68 @@
+import { Excalidraw } from "../packages/excalidraw/index";
+import { queryByTestId, fireEvent } from "@testing-library/react";
+import { render } from "../tests/test-utils";
+import { Pointer, UI } from "../tests/helpers/ui";
+import { API } from "../tests/helpers/api";
+
+const { h } = window;
+const mouse = new Pointer("mouse");
+
+describe("element locking", () => {
+ it("should not show unlockAllElements action in contextMenu if no elements locked", async () => {
+ await render();
+
+ mouse.rightClickAt(0, 0);
+
+ const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
+ expect(item).toBe(null);
+ });
+
+ it("should unlock all elements and select them when using unlockAllElements action in contextMenu", async () => {
+ await render(
+ ,
+ );
+
+ mouse.rightClickAt(0, 0);
+
+ expect(Object.keys(h.state.selectedElementIds).length).toBe(0);
+ expect(h.elements.map((el) => el.locked)).toEqual([true, true, false]);
+
+ const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
+ expect(item).not.toBe(null);
+
+ fireEvent.click(item!.querySelector("button")!);
+
+ expect(h.elements.map((el) => el.locked)).toEqual([false, false, false]);
+ // should select the unlocked elements
+ expect(h.state.selectedElementIds).toEqual({
+ [h.elements[0].id]: true,
+ [h.elements[1].id]: true,
+ });
+ });
+});
diff --git a/src/actions/actionToggleLock.ts b/src/actions/actionElementLock.ts
similarity index 53%
rename from src/actions/actionToggleLock.ts
rename to src/actions/actionElementLock.ts
index c44bd57008..922a5fae38 100644
--- a/src/actions/actionToggleLock.ts
+++ b/src/actions/actionElementLock.ts
@@ -5,8 +5,11 @@ import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils";
import { register } from "./register";
-export const actionToggleLock = register({
- name: "toggleLock",
+const shouldLock = (elements: readonly ExcalidrawElement[]) =>
+ elements.every((el) => !el.locked);
+
+export const actionToggleElementLock = register({
+ name: "toggleElementLock",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true);
@@ -15,20 +18,21 @@ export const actionToggleLock = register({
return false;
}
- const operation = getOperation(selectedElements);
+ const nextLockState = shouldLock(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements);
- const lock = operation === "lock";
return {
elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
- return newElementWith(element, { locked: lock });
+ return newElementWith(element, { locked: nextLockState });
}),
appState: {
...appState,
- selectedLinearElement: lock ? null : appState.selectedLinearElement,
+ selectedLinearElement: nextLockState
+ ? null
+ : appState.selectedLinearElement,
},
commitToHistory: true,
};
@@ -41,7 +45,7 @@ export const actionToggleLock = register({
: "labels.elementLock.lock";
}
- return getOperation(selected) === "lock"
+ return shouldLock(selected)
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
@@ -55,6 +59,31 @@ export const actionToggleLock = register({
},
});
-const getOperation = (
- elements: readonly ExcalidrawElement[],
-): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
+export const actionUnlockAllElements = register({
+ name: "unlockAllElements",
+ trackEvent: { category: "canvas" },
+ viewMode: false,
+ predicate: (elements) => {
+ return elements.some((element) => element.locked);
+ },
+ perform: (elements, appState) => {
+ const lockedElements = elements.filter((el) => el.locked);
+
+ return {
+ elements: elements.map((element) => {
+ if (element.locked) {
+ return newElementWith(element, { locked: false });
+ }
+ return element;
+ }),
+ appState: {
+ ...appState,
+ selectedElementIds: Object.fromEntries(
+ lockedElements.map((el) => [el.id, true]),
+ ),
+ },
+ commitToHistory: true,
+ };
+ },
+ contextItemLabel: "labels.elementLock.unlockAll",
+});
diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts
index a4efbc88b5..b662308bd3 100644
--- a/src/actions/actionFlip.ts
+++ b/src/actions/actionFlip.ts
@@ -4,9 +4,8 @@ import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { resizeMultipleElements } from "../element/resizeElements";
import { AppState, PointerDownState } from "../types";
-import { updateBoundElements } from "../element/binding";
import { arrayToMap } from "../utils";
-import { KEYS } from "../keys";
+import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
const enableActionFlipHorizontal = (
@@ -33,7 +32,7 @@ export const actionFlipHorizontal = register({
commitToHistory: true,
};
},
- keyTest: (event) => event.shiftKey && event.code === "KeyH",
+ keyTest: (event) => event.shiftKey && event.code === CODES.H,
contextItemLabel: "labels.flipHorizontal",
predicate: (elements, appState) =>
enableActionFlipHorizontal(elements, appState),
@@ -50,7 +49,7 @@ export const actionFlipVertical = register({
};
},
keyTest: (event) =>
- event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
+ event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical",
predicate: (elements, appState) =>
enableActionFlipVertical(elements, appState),
diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx
index 382e964b9c..6e921d6ea8 100644
--- a/src/actions/actionProperties.tsx
+++ b/src/actions/actionProperties.tsx
@@ -1,7 +1,13 @@
import { AppState } from "../../src/types";
+import {
+ DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
+ DEFAULT_ELEMENT_BACKGROUND_PICKS,
+ DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
+ DEFAULT_ELEMENT_STROKE_PICKS,
+} from "../colors";
import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
-import { ColorPicker } from "../components/ColorPicker";
+import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { IconPicker } from "../components/IconPicker";
// TODO barnabasmolnar/editor-redesign
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
@@ -226,10 +232,12 @@ export const actionChangeStrokeColor = register({
commitToHistory: !!value.currentItemStrokeColor,
};
},
- PanelComponent: ({ elements, appState, updateData }) => (
+ PanelComponent: ({ elements, appState, updateData, appProps }) => (
<>
{t("labels.stroke")}
updateData({ currentItemStrokeColor: color })}
- isActive={appState.openPopup === "strokeColorPicker"}
- setActive={(active) =>
- updateData({ openPopup: active ? "strokeColorPicker" : null })
- }
elements={elements}
appState={appState}
+ updateData={updateData}
/>
>
),
@@ -269,10 +274,12 @@ export const actionChangeBackgroundColor = register({
commitToHistory: !!value.currentItemBackgroundColor,
};
},
- PanelComponent: ({ elements, appState, updateData }) => (
+ PanelComponent: ({ elements, appState, updateData, appProps }) => (
<>
{t("labels.background")}
updateData({ currentItemBackgroundColor: color })}
- isActive={appState.openPopup === "backgroundColorPicker"}
- setActive={(active) =>
- updateData({ openPopup: active ? "backgroundColorPicker" : null })
- }
elements={elements}
appState={appState}
+ updateData={updateData}
/>
>
),
diff --git a/src/actions/actionStyles.test.tsx b/src/actions/actionStyles.test.tsx
index c73864cc48..238196dfc9 100644
--- a/src/actions/actionStyles.test.tsx
+++ b/src/actions/actionStyles.test.tsx
@@ -1,9 +1,14 @@
import ExcalidrawApp from "../excalidraw-app";
-import { t } from "../i18n";
import { CODES } from "../keys";
import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
-import { fireEvent, render, screen } from "../tests/test-utils";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ togglePopover,
+} from "../tests/test-utils";
import { copiedStyles } from "./actionStyles";
const { h } = window;
@@ -14,7 +19,14 @@ describe("actionStyles", () => {
beforeEach(async () => {
await render();
});
- it("should copy & paste styles via keyboard", () => {
+
+ afterEach(async () => {
+ // https://github.com/floating-ui/floating-ui/issues/1908#issuecomment-1301553793
+ // affects node v16+
+ await act(async () => {});
+ });
+
+ it("should copy & paste styles via keyboard", async () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
@@ -24,10 +36,10 @@ describe("actionStyles", () => {
mouse.up(20, 20);
// Change some styles of second rectangle
- UI.clickLabeledElement("Stroke");
- UI.clickLabeledElement(t("colors.c92a2a"));
- UI.clickLabeledElement("Background");
- UI.clickLabeledElement(t("colors.e64980"));
+ togglePopover("Stroke");
+ UI.clickOnTestId("color-red");
+ togglePopover("Background");
+ UI.clickOnTestId("color-blue");
// Fill style
fireEvent.click(screen.getByTitle("Cross-hatch"));
// Stroke width
@@ -60,8 +72,8 @@ describe("actionStyles", () => {
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
- expect(firstRect.strokeColor).toBe("#c92a2a");
- expect(firstRect.backgroundColor).toBe("#e64980");
+ expect(firstRect.strokeColor).toBe("#e03131");
+ expect(firstRect.backgroundColor).toBe("#a5d8ff");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
diff --git a/src/actions/index.ts b/src/actions/index.ts
index eea4faf7d5..9b53f81731 100644
--- a/src/actions/index.ts
+++ b/src/actions/index.ts
@@ -84,5 +84,5 @@ export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink";
-export { actionToggleLock } from "./actionToggleLock";
+export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";
diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts
index be48c64707..b9c24a7572 100644
--- a/src/actions/shortcuts.ts
+++ b/src/actions/shortcuts.ts
@@ -34,7 +34,7 @@ export type ShortcutName =
| "flipHorizontal"
| "flipVertical"
| "hyperlink"
- | "toggleLock"
+ | "toggleElementLock"
>
| "saveScene"
| "imageExport";
@@ -80,7 +80,7 @@ const shortcutMap: Record = {
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
- toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
+ toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
diff --git a/src/actions/types.ts b/src/actions/types.ts
index b03e1053b2..277934adc9 100644
--- a/src/actions/types.ts
+++ b/src/actions/types.ts
@@ -111,7 +111,8 @@ export type ActionName =
| "unbindText"
| "hyperlink"
| "bindText"
- | "toggleLock"
+ | "unlockAllElements"
+ | "toggleElementLock"
| "toggleLinearEditor"
| "toggleEraserTool"
| "toggleHandTool"
diff --git a/src/appState.ts b/src/appState.ts
index 6f4db75572..4468eb9666 100644
--- a/src/appState.ts
+++ b/src/appState.ts
@@ -1,4 +1,4 @@
-import oc from "open-color";
+import { COLOR_PALETTE } from "./colors";
import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
@@ -58,7 +58,7 @@ export const getDefaultAppState = (): Omit<
fileHandle: null,
gridSize: null,
isBindingEnabled: true,
- isSidebarDocked: false,
+ defaultSidebarDockedPreference: false,
isLoading: false,
isResizing: false,
isRotating: false,
@@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit<
startBoundElement: null,
suggestedBindings: [],
toast: null,
- viewBackgroundColor: oc.white,
+ viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false,
zoom: {
value: 1 as NormalizedZoomValue,
@@ -150,7 +150,11 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
- isSidebarDocked: { browser: true, export: false, server: false },
+ defaultSidebarDockedPreference: {
+ browser: true,
+ export: false,
+ server: false,
+ },
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
diff --git a/src/charts.ts b/src/charts.ts
index c3b0950d1f..b5714686c2 100644
--- a/src/charts.ts
+++ b/src/charts.ts
@@ -1,5 +1,14 @@
-import colors from "./colors";
-import { DEFAULT_FONT_SIZE, ENV } from "./constants";
+import {
+ COLOR_PALETTE,
+ DEFAULT_CHART_COLOR_INDEX,
+ getAllColorsSpecificShade,
+} from "./colors";
+import {
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_FONT_SIZE,
+ ENV,
+ VERTICAL_ALIGN,
+} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
@@ -153,15 +162,22 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
return result;
};
-const bgColors = colors.elementBackground.slice(
- 2,
- colors.elementBackground.length,
-);
+const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
// Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values
const commonProps = {
- strokeColor: colors.elementStroke[0],
+ fillStyle: "hachure",
+ fontFamily: DEFAULT_FONT_FAMILY,
+ fontSize: DEFAULT_FONT_SIZE,
+ opacity: 100,
+ roughness: 1,
+ strokeColor: COLOR_PALETTE.black,
+ roundness: null,
+ strokeStyle: "solid",
+ strokeWidth: 1,
+ verticalAlign: VERTICAL_ALIGN.MIDDLE,
+ locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
@@ -322,7 +338,7 @@ const chartBaseElements = (
y: y - chartHeight,
width: chartWidth,
height: chartHeight,
- strokeColor: colors.elementStroke[0],
+ strokeColor: COLOR_PALETTE.black,
fillStyle: "solid",
opacity: 6,
})
diff --git a/src/clients.ts b/src/clients.ts
index 9e1e6e144a..604936e334 100644
--- a/src/clients.ts
+++ b/src/clients.ts
@@ -1,6 +1,17 @@
-import colors from "./colors";
+import {
+ DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
+ DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
+ getAllColorsSpecificShade,
+} from "./colors";
import { AppState } from "./types";
+const BG_COLORS = getAllColorsSpecificShade(
+ DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
+);
+const STROKE_COLORS = getAllColorsSpecificShade(
+ DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
+);
+
export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
@@ -11,18 +22,19 @@ export const getClientColors = (clientId: string, appState: AppState) => {
// Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
- // Skip transparent & gray colors
- const backgrounds = colors.elementBackground.slice(3);
- const strokes = colors.elementStroke.slice(3);
return {
- background: backgrounds[sum % backgrounds.length],
- stroke: strokes[sum % strokes.length],
+ background: BG_COLORS[sum % BG_COLORS.length],
+ stroke: STROKE_COLORS[sum % STROKE_COLORS.length],
};
};
-export const getClientInitials = (userName?: string | null) => {
- if (!userName?.trim()) {
- return "?";
- }
- return userName.trim()[0].toUpperCase();
+/**
+ * returns first char, capitalized
+ */
+export const getNameInitial = (name?: string | null) => {
+ // first char can be a surrogate pair, hence using codePointAt
+ const firstCodePoint = name?.trim()?.codePointAt(0);
+ return (
+ firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
+ ).toUpperCase();
};
diff --git a/src/colors.ts b/src/colors.ts
index 63cd728395..198ec12e23 100644
--- a/src/colors.ts
+++ b/src/colors.ts
@@ -1,22 +1,167 @@
import oc from "open-color";
+import { Merge } from "./utility-types";
-const shades = (index: number) => [
- oc.red[index],
- oc.pink[index],
- oc.grape[index],
- oc.violet[index],
- oc.indigo[index],
- oc.blue[index],
- oc.cyan[index],
- oc.teal[index],
- oc.green[index],
- oc.lime[index],
- oc.yellow[index],
- oc.orange[index],
-];
-
-export default {
- canvasBackground: [oc.white, oc.gray[0], oc.gray[1], ...shades(0)],
- elementBackground: ["transparent", oc.gray[4], oc.gray[6], ...shades(6)],
- elementStroke: [oc.black, oc.gray[8], oc.gray[7], ...shades(9)],
+// FIXME can't put to utils.ts rn because of circular dependency
+const pick = , K extends readonly (keyof R)[]>(
+ source: R,
+ keys: K,
+) => {
+ return keys.reduce((acc, key: K[number]) => {
+ if (key in source) {
+ acc[key] = source[key];
+ }
+ return acc;
+ }, {} as Pick) as Pick;
};
+
+export type ColorPickerColor =
+ | Exclude
+ | "transparent"
+ | "bronze";
+export type ColorTuple = readonly [string, string, string, string, string];
+export type ColorPalette = Merge<
+ Record,
+ { black: string; white: string; transparent: string }
+>;
+
+// used general type instead of specific type (ColorPalette) to support custom colors
+export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
+export type ColorShadesIndexes = [number, number, number, number, number];
+
+export const MAX_CUSTOM_COLORS_USED_IN_CANVAS = 5;
+export const COLORS_PER_ROW = 5;
+
+export const DEFAULT_CHART_COLOR_INDEX = 4;
+
+export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
+export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
+export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
+export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
+
+export const getSpecificColorShades = (
+ color: Exclude<
+ ColorPickerColor,
+ "transparent" | "white" | "black" | "bronze"
+ >,
+ indexArr: Readonly,
+) => {
+ return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
+};
+
+export const COLOR_PALETTE = {
+ transparent: "transparent",
+ black: "#1e1e1e",
+ white: "#ffffff",
+ // open-colors
+ gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
+ red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
+ pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
+ grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
+ violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
+ blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
+ cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
+ teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
+ green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
+ yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
+ orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
+ // radix bronze shades 3,5,7,9,11
+ bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
+} as ColorPalette;
+
+const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
+ "cyan",
+ "blue",
+ "violet",
+ "grape",
+ "pink",
+ "green",
+ "teal",
+ "yellow",
+ "orange",
+ "red",
+]);
+
+// -----------------------------------------------------------------------------
+// quick picks defaults
+// -----------------------------------------------------------------------------
+
+// ORDER matters for positioning in quick picker
+export const DEFAULT_ELEMENT_STROKE_PICKS = [
+ COLOR_PALETTE.black,
+ COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
+ COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
+ COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
+ COLOR_PALETTE.yellow[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
+] as ColorTuple;
+
+// ORDER matters for positioning in quick picker
+export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
+ COLOR_PALETTE.transparent,
+ COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
+ COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
+ COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
+ COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
+] as ColorTuple;
+
+// ORDER matters for positioning in quick picker
+export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
+ COLOR_PALETTE.white,
+ // radix slate2
+ "#f8f9fa",
+ // radix blue2
+ "#f5faff",
+ // radix yellow2
+ "#fffce8",
+ // radix bronze2
+ "#fdf8f6",
+] as ColorTuple;
+
+// -----------------------------------------------------------------------------
+// palette defaults
+// -----------------------------------------------------------------------------
+
+export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
+ // 1st row
+ transparent: COLOR_PALETTE.transparent,
+ white: COLOR_PALETTE.white,
+ gray: COLOR_PALETTE.gray,
+ black: COLOR_PALETTE.black,
+ bronze: COLOR_PALETTE.bronze,
+ // rest
+ ...COMMON_ELEMENT_SHADES,
+} as const;
+
+// ORDER matters for positioning in pallete (5x3 grid)s
+export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
+ transparent: COLOR_PALETTE.transparent,
+ white: COLOR_PALETTE.white,
+ gray: COLOR_PALETTE.gray,
+ black: COLOR_PALETTE.black,
+ bronze: COLOR_PALETTE.bronze,
+
+ ...COMMON_ELEMENT_SHADES,
+} as const;
+
+// -----------------------------------------------------------------------------
+// helpers
+// -----------------------------------------------------------------------------
+
+// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
+export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
+ [
+ // 2nd row
+ COLOR_PALETTE.cyan[index],
+ COLOR_PALETTE.blue[index],
+ COLOR_PALETTE.violet[index],
+ COLOR_PALETTE.grape[index],
+ COLOR_PALETTE.pink[index],
+
+ // 3rd row
+ COLOR_PALETTE.green[index],
+ COLOR_PALETTE.teal[index],
+ COLOR_PALETTE.yellow[index],
+ COLOR_PALETTE.orange[index],
+ COLOR_PALETTE.red[index],
+ ] as const;
+
+// -----------------------------------------------------------------------------
diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx
index 3bbc0ff1a6..875d8447ca 100644
--- a/src/components/Actions.tsx
+++ b/src/components/Actions.tsx
@@ -14,7 +14,7 @@ import {
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
-import { AppState, Zoom } from "../types";
+import { UIAppState, Zoom } from "../types";
import {
capitalizeString,
isTransparent,
@@ -28,19 +28,20 @@ import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
-import "./Actions.scss";
import { Tooltip } from "./Tooltip";
import {
shouldAllowVerticalAlign,
suppportsHorizontalAlign,
} from "../element/textElement";
+import "./Actions.scss";
+
export const SelectedShapeActions = ({
appState,
elements,
renderAction,
}: {
- appState: AppState;
+ appState: UIAppState;
elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
}) => {
@@ -215,10 +216,10 @@ export const ShapesSwitcher = ({
appState,
}: {
canvas: HTMLCanvasElement | null;
- activeTool: AppState["activeTool"];
- setAppState: React.Component["setState"];
+ activeTool: UIAppState["activeTool"];
+ setAppState: React.Component["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
- appState: AppState;
+ appState: UIAppState;
}) => (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
diff --git a/src/components/App.tsx b/src/components/App.tsx
index d22a0507cb..e6f9616985 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -33,7 +33,7 @@ import {
actionBindText,
actionUngroup,
actionLink,
- actionToggleLock,
+ actionToggleElementLock,
actionToggleLinearEditor,
} from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
@@ -59,6 +59,7 @@ import {
ELEMENT_TRANSLATE_AMOUNT,
ENV,
EVENT,
+ EXPORT_IMAGE_TYPES,
GRID_SIZE,
IMAGE_MIME_TYPES,
IMAGE_RENDER_TIMEOUT,
@@ -82,7 +83,7 @@ import {
VERTICAL_ALIGN,
ZOOM_STEP,
} from "../constants";
-import { loadFromBlob } from "../data";
+import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restore, restoreElements } from "../data/restore";
import {
@@ -210,6 +211,8 @@ import {
PointerDownState,
SceneData,
Device,
+ SidebarName,
+ SidebarTabName,
} from "../types";
import {
debounce,
@@ -235,6 +238,7 @@ import {
getShortcutKey,
isTransparent,
easeToValuesRAF,
+ muteFSAbortError,
} from "../utils";
import {
ContextMenu,
@@ -249,6 +253,7 @@ import {
generateIdFromFile,
getDataURL,
getFileFromEvent,
+ isImageFileHandle,
isSupportedImageFile,
loadSceneOrLibraryFromBlob,
normalizeFile,
@@ -288,6 +293,7 @@ import {
isLocalLink,
} from "../element/Hyperlink";
import { shouldShowBoundingBox } from "../element/transformHandles";
+import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard";
import {
@@ -299,11 +305,15 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
+const AppContext = React.createContext(null!);
+const AppPropsContext = React.createContext(null!);
+
const deviceContextInitialValue = {
isSmScreen: false,
isMobile: false,
isTouchScreen: false,
canDeviceFitSidebar: false,
+ isLandscape: false,
};
const DeviceContext = React.createContext(deviceContextInitialValue);
DeviceContext.displayName = "DeviceContext";
@@ -340,6 +350,8 @@ const ExcalidrawActionManagerContext = React.createContext(
);
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
+export const useApp = () => useContext(AppContext);
+export const useAppProps = () => useContext(AppPropsContext);
export const useDevice = () => useContext(DeviceContext);
export const useExcalidrawContainer = () =>
useContext(ExcalidrawContainerContext);
@@ -400,7 +412,7 @@ class App extends React.Component {
private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
- private id: string;
+ public id: string;
private history: History;
private excalidrawContainerValue: {
container: HTMLDivElement | null;
@@ -438,7 +450,7 @@ class App extends React.Component {
width: window.innerWidth,
height: window.innerHeight,
showHyperlinkPopup: false,
- isSidebarDocked: false,
+ defaultSidebarDockedPreference: false,
};
this.id = nanoid();
@@ -469,7 +481,7 @@ class App extends React.Component {
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
- toggleMenu: this.toggleMenu,
+ toggleSidebar: this.toggleSidebar,
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
@@ -577,101 +589,92 @@ class App extends React.Component {
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
}
>
-
-
-
-
-
-
-
- this.addElementsFromPasteOrLibrary({
- elements,
- position: "center",
- files: null,
- })
- }
- langCode={getLanguage().code}
- renderTopRightUI={renderTopRightUI}
- renderCustomStats={renderCustomStats}
- renderCustomSidebar={this.props.renderSidebar}
- showExitZenModeBtn={
- typeof this.props?.zenModeEnabled === "undefined" &&
- this.state.zenModeEnabled
- }
- libraryReturnUrl={this.props.libraryReturnUrl}
- UIOptions={this.props.UIOptions}
- focusContainer={this.focusContainer}
- library={this.library}
- id={this.id}
- onImageAction={this.onImageAction}
- renderWelcomeScreen={
- !this.state.isLoading &&
- this.state.showWelcomeScreen &&
- this.state.activeTool.type === "selection" &&
- !this.scene.getElementsIncludingDeleted().length
- }
+
+
+
+
+
+
+
- {this.props.children}
-
-
-
- {selectedElement.length === 1 &&
- !this.state.contextMenu &&
- this.state.showHyperlinkPopup && (
-
+
- )}
- {this.state.toast !== null && (
- this.setToast(null)}
- duration={this.state.toast.duration}
- closable={this.state.toast.closable}
- />
- )}
- {this.state.contextMenu && (
-
- )}
- {this.renderCanvas()}
-
- {" "}
-
-
-
-
+ actionManager={this.actionManager}
+ elements={this.scene.getNonDeletedElements()}
+ onLockToggle={this.toggleLock}
+ onPenModeToggle={this.togglePenMode}
+ onHandToolToggle={this.onHandToolToggle}
+ langCode={getLanguage().code}
+ renderTopRightUI={renderTopRightUI}
+ renderCustomStats={renderCustomStats}
+ showExitZenModeBtn={
+ typeof this.props?.zenModeEnabled === "undefined" &&
+ this.state.zenModeEnabled
+ }
+ UIOptions={this.props.UIOptions}
+ onImageAction={this.onImageAction}
+ onExportImage={this.onExportImage}
+ renderWelcomeScreen={
+ !this.state.isLoading &&
+ this.state.showWelcomeScreen &&
+ this.state.activeTool.type === "selection" &&
+ !this.scene.getElementsIncludingDeleted().length
+ }
+ >
+ {this.props.children}
+
+
+
+ {selectedElement.length === 1 &&
+ !this.state.contextMenu &&
+ this.state.showHyperlinkPopup && (
+
+ )}
+ {this.state.toast !== null && (
+ this.setToast(null)}
+ duration={this.state.toast.duration}
+ closable={this.state.toast.closable}
+ />
+ )}
+ {this.state.contextMenu && (
+
+ )}
+ {this.renderCanvas()}
+
+
+
+
+
+
+
+
);
}
public focusContainer: AppClassProperties["focusContainer"] = () => {
- if (this.props.autoFocus) {
- this.excalidrawContainerRef.current?.focus();
- }
+ this.excalidrawContainerRef.current?.focus();
};
public getSceneElementsIncludingDeleted = () => {
@@ -682,6 +685,45 @@ class App extends React.Component {
return this.scene.getNonDeletedElements();
};
+ public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
+ this.addElementsFromPasteOrLibrary({
+ elements,
+ position: "center",
+ files: null,
+ });
+ };
+
+ public onExportImage = async (
+ type: keyof typeof EXPORT_IMAGE_TYPES,
+ elements: readonly NonDeletedExcalidrawElement[],
+ ) => {
+ trackEvent("export", type, "ui");
+ const fileHandle = await exportCanvas(
+ type,
+ elements,
+ this.state,
+ this.files,
+ {
+ exportBackground: this.state.exportBackground,
+ name: this.state.name,
+ viewBackgroundColor: this.state.viewBackgroundColor,
+ },
+ )
+ .catch(muteFSAbortError)
+ .catch((error) => {
+ console.error(error);
+ this.setState({ errorMessage: error.message });
+ });
+
+ if (
+ this.state.exportEmbedScene &&
+ fileHandle &&
+ isImageFileHandle(fileHandle)
+ ) {
+ this.setState({ fileHandle });
+ }
+ };
+
private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) {
@@ -906,6 +948,7 @@ class App extends React.Component {
? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
this.device = updateObject(this.device, {
+ isLandscape: width > height,
isSmScreen: width < MQ_SM_MAX_WIDTH,
isMobile:
width < MQ_MAX_WIDTH_PORTRAIT ||
@@ -951,7 +994,7 @@ class App extends React.Component {
this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners();
- if (this.excalidrawContainerRef.current) {
+ if (this.props.autoFocus && this.excalidrawContainerRef.current) {
this.focusContainer();
}
@@ -1679,7 +1722,7 @@ class App extends React.Component {
openSidebar:
this.state.openSidebar &&
this.device.canDeviceFitSidebar &&
- this.state.isSidebarDocked
+ this.state.defaultSidebarDockedPreference
? this.state.openSidebar
: null,
selectedElementIds: newElements.reduce(
@@ -2017,30 +2060,24 @@ class App extends React.Component {
/**
* @returns whether the menu was toggled on or off
*/
- public toggleMenu = (
- type: "library" | "customSidebar",
- force?: boolean,
- ): boolean => {
- if (type === "customSidebar" && !this.props.renderSidebar) {
- console.warn(
- `attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
- );
- return false;
+ public toggleSidebar = ({
+ name,
+ tab,
+ force,
+ }: {
+ name: SidebarName;
+ tab?: SidebarTabName;
+ force?: boolean;
+ }): boolean => {
+ let nextName;
+ if (force === undefined) {
+ nextName = this.state.openSidebar?.name === name ? null : name;
+ } else {
+ nextName = force ? name : null;
}
+ this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
- if (type === "library" || type === "customSidebar") {
- let nextValue;
- if (force === undefined) {
- nextValue = this.state.openSidebar === type ? null : type;
- } else {
- nextValue = force ? type : null;
- }
- this.setState({ openSidebar: nextValue });
-
- return !!nextValue;
- }
-
- return false;
+ return !!nextName;
};
private updateCurrentCursorPosition = withBatchedUpdates(
@@ -2288,11 +2325,11 @@ class App extends React.Component {
(hasBackground(this.state.activeTool.type) ||
selectedElements.some((element) => hasBackground(element.type)))
) {
- this.setState({ openPopup: "backgroundColorPicker" });
+ this.setState({ openPopup: "elementBackground" });
event.stopPropagation();
}
if (event.key === KEYS.S) {
- this.setState({ openPopup: "strokeColorPicker" });
+ this.setState({ openPopup: "elementStroke" });
event.stopPropagation();
}
}
@@ -6348,6 +6385,7 @@ class App extends React.Component {
copyText,
CONTEXT_MENU_SEPARATOR,
actionSelectAll,
+ actionUnlockAllElements,
CONTEXT_MENU_SEPARATOR,
actionToggleGridMode,
actionToggleZenMode,
@@ -6394,7 +6432,7 @@ class App extends React.Component {
actionToggleLinearEditor,
actionLink,
actionDuplicateSelection,
- actionToggleLock,
+ actionToggleElementLock,
CONTEXT_MENU_SEPARATOR,
actionDeleteSelected,
];
diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx
index 57a7eec261..20dc7b9f2b 100644
--- a/src/components/Avatar.tsx
+++ b/src/components/Avatar.tsx
@@ -1,7 +1,7 @@
import "./Avatar.scss";
import React, { useState } from "react";
-import { getClientInitials } from "../clients";
+import { getNameInitial } from "../clients";
type AvatarProps = {
onClick: (e: React.MouseEvent) => void;
@@ -12,7 +12,7 @@ type AvatarProps = {
};
export const Avatar = ({ color, onClick, name, src }: AvatarProps) => {
- const shortName = getClientInitials(name);
+ const shortName = getNameInitial(name);
const [error, setError] = useState(false);
const loadImg = !error && src;
const style = loadImg ? undefined : { background: color };
diff --git a/src/components/BraveMeasureTextError.tsx b/src/components/BraveMeasureTextError.tsx
index 8a4a71e4fd..1932d7a294 100644
--- a/src/components/BraveMeasureTextError.tsx
+++ b/src/components/BraveMeasureTextError.tsx
@@ -1,39 +1,40 @@
-import { t } from "../i18n";
+import Trans from "./Trans";
+
const BraveMeasureTextError = () => {
return (
- {t("errors.brave_measure_text_error.start")}
-
- {t("errors.brave_measure_text_error.aggressive_block_fingerprint")}
- {" "}
- {t("errors.brave_measure_text_error.setting_enabled")}.
-
-
- {t("errors.brave_measure_text_error.break")}{" "}
-
- {t("errors.brave_measure_text_error.text_elements")}
- {" "}
- {t("errors.brave_measure_text_error.in_your_drawings")}.
+ {el}}
+ />
- {t("errors.brave_measure_text_error.strongly_recommend")}{" "}
-
- {" "}
- {t("errors.brave_measure_text_error.steps")}
- {" "}
- {t("errors.brave_measure_text_error.how")}.
+ {el}}
+ />
- {t("errors.brave_measure_text_error.disable_setting")}{" "}
-
- {t("errors.brave_measure_text_error.issue")}
- {" "}
- {t("errors.brave_measure_text_error.write")}{" "}
-
- {t("errors.brave_measure_text_error.discord")}
-
- .
+ (
+
+ {el}
+
+ )}
+ />
+
+
+ (
+
+ {el}
+
+ )}
+ discordLink={(el) => {el}.}
+ />
);
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 3303c3ebf8..bf548d72fb 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -1,8 +1,12 @@
+import clsx from "clsx";
+import { composeEventHandlers } from "../utils";
import "./Button.scss";
interface ButtonProps extends React.HTMLAttributes {
type?: "button" | "submit" | "reset";
onSelect: () => any;
+ /** whether button is in active state */
+ selected?: boolean;
children: React.ReactNode;
className?: string;
}
@@ -15,18 +19,18 @@ interface ButtonProps extends React.HTMLAttributes {
export const Button = ({
type = "button",
onSelect,
+ selected,
children,
className = "",
...rest
}: ButtonProps) => {
return (