aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/ColorPicker
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/ColorPicker')
-rw-r--r--packages/excalidraw/components/ColorPicker/ColorInput.tsx130
-rw-r--r--packages/excalidraw/components/ColorPicker/ColorPicker.scss441
-rw-r--r--packages/excalidraw/components/ColorPicker/ColorPicker.tsx246
-rw-r--r--packages/excalidraw/components/ColorPicker/CustomColorList.tsx63
-rw-r--r--packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx29
-rw-r--r--packages/excalidraw/components/ColorPicker/Picker.tsx178
-rw-r--r--packages/excalidraw/components/ColorPicker/PickerColorList.tsx91
-rw-r--r--packages/excalidraw/components/ColorPicker/PickerHeading.tsx7
-rw-r--r--packages/excalidraw/components/ColorPicker/ShadeList.tsx105
-rw-r--r--packages/excalidraw/components/ColorPicker/TopPicks.tsx65
-rw-r--r--packages/excalidraw/components/ColorPicker/colorPickerUtils.ts133
-rw-r--r--packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts286
12 files changed, 1774 insertions, 0 deletions
diff --git a/packages/excalidraw/components/ColorPicker/ColorInput.tsx b/packages/excalidraw/components/ColorPicker/ColorInput.tsx
new file mode 100644
index 0000000..837c887
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/ColorInput.tsx
@@ -0,0 +1,130 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getColor } from "./ColorPicker";
+import type { ColorPickerType } from "./colorPickerUtils";
+import { activeColorPickerSectionAtom } from "./colorPickerUtils";
+import { eyeDropperIcon } from "../icons";
+import { useAtom } from "../../editor-jotai";
+import { KEYS } from "../../keys";
+import { activeEyeDropperAtom } from "../EyeDropper";
+import clsx from "clsx";
+import { t } from "../../i18n";
+import { useDevice } from "../App";
+import { getShortcutKey } from "../../utils";
+
+interface ColorInputProps {
+ color: string;
+ onChange: (color: string) => void;
+ label: string;
+ colorPickerType: ColorPickerType;
+}
+
+export const ColorInput = ({
+ color,
+ onChange,
+ label,
+ colorPickerType,
+}: ColorInputProps) => {
+ const device = useDevice();
+ 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 eyeDropperTriggerRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [activeSection]);
+
+ const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
+
+ useEffect(() => {
+ return () => {
+ setEyeDropperState(null);
+ };
+ }, [setEyeDropperState]);
+
+ return (
+ <div 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={(event) => {
+ if (event.key === KEYS.TAB) {
+ return;
+ } else if (event.key === KEYS.ESCAPE) {
+ eyeDropperTriggerRef.current?.focus();
+ }
+ event.stopPropagation();
+ }}
+ />
+ {/* TODO reenable on mobile with a better UX */}
+ {!device.editor.isMobile && (
+ <>
+ <div
+ style={{
+ width: "1px",
+ height: "1.25rem",
+ backgroundColor: "var(--default-border-color)",
+ }}
+ />
+ <div
+ ref={eyeDropperTriggerRef}
+ className={clsx("excalidraw-eye-dropper-trigger", {
+ selected: eyeDropperState,
+ })}
+ onClick={() =>
+ setEyeDropperState((s) =>
+ s
+ ? null
+ : {
+ keepOpenOnAlt: false,
+ onSelect: (color) => onChange(color),
+ colorPickerType,
+ },
+ )
+ }
+ title={`${t(
+ "labels.eyeDropper",
+ )} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
+ >
+ {eyeDropperIcon}
+ </div>
+ </>
+ )}
+ </div>
+ );
+};
diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss
new file mode 100644
index 0000000..39e1845
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/ColorPicker.scss
@@ -0,0 +1,441 @@
+@import "../../css/variables.module.scss";
+
+.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: 11rem;
+ }
+ }
+
+ .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);
+ box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
+ border-radius: 4px;
+ position: absolute;
+
+ :root[dir="ltr"] & {
+ left: -5.5px;
+ }
+
+ :root[dir="rtl"] & {
+ right: -5.5px;
+ }
+ }
+
+ .color-picker-control-container {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ column-gap: 0.5rem;
+ }
+
+ .color-picker-control-container + .popover {
+ position: static;
+ }
+
+ .color-picker-popover-container {
+ margin-top: -0.25rem;
+
+ :root[dir="ltr"] & {
+ margin-left: 0.5rem;
+ }
+
+ :root[dir="rtl"] & {
+ margin-left: -3rem;
+ }
+ }
+
+ .color-picker-triangle {
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: 0 9px 10px;
+ border-color: transparent transparent var(--popup-bg-color);
+ position: absolute;
+ top: 10px;
+
+ :root[dir="ltr"] & {
+ transform: rotate(270deg);
+ left: -14px;
+ }
+
+ :root[dir="rtl"] & {
+ transform: rotate(90deg);
+ right: -14px;
+ }
+ }
+
+ .color-picker-triangle-shadow {
+ border-color: transparent transparent transparentize($oc-black, 0.9);
+
+ :root[dir="ltr"] & {
+ left: -14px;
+ }
+
+ :root[dir="rtl"] & {
+ right: -16px;
+ }
+ }
+
+ .color-picker-content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ outline: none;
+ }
+
+ .color-picker-content--default {
+ padding: 0.5rem;
+ display: grid;
+ grid-template-columns: repeat(5, 1.875rem);
+ grid-gap: 0.25rem;
+ border-radius: 4px;
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--focus-highlight-color);
+ }
+ }
+
+ .color-picker-content--canvas {
+ display: flex;
+ flex-direction: column;
+ padding: 0.25rem;
+
+ &-title {
+ color: $oc-gray-6;
+ font-size: 12px;
+ padding: 0 0.25rem;
+ }
+
+ &-colors {
+ padding: 0.5rem 0;
+
+ .color-picker-swatch {
+ margin: 0 0.25rem;
+ }
+ }
+ }
+
+ .color-picker-content .color-input-container {
+ grid-column: 1 / span 5;
+ }
+
+ .color-picker-swatch {
+ position: relative;
+ height: 1.875rem;
+ width: 1.875rem;
+ cursor: pointer;
+ border-radius: 4px;
+ margin: 0;
+ box-sizing: border-box;
+ border: 1px solid #ddd;
+ background-color: currentColor !important;
+ filter: var(--theme-filter);
+
+ &:focus {
+ /* TODO: only show the border when the color is too light to see as a shadow */
+ box-shadow: 0 0 4px 1px currentColor;
+ border-color: var(--select-highlight-color);
+ }
+ }
+
+ .color-picker-transparent {
+ border-radius: 4px;
+ box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ }
+
+ .color-picker-transparent,
+ .color-picker-label-swatch {
+ background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
+ left center;
+ }
+
+ .color-picker-hash {
+ height: var(--default-button-size);
+ flex-shrink: 0;
+ padding: 0.5rem 0.5rem 0.5rem 0.75rem;
+ border: 1px solid var(--default-border-color);
+ border-right: 0;
+ box-sizing: border-box;
+
+ :root[dir="ltr"] & {
+ border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
+ }
+
+ :root[dir="rtl"] & {
+ border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
+ border-right: 1px solid var(--default-border-color);
+ border-left: 0;
+ }
+
+ color: var(--input-label-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ }
+
+ .color-input-container {
+ display: flex;
+
+ &:focus-within {
+ box-shadow: 0 0 0 1px var(--color-primary-darkest);
+ border-radius: var(--border-radius-lg);
+ }
+ }
+
+ .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%;
+ margin: 0;
+ font-size: 0.875rem;
+ font-family: inherit;
+ background-color: transparent;
+ color: var(--text-primary-color);
+ border: 0;
+ outline: none;
+ height: var(--default-button-size);
+ border: 1px solid var(--default-border-color);
+ border-left: 0;
+ letter-spacing: 0.4px;
+
+ :root[dir="ltr"] & {
+ border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
+ }
+
+ :root[dir="rtl"] & {
+ border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
+ border-left: 1px solid var(--default-border-color);
+ border-right: 0;
+ }
+
+ padding: 0.5rem;
+ padding-left: 0.25rem;
+ appearance: none;
+
+ &:focus-visible {
+ box-shadow: none;
+ }
+ }
+
+ .color-picker-label-swatch-container {
+ border: 1px solid var(--default-border-color);
+ border-radius: var(--border-radius-lg);
+ width: var(--default-button-size);
+ height: var(--default-button-size);
+ box-sizing: border-box;
+ overflow: hidden;
+ }
+
+ .color-picker-label-swatch {
+ @include outlineButtonStyles;
+ background-color: var(--swatch-color) !important;
+ overflow: hidden;
+ position: relative;
+ filter: var(--theme-filter);
+ border: 0 !important;
+
+ &:after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--swatch-color);
+ }
+ }
+
+ .color-picker-keybinding {
+ position: absolute;
+ bottom: 2px;
+ font-size: 0.7em;
+
+ :root[dir="ltr"] & {
+ right: 2px;
+ }
+
+ :root[dir="rtl"] & {
+ left: 2px;
+ }
+
+ @include isMobile {
+ display: none;
+ }
+ }
+
+ .color-picker-type-canvasBackground .color-picker-keybinding {
+ color: #aaa;
+ }
+
+ .color-picker-type-elementBackground .color-picker-keybinding {
+ color: $oc-white;
+ }
+
+ .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
+ color: #aaa;
+ }
+
+ .color-picker-type-elementStroke .color-picker-keybinding {
+ color: #d4d4d4;
+ }
+
+ &.theme--dark {
+ .color-picker-type-elementBackground .color-picker-keybinding {
+ color: $oc-black;
+ }
+ .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
+ color: $oc-black;
+ }
+ }
+}
diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx
new file mode 100644
index 0000000..74d5527
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx
@@ -0,0 +1,246 @@
+import { isTransparent } from "../../utils";
+import type { ExcalidrawElement } from "../../element/types";
+import type { AppState } from "../../types";
+import { TopPicks } from "./TopPicks";
+import { ButtonSeparator } from "../ButtonSeparator";
+import { Picker } from "./Picker";
+import * as Popover from "@radix-ui/react-popover";
+import type { ColorPickerType } from "./colorPickerUtils";
+import { activeColorPickerSectionAtom } from "./colorPickerUtils";
+import { useExcalidrawContainer } from "../App";
+import type { ColorTuple, ColorPaletteCustom } from "../../colors";
+import { COLOR_PALETTE } from "../../colors";
+import PickerHeading from "./PickerHeading";
+import { t } from "../../i18n";
+import clsx from "clsx";
+import { useRef } from "react";
+import { useAtom } from "../../editor-jotai";
+import { ColorInput } from "./ColorInput";
+import { activeEyeDropperAtom } from "../EyeDropper";
+import { PropertiesPopover } from "../PropertiesPopover";
+
+import "./ColorPicker.scss";
+
+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;
+};
+
+interface ColorPickerProps {
+ type: ColorPickerType;
+ color: string;
+ 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"
+ | "elements"
+ | "palette"
+ | "updateData"
+>) => {
+ const { container } = useExcalidrawContainer();
+ const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
+
+ const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
+
+ const colorInputJSX = (
+ <div>
+ <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
+ <ColorInput
+ color={color}
+ label={label}
+ onChange={(color) => {
+ onChange(color);
+ }}
+ colorPickerType={type}
+ />
+ </div>
+ );
+
+ const popoverRef = useRef<HTMLDivElement>(null);
+
+ const focusPickerContent = () => {
+ popoverRef.current
+ ?.querySelector<HTMLDivElement>(".color-picker-content")
+ ?.focus();
+ };
+
+ return (
+ <PropertiesPopover
+ container={container}
+ style={{ maxWidth: "13rem" }}
+ onFocusOutside={(event) => {
+ // refocus due to eye dropper
+ focusPickerContent();
+ event.preventDefault();
+ }}
+ onPointerDownOutside={(event) => {
+ if (eyeDropperState) {
+ // prevent from closing if we click outside the popover
+ // while eyedropping (e.g. click when clicking the sidebar;
+ // the eye-dropper-backdrop is prevented downstream)
+ event.preventDefault();
+ }
+ }}
+ onClose={() => {
+ updateData({ openPopup: null });
+ setActiveColorPickerSection(null);
+ }}
+ >
+ {palette ? (
+ <Picker
+ palette={palette}
+ color={color}
+ onChange={(changedColor) => {
+ onChange(changedColor);
+ }}
+ onEyeDropperToggle={(force) => {
+ setEyeDropperState((state) => {
+ if (force) {
+ state = state || {
+ keepOpenOnAlt: true,
+ onSelect: onChange,
+ colorPickerType: type,
+ };
+ state.keepOpenOnAlt = true;
+ return state;
+ }
+
+ return force === false || state
+ ? null
+ : {
+ keepOpenOnAlt: false,
+ onSelect: onChange,
+ colorPickerType: type,
+ };
+ });
+ }}
+ onEscape={(event) => {
+ if (eyeDropperState) {
+ setEyeDropperState(null);
+ } else {
+ updateData({ openPopup: null });
+ }
+ }}
+ label={label}
+ type={type}
+ elements={elements}
+ updateData={updateData}
+ >
+ {colorInputJSX}
+ </Picker>
+ ) : (
+ colorInputJSX
+ )}
+ </PropertiesPopover>
+ );
+};
+
+const ColorPickerTrigger = ({
+ label,
+ color,
+ type,
+}: {
+ color: string;
+ label: string;
+ type: ColorPickerType;
+}) => {
+ return (
+ <Popover.Trigger
+ type="button"
+ className={clsx("color-picker__button active-color properties-trigger", {
+ "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}
+ />
+ <ButtonSeparator />
+ <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>
+ );
+};
diff --git a/packages/excalidraw/components/ColorPicker/CustomColorList.tsx b/packages/excalidraw/components/ColorPicker/CustomColorList.tsx
new file mode 100644
index 0000000..5fe1e3e
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/CustomColorList.tsx
@@ -0,0 +1,63 @@
+import clsx from "clsx";
+import { useAtom } from "../../editor-jotai";
+import { useEffect, useRef } from "react";
+import { activeColorPickerSectionAtom } from "./colorPickerUtils";
+import HotkeyLabel from "./HotkeyLabel";
+
+interface CustomColorListProps {
+ colors: string[];
+ color: string;
+ 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>
+ );
+};
diff --git a/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx b/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx
new file mode 100644
index 0000000..145060d
--- /dev/null
+++ b/packages/excalidraw/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;
diff --git a/packages/excalidraw/components/ColorPicker/Picker.tsx b/packages/excalidraw/components/ColorPicker/Picker.tsx
new file mode 100644
index 0000000..88d6876
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/Picker.tsx
@@ -0,0 +1,178 @@
+import React, { useEffect, useState } from "react";
+import { t } from "../../i18n";
+
+import type { ExcalidrawElement } from "../../element/types";
+import { ShadeList } from "./ShadeList";
+
+import PickerColorList from "./PickerColorList";
+import { useAtom } from "../../editor-jotai";
+import { CustomColorList } from "./CustomColorList";
+import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
+import PickerHeading from "./PickerHeading";
+import type { ColorPickerType } from "./colorPickerUtils";
+import {
+ activeColorPickerSectionAtom,
+ getColorNameAndShadeFromColor,
+ getMostUsedCustomColors,
+ isCustomColor,
+} from "./colorPickerUtils";
+import type { ColorPaletteCustom } from "../../colors";
+import {
+ DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
+ DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
+} from "../../colors";
+import { KEYS } from "../../keys";
+import { EVENT } from "../../constants";
+
+interface PickerProps {
+ color: string;
+ onChange: (color: string) => void;
+ label: string;
+ type: ColorPickerType;
+ elements: readonly ExcalidrawElement[];
+ palette: ColorPaletteCustom;
+ updateData: (formData?: any) => void;
+ children?: React.ReactNode;
+ onEyeDropperToggle: (force?: boolean) => void;
+ onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
+}
+
+export const Picker = ({
+ color,
+ onChange,
+ label,
+ type,
+ elements,
+ palette,
+ updateData,
+ children,
+ onEyeDropperToggle,
+ onEscape,
+}: PickerProps) => {
+ const [customColors] = React.useState(() => {
+ if (type === "canvasBackground") {
+ return [];
+ }
+ return getMostUsedCustomColors(elements, type, palette);
+ });
+
+ const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
+ activeColorPickerSectionAtom,
+ );
+
+ const colorObj = getColorNameAndShadeFromColor({
+ color,
+ 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);
+ }
+
+ const keyup = (event: KeyboardEvent) => {
+ if (event.key === KEYS.ALT) {
+ onEyeDropperToggle(false);
+ }
+ };
+ document.addEventListener(EVENT.KEYUP, keyup, { capture: true });
+ return () => {
+ document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
+ };
+ }, [colorObj, onEyeDropperToggle]);
+
+ const pickerRef = React.useRef<HTMLDivElement>(null);
+
+ return (
+ <div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
+ <div
+ ref={pickerRef}
+ onKeyDown={(event) => {
+ const handled = colorPickerKeyNavHandler({
+ event,
+ activeColorPickerSection,
+ palette,
+ color,
+ onChange,
+ onEyeDropperToggle,
+ customColors,
+ setActiveColorPickerSection,
+ updateData,
+ activeShade,
+ onEscape,
+ });
+
+ if (handled) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }}
+ className="color-picker-content properties-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>
+ );
+};
diff --git a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx
new file mode 100644
index 0000000..f43559d
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx
@@ -0,0 +1,91 @@
+import clsx from "clsx";
+import { useAtom } from "../../editor-jotai";
+import { useEffect, useRef } from "react";
+import {
+ activeColorPickerSectionAtom,
+ colorPickerHotkeyBindings,
+ getColorNameAndShadeFromColor,
+} from "./colorPickerUtils";
+import HotkeyLabel from "./HotkeyLabel";
+import type { ColorPaletteCustom } from "../../colors";
+import type { TranslationKeys } from "../../i18n";
+import { t } from "../../i18n";
+
+interface PickerColorListProps {
+ palette: ColorPaletteCustom;
+ color: string;
+ onChange: (color: string) => void;
+ label: string;
+ activeShade: number;
+}
+
+const PickerColorList = ({
+ palette,
+ color,
+ onChange,
+ label,
+ activeShade,
+}: PickerColorListProps) => {
+ const colorObj = getColorNameAndShadeFromColor({
+ color: 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+/, "")}` as unknown as TranslationKeys,
+ 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;
diff --git a/packages/excalidraw/components/ColorPicker/PickerHeading.tsx b/packages/excalidraw/components/ColorPicker/PickerHeading.tsx
new file mode 100644
index 0000000..3999a49
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/PickerHeading.tsx
@@ -0,0 +1,7 @@
+import type { ReactNode } from "react";
+
+const PickerHeading = ({ children }: { children: ReactNode }) => (
+ <div className="color-picker__heading">{children}</div>
+);
+
+export default PickerHeading;
diff --git a/packages/excalidraw/components/ColorPicker/ShadeList.tsx b/packages/excalidraw/components/ColorPicker/ShadeList.tsx
new file mode 100644
index 0000000..8d3d4cc
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/ShadeList.tsx
@@ -0,0 +1,105 @@
+import clsx from "clsx";
+import { useAtom } from "../../editor-jotai";
+import { useEffect, useRef } from "react";
+import {
+ activeColorPickerSectionAtom,
+ getColorNameAndShadeFromColor,
+} from "./colorPickerUtils";
+import HotkeyLabel from "./HotkeyLabel";
+import { t } from "../../i18n";
+import type { ColorPaletteCustom } from "../../colors";
+
+interface ShadeListProps {
+ hex: string;
+ onChange: (color: string) => void;
+ palette: ColorPaletteCustom;
+}
+
+export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
+ const colorObj = getColorNameAndShadeFromColor({
+ color: 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>
+ );
+};
diff --git a/packages/excalidraw/components/ColorPicker/TopPicks.tsx b/packages/excalidraw/components/ColorPicker/TopPicks.tsx
new file mode 100644
index 0000000..5c69d1e
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/TopPicks.tsx
@@ -0,0 +1,65 @@
+import clsx from "clsx";
+import type { 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;
+ 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)}
+ data-testid={`color-top-pick-${color}`}
+ >
+ <div className="color-picker__button-outline" />
+ </button>
+ ))}
+ </div>
+ );
+};
diff --git a/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
new file mode 100644
index 0000000..2733b7a
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
@@ -0,0 +1,133 @@
+import type { ExcalidrawElement } from "../../element/types";
+import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
+import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
+import { atom } from "../../editor-jotai";
+
+export const getColorNameAndShadeFromColor = ({
+ palette,
+ color,
+}: {
+ palette: ColorPaletteCustom;
+ color: string;
+}): {
+ colorName: ColorPickerColor;
+ shade: number | null;
+} | null => {
+ for (const [colorName, colorVal] of Object.entries(palette)) {
+ if (Array.isArray(colorVal)) {
+ const shade = colorVal.indexOf(color);
+ if (shade > -1) {
+ return { colorName: colorName as ColorPickerColor, shade };
+ }
+ } else if (colorVal === color) {
+ 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;
+ palette: ColorPaletteCustom;
+}) => {
+ 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";
diff --git a/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts
new file mode 100644
index 0000000..7767692
--- /dev/null
+++ b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts
@@ -0,0 +1,286 @@
+import { KEYS } from "../../keys";
+import type {
+ ColorPickerColor,
+ ColorPalette,
+ ColorPaletteCustom,
+} from "../../colors";
+import { COLORS_PER_ROW, COLOR_PALETTE } from "../../colors";
+import type { ValueOf } from "../../utility-types";
+import type { ActiveColorPickerSectionAtomType } from "./colorPickerUtils";
+import {
+ colorPickerHotkeyBindings,
+ getColorNameAndShadeFromColor,
+} 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;
+}
+
+/**
+ * @returns true if the event was handled
+ */
+const hotkeyHandler = ({
+ e,
+ colorObj,
+ onChange,
+ palette,
+ customColors,
+ setActiveColorPickerSection,
+ activeShade,
+}: HotkeyHandlerProps): boolean => {
+ 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");
+ return true;
+ }
+ }
+
+ 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");
+ return true;
+ }
+ }
+
+ 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");
+ return true;
+ }
+ return false;
+};
+
+interface ColorPickerKeyNavHandlerProps {
+ event: React.KeyboardEvent;
+ activeColorPickerSection: ActiveColorPickerSectionAtomType;
+ palette: ColorPaletteCustom;
+ color: string;
+ onChange: (color: string) => void;
+ customColors: string[];
+ setActiveColorPickerSection: (
+ update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
+ ) => void;
+ updateData: (formData?: any) => void;
+ activeShade: number;
+ onEyeDropperToggle: (force?: boolean) => void;
+ onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
+}
+
+/**
+ * @returns true if the event was handled
+ */
+export const colorPickerKeyNavHandler = ({
+ event,
+ activeColorPickerSection,
+ palette,
+ color,
+ onChange,
+ customColors,
+ setActiveColorPickerSection,
+ updateData,
+ activeShade,
+ onEyeDropperToggle,
+ onEscape,
+}: ColorPickerKeyNavHandlerProps): boolean => {
+ if (event[KEYS.CTRL_OR_CMD]) {
+ return false;
+ }
+
+ if (event.key === KEYS.ESCAPE) {
+ onEscape(event);
+ return true;
+ }
+
+ // checkt using `key` to ignore combos with Alt modifier
+ if (event.key === KEYS.ALT) {
+ onEyeDropperToggle(true);
+ return true;
+ }
+
+ if (event.key === KEYS.I) {
+ onEyeDropperToggle();
+ return true;
+ }
+
+ const colorObj = getColorNameAndShadeFromColor({ color, palette });
+
+ if (event.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 = event.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(color);
+ } else if (shades === color) {
+ return name;
+ }
+ return null;
+ });
+
+ if (!baseColorName) {
+ onChange(COLOR_PALETTE.black);
+ }
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ return true;
+ }
+
+ if (
+ hotkeyHandler({
+ e: event,
+ colorObj,
+ onChange,
+ palette,
+ customColors,
+ setActiveColorPickerSection,
+ activeShade,
+ })
+ ) {
+ return true;
+ }
+
+ if (activeColorPickerSection === "shades") {
+ if (colorObj) {
+ const { shade } = colorObj;
+ const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW);
+
+ if (newShade !== undefined) {
+ onChange(palette[colorObj.colorName][newShade]);
+ return true;
+ }
+ }
+ }
+
+ if (activeColorPickerSection === "baseColors") {
+ if (colorObj) {
+ const { colorName } = colorObj;
+ const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
+ const indexOfColorName = colorNames.indexOf(colorName);
+
+ const newColorIndex = arrowHandler(
+ event.key,
+ indexOfColorName,
+ colorNames.length,
+ );
+
+ if (newColorIndex !== undefined) {
+ const newColorName = colorNames[newColorIndex];
+ const newColorNameValue = palette[newColorName];
+
+ onChange(
+ Array.isArray(newColorNameValue)
+ ? newColorNameValue[activeShade]
+ : newColorNameValue,
+ );
+ return true;
+ }
+ }
+ }
+
+ if (activeColorPickerSection === "custom") {
+ const indexOfColor = customColors.indexOf(color);
+
+ const newColorIndex = arrowHandler(
+ event.key,
+ indexOfColor,
+ customColors.length,
+ );
+
+ if (newColorIndex !== undefined) {
+ const newColor = customColors[newColorIndex];
+ onChange(newColor);
+ return true;
+ }
+ }
+
+ return false;
+};