|
@@ -7,6 +7,53 @@ 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;
|
|
@@ -35,6 +82,7 @@ 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 = ({
|
|
@@ -45,6 +93,7 @@ const Picker = ({
|
|
|
label,
|
|
|
showInput = true,
|
|
|
type,
|
|
|
+ elements,
|
|
|
}: {
|
|
|
colors: string[];
|
|
|
color: string | null;
|
|
@@ -53,12 +102,20 @@ const Picker = ({
|
|
|
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) {
|
|
@@ -85,23 +142,42 @@ const Picker = ({
|
|
|
} else if (isArrowKey(event.key)) {
|
|
|
const { activeElement } = document;
|
|
|
const isRTL = getLanguage().rtl;
|
|
|
- const index = Array.prototype.indexOf.call(
|
|
|
- gallery!.current!.children,
|
|
|
+ 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 parentSelector = isCustom
|
|
|
+ ? gallery!.current!.querySelector(
|
|
|
+ ".color-picker-content--canvas-colors",
|
|
|
+ )!
|
|
|
+ : gallery!.current!.querySelector(".color-picker-content--default")!;
|
|
|
+
|
|
|
if (index !== -1) {
|
|
|
- const length = gallery!.current!.children.length - (showInput ? 1 : 0);
|
|
|
+ const length = parentSelector!.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
|
|
|
- : event.key === KEYS.ARROW_DOWN
|
|
|
+ : !isCustom && event.key === KEYS.ARROW_DOWN
|
|
|
? (index + 5) % length
|
|
|
- : event.key === KEYS.ARROW_UP
|
|
|
+ : !isCustom && event.key === KEYS.ARROW_UP
|
|
|
? (length + index - 5) % length
|
|
|
: index;
|
|
|
- (gallery!.current!.children![nextIndex] as any).focus();
|
|
|
+ (parentSelector!.children![nextIndex] as HTMLElement)?.focus();
|
|
|
}
|
|
|
event.preventDefault();
|
|
|
} else if (
|
|
@@ -109,7 +185,15 @@ const Picker = ({
|
|
|
!isWritableElement(event.target)
|
|
|
) {
|
|
|
const index = keyBindings.indexOf(event.key.toLowerCase());
|
|
|
- (gallery!.current!.children![index] as any).focus();
|
|
|
+ const isCustom = index >= MAX_DEFAULT_COLORS;
|
|
|
+ const parentSelector = isCustom
|
|
|
+ ? gallery!.current!.querySelector(
|
|
|
+ ".color-picker-content--canvas-colors",
|
|
|
+ )!
|
|
|
+ : gallery!.current!.querySelector(".color-picker-content--default")!;
|
|
|
+ const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
|
|
|
+ (parentSelector!.children![actualIndex] as HTMLElement)?.focus();
|
|
|
+
|
|
|
event.preventDefault();
|
|
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
|
|
event.preventDefault();
|
|
@@ -119,6 +203,50 @@ const Picker = ({
|
|
|
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}`}
|
|
@@ -138,41 +266,20 @@ const Picker = ({
|
|
|
}}
|
|
|
tabIndex={0}
|
|
|
>
|
|
|
- {colors.map((_color, i) => {
|
|
|
- const _colorWithoutHash = _color.replace("#", "");
|
|
|
- return (
|
|
|
- <button
|
|
|
- className="color-picker-swatch"
|
|
|
- onClick={(event) => {
|
|
|
- (event.currentTarget as HTMLButtonElement).focus();
|
|
|
- onChange(_color);
|
|
|
- }}
|
|
|
- title={`${t(`colors.${_colorWithoutHash}`)}${
|
|
|
- !isTransparent(_color) ? ` (${_color})` : ""
|
|
|
- } — ${keyBindings[i].toUpperCase()}`}
|
|
|
- aria-label={t(`colors.${_colorWithoutHash}`)}
|
|
|
- aria-keyshortcuts={keyBindings[i]}
|
|
|
- style={{ color: _color }}
|
|
|
- key={_color}
|
|
|
- ref={(el) => {
|
|
|
- if (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">{keyBindings[i]}</span>
|
|
|
- </button>
|
|
|
- );
|
|
|
- })}
|
|
|
+ <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}
|
|
@@ -246,6 +353,8 @@ export const ColorPicker = ({
|
|
|
label,
|
|
|
isActive,
|
|
|
setActive,
|
|
|
+ elements,
|
|
|
+ appState,
|
|
|
}: {
|
|
|
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
|
|
color: string | null;
|
|
@@ -253,6 +362,8 @@ export const ColorPicker = ({
|
|
|
label: string;
|
|
|
isActive: boolean;
|
|
|
setActive: (active: boolean) => void;
|
|
|
+ elements: readonly ExcalidrawElement[];
|
|
|
+ appState: AppState;
|
|
|
}) => {
|
|
|
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
|
|
|
|
@@ -294,6 +405,7 @@ export const ColorPicker = ({
|
|
|
label={label}
|
|
|
showInput={false}
|
|
|
type={type}
|
|
|
+ elements={elements}
|
|
|
/>
|
|
|
</Popover>
|
|
|
) : null}
|