ColorPicker.tsx 7.8 KB

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