ColorPicker.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import React from "react";
  2. import { Popover } from "./Popover";
  3. import { isTransparent } from "../utils";
  4. import "./ColorPicker.scss";
  5. import { isArrowKey, KEYS } from "../keys";
  6. import { t, getLanguage } from "../i18n";
  7. import { isWritableElement } from "../utils";
  8. import colors from "../colors";
  9. import { ExcalidrawElement } from "../element/types";
  10. import { AppState } from "../types";
  11. const MAX_CUSTOM_COLORS = 5;
  12. const MAX_DEFAULT_COLORS = 15;
  13. export const getCustomColors = (
  14. elements: readonly ExcalidrawElement[],
  15. type: "elementBackground" | "elementStroke",
  16. ) => {
  17. const customColors: string[] = [];
  18. const updatedElements = elements
  19. .filter((element) => !element.isDeleted)
  20. .sort((ele1, ele2) => ele2.updated - ele1.updated);
  21. let index = 0;
  22. const elementColorTypeMap = {
  23. elementBackground: "backgroundColor",
  24. elementStroke: "strokeColor",
  25. };
  26. const colorType = elementColorTypeMap[type] as
  27. | "backgroundColor"
  28. | "strokeColor";
  29. while (
  30. index < updatedElements.length &&
  31. customColors.length < MAX_CUSTOM_COLORS
  32. ) {
  33. const element = updatedElements[index];
  34. if (
  35. customColors.length < MAX_CUSTOM_COLORS &&
  36. isCustomColor(element[colorType], type) &&
  37. !customColors.includes(element[colorType])
  38. ) {
  39. customColors.push(element[colorType]);
  40. }
  41. index++;
  42. }
  43. return customColors;
  44. };
  45. const isCustomColor = (
  46. color: string,
  47. type: "elementBackground" | "elementStroke",
  48. ) => {
  49. return !colors[type].includes(color);
  50. };
  51. const isValidColor = (color: string) => {
  52. const style = new Option().style;
  53. style.color = color;
  54. return !!style.color;
  55. };
  56. const getColor = (color: string): string | null => {
  57. if (isTransparent(color)) {
  58. return color;
  59. }
  60. return isValidColor(color)
  61. ? color
  62. : isValidColor(`#${color}`)
  63. ? `#${color}`
  64. : null;
  65. };
  66. // This is a narrow reimplementation of the awesome react-color Twitter component
  67. // https://github.com/casesandberg/react-color/blob/master/src/components/twitter/Twitter.js
  68. // Unfortunately, we can't detect keyboard layout in the browser. So this will
  69. // only work well for QWERTY but not AZERTY or others...
  70. const keyBindings = [
  71. ["1", "2", "3", "4", "5"],
  72. ["q", "w", "e", "r", "t"],
  73. ["a", "s", "d", "f", "g"],
  74. ["z", "x", "c", "v", "b"],
  75. ].flat();
  76. const Picker = ({
  77. colors,
  78. color,
  79. onChange,
  80. onClose,
  81. label,
  82. showInput = true,
  83. type,
  84. elements,
  85. }: {
  86. colors: string[];
  87. color: string | null;
  88. onChange: (color: string) => void;
  89. onClose: () => void;
  90. label: string;
  91. showInput: boolean;
  92. type: "canvasBackground" | "elementBackground" | "elementStroke";
  93. elements: readonly ExcalidrawElement[];
  94. }) => {
  95. const firstItem = React.useRef<HTMLButtonElement>();
  96. const activeItem = React.useRef<HTMLButtonElement>();
  97. const gallery = React.useRef<HTMLDivElement>();
  98. const colorInput = React.useRef<HTMLInputElement>();
  99. const [customColors] = React.useState(() => {
  100. if (type === "canvasBackground") {
  101. return [];
  102. }
  103. return getCustomColors(elements, type);
  104. });
  105. React.useEffect(() => {
  106. // After the component is first mounted focus on first input
  107. if (activeItem.current) {
  108. activeItem.current.focus();
  109. } else if (colorInput.current) {
  110. colorInput.current.focus();
  111. } else if (gallery.current) {
  112. gallery.current.focus();
  113. }
  114. }, []);
  115. const handleKeyDown = (event: React.KeyboardEvent) => {
  116. let handled = false;
  117. if (isArrowKey(event.key)) {
  118. handled = true;
  119. const { activeElement } = document;
  120. const isRTL = getLanguage().rtl;
  121. let isCustom = false;
  122. let index = Array.prototype.indexOf.call(
  123. gallery.current!.querySelector(".color-picker-content--default")
  124. ?.children,
  125. activeElement,
  126. );
  127. if (index === -1) {
  128. index = Array.prototype.indexOf.call(
  129. gallery.current!.querySelector(".color-picker-content--canvas-colors")
  130. ?.children,
  131. activeElement,
  132. );
  133. if (index !== -1) {
  134. isCustom = true;
  135. }
  136. }
  137. const parentElement = isCustom
  138. ? gallery.current?.querySelector(".color-picker-content--canvas-colors")
  139. : gallery.current?.querySelector(".color-picker-content--default");
  140. if (parentElement && index !== -1) {
  141. const length = parentElement.children.length - (showInput ? 1 : 0);
  142. const nextIndex =
  143. event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
  144. ? (index + 1) % length
  145. : event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
  146. ? (length + index - 1) % length
  147. : !isCustom && event.key === KEYS.ARROW_DOWN
  148. ? (index + 5) % length
  149. : !isCustom && event.key === KEYS.ARROW_UP
  150. ? (length + index - 5) % length
  151. : index;
  152. (parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
  153. }
  154. event.preventDefault();
  155. } else if (
  156. keyBindings.includes(event.key.toLowerCase()) &&
  157. !event[KEYS.CTRL_OR_CMD] &&
  158. !event.altKey &&
  159. !isWritableElement(event.target)
  160. ) {
  161. handled = true;
  162. const index = keyBindings.indexOf(event.key.toLowerCase());
  163. const isCustom = index >= MAX_DEFAULT_COLORS;
  164. const parentElement = isCustom
  165. ? gallery?.current?.querySelector(
  166. ".color-picker-content--canvas-colors",
  167. )
  168. : gallery?.current?.querySelector(".color-picker-content--default");
  169. const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
  170. (
  171. parentElement?.children[actualIndex] as HTMLElement | undefined
  172. )?.focus();
  173. event.preventDefault();
  174. } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
  175. handled = true;
  176. event.preventDefault();
  177. onClose();
  178. }
  179. if (handled) {
  180. event.nativeEvent.stopImmediatePropagation();
  181. event.stopPropagation();
  182. }
  183. };
  184. const renderColors = (colors: Array<string>, custom: boolean = false) => {
  185. return colors.map((_color, i) => {
  186. const _colorWithoutHash = _color.replace("#", "");
  187. const keyBinding = custom
  188. ? keyBindings[i + MAX_DEFAULT_COLORS]
  189. : keyBindings[i];
  190. const label = custom
  191. ? _colorWithoutHash
  192. : t(`colors.${_colorWithoutHash}`);
  193. return (
  194. <button
  195. className="color-picker-swatch"
  196. onClick={(event) => {
  197. (event.currentTarget as HTMLButtonElement).focus();
  198. onChange(_color);
  199. }}
  200. title={`${label}${
  201. !isTransparent(_color) ? ` (${_color})` : ""
  202. } — ${keyBinding.toUpperCase()}`}
  203. aria-label={label}
  204. aria-keyshortcuts={keyBindings[i]}
  205. style={{ color: _color }}
  206. key={_color}
  207. ref={(el) => {
  208. if (!custom && el && i === 0) {
  209. firstItem.current = el;
  210. }
  211. if (el && _color === color) {
  212. activeItem.current = el;
  213. }
  214. }}
  215. onFocus={() => {
  216. onChange(_color);
  217. }}
  218. >
  219. {isTransparent(_color) ? (
  220. <div className="color-picker-transparent"></div>
  221. ) : undefined}
  222. <span className="color-picker-keybinding">{keyBinding}</span>
  223. </button>
  224. );
  225. });
  226. };
  227. return (
  228. <div
  229. className={`color-picker color-picker-type-${type}`}
  230. role="dialog"
  231. aria-modal="true"
  232. aria-label={t("labels.colorPicker")}
  233. onKeyDown={handleKeyDown}
  234. >
  235. <div className="color-picker-triangle color-picker-triangle-shadow"></div>
  236. <div className="color-picker-triangle"></div>
  237. <div
  238. className="color-picker-content"
  239. ref={(el) => {
  240. if (el) {
  241. gallery.current = el;
  242. }
  243. }}
  244. // to allow focusing by clicking but not by tabbing
  245. tabIndex={-1}
  246. >
  247. <div className="color-picker-content--default">
  248. {renderColors(colors)}
  249. </div>
  250. {!!customColors.length && (
  251. <div className="color-picker-content--canvas">
  252. <span className="color-picker-content--canvas-title">
  253. {t("labels.canvasColors")}
  254. </span>
  255. <div className="color-picker-content--canvas-colors">
  256. {renderColors(customColors, true)}
  257. </div>
  258. </div>
  259. )}
  260. {showInput && (
  261. <ColorInput
  262. color={color}
  263. label={label}
  264. onChange={(color) => {
  265. onChange(color);
  266. }}
  267. ref={colorInput}
  268. />
  269. )}
  270. </div>
  271. </div>
  272. );
  273. };
  274. const ColorInput = React.forwardRef(
  275. (
  276. {
  277. color,
  278. onChange,
  279. label,
  280. }: {
  281. color: string | null;
  282. onChange: (color: string) => void;
  283. label: string;
  284. },
  285. ref,
  286. ) => {
  287. const [innerValue, setInnerValue] = React.useState(color);
  288. const inputRef = React.useRef(null);
  289. React.useEffect(() => {
  290. setInnerValue(color);
  291. }, [color]);
  292. React.useImperativeHandle(ref, () => inputRef.current);
  293. const changeColor = React.useCallback(
  294. (inputValue: string) => {
  295. const value = inputValue.toLowerCase();
  296. const color = getColor(value);
  297. if (color) {
  298. onChange(color);
  299. }
  300. setInnerValue(value);
  301. },
  302. [onChange],
  303. );
  304. return (
  305. <label className="color-input-container">
  306. <div className="color-picker-hash">#</div>
  307. <input
  308. spellCheck={false}
  309. className="color-picker-input"
  310. aria-label={label}
  311. onChange={(event) => changeColor(event.target.value)}
  312. value={(innerValue || "").replace(/^#/, "")}
  313. onBlur={() => setInnerValue(color)}
  314. ref={inputRef}
  315. />
  316. </label>
  317. );
  318. },
  319. );
  320. export const ColorPicker = ({
  321. type,
  322. color,
  323. onChange,
  324. label,
  325. isActive,
  326. setActive,
  327. elements,
  328. appState,
  329. }: {
  330. type: "canvasBackground" | "elementBackground" | "elementStroke";
  331. color: string | null;
  332. onChange: (color: string) => void;
  333. label: string;
  334. isActive: boolean;
  335. setActive: (active: boolean) => void;
  336. elements: readonly ExcalidrawElement[];
  337. appState: AppState;
  338. }) => {
  339. const pickerButton = React.useRef<HTMLButtonElement>(null);
  340. return (
  341. <div>
  342. <div className="color-picker-control-container">
  343. <button
  344. className="color-picker-label-swatch"
  345. aria-label={label}
  346. style={color ? { "--swatch-color": color } : undefined}
  347. onClick={() => setActive(!isActive)}
  348. ref={pickerButton}
  349. />
  350. <ColorInput
  351. color={color}
  352. label={label}
  353. onChange={(color) => {
  354. onChange(color);
  355. }}
  356. />
  357. </div>
  358. <React.Suspense fallback="">
  359. {isActive ? (
  360. <Popover
  361. onCloseRequest={(event) =>
  362. event.target !== pickerButton.current && setActive(false)
  363. }
  364. >
  365. <Picker
  366. colors={colors[type]}
  367. color={color || null}
  368. onChange={(changedColor) => {
  369. onChange(changedColor);
  370. }}
  371. onClose={() => {
  372. setActive(false);
  373. pickerButton.current?.focus();
  374. }}
  375. label={label}
  376. showInput={false}
  377. type={type}
  378. elements={elements}
  379. />
  380. </Popover>
  381. ) : null}
  382. </React.Suspense>
  383. </div>
  384. );
  385. };