Переглянути джерело

feat: color picker redesign (#6216)

Co-authored-by: Maielo <[email protected]>
Co-authored-by: dwelle <[email protected]>
Co-authored-by: Aakansha Doshi <[email protected]>
Barnabás Molnár 2 роки тому
батько
коміт
5b7596582f
55 змінених файлів з 2431 додано та 1303 видалено
  1. 1 0
      .npmrc
  2. 1 0
      package.json
  3. 15 17
      src/actions/actionCanvas.tsx
  4. 3 3
      src/actions/actionFlip.ts
  5. 15 11
      src/actions/actionProperties.tsx
  6. 21 9
      src/actions/actionStyles.test.tsx
  7. 2 2
      src/appState.ts
  8. 24 8
      src/charts.ts
  9. 14 6
      src/clients.ts
  10. 164 19
      src/colors.ts
  11. 4 2
      src/components/App.tsx
  12. 0 430
      src/components/ColorPicker.tsx
  13. 75 0
      src/components/ColorPicker/ColorInput.tsx
  14. 158 3
      src/components/ColorPicker/ColorPicker.scss
  15. 235 0
      src/components/ColorPicker/ColorPicker.tsx
  16. 63 0
      src/components/ColorPicker/CustomColorList.tsx
  17. 29 0
      src/components/ColorPicker/HotkeyLabel.tsx
  18. 156 0
      src/components/ColorPicker/Picker.tsx
  19. 86 0
      src/components/ColorPicker/PickerColorList.tsx
  20. 7 0
      src/components/ColorPicker/PickerHeading.tsx
  21. 105 0
      src/components/ColorPicker/ShadeList.tsx
  22. 64 0
      src/components/ColorPicker/TopPicks.tsx
  23. 139 0
      src/components/ColorPicker/colorPickerUtils.ts
  24. 249 0
      src/components/ColorPicker/keyboardNavHandlers.ts
  25. 2 2
      src/components/LibraryUnit.tsx
  26. 2 1
      src/components/ProjectName.tsx
  27. 1 1
      src/components/dropdownMenu/DropdownMenuContent.tsx
  28. 3 3
      src/constants.ts
  29. 3 3
      src/data/restore.ts
  30. 1 1
      src/element/textWysiwyg.test.tsx
  31. 40 14
      src/element/textWysiwyg.tsx
  32. 4 1
      src/excalidraw-app/collab/RoomDialog.tsx
  33. 11 3
      src/i18n.ts
  34. 21 44
      src/locales/en.json
  35. 5 2
      src/packages/excalidraw/example/App.tsx
  36. 1 1
      src/packages/excalidraw/package.json
  37. 4 4
      src/packages/excalidraw/yarn.lock
  38. 1 0
      src/tests/MobileMenu.test.tsx
  39. 115 115
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  40. 5 5
      src/tests/__snapshots__/dragCreate.test.tsx.snap
  41. 1 1
      src/tests/__snapshots__/linearElementEditor.test.tsx.snap
  42. 6 6
      src/tests/__snapshots__/move.test.tsx.snap
  43. 2 2
      src/tests/__snapshots__/multiPointCreate.test.tsx.snap
  44. 149 107
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  45. 5 5
      src/tests/__snapshots__/selection.test.tsx.snap
  46. 14 7
      src/tests/contextmenu.test.tsx
  47. 6 6
      src/tests/data/__snapshots__/restore.test.ts.snap
  48. 9 0
      src/tests/helpers/ui.ts
  49. 72 26
      src/tests/packages/__snapshots__/excalidraw.test.tsx.snap
  50. 1 1
      src/tests/packages/__snapshots__/utils.test.ts.snap
  51. 14 15
      src/tests/regressionTests.test.tsx
  52. 22 5
      src/tests/test-utils.ts
  53. 2 5
      src/types.ts
  54. 2 3
      src/utils.ts
  55. 277 404
      yarn.lock

+ 1 - 0
.npmrc

@@ -1 +1,2 @@
 save-exact=true
+legacy-peer-deps=true

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     ]
   },
   "dependencies": {
+    "@radix-ui/react-popover": "1.0.3",
     "@radix-ui/react-tabs": "1.0.2",
     "@sentry/browser": "6.2.5",
     "@sentry/integrations": "6.2.5",

+ 15 - 17
src/actions/actionCanvas.tsx

@@ -1,4 +1,4 @@
-import { ColorPicker } from "../components/ColorPicker";
+import { ColorPicker } from "../components/ColorPicker/ColorPicker";
 import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
@@ -19,6 +19,7 @@ import {
   isEraserActive,
   isHandToolActive,
 } from "../appState";
+import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
@@ -35,24 +36,21 @@ export const actionChangeViewBackgroundColor = register({
       commitToHistory: !!value.viewBackgroundColor,
     };
   },
-  PanelComponent: ({ elements, appState, updateData }) => {
+  PanelComponent: ({ elements, appState, updateData, appProps }) => {
     // FIXME move me to src/components/mainMenu/DefaultItems.tsx
     return (
-      <div style={{ position: "relative" }}>
-        <ColorPicker
-          label={t("labels.canvasBackground")}
-          type="canvasBackground"
-          color={appState.viewBackgroundColor}
-          onChange={(color) => updateData({ viewBackgroundColor: color })}
-          isActive={appState.openPopup === "canvasColorPicker"}
-          setActive={(active) =>
-            updateData({ openPopup: active ? "canvasColorPicker" : null })
-          }
-          data-testid="canvas-background-picker"
-          elements={elements}
-          appState={appState}
-        />
-      </div>
+      <ColorPicker
+        palette={null}
+        topPicks={DEFAULT_CANVAS_BACKGROUND_PICKS}
+        label={t("labels.canvasBackground")}
+        type="canvasBackground"
+        color={appState.viewBackgroundColor}
+        onChange={(color) => updateData({ viewBackgroundColor: color })}
+        data-testid="canvas-background-picker"
+        elements={elements}
+        appState={appState}
+        updateData={updateData}
+      />
     );
   },
 });

+ 3 - 3
src/actions/actionFlip.ts

@@ -14,7 +14,7 @@ import {
 } from "../element/bounds";
 import { isLinearElement } from "../element/typeChecks";
 import { LinearElementEditor } from "../element/linearElementEditor";
-import { KEYS } from "../keys";
+import { CODES, KEYS } from "../keys";
 
 const enableActionFlipHorizontal = (
   elements: readonly ExcalidrawElement[],
@@ -48,7 +48,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),
@@ -65,7 +65,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),

+ 15 - 11
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 }) => (
     <>
       <h3 aria-hidden="true">{t("labels.stroke")}</h3>
       <ColorPicker
+        topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
+        palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
         type="elementStroke"
         label={t("labels.stroke")}
         color={getFormValue(
@@ -239,12 +247,9 @@ export const actionChangeStrokeColor = register({
           appState.currentItemStrokeColor,
         )}
         onChange={(color) => 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 }) => (
     <>
       <h3 aria-hidden="true">{t("labels.background")}</h3>
       <ColorPicker
+        topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
+        palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
         type="elementBackground"
         label={t("labels.background")}
         color={getFormValue(
@@ -282,12 +289,9 @@ export const actionChangeBackgroundColor = register({
           appState.currentItemBackgroundColor,
         )}
         onChange={(color) => updateData({ currentItemBackgroundColor: color })}
-        isActive={appState.openPopup === "backgroundColorPicker"}
-        setActive={(active) =>
-          updateData({ openPopup: active ? "backgroundColorPicker" : null })
-        }
         elements={elements}
         appState={appState}
+        updateData={updateData}
       />
     </>
   ),

+ 21 - 9
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(<ExcalidrawApp />);
   });
-  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");

+ 2 - 2
src/appState.ts

@@ -1,4 +1,4 @@
-import oc from "open-color";
+import { COLOR_PALETTE } from "./colors";
 import {
   DEFAULT_ELEMENT_PROPS,
   DEFAULT_FONT_FAMILY,
@@ -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,

+ 24 - 8
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,
       })

+ 14 - 6
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,12 +22,9 @@ 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],
   };
 };
 

+ 164 - 19
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 = <R extends Record<string, any>, 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<R, K[number]>) as Pick<R, K[number]>;
 };
+
+export type ColorPickerColor =
+  | Exclude<keyof oc, "indigo" | "lime">
+  | "transparent"
+  | "bronze";
+export type ColorTuple = readonly [string, string, string, string, string];
+export type ColorPalette = Merge<
+  Record<ColorPickerColor, ColorTuple>,
+  { 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<ColorShadesIndexes>,
+) => {
+  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;
+
+// -----------------------------------------------------------------------------

+ 4 - 2
src/components/App.tsx

@@ -313,6 +313,7 @@ const deviceContextInitialValue = {
   isMobile: false,
   isTouchScreen: false,
   canDeviceFitSidebar: false,
+  isLandscape: false,
 };
 const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
 DeviceContext.displayName = "DeviceContext";
@@ -947,6 +948,7 @@ class App extends React.Component<AppProps, AppState> {
         ? 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 ||
@@ -2323,11 +2325,11 @@ class App extends React.Component<AppProps, AppState> {
           (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();
         }
       }

+ 0 - 430
src/components/ColorPicker.tsx

@@ -1,430 +0,0 @@
-import React from "react";
-import { Popover } from "./Popover";
-import { isTransparent } from "../utils";
-
-import "./ColorPicker.scss";
-import { isArrowKey, KEYS } from "../keys";
-import { t, getLanguage } from "../i18n";
-import { isWritableElement } from "../utils";
-import colors from "../colors";
-import { ExcalidrawElement } from "../element/types";
-import { AppState } from "../types";
-
-const MAX_CUSTOM_COLORS = 5;
-const MAX_DEFAULT_COLORS = 15;
-
-export const getCustomColors = (
-  elements: readonly ExcalidrawElement[],
-  type: "elementBackground" | "elementStroke",
-) => {
-  const customColors: string[] = [];
-  const updatedElements = elements
-    .filter((element) => !element.isDeleted)
-    .sort((ele1, ele2) => ele2.updated - ele1.updated);
-
-  let index = 0;
-  const elementColorTypeMap = {
-    elementBackground: "backgroundColor",
-    elementStroke: "strokeColor",
-  };
-  const colorType = elementColorTypeMap[type] as
-    | "backgroundColor"
-    | "strokeColor";
-  while (
-    index < updatedElements.length &&
-    customColors.length < MAX_CUSTOM_COLORS
-  ) {
-    const element = updatedElements[index];
-
-    if (
-      customColors.length < MAX_CUSTOM_COLORS &&
-      isCustomColor(element[colorType], type) &&
-      !customColors.includes(element[colorType])
-    ) {
-      customColors.push(element[colorType]);
-    }
-    index++;
-  }
-  return customColors;
-};
-
-const isCustomColor = (
-  color: string,
-  type: "elementBackground" | "elementStroke",
-) => {
-  return !colors[type].includes(color);
-};
-
-const isValidColor = (color: string) => {
-  const style = new Option().style;
-  style.color = color;
-  return !!style.color;
-};
-
-const getColor = (color: string): string | null => {
-  if (isTransparent(color)) {
-    return color;
-  }
-
-  // testing for `#` first fixes a bug on Electron (more specfically, an
-  // Obsidian popout window), where a hex color without `#` is (incorrectly)
-  // considered valid
-  return isValidColor(`#${color}`)
-    ? `#${color}`
-    : isValidColor(color)
-    ? color
-    : null;
-};
-
-// This is a narrow reimplementation of the awesome react-color Twitter component
-// https://github.com/casesandberg/react-color/blob/master/src/components/twitter/Twitter.js
-
-// Unfortunately, we can't detect keyboard layout in the browser. So this will
-// only work well for QWERTY but not AZERTY or others...
-const keyBindings = [
-  ["1", "2", "3", "4", "5"],
-  ["q", "w", "e", "r", "t"],
-  ["a", "s", "d", "f", "g"],
-  ["z", "x", "c", "v", "b"],
-].flat();
-
-const Picker = ({
-  colors,
-  color,
-  onChange,
-  onClose,
-  label,
-  showInput = true,
-  type,
-  elements,
-}: {
-  colors: string[];
-  color: string | null;
-  onChange: (color: string) => void;
-  onClose: () => void;
-  label: string;
-  showInput: boolean;
-  type: "canvasBackground" | "elementBackground" | "elementStroke";
-  elements: readonly ExcalidrawElement[];
-}) => {
-  const firstItem = React.useRef<HTMLButtonElement>();
-  const activeItem = React.useRef<HTMLButtonElement>();
-  const gallery = React.useRef<HTMLDivElement>();
-  const colorInput = React.useRef<HTMLInputElement>();
-
-  const [customColors] = React.useState(() => {
-    if (type === "canvasBackground") {
-      return [];
-    }
-    return getCustomColors(elements, type);
-  });
-
-  React.useEffect(() => {
-    // After the component is first mounted focus on first input
-    if (activeItem.current) {
-      activeItem.current.focus();
-    } else if (colorInput.current) {
-      colorInput.current.focus();
-    } else if (gallery.current) {
-      gallery.current.focus();
-    }
-  }, []);
-
-  const handleKeyDown = (event: React.KeyboardEvent) => {
-    let handled = false;
-    if (isArrowKey(event.key)) {
-      handled = true;
-      const { activeElement } = document;
-      const isRTL = getLanguage().rtl;
-      let isCustom = false;
-      let index = Array.prototype.indexOf.call(
-        gallery.current!.querySelector(".color-picker-content--default")
-          ?.children,
-        activeElement,
-      );
-      if (index === -1) {
-        index = Array.prototype.indexOf.call(
-          gallery.current!.querySelector(".color-picker-content--canvas-colors")
-            ?.children,
-          activeElement,
-        );
-        if (index !== -1) {
-          isCustom = true;
-        }
-      }
-      const parentElement = isCustom
-        ? gallery.current?.querySelector(".color-picker-content--canvas-colors")
-        : gallery.current?.querySelector(".color-picker-content--default");
-
-      if (parentElement && index !== -1) {
-        const length = parentElement.children.length - (showInput ? 1 : 0);
-        const nextIndex =
-          event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
-            ? (index + 1) % length
-            : event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
-            ? (length + index - 1) % length
-            : !isCustom && event.key === KEYS.ARROW_DOWN
-            ? (index + 5) % length
-            : !isCustom && event.key === KEYS.ARROW_UP
-            ? (length + index - 5) % length
-            : index;
-        (parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
-      }
-      event.preventDefault();
-    } else if (
-      keyBindings.includes(event.key.toLowerCase()) &&
-      !event[KEYS.CTRL_OR_CMD] &&
-      !event.altKey &&
-      !isWritableElement(event.target)
-    ) {
-      handled = true;
-      const index = keyBindings.indexOf(event.key.toLowerCase());
-      const isCustom = index >= MAX_DEFAULT_COLORS;
-      const parentElement = isCustom
-        ? gallery?.current?.querySelector(
-            ".color-picker-content--canvas-colors",
-          )
-        : gallery?.current?.querySelector(".color-picker-content--default");
-      const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
-      (
-        parentElement?.children[actualIndex] as HTMLElement | undefined
-      )?.focus();
-
-      event.preventDefault();
-    } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
-      handled = true;
-      event.preventDefault();
-      onClose();
-    }
-    if (handled) {
-      event.nativeEvent.stopImmediatePropagation();
-      event.stopPropagation();
-    }
-  };
-
-  const renderColors = (colors: Array<string>, custom: boolean = false) => {
-    return colors.map((_color, i) => {
-      const _colorWithoutHash = _color.replace("#", "");
-      const keyBinding = custom
-        ? keyBindings[i + MAX_DEFAULT_COLORS]
-        : keyBindings[i];
-      const label = custom
-        ? _colorWithoutHash
-        : t(`colors.${_colorWithoutHash}`);
-      return (
-        <button
-          className="color-picker-swatch"
-          onClick={(event) => {
-            (event.currentTarget as HTMLButtonElement).focus();
-            onChange(_color);
-          }}
-          title={`${label}${
-            !isTransparent(_color) ? ` (${_color})` : ""
-          } — ${keyBinding.toUpperCase()}`}
-          aria-label={label}
-          aria-keyshortcuts={keyBindings[i]}
-          style={{ color: _color }}
-          key={_color}
-          ref={(el) => {
-            if (!custom && el && i === 0) {
-              firstItem.current = el;
-            }
-            if (el && _color === color) {
-              activeItem.current = el;
-            }
-          }}
-          onFocus={() => {
-            onChange(_color);
-          }}
-        >
-          {isTransparent(_color) ? (
-            <div className="color-picker-transparent"></div>
-          ) : undefined}
-          <span className="color-picker-keybinding">{keyBinding}</span>
-        </button>
-      );
-    });
-  };
-
-  return (
-    <div
-      className={`color-picker color-picker-type-${type}`}
-      role="dialog"
-      aria-modal="true"
-      aria-label={t("labels.colorPicker")}
-      onKeyDown={handleKeyDown}
-    >
-      <div className="color-picker-triangle color-picker-triangle-shadow"></div>
-      <div className="color-picker-triangle"></div>
-      <div
-        className="color-picker-content"
-        ref={(el) => {
-          if (el) {
-            gallery.current = el;
-          }
-        }}
-        // to allow focusing by clicking but not by tabbing
-        tabIndex={-1}
-      >
-        <div className="color-picker-content--default">
-          {renderColors(colors)}
-        </div>
-        {!!customColors.length && (
-          <div className="color-picker-content--canvas">
-            <span className="color-picker-content--canvas-title">
-              {t("labels.canvasColors")}
-            </span>
-            <div className="color-picker-content--canvas-colors">
-              {renderColors(customColors, true)}
-            </div>
-          </div>
-        )}
-
-        {showInput && (
-          <ColorInput
-            color={color}
-            label={label}
-            onChange={(color) => {
-              onChange(color);
-            }}
-            ref={colorInput}
-          />
-        )}
-      </div>
-    </div>
-  );
-};
-
-const ColorInput = React.forwardRef(
-  (
-    {
-      color,
-      onChange,
-      label,
-    }: {
-      color: string | null;
-      onChange: (color: string) => void;
-      label: string;
-    },
-    ref,
-  ) => {
-    const [innerValue, setInnerValue] = React.useState(color);
-    const inputRef = React.useRef(null);
-
-    React.useEffect(() => {
-      setInnerValue(color);
-    }, [color]);
-
-    React.useImperativeHandle(ref, () => inputRef.current);
-
-    const changeColor = React.useCallback(
-      (inputValue: string) => {
-        const value = inputValue.toLowerCase();
-        const color = getColor(value);
-        if (color) {
-          onChange(color);
-        }
-        setInnerValue(value);
-      },
-      [onChange],
-    );
-
-    return (
-      <label className="color-input-container">
-        <div className="color-picker-hash">#</div>
-        <input
-          spellCheck={false}
-          className="color-picker-input"
-          aria-label={label}
-          onChange={(event) => changeColor(event.target.value)}
-          value={(innerValue || "").replace(/^#/, "")}
-          onBlur={() => setInnerValue(color)}
-          ref={inputRef}
-        />
-      </label>
-    );
-  },
-);
-
-ColorInput.displayName = "ColorInput";
-
-export const ColorPicker = ({
-  type,
-  color,
-  onChange,
-  label,
-  isActive,
-  setActive,
-  elements,
-  appState,
-}: {
-  type: "canvasBackground" | "elementBackground" | "elementStroke";
-  color: string | null;
-  onChange: (color: string) => void;
-  label: string;
-  isActive: boolean;
-  setActive: (active: boolean) => void;
-  elements: readonly ExcalidrawElement[];
-  appState: AppState;
-}) => {
-  const pickerButton = React.useRef<HTMLButtonElement>(null);
-  const coords = pickerButton.current?.getBoundingClientRect();
-
-  return (
-    <div>
-      <div className="color-picker-control-container">
-        <div className="color-picker-label-swatch-container">
-          <button
-            className="color-picker-label-swatch"
-            aria-label={label}
-            style={color ? { "--swatch-color": color } : undefined}
-            onClick={() => setActive(!isActive)}
-            ref={pickerButton}
-          />
-        </div>
-        <ColorInput
-          color={color}
-          label={label}
-          onChange={(color) => {
-            onChange(color);
-          }}
-        />
-      </div>
-      <React.Suspense fallback="">
-        {isActive ? (
-          <div
-            className="color-picker-popover-container"
-            style={{
-              position: "fixed",
-              top: coords?.top,
-              left: coords?.right,
-              zIndex: 1,
-            }}
-          >
-            <Popover
-              onCloseRequest={(event) =>
-                event.target !== pickerButton.current && setActive(false)
-              }
-            >
-              <Picker
-                colors={colors[type]}
-                color={color || null}
-                onChange={(changedColor) => {
-                  onChange(changedColor);
-                }}
-                onClose={() => {
-                  setActive(false);
-                  pickerButton.current?.focus();
-                }}
-                label={label}
-                showInput={false}
-                type={type}
-                elements={elements}
-              />
-            </Popover>
-          </div>
-        ) : null}
-      </React.Suspense>
-    </div>
-  );
-};

+ 75 - 0
src/components/ColorPicker/ColorInput.tsx

@@ -0,0 +1,75 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getColor } from "./ColorPicker";
+import { useAtom } from "jotai";
+import { activeColorPickerSectionAtom } from "./colorPickerUtils";
+import { KEYS } from "../../keys";
+
+interface ColorInputProps {
+  color: string | null;
+  onChange: (color: string) => void;
+  label: string;
+}
+
+export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
+  const [innerValue, setInnerValue] = useState(color);
+  const [activeSection, setActiveColorPickerSection] = useAtom(
+    activeColorPickerSectionAtom,
+  );
+
+  useEffect(() => {
+    setInnerValue(color);
+  }, [color]);
+
+  const changeColor = useCallback(
+    (inputValue: string) => {
+      const value = inputValue.toLowerCase();
+      const color = getColor(value);
+
+      if (color) {
+        onChange(color);
+      }
+      setInnerValue(value);
+    },
+    [onChange],
+  );
+
+  const inputRef = useRef<HTMLInputElement>(null);
+  const divRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (inputRef.current) {
+      inputRef.current.focus();
+    }
+  }, [activeSection]);
+
+  return (
+    <label className="color-picker__input-label">
+      <div className="color-picker__input-hash">#</div>
+      <input
+        ref={activeSection === "hex" ? inputRef : undefined}
+        style={{ border: 0, padding: 0 }}
+        spellCheck={false}
+        className="color-picker-input"
+        aria-label={label}
+        onChange={(event) => {
+          changeColor(event.target.value);
+        }}
+        value={(innerValue || "").replace(/^#/, "")}
+        onBlur={() => {
+          setInnerValue(color);
+        }}
+        tabIndex={-1}
+        onFocus={() => setActiveColorPickerSection("hex")}
+        onKeyDown={(e) => {
+          if (e.key === KEYS.TAB) {
+            return;
+          }
+          if (e.key === KEYS.ESCAPE) {
+            divRef.current?.focus();
+          }
+          e.stopPropagation();
+        }}
+      />
+    </label>
+  );
+};

+ 158 - 3
src/components/ColorPicker.scss → src/components/ColorPicker/ColorPicker.scss

@@ -1,6 +1,134 @@
-@import "../css/variables.module";
+@import "../../css/variables.module";
 
 .excalidraw {
+  .focus-visible-none {
+    &:focus-visible {
+      outline: none !important;
+    }
+  }
+
+  .color-picker__heading {
+    padding: 0 0.5rem;
+    font-size: 0.75rem;
+    text-align: left;
+  }
+
+  .color-picker-container {
+    display: grid;
+    grid-template-columns: 1fr 20px 1.625rem;
+    padding: 0.25rem 0px;
+    align-items: center;
+
+    @include isMobile {
+      max-width: 175px;
+    }
+  }
+
+  .color-picker__top-picks {
+    display: flex;
+    justify-content: space-between;
+  }
+
+  .color-picker__button {
+    --radius: 0.25rem;
+
+    padding: 0;
+    margin: 0;
+    width: 1.35rem;
+    height: 1.35rem;
+    border: 1px solid var(--color-gray-30);
+    border-radius: var(--radius);
+    filter: var(--theme-filter);
+    background-color: var(--swatch-color);
+    background-position: left center;
+    position: relative;
+    font-family: inherit;
+    box-sizing: border-box;
+
+    &:hover {
+      &::after {
+        content: "";
+        position: absolute;
+        top: -2px;
+        left: -2px;
+        right: -2px;
+        bottom: -2px;
+        box-shadow: 0 0 0 1px var(--color-gray-30);
+        border-radius: calc(var(--radius) + 1px);
+        filter: var(--theme-filter);
+      }
+    }
+
+    &.active {
+      .color-picker__button-outline {
+        position: absolute;
+        top: -2px;
+        left: -2px;
+        right: -2px;
+        bottom: -2px;
+        box-shadow: 0 0 0 1px var(--color-primary-darkest);
+        z-index: 1; // due hover state so this has preference
+        border-radius: calc(var(--radius) + 1px);
+        filter: var(--theme-filter);
+      }
+    }
+
+    &:focus-visible {
+      outline: none;
+
+      &::after {
+        content: "";
+        position: absolute;
+        top: -4px;
+        right: -4px;
+        bottom: -4px;
+        left: -4px;
+        border: 3px solid var(--focus-highlight-color);
+        border-radius: calc(var(--radius) + 1px);
+      }
+
+      &.active {
+        .color-picker__button-outline {
+          display: none;
+        }
+      }
+    }
+
+    &--large {
+      --radius: 0.5rem;
+      width: 1.875rem;
+      height: 1.875rem;
+    }
+
+    &.is-transparent {
+      background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==");
+    }
+
+    &--no-focus-visible {
+      border: 0;
+      &::after {
+        display: none;
+      }
+      &:focus-visible {
+        outline: none !important;
+      }
+    }
+
+    &.active-color {
+      border-radius: calc(var(--radius) + 1px);
+      width: 1.625rem;
+      height: 1.625rem;
+    }
+  }
+
+  .color-picker__button__hotkey-label {
+    position: absolute;
+    right: 4px;
+    bottom: 4px;
+    filter: none;
+    font-size: 11px;
+  }
+
   .color-picker {
     background: var(--popup-bg-color);
     border: 0 solid transparentize($oc-white, 0.75);
@@ -72,11 +200,17 @@
     }
   }
 
+  .color-picker-content {
+    display: flex;
+    flex-direction: column;
+    gap: 0.75rem;
+  }
+
   .color-picker-content--default {
     padding: 0.5rem;
     display: grid;
-    grid-template-columns: repeat(5, auto);
-    grid-gap: 0.5rem;
+    grid-template-columns: repeat(5, 1.875rem);
+    grid-gap: 0.25rem;
     border-radius: 4px;
 
     &:focus {
@@ -178,6 +312,27 @@
     }
   }
 
+  .color-picker__input-label {
+    display: grid;
+    grid-template-columns: auto 1fr auto auto;
+    gap: 8px;
+    align-items: center;
+    border: 1px solid var(--default-border-color);
+    border-radius: 8px;
+    padding: 0 12px;
+    margin: 8px;
+    box-sizing: border-box;
+
+    &:focus-within {
+      box-shadow: 0 0 0 1px var(--color-primary-darkest);
+      border-radius: var(--border-radius-lg);
+    }
+  }
+
+  .color-picker__input-hash {
+    padding: 0 0.25rem;
+  }
+
   .color-picker-input {
     box-sizing: border-box;
     width: 100%;

+ 235 - 0
src/components/ColorPicker/ColorPicker.tsx

@@ -0,0 +1,235 @@
+import { isTransparent } from "../../utils";
+import { ExcalidrawElement } from "../../element/types";
+import { AppState } from "../../types";
+import { TopPicks } from "./TopPicks";
+import { Picker } from "./Picker";
+import * as Popover from "@radix-ui/react-popover";
+import { useAtom } from "jotai";
+import {
+  activeColorPickerSectionAtom,
+  ColorPickerType,
+} from "./colorPickerUtils";
+import { useDevice, useExcalidrawContainer } from "../App";
+import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
+import PickerHeading from "./PickerHeading";
+import { ColorInput } from "./ColorInput";
+import { t } from "../../i18n";
+import clsx from "clsx";
+
+import "./ColorPicker.scss";
+import React from "react";
+
+const isValidColor = (color: string) => {
+  const style = new Option().style;
+  style.color = color;
+  return !!style.color;
+};
+
+export const getColor = (color: string): string | null => {
+  if (isTransparent(color)) {
+    return color;
+  }
+
+  // testing for `#` first fixes a bug on Electron (more specfically, an
+  // Obsidian popout window), where a hex color without `#` is (incorrectly)
+  // considered valid
+  return isValidColor(`#${color}`)
+    ? `#${color}`
+    : isValidColor(color)
+    ? color
+    : null;
+};
+
+export interface ColorPickerProps {
+  type: ColorPickerType;
+  color: string | null;
+  onChange: (color: string) => void;
+  label: string;
+  elements: readonly ExcalidrawElement[];
+  appState: AppState;
+  palette?: ColorPaletteCustom | null;
+  topPicks?: ColorTuple;
+  updateData: (formData?: any) => void;
+}
+
+const ColorPickerPopupContent = ({
+  type,
+  color,
+  onChange,
+  label,
+  elements,
+  palette = COLOR_PALETTE,
+  updateData,
+}: Pick<
+  ColorPickerProps,
+  | "type"
+  | "color"
+  | "onChange"
+  | "label"
+  | "label"
+  | "elements"
+  | "palette"
+  | "updateData"
+>) => {
+  const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
+
+  const { container } = useExcalidrawContainer();
+  const { isMobile, isLandscape } = useDevice();
+
+  const colorInputJSX = (
+    <div>
+      <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
+      <ColorInput
+        color={color}
+        label={label}
+        onChange={(color) => {
+          onChange(color);
+        }}
+      />
+    </div>
+  );
+
+  return (
+    <Popover.Portal container={container}>
+      <Popover.Content
+        className="focus-visible-none"
+        data-prevent-outside-click
+        onCloseAutoFocus={(e) => {
+          // return focus to excalidraw container
+          if (container) {
+            container.focus();
+          }
+
+          e.preventDefault();
+          e.stopPropagation();
+
+          setActiveColorPickerSection(null);
+        }}
+        side={isMobile && !isLandscape ? "bottom" : "right"}
+        align={isMobile && !isLandscape ? "center" : "start"}
+        alignOffset={-16}
+        sideOffset={20}
+        style={{
+          zIndex: 9999,
+          backgroundColor: "var(--popup-bg-color)",
+          maxWidth: "208px",
+          maxHeight: window.innerHeight,
+          padding: "12px",
+          borderRadius: "8px",
+          boxSizing: "border-box",
+          overflowY: "auto",
+          boxShadow:
+            "0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
+        }}
+      >
+        {palette ? (
+          <Picker
+            palette={palette}
+            color={color || null}
+            onChange={(changedColor) => {
+              onChange(changedColor);
+            }}
+            label={label}
+            type={type}
+            elements={elements}
+            updateData={updateData}
+          >
+            {colorInputJSX}
+          </Picker>
+        ) : (
+          colorInputJSX
+        )}
+        <Popover.Arrow
+          width={20}
+          height={10}
+          style={{
+            fill: "var(--popup-bg-color)",
+            filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
+          }}
+        />
+      </Popover.Content>
+    </Popover.Portal>
+  );
+};
+
+const ColorPickerTrigger = ({
+  label,
+  color,
+  type,
+}: {
+  color: string | null;
+  label: string;
+  type: ColorPickerType;
+}) => {
+  return (
+    <Popover.Trigger
+      type="button"
+      className={clsx("color-picker__button active-color", {
+        "is-transparent": color === "transparent" || !color,
+      })}
+      aria-label={label}
+      style={color ? { "--swatch-color": color } : undefined}
+      title={
+        type === "elementStroke"
+          ? t("labels.showStroke")
+          : t("labels.showBackground")
+      }
+    >
+      <div className="color-picker__button-outline" />
+    </Popover.Trigger>
+  );
+};
+
+export const ColorPicker = ({
+  type,
+  color,
+  onChange,
+  label,
+  elements,
+  palette = COLOR_PALETTE,
+  topPicks,
+  updateData,
+  appState,
+}: ColorPickerProps) => {
+  return (
+    <div>
+      <div role="dialog" aria-modal="true" className="color-picker-container">
+        <TopPicks
+          activeColor={color}
+          onChange={onChange}
+          type={type}
+          topPicks={topPicks}
+        />
+        <div
+          style={{
+            width: 1,
+            height: "100%",
+            backgroundColor: "var(--default-border-color)",
+            margin: "0 auto",
+          }}
+        />
+        <Popover.Root
+          open={appState.openPopup === type}
+          onOpenChange={(open) => {
+            updateData({ openPopup: open ? type : null });
+          }}
+        >
+          {/* serves as an active color indicator as well */}
+          <ColorPickerTrigger color={color} label={label} type={type} />
+          {/* popup content */}
+          {appState.openPopup === type && (
+            <ColorPickerPopupContent
+              type={type}
+              color={color}
+              onChange={onChange}
+              label={label}
+              elements={elements}
+              palette={palette}
+              updateData={updateData}
+            />
+          )}
+        </Popover.Root>
+      </div>
+    </div>
+  );
+};

+ 63 - 0
src/components/ColorPicker/CustomColorList.tsx

@@ -0,0 +1,63 @@
+import clsx from "clsx";
+import { useAtom } from "jotai";
+import { useEffect, useRef } from "react";
+import { activeColorPickerSectionAtom } from "./colorPickerUtils";
+import HotkeyLabel from "./HotkeyLabel";
+
+interface CustomColorListProps {
+  colors: string[];
+  color: string | null;
+  onChange: (color: string) => void;
+  label: string;
+}
+
+export const CustomColorList = ({
+  colors,
+  color,
+  onChange,
+  label,
+}: CustomColorListProps) => {
+  const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
+    activeColorPickerSectionAtom,
+  );
+
+  const btnRef = useRef<HTMLButtonElement>(null);
+
+  useEffect(() => {
+    if (btnRef.current) {
+      btnRef.current.focus();
+    }
+  }, [color, activeColorPickerSection]);
+
+  return (
+    <div className="color-picker-content--default">
+      {colors.map((c, i) => {
+        return (
+          <button
+            ref={color === c ? btnRef : undefined}
+            tabIndex={-1}
+            type="button"
+            className={clsx(
+              "color-picker__button color-picker__button--large",
+              {
+                active: color === c,
+                "is-transparent": c === "transparent" || !c,
+              },
+            )}
+            onClick={() => {
+              onChange(c);
+              setActiveColorPickerSection("custom");
+            }}
+            title={c}
+            aria-label={label}
+            style={{ "--swatch-color": c }}
+            key={i}
+          >
+            <div className="color-picker__button-outline" />
+            <HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
+          </button>
+        );
+      })}
+    </div>
+  );
+};

+ 29 - 0
src/components/ColorPicker/HotkeyLabel.tsx

@@ -0,0 +1,29 @@
+import React from "react";
+import { getContrastYIQ } from "./colorPickerUtils";
+
+interface HotkeyLabelProps {
+  color: string;
+  keyLabel: string | number;
+  isCustomColor?: boolean;
+  isShade?: boolean;
+}
+const HotkeyLabel = ({
+  color,
+  keyLabel,
+  isCustomColor = false,
+  isShade = false,
+}: HotkeyLabelProps) => {
+  return (
+    <div
+      className="color-picker__button__hotkey-label"
+      style={{
+        color: getContrastYIQ(color, isCustomColor),
+      }}
+    >
+      {isShade && "⇧"}
+      {keyLabel}
+    </div>
+  );
+};
+
+export default HotkeyLabel;

+ 156 - 0
src/components/ColorPicker/Picker.tsx

@@ -0,0 +1,156 @@
+import React, { useEffect, useState } from "react";
+import { t } from "../../i18n";
+
+import { ExcalidrawElement } from "../../element/types";
+import { ShadeList } from "./ShadeList";
+
+import PickerColorList from "./PickerColorList";
+import { useAtom } from "jotai";
+import { CustomColorList } from "./CustomColorList";
+import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
+import PickerHeading from "./PickerHeading";
+import {
+  ColorPickerType,
+  activeColorPickerSectionAtom,
+  getColorNameAndShadeFromHex,
+  getMostUsedCustomColors,
+  isCustomColor,
+} from "./colorPickerUtils";
+import {
+  ColorPaletteCustom,
+  DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
+  DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
+} from "../../colors";
+
+interface PickerProps {
+  color: string | null;
+  onChange: (color: string) => void;
+  label: string;
+  type: ColorPickerType;
+  elements: readonly ExcalidrawElement[];
+  palette: ColorPaletteCustom;
+  updateData: (formData?: any) => void;
+  children?: React.ReactNode;
+}
+
+export const Picker = ({
+  color,
+  onChange,
+  label,
+  type,
+  elements,
+  palette,
+  updateData,
+  children,
+}: PickerProps) => {
+  const [customColors] = React.useState(() => {
+    if (type === "canvasBackground") {
+      return [];
+    }
+    return getMostUsedCustomColors(elements, type, palette);
+  });
+
+  const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
+    activeColorPickerSectionAtom,
+  );
+
+  const colorObj = getColorNameAndShadeFromHex({
+    hex: color || "transparent",
+    palette,
+  });
+
+  useEffect(() => {
+    if (!activeColorPickerSection) {
+      const isCustom = isCustomColor({ color, palette });
+      const isCustomButNotInList =
+        isCustom && !customColors.includes(color || "");
+
+      setActiveColorPickerSection(
+        isCustomButNotInList
+          ? "hex"
+          : isCustom
+          ? "custom"
+          : colorObj?.shade != null
+          ? "shades"
+          : "baseColors",
+      );
+    }
+  }, [
+    activeColorPickerSection,
+    color,
+    palette,
+    setActiveColorPickerSection,
+    colorObj,
+    customColors,
+  ]);
+
+  const [activeShade, setActiveShade] = useState(
+    colorObj?.shade ??
+      (type === "elementBackground"
+        ? DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX
+        : DEFAULT_ELEMENT_STROKE_COLOR_INDEX),
+  );
+
+  useEffect(() => {
+    if (colorObj?.shade != null) {
+      setActiveShade(colorObj.shade);
+    }
+  }, [colorObj]);
+
+  return (
+    <div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
+      <div
+        onKeyDown={(e) => {
+          e.preventDefault();
+          e.stopPropagation();
+
+          colorPickerKeyNavHandler({
+            e,
+            activeColorPickerSection,
+            palette,
+            hex: color,
+            onChange,
+            customColors,
+            setActiveColorPickerSection,
+            updateData,
+            activeShade,
+          });
+        }}
+        className="color-picker-content"
+        // to allow focusing by clicking but not by tabbing
+        tabIndex={-1}
+      >
+        {!!customColors.length && (
+          <div>
+            <PickerHeading>
+              {t("colorPicker.mostUsedCustomColors")}
+            </PickerHeading>
+            <CustomColorList
+              colors={customColors}
+              color={color}
+              label={t("colorPicker.mostUsedCustomColors")}
+              onChange={onChange}
+            />
+          </div>
+        )}
+
+        <div>
+          <PickerHeading>{t("colorPicker.colors")}</PickerHeading>
+          <PickerColorList
+            color={color}
+            label={label}
+            palette={palette}
+            onChange={onChange}
+            activeShade={activeShade}
+          />
+        </div>
+
+        <div>
+          <PickerHeading>{t("colorPicker.shades")}</PickerHeading>
+          <ShadeList hex={color} onChange={onChange} palette={palette} />
+        </div>
+        {children}
+      </div>
+    </div>
+  );
+};

+ 86 - 0
src/components/ColorPicker/PickerColorList.tsx

@@ -0,0 +1,86 @@
+import clsx from "clsx";
+import { useAtom } from "jotai";
+import { useEffect, useRef } from "react";
+import {
+  activeColorPickerSectionAtom,
+  colorPickerHotkeyBindings,
+  getColorNameAndShadeFromHex,
+} from "./colorPickerUtils";
+import HotkeyLabel from "./HotkeyLabel";
+import { ColorPaletteCustom } from "../../colors";
+import { t } from "../../i18n";
+
+interface PickerColorListProps {
+  palette: ColorPaletteCustom;
+  color: string | null;
+  onChange: (color: string) => void;
+  label: string;
+  activeShade: number;
+}
+
+const PickerColorList = ({
+  palette,
+  color,
+  onChange,
+  label,
+  activeShade,
+}: PickerColorListProps) => {
+  const colorObj = getColorNameAndShadeFromHex({
+    hex: color || "transparent",
+    palette,
+  });
+  const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
+    activeColorPickerSectionAtom,
+  );
+
+  const btnRef = useRef<HTMLButtonElement>(null);
+
+  useEffect(() => {
+    if (btnRef.current && activeColorPickerSection === "baseColors") {
+      btnRef.current.focus();
+    }
+  }, [colorObj?.colorName, activeColorPickerSection]);
+
+  return (
+    <div className="color-picker-content--default">
+      {Object.entries(palette).map(([key, value], index) => {
+        const color =
+          (Array.isArray(value) ? value[activeShade] : value) || "transparent";
+
+        const keybinding = colorPickerHotkeyBindings[index];
+        const label = t(`colors.${key.replace(/\d+/, "")}`, null, "");
+
+        return (
+          <button
+            ref={colorObj?.colorName === key ? btnRef : undefined}
+            tabIndex={-1}
+            type="button"
+            className={clsx(
+              "color-picker__button color-picker__button--large",
+              {
+                active: colorObj?.colorName === key,
+                "is-transparent": color === "transparent" || !color,
+              },
+            )}
+            onClick={() => {
+              onChange(color);
+              setActiveColorPickerSection("baseColors");
+            }}
+            title={`${label}${
+              color.startsWith("#") ? ` ${color}` : ""
+            } — ${keybinding}`}
+            aria-label={`${label} — ${keybinding}`}
+            style={color ? { "--swatch-color": color } : undefined}
+            data-testid={`color-${key}`}
+            key={key}
+          >
+            <div className="color-picker__button-outline" />
+            <HotkeyLabel color={color} keyLabel={keybinding} />
+          </button>
+        );
+      })}
+    </div>
+  );
+};
+
+export default PickerColorList;

+ 7 - 0
src/components/ColorPicker/PickerHeading.tsx

@@ -0,0 +1,7 @@
+import { ReactNode } from "react";
+
+const PickerHeading = ({ children }: { children: ReactNode }) => (
+  <div className="color-picker__heading">{children}</div>
+);
+
+export default PickerHeading;

+ 105 - 0
src/components/ColorPicker/ShadeList.tsx

@@ -0,0 +1,105 @@
+import clsx from "clsx";
+import { useAtom } from "jotai";
+import { useEffect, useRef } from "react";
+import {
+  activeColorPickerSectionAtom,
+  getColorNameAndShadeFromHex,
+} from "./colorPickerUtils";
+import HotkeyLabel from "./HotkeyLabel";
+import { t } from "../../i18n";
+import { ColorPaletteCustom } from "../../colors";
+
+interface ShadeListProps {
+  hex: string | null;
+  onChange: (color: string) => void;
+  palette: ColorPaletteCustom;
+}
+
+export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
+  const colorObj = getColorNameAndShadeFromHex({
+    hex: hex || "transparent",
+    palette,
+  });
+
+  const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
+    activeColorPickerSectionAtom,
+  );
+
+  const btnRef = useRef<HTMLButtonElement>(null);
+
+  useEffect(() => {
+    if (btnRef.current && activeColorPickerSection === "shades") {
+      btnRef.current.focus();
+    }
+  }, [colorObj, activeColorPickerSection]);
+
+  if (colorObj) {
+    const { colorName, shade } = colorObj;
+
+    const shades = palette[colorName];
+
+    if (Array.isArray(shades)) {
+      return (
+        <div className="color-picker-content--default shades">
+          {shades.map((color, i) => (
+            <button
+              ref={
+                i === shade && activeColorPickerSection === "shades"
+                  ? btnRef
+                  : undefined
+              }
+              tabIndex={-1}
+              key={i}
+              type="button"
+              className={clsx(
+                "color-picker__button color-picker__button--large",
+                { active: i === shade },
+              )}
+              aria-label="Shade"
+              title={`${colorName} - ${i + 1}`}
+              style={color ? { "--swatch-color": color } : undefined}
+              onClick={() => {
+                onChange(color);
+                setActiveColorPickerSection("shades");
+              }}
+            >
+              <div className="color-picker__button-outline" />
+              <HotkeyLabel color={color} keyLabel={i + 1} isShade />
+            </button>
+          ))}
+        </div>
+      );
+    }
+  }
+
+  return (
+    <div
+      className="color-picker-content--default"
+      style={{ position: "relative" }}
+      tabIndex={-1}
+    >
+      <button
+        type="button"
+        tabIndex={-1}
+        className="color-picker__button color-picker__button--large color-picker__button--no-focus-visible"
+      />
+      <div
+        tabIndex={-1}
+        style={{
+          position: "absolute",
+          top: 0,
+          left: 0,
+          right: 0,
+          bottom: 0,
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+          textAlign: "center",
+          fontSize: "0.75rem",
+        }}
+      >
+        {t("colorPicker.noShades")}
+      </div>
+    </div>
+  );
+};

+ 64 - 0
src/components/ColorPicker/TopPicks.tsx

@@ -0,0 +1,64 @@
+import clsx from "clsx";
+import { ColorPickerType } from "./colorPickerUtils";
+import {
+  DEFAULT_CANVAS_BACKGROUND_PICKS,
+  DEFAULT_ELEMENT_BACKGROUND_PICKS,
+  DEFAULT_ELEMENT_STROKE_PICKS,
+} from "../../colors";
+
+interface TopPicksProps {
+  onChange: (color: string) => void;
+  type: ColorPickerType;
+  activeColor: string | null;
+  topPicks?: readonly string[];
+}
+
+export const TopPicks = ({
+  onChange,
+  type,
+  activeColor,
+  topPicks,
+}: TopPicksProps) => {
+  let colors;
+  if (type === "elementStroke") {
+    colors = DEFAULT_ELEMENT_STROKE_PICKS;
+  }
+
+  if (type === "elementBackground") {
+    colors = DEFAULT_ELEMENT_BACKGROUND_PICKS;
+  }
+
+  if (type === "canvasBackground") {
+    colors = DEFAULT_CANVAS_BACKGROUND_PICKS;
+  }
+
+  // this one can overwrite defaults
+  if (topPicks) {
+    colors = topPicks;
+  }
+
+  if (!colors) {
+    console.error("Invalid type for TopPicks");
+    return null;
+  }
+
+  return (
+    <div className="color-picker__top-picks">
+      {colors.map((color: string) => (
+        <button
+          className={clsx("color-picker__button", {
+            active: color === activeColor,
+            "is-transparent": color === "transparent" || !color,
+          })}
+          style={{ "--swatch-color": color }}
+          key={color}
+          type="button"
+          title={color}
+          onClick={() => onChange(color)}
+        >
+          <div className="color-picker__button-outline" />
+        </button>
+      ))}
+    </div>
+  );
+};

+ 139 - 0
src/components/ColorPicker/colorPickerUtils.ts

@@ -0,0 +1,139 @@
+import { ExcalidrawElement } from "../../element/types";
+import { atom } from "jotai";
+import {
+  ColorPickerColor,
+  ColorPaletteCustom,
+  MAX_CUSTOM_COLORS_USED_IN_CANVAS,
+} from "../../colors";
+
+export const getColorNameAndShadeFromHex = ({
+  palette,
+  hex,
+}: {
+  palette: ColorPaletteCustom;
+  hex: string;
+}): {
+  colorName: ColorPickerColor;
+  shade: number | null;
+} | null => {
+  for (const [colorName, colorVal] of Object.entries(palette)) {
+    if (Array.isArray(colorVal)) {
+      const shade = colorVal.indexOf(hex);
+      if (shade > -1) {
+        return { colorName: colorName as ColorPickerColor, shade };
+      }
+    } else if (colorVal === hex) {
+      return { colorName: colorName as ColorPickerColor, shade: null };
+    }
+  }
+  return null;
+};
+
+export const colorPickerHotkeyBindings = [
+  ["q", "w", "e", "r", "t"],
+  ["a", "s", "d", "f", "g"],
+  ["z", "x", "c", "v", "b"],
+].flat();
+
+export const isCustomColor = ({
+  color,
+  palette,
+}: {
+  color: string | null;
+  palette: ColorPaletteCustom;
+}) => {
+  if (!color) {
+    return false;
+  }
+  const paletteValues = Object.values(palette).flat();
+  return !paletteValues.includes(color);
+};
+
+export const getMostUsedCustomColors = (
+  elements: readonly ExcalidrawElement[],
+  type: "elementBackground" | "elementStroke",
+  palette: ColorPaletteCustom,
+) => {
+  const elementColorTypeMap = {
+    elementBackground: "backgroundColor",
+    elementStroke: "strokeColor",
+  };
+
+  const colors = elements.filter((element) => {
+    if (element.isDeleted) {
+      return false;
+    }
+
+    const color =
+      element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
+
+    return isCustomColor({ color, palette });
+  });
+
+  const colorCountMap = new Map<string, number>();
+  colors.forEach((element) => {
+    const color =
+      element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
+    if (colorCountMap.has(color)) {
+      colorCountMap.set(color, colorCountMap.get(color)! + 1);
+    } else {
+      colorCountMap.set(color, 1);
+    }
+  });
+
+  return [...colorCountMap.entries()]
+    .sort((a, b) => b[1] - a[1])
+    .map((c) => c[0])
+    .slice(0, MAX_CUSTOM_COLORS_USED_IN_CANVAS);
+};
+
+export type ActiveColorPickerSectionAtomType =
+  | "custom"
+  | "baseColors"
+  | "shades"
+  | "hex"
+  | null;
+export const activeColorPickerSectionAtom =
+  atom<ActiveColorPickerSectionAtomType>(null);
+
+const calculateContrast = (r: number, g: number, b: number) => {
+  const yiq = (r * 299 + g * 587 + b * 114) / 1000;
+  return yiq >= 160 ? "black" : "white";
+};
+
+// inspiration from https://stackoverflow.com/a/11868398
+export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => {
+  if (isCustomColor) {
+    const style = new Option().style;
+    style.color = bgHex;
+
+    if (style.color) {
+      const rgb = style.color
+        .replace(/^(rgb|rgba)\(/, "")
+        .replace(/\)$/, "")
+        .replace(/\s/g, "")
+        .split(",");
+      const r = parseInt(rgb[0]);
+      const g = parseInt(rgb[1]);
+      const b = parseInt(rgb[2]);
+
+      return calculateContrast(r, g, b);
+    }
+  }
+
+  // TODO: ? is this wanted?
+  if (bgHex === "transparent") {
+    return "black";
+  }
+
+  const r = parseInt(bgHex.substring(1, 3), 16);
+  const g = parseInt(bgHex.substring(3, 5), 16);
+  const b = parseInt(bgHex.substring(5, 7), 16);
+
+  return calculateContrast(r, g, b);
+};
+
+export type ColorPickerType =
+  | "canvasBackground"
+  | "elementBackground"
+  | "elementStroke";

+ 249 - 0
src/components/ColorPicker/keyboardNavHandlers.ts

@@ -0,0 +1,249 @@
+import {
+  ColorPickerColor,
+  ColorPalette,
+  ColorPaletteCustom,
+  COLORS_PER_ROW,
+  COLOR_PALETTE,
+} from "../../colors";
+import { KEYS } from "../../keys";
+import { ValueOf } from "../../utility-types";
+import {
+  ActiveColorPickerSectionAtomType,
+  colorPickerHotkeyBindings,
+  getColorNameAndShadeFromHex,
+} from "./colorPickerUtils";
+
+const arrowHandler = (
+  eventKey: string,
+  currentIndex: number | null,
+  length: number,
+) => {
+  const rows = Math.ceil(length / COLORS_PER_ROW);
+
+  currentIndex = currentIndex ?? -1;
+
+  switch (eventKey) {
+    case "ArrowLeft": {
+      const prevIndex = currentIndex - 1;
+      return prevIndex < 0 ? length - 1 : prevIndex;
+    }
+    case "ArrowRight": {
+      return (currentIndex + 1) % length;
+    }
+    case "ArrowDown": {
+      const nextIndex = currentIndex + COLORS_PER_ROW;
+      return nextIndex >= length ? currentIndex % COLORS_PER_ROW : nextIndex;
+    }
+    case "ArrowUp": {
+      const prevIndex = currentIndex - COLORS_PER_ROW;
+      const newIndex =
+        prevIndex < 0 ? COLORS_PER_ROW * rows + prevIndex : prevIndex;
+      return newIndex >= length ? undefined : newIndex;
+    }
+  }
+};
+
+interface HotkeyHandlerProps {
+  e: React.KeyboardEvent;
+  colorObj: { colorName: ColorPickerColor; shade: number | null } | null;
+  onChange: (color: string) => void;
+  palette: ColorPaletteCustom;
+  customColors: string[];
+  setActiveColorPickerSection: (
+    update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
+  ) => void;
+  activeShade: number;
+}
+
+const hotkeyHandler = ({
+  e,
+  colorObj,
+  onChange,
+  palette,
+  customColors,
+  setActiveColorPickerSection,
+  activeShade,
+}: HotkeyHandlerProps) => {
+  if (colorObj?.shade != null) {
+    // shift + numpad is extremely messed up on windows apparently
+    if (
+      ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].includes(e.code) &&
+      e.shiftKey
+    ) {
+      const newShade = Number(e.code.slice(-1)) - 1;
+      onChange(palette[colorObj.colorName][newShade]);
+      setActiveColorPickerSection("shades");
+    }
+  }
+
+  if (["1", "2", "3", "4", "5"].includes(e.key)) {
+    const c = customColors[Number(e.key) - 1];
+    if (c) {
+      onChange(customColors[Number(e.key) - 1]);
+      setActiveColorPickerSection("custom");
+    }
+  }
+
+  if (colorPickerHotkeyBindings.includes(e.key)) {
+    const index = colorPickerHotkeyBindings.indexOf(e.key);
+    const paletteKey = Object.keys(palette)[index] as keyof ColorPalette;
+    const paletteValue = palette[paletteKey];
+    const r = Array.isArray(paletteValue)
+      ? paletteValue[activeShade]
+      : paletteValue;
+    onChange(r);
+    setActiveColorPickerSection("baseColors");
+  }
+};
+
+interface ColorPickerKeyNavHandlerProps {
+  e: React.KeyboardEvent;
+  activeColorPickerSection: ActiveColorPickerSectionAtomType;
+  palette: ColorPaletteCustom;
+  hex: string | null;
+  onChange: (color: string) => void;
+  customColors: string[];
+  setActiveColorPickerSection: (
+    update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
+  ) => void;
+  updateData: (formData?: any) => void;
+  activeShade: number;
+}
+
+export const colorPickerKeyNavHandler = ({
+  e,
+  activeColorPickerSection,
+  palette,
+  hex,
+  onChange,
+  customColors,
+  setActiveColorPickerSection,
+  updateData,
+  activeShade,
+}: ColorPickerKeyNavHandlerProps) => {
+  if (e.key === KEYS.ESCAPE || !hex) {
+    updateData({ openPopup: null });
+    return;
+  }
+
+  const colorObj = getColorNameAndShadeFromHex({ hex, palette });
+
+  if (e.key === KEYS.TAB) {
+    const sectionsMap: Record<
+      NonNullable<ActiveColorPickerSectionAtomType>,
+      boolean
+    > = {
+      custom: !!customColors.length,
+      baseColors: true,
+      shades: colorObj?.shade != null,
+      hex: true,
+    };
+
+    const sections = Object.entries(sectionsMap).reduce((acc, [key, value]) => {
+      if (value) {
+        acc.push(key as ActiveColorPickerSectionAtomType);
+      }
+      return acc;
+    }, [] as ActiveColorPickerSectionAtomType[]);
+
+    const activeSectionIndex = sections.indexOf(activeColorPickerSection);
+    const indexOffset = e.shiftKey ? -1 : 1;
+    const nextSectionIndex =
+      activeSectionIndex + indexOffset > sections.length - 1
+        ? 0
+        : activeSectionIndex + indexOffset < 0
+        ? sections.length - 1
+        : activeSectionIndex + indexOffset;
+
+    const nextSection = sections[nextSectionIndex];
+
+    if (nextSection) {
+      setActiveColorPickerSection(nextSection);
+    }
+
+    if (nextSection === "custom") {
+      onChange(customColors[0]);
+    } else if (nextSection === "baseColors") {
+      const baseColorName = (
+        Object.entries(palette) as [string, ValueOf<ColorPalette>][]
+      ).find(([name, shades]) => {
+        if (Array.isArray(shades)) {
+          return shades.includes(hex);
+        } else if (shades === hex) {
+          return name;
+        }
+        return null;
+      });
+
+      if (!baseColorName) {
+        onChange(COLOR_PALETTE.black);
+      }
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
+
+    return;
+  }
+
+  hotkeyHandler({
+    e,
+    colorObj,
+    onChange,
+    palette,
+    customColors,
+    setActiveColorPickerSection,
+    activeShade,
+  });
+
+  if (activeColorPickerSection === "shades") {
+    if (colorObj) {
+      const { shade } = colorObj;
+      const newShade = arrowHandler(e.key, shade, COLORS_PER_ROW);
+
+      if (newShade !== undefined) {
+        onChange(palette[colorObj.colorName][newShade]);
+      }
+    }
+  }
+
+  if (activeColorPickerSection === "baseColors") {
+    if (colorObj) {
+      const { colorName } = colorObj;
+      const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
+      const indexOfColorName = colorNames.indexOf(colorName);
+
+      const newColorIndex = arrowHandler(
+        e.key,
+        indexOfColorName,
+        colorNames.length,
+      );
+
+      if (newColorIndex !== undefined) {
+        const newColorName = colorNames[newColorIndex];
+        const newColorNameValue = palette[newColorName];
+
+        onChange(
+          Array.isArray(newColorNameValue)
+            ? newColorNameValue[activeShade]
+            : newColorNameValue,
+        );
+      }
+    }
+  }
+
+  if (activeColorPickerSection === "custom") {
+    const indexOfColor = customColors.indexOf(hex);
+
+    const newColorIndex = arrowHandler(
+      e.key,
+      indexOfColor,
+      customColors.length,
+    );
+
+    if (newColorIndex !== undefined) {
+      const newColor = customColors[newColorIndex];
+      onChange(newColor);
+    }
+  }
+};

+ 2 - 2
src/components/LibraryUnit.tsx

@@ -1,5 +1,4 @@
 import clsx from "clsx";
-import oc from "open-color";
 import { useEffect, useRef, useState } from "react";
 import { useDevice } from "../components/App";
 import { exportToSvg } from "../packages/utils";
@@ -7,6 +6,7 @@ import { LibraryItem } from "../types";
 import "./LibraryUnit.scss";
 import { CheckboxItem } from "./CheckboxItem";
 import { PlusIcon } from "./icons";
+import { COLOR_PALETTE } from "../colors";
 
 export const LibraryUnit = ({
   id,
@@ -40,7 +40,7 @@ export const LibraryUnit = ({
         elements,
         appState: {
           exportBackground: false,
-          viewBackgroundColor: oc.white,
+          viewBackgroundColor: COLOR_PALETTE.white,
         },
         files: null,
       });

+ 2 - 1
src/components/ProjectName.tsx

@@ -5,6 +5,7 @@ import { focusNearestParent } from "../utils";
 
 import "./ProjectName.scss";
 import { useExcalidrawContainer } from "./App";
+import { KEYS } from "../keys";
 
 type Props = {
   value: string;
@@ -26,7 +27,7 @@ export const ProjectName = (props: Props) => {
   };
 
   const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
-    if (event.key === "Enter") {
+    if (event.key === KEYS.ENTER) {
       event.preventDefault();
       if (event.nativeEvent.isComposing || event.keyCode === 229) {
         return;

+ 1 - 1
src/components/dropdownMenu/DropdownMenuContent.tsx

@@ -48,7 +48,7 @@ const MenuContent = ({
           <Island
             className="dropdown-menu-container"
             padding={2}
-            style={{ zIndex: 1 }}
+            style={{ zIndex: 2 }}
           >
             {children}
           </Island>

+ 3 - 3
src/constants.ts

@@ -1,7 +1,7 @@
 import cssVariables from "./css/variables.module.scss";
 import { AppProps } from "./types";
 import { ExcalidrawElement, FontFamilyValues } from "./element/types";
-import oc from "open-color";
+import { COLOR_PALETTE } from "./colors";
 
 export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
 export const isWindows = /^Win/.test(navigator.platform);
@@ -272,8 +272,8 @@ export const DEFAULT_ELEMENT_PROPS: {
   opacity: ExcalidrawElement["opacity"];
   locked: ExcalidrawElement["locked"];
 } = {
-  strokeColor: oc.black,
-  backgroundColor: "transparent",
+  strokeColor: COLOR_PALETTE.black,
+  backgroundColor: COLOR_PALETTE.transparent,
   fillStyle: "hachure",
   strokeWidth: 1,
   strokeStyle: "solid",

+ 3 - 3
src/data/restore.ts

@@ -34,13 +34,13 @@ import { LinearElementEditor } from "../element/linearElementEditor";
 import { bumpVersion } from "../element/mutateElement";
 import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
 import { arrayToMap } from "../utils";
-import oc from "open-color";
 import { MarkOptional, Mutable } from "../utility-types";
 import {
   detectLineHeight,
   getDefaultLineHeight,
   measureBaseline,
 } from "../element/textElement";
+import { COLOR_PALETTE } from "../colors";
 
 type RestoredAppState = Omit<
   AppState,
@@ -119,8 +119,8 @@ const restoreElementWithProperties = <
     angle: element.angle || 0,
     x: extra.x ?? element.x ?? 0,
     y: extra.y ?? element.y ?? 0,
-    strokeColor: element.strokeColor || oc.black,
-    backgroundColor: element.backgroundColor || "transparent",
+    strokeColor: element.strokeColor || COLOR_PALETTE.black,
+    backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
     width: element.width || 0,
     height: element.height || 0,
     seed: element.seed ?? 1,

+ 1 - 1
src/element/textWysiwyg.test.tsx

@@ -1521,7 +1521,7 @@ describe("textWysiwyg", () => {
           roundness: {
             type: 3,
           },
-          strokeColor: "#000000",
+          strokeColor: "#1e1e1e",
           strokeStyle: "solid",
           strokeWidth: 1,
           type: "rectangle",

+ 40 - 14
src/element/textWysiwyg.tsx

@@ -636,20 +636,46 @@ export const textWysiwyg = ({
     // in that same tick.
     const target = event?.target;
 
-    const isTargetColorPicker =
-      target instanceof HTMLInputElement &&
-      target.closest(".color-picker-input") &&
-      isWritableElement(target);
+    const isTargetPickerTrigger =
+      target instanceof HTMLElement &&
+      target.classList.contains("active-color");
 
     setTimeout(() => {
       editable.onblur = handleSubmit;
-      if (target && isTargetColorPicker) {
-        target.onblur = () => {
-          editable.focus();
+
+      if (isTargetPickerTrigger) {
+        const callback = (
+          mutationList: MutationRecord[],
+          observer: MutationObserver,
+        ) => {
+          const radixIsRemoved = mutationList.find(
+            (mutation) =>
+              mutation.removedNodes.length > 0 &&
+              (mutation.removedNodes[0] as HTMLElement).dataset
+                ?.radixPopperContentWrapper !== undefined,
+          );
+
+          if (radixIsRemoved) {
+            // should work without this in theory
+            // and i think it does actually but radix probably somewhere,
+            // somehow sets the focus elsewhere
+            setTimeout(() => {
+              editable.focus();
+            });
+
+            observer.disconnect();
+          }
         };
+
+        const observer = new MutationObserver(callback);
+
+        observer.observe(document.querySelector(".excalidraw-container")!, {
+          childList: true,
+        });
       }
+
       // case: clicking on the same property → no change → no update → no focus
-      if (!isTargetColorPicker) {
+      if (!isTargetPickerTrigger) {
         editable.focus();
       }
     });
@@ -657,16 +683,16 @@ export const textWysiwyg = ({
 
   // prevent blur when changing properties from the menu
   const onPointerDown = (event: MouseEvent) => {
-    const isTargetColorPicker =
-      event.target instanceof HTMLInputElement &&
-      event.target.closest(".color-picker-input") &&
-      isWritableElement(event.target);
+    const isTargetPickerTrigger =
+      event.target instanceof HTMLElement &&
+      event.target.classList.contains("active-color");
+
     if (
       ((event.target instanceof HTMLElement ||
         event.target instanceof SVGElement) &&
         event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
         !isWritableElement(event.target)) ||
-      isTargetColorPicker
+      isTargetPickerTrigger
     ) {
       editable.onblur = null;
       window.addEventListener("pointerup", bindBlurEvent);
@@ -680,7 +706,7 @@ export const textWysiwyg = ({
   const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
     updateWysiwygStyle();
     const isColorPickerActive = !!document.activeElement?.closest(
-      ".color-picker-input",
+      ".color-picker-content",
     );
     if (!isColorPickerActive) {
       editable.focus();

+ 4 - 1
src/excalidraw-app/collab/RoomDialog.tsx

@@ -17,6 +17,7 @@ import { trackEvent } from "../../analytics";
 import { getFrame } from "../../utils";
 import DialogActionButton from "../../components/DialogActionButton";
 import { useI18n } from "../../i18n";
+import { KEYS } from "../../keys";
 
 const getShareIcon = () => {
   const navigator = window.navigator as any;
@@ -148,7 +149,9 @@ const RoomDialog = ({
                 value={username.trim() || ""}
                 className="RoomDialog-username TextInput"
                 onChange={(event) => onUsernameChange(event.target.value)}
-                onKeyPress={(event) => event.key === "Enter" && handleClose()}
+                onKeyPress={(event) =>
+                  event.key === KEYS.ENTER && handleClose()
+                }
               />
             </div>
             <p>

+ 11 - 3
src/i18n.ts

@@ -124,7 +124,8 @@ const findPartsForData = (data: any, parts: string[]) => {
 
 export const t = (
   path: string,
-  replacement?: { [key: string]: string | number },
+  replacement?: { [key: string]: string | number } | null,
+  fallback?: string,
 ) => {
   if (currentLang.code.startsWith(TEST_LANG_CODE)) {
     const name = replacement
@@ -136,9 +137,16 @@ export const t = (
   const parts = path.split(".");
   let translation =
     findPartsForData(currentLangData, parts) ||
-    findPartsForData(fallbackLangData, parts);
+    findPartsForData(fallbackLangData, parts) ||
+    fallback;
   if (translation === undefined) {
-    throw new Error(`Can't find translation for ${path}`);
+    const errorMessage = `Can't find translation for ${path}`;
+    // in production, don't blow up the app on a missing translation key
+    if (process.env.NODE_ENV === "production") {
+      console.warn(errorMessage);
+      return "";
+    }
+    throw new Error(errorMessage);
   }
 
   if (replacement) {

+ 21 - 44
src/locales/en.json

@@ -394,51 +394,21 @@
     "pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
   },
   "colors": {
-    "ffffff": "White",
-    "f8f9fa": "Gray 0",
-    "f1f3f5": "Gray 1",
-    "fff5f5": "Red 0",
-    "fff0f6": "Pink 0",
-    "f8f0fc": "Grape 0",
-    "f3f0ff": "Violet 0",
-    "edf2ff": "Indigo 0",
-    "e7f5ff": "Blue 0",
-    "e3fafc": "Cyan 0",
-    "e6fcf5": "Teal 0",
-    "ebfbee": "Green 0",
-    "f4fce3": "Lime 0",
-    "fff9db": "Yellow 0",
-    "fff4e6": "Orange 0",
     "transparent": "Transparent",
-    "ced4da": "Gray 4",
-    "868e96": "Gray 6",
-    "fa5252": "Red 6",
-    "e64980": "Pink 6",
-    "be4bdb": "Grape 6",
-    "7950f2": "Violet 6",
-    "4c6ef5": "Indigo 6",
-    "228be6": "Blue 6",
-    "15aabf": "Cyan 6",
-    "12b886": "Teal 6",
-    "40c057": "Green 6",
-    "82c91e": "Lime 6",
-    "fab005": "Yellow 6",
-    "fd7e14": "Orange 6",
-    "000000": "Black",
-    "343a40": "Gray 8",
-    "495057": "Gray 7",
-    "c92a2a": "Red 9",
-    "a61e4d": "Pink 9",
-    "862e9c": "Grape 9",
-    "5f3dc4": "Violet 9",
-    "364fc7": "Indigo 9",
-    "1864ab": "Blue 9",
-    "0b7285": "Cyan 9",
-    "087f5b": "Teal 9",
-    "2b8a3e": "Green 9",
-    "5c940d": "Lime 9",
-    "e67700": "Yellow 9",
-    "d9480f": "Orange 9"
+    "black": "Black",
+    "white": "White",
+    "red": "Red",
+    "pink": "Pink",
+    "grape": "Grape",
+    "violet": "Violet",
+    "gray": "Gray",
+    "blue": "Blue",
+    "cyan": "Cyan",
+    "teal": "Teal",
+    "green": "Green",
+    "yellow": "Yellow",
+    "orange": "Orange",
+    "bronze": "Bronze"
   },
   "welcomeScreen": {
     "app": {
@@ -452,5 +422,12 @@
       "toolbarHint": "Pick a tool & Start drawing!",
       "helpHint": "Shortcuts & help"
     }
+  },
+  "colorPicker": {
+    "mostUsedCustomColors": "Most used custom colors",
+    "colors": "Colors",
+    "shades": "Shades",
+    "hexCode": "Hex code",
+    "noShades": "No shades available for this color"
   }
 }

+ 5 - 2
src/packages/excalidraw/example/App.tsx

@@ -30,6 +30,7 @@ import { NonDeletedExcalidrawElement } from "../../../element/types";
 import { ImportedLibraryData } from "../../../data/types";
 import CustomFooter from "./CustomFooter";
 import MobileFooter from "./MobileFooter";
+import { KEYS } from "../../../keys";
 
 declare global {
   interface Window {
@@ -55,9 +56,9 @@ type PointerDownState = {
     y: number;
   };
 };
+
 // This is so that we use the bundled excalidraw.development.js file instead
 // of the actual source code
-
 const {
   exportToCanvas,
   exportToSvg,
@@ -484,7 +485,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
         }}
         onBlur={saveComment}
         onKeyDown={(event) => {
-          if (!event.shiftKey && event.key === "Enter") {
+          if (!event.shiftKey && event.key === KEYS.ENTER) {
             event.preventDefault();
             saveComment();
           }
@@ -521,9 +522,11 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
       </MainMenu>
     );
   };
+
   return (
     <div className="App" ref={appRef}>
       <h1>{appTitle}</h1>
+      {/* TODO fix type */}
       <ExampleSidebar>
         <div className="button-wrapper">
           <button onClick={loadSceneOrLibrary}>Load Scene or Library</button>

+ 1 - 1
src/packages/excalidraw/package.json

@@ -63,7 +63,7 @@
     "sass-loader": "13.0.2",
     "terser-webpack-plugin": "5.3.3",
     "ts-loader": "9.3.1",
-    "typescript": "4.7.4",
+    "typescript": "4.9.4",
     "webpack": "5.76.0",
     "webpack-bundle-analyzer": "4.5.0",
     "webpack-cli": "4.10.0",

+ 4 - 4
src/packages/excalidraw/yarn.lock

@@ -3678,10 +3678,10 @@ type-is@~1.6.18:
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
-typescript@4.7.4:
-  version "4.7.4"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
-  integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
+typescript@4.9.4:
+  version "4.9.4"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
+  integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
 
 unicode-canonical-property-names-ecmascript@^2.0.0:
   version "2.0.0"

+ 1 - 0
src/tests/MobileMenu.test.tsx

@@ -29,6 +29,7 @@ describe("Test MobileMenu", () => {
     expect(h.app.device).toMatchInlineSnapshot(`
       Object {
         "canDeviceFitSidebar": false,
+        "isLandscape": true,
         "isMobile": true,
         "isSmScreen": false,
         "isTouchScreen": false,

Різницю між файлами не показано, бо вона завелика
+ 115 - 115
src/tests/__snapshots__/contextmenu.test.tsx.snap


+ 5 - 5
src/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -35,7 +35,7 @@ Object {
   "seed": 337897,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "arrow",
@@ -68,7 +68,7 @@ Object {
     "type": 2,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "diamond",
@@ -101,7 +101,7 @@ Object {
     "type": 2,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "ellipse",
@@ -147,7 +147,7 @@ Object {
   "seed": 337897,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "line",
@@ -180,7 +180,7 @@ Object {
     "type": 3,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",

+ 1 - 1
src/tests/__snapshots__/linearElementEditor.test.tsx.snap

@@ -5,7 +5,7 @@ exports[`Test Linear Elements Test bound text element should match styles for te
   class="excalidraw-wysiwyg"
   data-type="wysiwyg"
   dir="auto"
-  style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
+  style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: -7.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
   tabindex="0"
   wrap="off"
 />

+ 6 - 6
src/tests/__snapshots__/move.test.tsx.snap

@@ -18,7 +18,7 @@ Object {
     "type": 3,
   },
   "seed": 401146281,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
@@ -49,7 +49,7 @@ Object {
     "type": 3,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
@@ -80,7 +80,7 @@ Object {
     "type": 3,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
@@ -116,7 +116,7 @@ Object {
     "type": 3,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
@@ -152,7 +152,7 @@ Object {
     "type": 3,
   },
   "seed": 449462985,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",
@@ -206,7 +206,7 @@ Object {
     "focus": -0.6000000000000001,
     "gap": 10,
   },
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "line",

+ 2 - 2
src/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -40,7 +40,7 @@ Object {
   "seed": 337897,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "arrow",
@@ -93,7 +93,7 @@ Object {
   "seed": 337897,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "line",

Різницю між файлами не показано, бо вона завелика
+ 149 - 107
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 5 - 5
src/tests/__snapshots__/selection.test.tsx.snap

@@ -33,7 +33,7 @@ Object {
   "seed": 337897,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "arrow",
@@ -79,7 +79,7 @@ Object {
   "seed": 337897,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "line",
@@ -110,7 +110,7 @@ Object {
     "type": 2,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "diamond",
@@ -141,7 +141,7 @@ Object {
     "type": 2,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "ellipse",
@@ -172,7 +172,7 @@ Object {
     "type": 3,
   },
   "seed": 337897,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "rectangle",

+ 14 - 7
src/tests/contextmenu.test.tsx

@@ -9,6 +9,7 @@ import {
   queryByText,
   queryAllByText,
   waitFor,
+  togglePopover,
 } from "./test-utils";
 import ExcalidrawApp from "../excalidraw-app";
 import * as Renderer from "../renderer/renderScene";
@@ -19,7 +20,6 @@ import { ShortcutName } from "../actions/shortcuts";
 import { copiedStyles } from "../actions/actionStyles";
 import { API } from "./helpers/api";
 import { setDateTimeForTests } from "../utils";
-import { t } from "../i18n";
 import { LibraryItem } from "../types";
 
 const checkpoint = (name: string) => {
@@ -303,10 +303,10 @@ describe("contextMenu element", () => {
     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
@@ -320,13 +320,20 @@ describe("contextMenu element", () => {
       target: { value: "60" },
     });
 
+    // closing the background popover as this blocks
+    // context menu from rendering after we started focussing
+    // the popover once rendered :/
+    togglePopover("Background");
+
     mouse.reset();
+
     // Copy styles of second rectangle
     fireEvent.contextMenu(GlobalTestState.canvas, {
       button: 2,
       clientX: 40,
       clientY: 40,
     });
+
     let contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
     const secondRect = JSON.parse(copiedStyles)[0];
@@ -344,8 +351,8 @@ describe("contextMenu element", () => {
 
     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");

+ 6 - 6
src/tests/data/__snapshots__/restore.test.ts.snap

@@ -33,7 +33,7 @@ Object {
   "seed": Any<Number>,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "arrow",
@@ -173,7 +173,7 @@ Object {
   },
   "seed": Any<Number>,
   "simulatePressure": true,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "freedraw",
@@ -219,7 +219,7 @@ Object {
   "seed": Any<Number>,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "line",
@@ -265,7 +265,7 @@ Object {
   "seed": Any<Number>,
   "startArrowhead": null,
   "startBinding": null,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "type": "line",
@@ -302,7 +302,7 @@ Object {
     "type": 3,
   },
   "seed": Any<Number>,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "text": "text",
@@ -342,7 +342,7 @@ Object {
     "type": 3,
   },
   "seed": Any<Number>,
-  "strokeColor": "#000000",
+  "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 1,
   "text": "",

+ 9 - 0
src/tests/helpers/ui.ts

@@ -237,6 +237,15 @@ export class UI {
     fireEvent.click(element);
   };
 
+  static clickOnTestId = (testId: string) => {
+    const element = document.querySelector(`[data-testid='${testId}']`);
+    // const element = GlobalTestState.renderResult.queryByTestId(testId);
+    if (!element) {
+      throw new Error(`No element with testid "${testId}" found`);
+    }
+    fireEvent.click(element);
+  };
+
   /**
    * 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

+ 72 - 26
src/tests/packages/__snapshots__/excalidraw.test.tsx.snap

@@ -7,7 +7,7 @@ exports[`<Excalidraw/> <MainMenu/> should render main menu with host menu items
 >
   <div
     class="Island dropdown-menu-container"
-    style="--padding: 2; z-index: 1;"
+    style="--padding: 2; z-index: 2;"
   >
     <button
       class="dropdown-menu-item dropdown-menu-item-base"
@@ -115,7 +115,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu
 >
   <div
     class="Island dropdown-menu-container"
-    style="--padding: 2; z-index: 1;"
+    style="--padding: 2; z-index: 2;"
   >
     <button
       aria-label="Open"
@@ -523,38 +523,84 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should render menu
       <div
         style="padding: 0px 0.625rem;"
       >
-        <div
-          style="position: relative;"
-        >
-          <div>
+        <div>
+          <div
+            aria-modal="true"
+            class="color-picker-container"
+            role="dialog"
+          >
             <div
-              class="color-picker-control-container"
+              class="color-picker__top-picks"
             >
-              <div
-                class="color-picker-label-swatch-container"
+              <button
+                class="color-picker__button active"
+                style="--swatch-color: #ffffff;"
+                title="#ffffff"
+                type="button"
+              >
+                <div
+                  class="color-picker__button-outline"
+                />
+              </button>
+              <button
+                class="color-picker__button"
+                style="--swatch-color: #f8f9fa;"
+                title="#f8f9fa"
+                type="button"
               >
-                <button
-                  aria-label="Canvas background"
-                  class="color-picker-label-swatch"
-                  style="--swatch-color: #ffffff;"
+                <div
+                  class="color-picker__button-outline"
                 />
-              </div>
-              <label
-                class="color-input-container"
+              </button>
+              <button
+                class="color-picker__button"
+                style="--swatch-color: #f5faff;"
+                title="#f5faff"
+                type="button"
               >
                 <div
-                  class="color-picker-hash"
-                >
-                  #
-                </div>
-                <input
-                  aria-label="Canvas background"
-                  class="color-picker-input"
-                  spellcheck="false"
-                  value="ffffff"
+                  class="color-picker__button-outline"
                 />
-              </label>
+              </button>
+              <button
+                class="color-picker__button"
+                style="--swatch-color: #fffce8;"
+                title="#fffce8"
+                type="button"
+              >
+                <div
+                  class="color-picker__button-outline"
+                />
+              </button>
+              <button
+                class="color-picker__button"
+                style="--swatch-color: #fdf8f6;"
+                title="#fdf8f6"
+                type="button"
+              >
+                <div
+                  class="color-picker__button-outline"
+                />
+              </button>
             </div>
+            <div
+              style="width: 1px; height: 100%; margin: 0px auto;"
+            />
+            <button
+              aria-controls="radix-:r0:"
+              aria-expanded="false"
+              aria-haspopup="dialog"
+              aria-label="Canvas background"
+              class="color-picker__button active-color"
+              data-state="closed"
+              style="--swatch-color: #ffffff;"
+              title="Show background color picker"
+              type="button"
+            >
+              <div
+                class="color-picker__button-outline"
+              />
+            </button>
           </div>
         </div>
       </div>

+ 1 - 1
src/tests/packages/__snapshots__/utils.test.ts.snap

@@ -20,7 +20,7 @@ Object {
   "currentItemRoughness": 1,
   "currentItemRoundness": "round",
   "currentItemStartArrowhead": null,
-  "currentItemStrokeColor": "#000000",
+  "currentItemStrokeColor": "#1e1e1e",
   "currentItemStrokeStyle": "solid",
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",

+ 14 - 15
src/tests/regressionTests.test.tsx

@@ -12,11 +12,11 @@ import {
   fireEvent,
   render,
   screen,
+  togglePopover,
   waitFor,
 } from "./test-utils";
 import { defaultLang } from "../i18n";
 import { FONT_FAMILY } from "../constants";
-import { t } from "../i18n";
 
 const { h } = window;
 
@@ -42,7 +42,6 @@ const checkpoint = (name: string) => {
     expect(element).toMatchSnapshot(`[${name}] element ${i}`),
   );
 };
-
 beforeEach(async () => {
   // Unmount ReactDOM from root
   ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -159,13 +158,14 @@ describe("regression tests", () => {
     UI.clickTool("rectangle");
     mouse.down(10, 10);
     mouse.up(10, 10);
-
-    UI.clickLabeledElement("Background");
-    UI.clickLabeledElement(t("colors.fa5252"));
-    UI.clickLabeledElement("Stroke");
-    UI.clickLabeledElement(t("colors.5f3dc4"));
-    expect(API.getSelectedElement().backgroundColor).toBe("#fa5252");
-    expect(API.getSelectedElement().strokeColor).toBe("#5f3dc4");
+    togglePopover("Background");
+    UI.clickOnTestId("color-yellow");
+    UI.clickOnTestId("color-red");
+
+    togglePopover("Stroke");
+    UI.clickOnTestId("color-blue");
+    expect(API.getSelectedElement().backgroundColor).toBe("#ffc9c9");
+    expect(API.getSelectedElement().strokeColor).toBe("#1971c2");
   });
 
   it("click on an element and drag it", () => {
@@ -988,8 +988,8 @@ describe("regression tests", () => {
       UI.clickTool("rectangle");
       // change background color since default is transparent
       // and transparent elements can't be selected by clicking inside of them
-      UI.clickLabeledElement("Background");
-      UI.clickLabeledElement(t("colors.fa5252"));
+      togglePopover("Background");
+      UI.clickOnTestId("color-red");
       mouse.down();
       mouse.up(1000, 1000);
 
@@ -1088,15 +1088,14 @@ describe("regression tests", () => {
     assertSelectedElements(rect3);
   });
 
-  it("should show fill icons when element has non transparent background", () => {
+  it("should show fill icons when element has non transparent background", async () => {
     UI.clickTool("rectangle");
     expect(screen.queryByText(/fill/i)).not.toBeNull();
     mouse.down();
     mouse.up(10, 10);
     expect(screen.queryByText(/fill/i)).toBeNull();
-
-    UI.clickLabeledElement("Background");
-    UI.clickLabeledElement(t("colors.fa5252"));
+    togglePopover("Background");
+    UI.clickOnTestId("color-red");
     // select rectangle
     mouse.reset();
     mouse.click();

+ 22 - 5
src/tests/test-utils.ts

@@ -16,6 +16,7 @@ import { STORAGE_KEYS } from "../excalidraw-app/app_constants";
 import { SceneData } from "../types";
 import { getSelectedElements } from "../scene/selection";
 import { ExcalidrawElement } from "../element/types";
+import { UI } from "./helpers/ui";
 
 const customQueries = {
   ...queries,
@@ -186,11 +187,6 @@ export const assertSelectedElements = (
   expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
 };
 
-export const toggleMenu = (container: HTMLElement) => {
-  // open menu
-  fireEvent.click(container.querySelector(".dropdown-menu-button")!);
-};
-
 export const createPasteEvent = (
   text:
     | string
@@ -211,3 +207,24 @@ export const createPasteEvent = (
     },
   );
 };
+
+export const toggleMenu = (container: HTMLElement) => {
+  // open menu
+  fireEvent.click(container.querySelector(".dropdown-menu-button")!);
+};
+
+export const togglePopover = (label: string) => {
+  // Needed for radix-ui/react-popover as tests fail due to resize observer not being present
+  (global as any).ResizeObserver = class ResizeObserver {
+    constructor(cb: any) {
+      (this as any).cb = cb;
+    }
+
+    observe() {}
+
+    unobserve() {}
+    disconnect() {}
+  };
+
+  UI.clickLabeledElement(label);
+};

+ 2 - 5
src/types.ts

@@ -163,11 +163,7 @@ export type AppState = {
   isRotating: boolean;
   zoom: Zoom;
   openMenu: "canvas" | "shape" | null;
-  openPopup:
-    | "canvasColorPicker"
-    | "backgroundColorPicker"
-    | "strokeColorPicker"
-    | null;
+  openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
   openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
   openDialog: "imageExport" | "help" | "jsonExport" | null;
   /**
@@ -542,4 +538,5 @@ export type Device = Readonly<{
   isMobile: boolean;
   isTouchScreen: boolean;
   canDeviceFitSidebar: boolean;
+  isLandscape: boolean;
 }>;

+ 2 - 3
src/utils.ts

@@ -1,6 +1,5 @@
 import oc from "open-color";
-
-import colors from "./colors";
+import { COLOR_PALETTE } from "./colors";
 import {
   CURSOR_TYPE,
   DEFAULT_VERSION,
@@ -529,7 +528,7 @@ export const isTransparent = (color: string) => {
   return (
     isRGBTransparent ||
     isRRGGBBTransparent ||
-    color === colors.elementBackground[0]
+    color === COLOR_PALETTE.transparent
   );
 };
 

Різницю між файлами не показано, бо вона завелика
+ 277 - 404
yarn.lock


Деякі файли не було показано, через те що забагато файлів було змінено