ColorPicker.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  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. const isValidColor = (color: string) => {
  10. const style = new Option().style;
  11. style.color = color;
  12. return !!style.color;
  13. };
  14. const getColor = (color: string): string | null => {
  15. if (isTransparent(color)) {
  16. return color;
  17. }
  18. return isValidColor(color)
  19. ? color
  20. : isValidColor(`#${color}`)
  21. ? `#${color}`
  22. : null;
  23. };
  24. // This is a narrow reimplementation of the awesome react-color Twitter component
  25. // https://github.com/casesandberg/react-color/blob/master/src/components/twitter/Twitter.js
  26. // Unfortunately, we can't detect keyboard layout in the browser. So this will
  27. // only work well for QWERTY but not AZERTY or others...
  28. const keyBindings = [
  29. ["1", "2", "3", "4", "5"],
  30. ["q", "w", "e", "r", "t"],
  31. ["a", "s", "d", "f", "g"],
  32. ].flat();
  33. const Picker = ({
  34. colors,
  35. color,
  36. onChange,
  37. onClose,
  38. label,
  39. showInput = true,
  40. type,
  41. }: {
  42. colors: string[];
  43. color: string | null;
  44. onChange: (color: string) => void;
  45. onClose: () => void;
  46. label: string;
  47. showInput: boolean;
  48. type: "canvasBackground" | "elementBackground" | "elementStroke";
  49. }) => {
  50. const firstItem = React.useRef<HTMLButtonElement>();
  51. const activeItem = React.useRef<HTMLButtonElement>();
  52. const gallery = React.useRef<HTMLDivElement>();
  53. const colorInput = React.useRef<HTMLInputElement>();
  54. React.useEffect(() => {
  55. // After the component is first mounted focus on first input
  56. if (activeItem.current) {
  57. activeItem.current.focus();
  58. } else if (colorInput.current) {
  59. colorInput.current.focus();
  60. } else if (gallery.current) {
  61. gallery.current.focus();
  62. }
  63. }, []);
  64. const handleKeyDown = (event: React.KeyboardEvent) => {
  65. if (event.key === KEYS.TAB) {
  66. const { activeElement } = document;
  67. if (event.shiftKey) {
  68. if (activeElement === firstItem.current) {
  69. colorInput.current?.focus();
  70. event.preventDefault();
  71. }
  72. } else if (activeElement === colorInput.current) {
  73. firstItem.current?.focus();
  74. event.preventDefault();
  75. }
  76. } else if (isArrowKey(event.key)) {
  77. const { activeElement } = document;
  78. const isRTL = getLanguage().rtl;
  79. const index = Array.prototype.indexOf.call(
  80. gallery!.current!.children,
  81. activeElement,
  82. );
  83. if (index !== -1) {
  84. const length = gallery!.current!.children.length - (showInput ? 1 : 0);
  85. const nextIndex =
  86. event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
  87. ? (index + 1) % length
  88. : event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
  89. ? (length + index - 1) % length
  90. : event.key === KEYS.ARROW_DOWN
  91. ? (index + 5) % length
  92. : event.key === KEYS.ARROW_UP
  93. ? (length + index - 5) % length
  94. : index;
  95. (gallery!.current!.children![nextIndex] as any).focus();
  96. }
  97. event.preventDefault();
  98. } else if (
  99. keyBindings.includes(event.key.toLowerCase()) &&
  100. !isWritableElement(event.target)
  101. ) {
  102. const index = keyBindings.indexOf(event.key.toLowerCase());
  103. (gallery!.current!.children![index] as any).focus();
  104. event.preventDefault();
  105. } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
  106. event.preventDefault();
  107. onClose();
  108. }
  109. event.nativeEvent.stopImmediatePropagation();
  110. event.stopPropagation();
  111. };
  112. return (
  113. <div
  114. className={`color-picker color-picker-type-${type}`}
  115. role="dialog"
  116. aria-modal="true"
  117. aria-label={t("labels.colorPicker")}
  118. onKeyDown={handleKeyDown}
  119. >
  120. <div className="color-picker-triangle color-picker-triangle-shadow"></div>
  121. <div className="color-picker-triangle"></div>
  122. <div
  123. className="color-picker-content"
  124. ref={(el) => {
  125. if (el) {
  126. gallery.current = el;
  127. }
  128. }}
  129. tabIndex={0}
  130. >
  131. {colors.map((_color, i) => {
  132. const _colorWithoutHash = _color.replace("#", "");
  133. return (
  134. <button
  135. className="color-picker-swatch"
  136. onClick={(event) => {
  137. (event.currentTarget as HTMLButtonElement).focus();
  138. onChange(_color);
  139. }}
  140. title={`${t(`colors.${_colorWithoutHash}`)}${
  141. !isTransparent(_color) ? ` (${_color})` : ""
  142. } — ${keyBindings[i].toUpperCase()}`}
  143. aria-label={t(`colors.${_colorWithoutHash}`)}
  144. aria-keyshortcuts={keyBindings[i]}
  145. style={{ color: _color }}
  146. key={_color}
  147. ref={(el) => {
  148. if (el && i === 0) {
  149. firstItem.current = el;
  150. }
  151. if (el && _color === color) {
  152. activeItem.current = el;
  153. }
  154. }}
  155. onFocus={() => {
  156. onChange(_color);
  157. }}
  158. >
  159. {isTransparent(_color) ? (
  160. <div className="color-picker-transparent"></div>
  161. ) : undefined}
  162. <span className="color-picker-keybinding">{keyBindings[i]}</span>
  163. </button>
  164. );
  165. })}
  166. {showInput && (
  167. <ColorInput
  168. color={color}
  169. label={label}
  170. onChange={(color) => {
  171. onChange(color);
  172. }}
  173. ref={colorInput}
  174. />
  175. )}
  176. </div>
  177. </div>
  178. );
  179. };
  180. const ColorInput = React.forwardRef(
  181. (
  182. {
  183. color,
  184. onChange,
  185. label,
  186. }: {
  187. color: string | null;
  188. onChange: (color: string) => void;
  189. label: string;
  190. },
  191. ref,
  192. ) => {
  193. const [innerValue, setInnerValue] = React.useState(color);
  194. const inputRef = React.useRef(null);
  195. React.useEffect(() => {
  196. setInnerValue(color);
  197. }, [color]);
  198. React.useImperativeHandle(ref, () => inputRef.current);
  199. const changeColor = React.useCallback(
  200. (inputValue: string) => {
  201. const value = inputValue.toLowerCase();
  202. const color = getColor(value);
  203. if (color) {
  204. onChange(color);
  205. }
  206. setInnerValue(value);
  207. },
  208. [onChange],
  209. );
  210. return (
  211. <label className="color-input-container">
  212. <div className="color-picker-hash">#</div>
  213. <input
  214. spellCheck={false}
  215. className="color-picker-input"
  216. aria-label={label}
  217. onChange={(event) => changeColor(event.target.value)}
  218. value={(innerValue || "").replace(/^#/, "")}
  219. onBlur={() => setInnerValue(color)}
  220. ref={inputRef}
  221. />
  222. </label>
  223. );
  224. },
  225. );
  226. export const ColorPicker = ({
  227. type,
  228. color,
  229. onChange,
  230. label,
  231. isActive,
  232. setActive,
  233. }: {
  234. type: "canvasBackground" | "elementBackground" | "elementStroke";
  235. color: string | null;
  236. onChange: (color: string) => void;
  237. label: string;
  238. isActive: boolean;
  239. setActive: (active: boolean) => void;
  240. }) => {
  241. const pickerButton = React.useRef<HTMLButtonElement>(null);
  242. return (
  243. <div>
  244. <div className="color-picker-control-container">
  245. <button
  246. className="color-picker-label-swatch"
  247. aria-label={label}
  248. style={color ? { "--swatch-color": color } : undefined}
  249. onClick={() => setActive(!isActive)}
  250. ref={pickerButton}
  251. />
  252. <ColorInput
  253. color={color}
  254. label={label}
  255. onChange={(color) => {
  256. onChange(color);
  257. }}
  258. />
  259. </div>
  260. <React.Suspense fallback="">
  261. {isActive ? (
  262. <Popover
  263. onCloseRequest={(event) =>
  264. event.target !== pickerButton.current && setActive(false)
  265. }
  266. >
  267. <Picker
  268. colors={colors[type]}
  269. color={color || null}
  270. onChange={(changedColor) => {
  271. onChange(changedColor);
  272. }}
  273. onClose={() => {
  274. setActive(false);
  275. pickerButton.current?.focus();
  276. }}
  277. label={label}
  278. showInput={false}
  279. type={type}
  280. />
  281. </Popover>
  282. ) : null}
  283. </React.Suspense>
  284. </div>
  285. );
  286. };