123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412 |
- 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;
- }
- 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>
- );
- },
- );
- 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);
- return (
- <div>
- <div className="color-picker-control-container">
- <button
- className="color-picker-label-swatch"
- aria-label={label}
- style={color ? { "--swatch-color": color } : undefined}
- onClick={() => setActive(!isActive)}
- ref={pickerButton}
- />
- <ColorInput
- color={color}
- label={label}
- onChange={(color) => {
- onChange(color);
- }}
- />
- </div>
- <React.Suspense fallback="">
- {isActive ? (
- <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>
- ) : null}
- </React.Suspense>
- </div>
- );
- };
|