ColorPicker.tsx 8.0 KB

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