feat: color picker redesign (#6216)
Co-authored-by: Maielo <maielo.mv@gmail.com> Co-authored-by: dwelle <luzar.david@gmail.com> Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>pull/6599/head
parent
6977c32631
commit
5b7596582f
@ -1 +1,2 @@
|
|||||||
save-exact=true
|
save-exact=true
|
||||||
|
legacy-peer-deps=true
|
||||||
|
@ -1,22 +1,167 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
|
import { Merge } from "./utility-types";
|
||||||
|
|
||||||
const shades = (index: number) => [
|
// FIXME can't put to utils.ts rn because of circular dependency
|
||||||
oc.red[index],
|
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||||
oc.pink[index],
|
source: R,
|
||||||
oc.grape[index],
|
keys: K,
|
||||||
oc.violet[index],
|
) => {
|
||||||
oc.indigo[index],
|
return keys.reduce((acc, key: K[number]) => {
|
||||||
oc.blue[index],
|
if (key in source) {
|
||||||
oc.cyan[index],
|
acc[key] = source[key];
|
||||||
oc.teal[index],
|
}
|
||||||
oc.green[index],
|
return acc;
|
||||||
oc.lime[index],
|
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
|
||||||
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)],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
@ -0,0 +1,7 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
||||||
|
<div className="color-picker__heading">{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PickerHeading;
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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";
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue