ColorPicker.tsx 8.4 KB

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