diff options
Diffstat (limited to 'packages/excalidraw/components/ColorPicker')
12 files changed, 1774 insertions, 0 deletions
diff --git a/packages/excalidraw/components/ColorPicker/ColorInput.tsx b/packages/excalidraw/components/ColorPicker/ColorInput.tsx new file mode 100644 index 0000000..837c887 --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/ColorInput.tsx @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getColor } from "./ColorPicker"; +import type { ColorPickerType } from "./colorPickerUtils"; +import { activeColorPickerSectionAtom } from "./colorPickerUtils"; +import { eyeDropperIcon } from "../icons"; +import { useAtom } from "../../editor-jotai"; +import { KEYS } from "../../keys"; +import { activeEyeDropperAtom } from "../EyeDropper"; +import clsx from "clsx"; +import { t } from "../../i18n"; +import { useDevice } from "../App"; +import { getShortcutKey } from "../../utils"; + +interface ColorInputProps { + color: string; + onChange: (color: string) => void; + label: string; + colorPickerType: ColorPickerType; +} + +export const ColorInput = ({ + color, + onChange, + label, + colorPickerType, +}: ColorInputProps) => { + const device = useDevice(); + const [innerValue, setInnerValue] = useState(color); + const [activeSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + useEffect(() => { + setInnerValue(color); + }, [color]); + + const changeColor = useCallback( + (inputValue: string) => { + const value = inputValue.toLowerCase(); + const color = getColor(value); + + if (color) { + onChange(color); + } + setInnerValue(value); + }, + [onChange], + ); + + const inputRef = useRef<HTMLInputElement>(null); + const eyeDropperTriggerRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [activeSection]); + + const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); + + useEffect(() => { + return () => { + setEyeDropperState(null); + }; + }, [setEyeDropperState]); + + return ( + <div className="color-picker__input-label"> + <div className="color-picker__input-hash">#</div> + <input + ref={activeSection === "hex" ? inputRef : undefined} + style={{ border: 0, padding: 0 }} + spellCheck={false} + className="color-picker-input" + aria-label={label} + onChange={(event) => { + changeColor(event.target.value); + }} + value={(innerValue || "").replace(/^#/, "")} + onBlur={() => { + setInnerValue(color); + }} + tabIndex={-1} + onFocus={() => setActiveColorPickerSection("hex")} + onKeyDown={(event) => { + if (event.key === KEYS.TAB) { + return; + } else if (event.key === KEYS.ESCAPE) { + eyeDropperTriggerRef.current?.focus(); + } + event.stopPropagation(); + }} + /> + {/* TODO reenable on mobile with a better UX */} + {!device.editor.isMobile && ( + <> + <div + style={{ + width: "1px", + height: "1.25rem", + backgroundColor: "var(--default-border-color)", + }} + /> + <div + ref={eyeDropperTriggerRef} + className={clsx("excalidraw-eye-dropper-trigger", { + selected: eyeDropperState, + })} + onClick={() => + setEyeDropperState((s) => + s + ? null + : { + keepOpenOnAlt: false, + onSelect: (color) => onChange(color), + colorPickerType, + }, + ) + } + title={`${t( + "labels.eyeDropper", + )} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `} + > + {eyeDropperIcon} + </div> + </> + )} + </div> + ); +}; diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.scss b/packages/excalidraw/components/ColorPicker/ColorPicker.scss new file mode 100644 index 0000000..39e1845 --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.scss @@ -0,0 +1,441 @@ +@import "../../css/variables.module.scss"; + +.excalidraw { + .focus-visible-none { + &:focus-visible { + outline: none !important; + } + } + + .color-picker__heading { + padding: 0 0.5rem; + font-size: 0.75rem; + text-align: left; + } + + .color-picker-container { + display: grid; + grid-template-columns: 1fr 20px 1.625rem; + padding: 0.25rem 0px; + align-items: center; + + @include isMobile { + max-width: 11rem; + } + } + + .color-picker__top-picks { + display: flex; + justify-content: space-between; + } + + .color-picker__button { + --radius: 0.25rem; + + padding: 0; + margin: 0; + width: 1.35rem; + height: 1.35rem; + border: 1px solid var(--color-gray-30); + border-radius: var(--radius); + filter: var(--theme-filter); + background-color: var(--swatch-color); + background-position: left center; + position: relative; + font-family: inherit; + box-sizing: border-box; + + &:hover { + &::after { + content: ""; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + box-shadow: 0 0 0 1px var(--color-gray-30); + border-radius: calc(var(--radius) + 1px); + filter: var(--theme-filter); + } + } + + &.active { + .color-picker__button-outline { + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + box-shadow: 0 0 0 1px var(--color-primary-darkest); + z-index: 1; // due hover state so this has preference + border-radius: calc(var(--radius) + 1px); + filter: var(--theme-filter); + } + } + + &:focus-visible { + outline: none; + + &::after { + content: ""; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + border: 3px solid var(--focus-highlight-color); + border-radius: calc(var(--radius) + 1px); + } + + &.active { + .color-picker__button-outline { + display: none; + } + } + } + + &--large { + --radius: 0.5rem; + width: 1.875rem; + height: 1.875rem; + } + + &.is-transparent { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg=="); + } + + &--no-focus-visible { + border: 0; + &::after { + display: none; + } + &:focus-visible { + outline: none !important; + } + } + + &.active-color { + border-radius: calc(var(--radius) + 1px); + width: 1.625rem; + height: 1.625rem; + } + } + + .color-picker__button__hotkey-label { + position: absolute; + right: 4px; + bottom: 4px; + filter: none; + font-size: 11px; + } + + .color-picker { + background: var(--popup-bg-color); + border: 0 solid transparentize($oc-white, 0.75); + box-shadow: transparentize($oc-black, 0.75) 0 1px 4px; + border-radius: 4px; + position: absolute; + + :root[dir="ltr"] & { + left: -5.5px; + } + + :root[dir="rtl"] & { + right: -5.5px; + } + } + + .color-picker-control-container { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: 0.5rem; + } + + .color-picker-control-container + .popover { + position: static; + } + + .color-picker-popover-container { + margin-top: -0.25rem; + + :root[dir="ltr"] & { + margin-left: 0.5rem; + } + + :root[dir="rtl"] & { + margin-left: -3rem; + } + } + + .color-picker-triangle { + width: 0; + height: 0; + border-style: solid; + border-width: 0 9px 10px; + border-color: transparent transparent var(--popup-bg-color); + position: absolute; + top: 10px; + + :root[dir="ltr"] & { + transform: rotate(270deg); + left: -14px; + } + + :root[dir="rtl"] & { + transform: rotate(90deg); + right: -14px; + } + } + + .color-picker-triangle-shadow { + border-color: transparent transparent transparentize($oc-black, 0.9); + + :root[dir="ltr"] & { + left: -14px; + } + + :root[dir="rtl"] & { + right: -16px; + } + } + + .color-picker-content { + display: flex; + flex-direction: column; + gap: 0.75rem; + outline: none; + } + + .color-picker-content--default { + padding: 0.5rem; + display: grid; + grid-template-columns: repeat(5, 1.875rem); + grid-gap: 0.25rem; + border-radius: 4px; + + &:focus { + outline: none; + box-shadow: 0 0 0 2px var(--focus-highlight-color); + } + } + + .color-picker-content--canvas { + display: flex; + flex-direction: column; + padding: 0.25rem; + + &-title { + color: $oc-gray-6; + font-size: 12px; + padding: 0 0.25rem; + } + + &-colors { + padding: 0.5rem 0; + + .color-picker-swatch { + margin: 0 0.25rem; + } + } + } + + .color-picker-content .color-input-container { + grid-column: 1 / span 5; + } + + .color-picker-swatch { + position: relative; + height: 1.875rem; + width: 1.875rem; + cursor: pointer; + border-radius: 4px; + margin: 0; + box-sizing: border-box; + border: 1px solid #ddd; + background-color: currentColor !important; + filter: var(--theme-filter); + + &:focus { + /* TODO: only show the border when the color is too light to see as a shadow */ + box-shadow: 0 0 4px 1px currentColor; + border-color: var(--select-highlight-color); + } + } + + .color-picker-transparent { + border-radius: 4px; + box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + .color-picker-transparent, + .color-picker-label-swatch { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") + left center; + } + + .color-picker-hash { + height: var(--default-button-size); + flex-shrink: 0; + padding: 0.5rem 0.5rem 0.5rem 0.75rem; + border: 1px solid var(--default-border-color); + border-right: 0; + box-sizing: border-box; + + :root[dir="ltr"] & { + border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); + } + + :root[dir="rtl"] & { + border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; + border-right: 1px solid var(--default-border-color); + border-left: 0; + } + + color: var(--input-label-color); + display: flex; + align-items: center; + justify-content: center; + position: relative; + } + + .color-input-container { + display: flex; + + &:focus-within { + box-shadow: 0 0 0 1px var(--color-primary-darkest); + border-radius: var(--border-radius-lg); + } + } + + .color-picker__input-label { + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: 8px; + align-items: center; + border: 1px solid var(--default-border-color); + border-radius: 8px; + padding: 0 12px; + margin: 8px; + box-sizing: border-box; + + &:focus-within { + box-shadow: 0 0 0 1px var(--color-primary-darkest); + border-radius: var(--border-radius-lg); + } + } + + .color-picker__input-hash { + padding: 0 0.25rem; + } + + .color-picker-input { + box-sizing: border-box; + width: 100%; + margin: 0; + font-size: 0.875rem; + font-family: inherit; + background-color: transparent; + color: var(--text-primary-color); + border: 0; + outline: none; + height: var(--default-button-size); + border: 1px solid var(--default-border-color); + border-left: 0; + letter-spacing: 0.4px; + + :root[dir="ltr"] & { + border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; + } + + :root[dir="rtl"] & { + border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); + border-left: 1px solid var(--default-border-color); + border-right: 0; + } + + padding: 0.5rem; + padding-left: 0.25rem; + appearance: none; + + &:focus-visible { + box-shadow: none; + } + } + + .color-picker-label-swatch-container { + border: 1px solid var(--default-border-color); + border-radius: var(--border-radius-lg); + width: var(--default-button-size); + height: var(--default-button-size); + box-sizing: border-box; + overflow: hidden; + } + + .color-picker-label-swatch { + @include outlineButtonStyles; + background-color: var(--swatch-color) !important; + overflow: hidden; + position: relative; + filter: var(--theme-filter); + border: 0 !important; + + &:after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--swatch-color); + } + } + + .color-picker-keybinding { + position: absolute; + bottom: 2px; + font-size: 0.7em; + + :root[dir="ltr"] & { + right: 2px; + } + + :root[dir="rtl"] & { + left: 2px; + } + + @include isMobile { + display: none; + } + } + + .color-picker-type-canvasBackground .color-picker-keybinding { + color: #aaa; + } + + .color-picker-type-elementBackground .color-picker-keybinding { + color: $oc-white; + } + + .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { + color: #aaa; + } + + .color-picker-type-elementStroke .color-picker-keybinding { + color: #d4d4d4; + } + + &.theme--dark { + .color-picker-type-elementBackground .color-picker-keybinding { + color: $oc-black; + } + .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { + color: $oc-black; + } + } +} diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000..74d5527 --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,246 @@ +import { isTransparent } from "../../utils"; +import type { ExcalidrawElement } from "../../element/types"; +import type { AppState } from "../../types"; +import { TopPicks } from "./TopPicks"; +import { ButtonSeparator } from "../ButtonSeparator"; +import { Picker } from "./Picker"; +import * as Popover from "@radix-ui/react-popover"; +import type { ColorPickerType } from "./colorPickerUtils"; +import { activeColorPickerSectionAtom } from "./colorPickerUtils"; +import { useExcalidrawContainer } from "../App"; +import type { ColorTuple, ColorPaletteCustom } from "../../colors"; +import { COLOR_PALETTE } from "../../colors"; +import PickerHeading from "./PickerHeading"; +import { t } from "../../i18n"; +import clsx from "clsx"; +import { useRef } from "react"; +import { useAtom } from "../../editor-jotai"; +import { ColorInput } from "./ColorInput"; +import { activeEyeDropperAtom } from "../EyeDropper"; +import { PropertiesPopover } from "../PropertiesPopover"; + +import "./ColorPicker.scss"; + +const isValidColor = (color: string) => { + const style = new Option().style; + style.color = color; + return !!style.color; +}; + +export const getColor = (color: string): string | null => { + if (isTransparent(color)) { + return color; + } + + // testing for `#` first fixes a bug on Electron (more specfically, an + // Obsidian popout window), where a hex color without `#` is (incorrectly) + // considered valid + return isValidColor(`#${color}`) + ? `#${color}` + : isValidColor(color) + ? color + : null; +}; + +interface ColorPickerProps { + type: ColorPickerType; + color: string; + onChange: (color: string) => void; + label: string; + elements: readonly ExcalidrawElement[]; + appState: AppState; + palette?: ColorPaletteCustom | null; + topPicks?: ColorTuple; + updateData: (formData?: any) => void; +} + +const ColorPickerPopupContent = ({ + type, + color, + onChange, + label, + elements, + palette = COLOR_PALETTE, + updateData, +}: Pick< + ColorPickerProps, + | "type" + | "color" + | "onChange" + | "label" + | "elements" + | "palette" + | "updateData" +>) => { + const { container } = useExcalidrawContainer(); + const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); + + const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); + + const colorInputJSX = ( + <div> + <PickerHeading>{t("colorPicker.hexCode")}</PickerHeading> + <ColorInput + color={color} + label={label} + onChange={(color) => { + onChange(color); + }} + colorPickerType={type} + /> + </div> + ); + + const popoverRef = useRef<HTMLDivElement>(null); + + const focusPickerContent = () => { + popoverRef.current + ?.querySelector<HTMLDivElement>(".color-picker-content") + ?.focus(); + }; + + return ( + <PropertiesPopover + container={container} + style={{ maxWidth: "13rem" }} + onFocusOutside={(event) => { + // refocus due to eye dropper + focusPickerContent(); + event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (eyeDropperState) { + // prevent from closing if we click outside the popover + // while eyedropping (e.g. click when clicking the sidebar; + // the eye-dropper-backdrop is prevented downstream) + event.preventDefault(); + } + }} + onClose={() => { + updateData({ openPopup: null }); + setActiveColorPickerSection(null); + }} + > + {palette ? ( + <Picker + palette={palette} + color={color} + onChange={(changedColor) => { + onChange(changedColor); + }} + onEyeDropperToggle={(force) => { + setEyeDropperState((state) => { + if (force) { + state = state || { + keepOpenOnAlt: true, + onSelect: onChange, + colorPickerType: type, + }; + state.keepOpenOnAlt = true; + return state; + } + + return force === false || state + ? null + : { + keepOpenOnAlt: false, + onSelect: onChange, + colorPickerType: type, + }; + }); + }} + onEscape={(event) => { + if (eyeDropperState) { + setEyeDropperState(null); + } else { + updateData({ openPopup: null }); + } + }} + label={label} + type={type} + elements={elements} + updateData={updateData} + > + {colorInputJSX} + </Picker> + ) : ( + colorInputJSX + )} + </PropertiesPopover> + ); +}; + +const ColorPickerTrigger = ({ + label, + color, + type, +}: { + color: string; + label: string; + type: ColorPickerType; +}) => { + return ( + <Popover.Trigger + type="button" + className={clsx("color-picker__button active-color properties-trigger", { + "is-transparent": color === "transparent" || !color, + })} + aria-label={label} + style={color ? { "--swatch-color": color } : undefined} + title={ + type === "elementStroke" + ? t("labels.showStroke") + : t("labels.showBackground") + } + > + <div className="color-picker__button-outline" /> + </Popover.Trigger> + ); +}; + +export const ColorPicker = ({ + type, + color, + onChange, + label, + elements, + palette = COLOR_PALETTE, + topPicks, + updateData, + appState, +}: ColorPickerProps) => { + return ( + <div> + <div role="dialog" aria-modal="true" className="color-picker-container"> + <TopPicks + activeColor={color} + onChange={onChange} + type={type} + topPicks={topPicks} + /> + <ButtonSeparator /> + <Popover.Root + open={appState.openPopup === type} + onOpenChange={(open) => { + updateData({ openPopup: open ? type : null }); + }} + > + {/* serves as an active color indicator as well */} + <ColorPickerTrigger color={color} label={label} type={type} /> + {/* popup content */} + {appState.openPopup === type && ( + <ColorPickerPopupContent + type={type} + color={color} + onChange={onChange} + label={label} + elements={elements} + palette={palette} + updateData={updateData} + /> + )} + </Popover.Root> + </div> + </div> + ); +}; diff --git a/packages/excalidraw/components/ColorPicker/CustomColorList.tsx b/packages/excalidraw/components/ColorPicker/CustomColorList.tsx new file mode 100644 index 0000000..5fe1e3e --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/CustomColorList.tsx @@ -0,0 +1,63 @@ +import clsx from "clsx"; +import { useAtom } from "../../editor-jotai"; +import { useEffect, useRef } from "react"; +import { activeColorPickerSectionAtom } from "./colorPickerUtils"; +import HotkeyLabel from "./HotkeyLabel"; + +interface CustomColorListProps { + colors: string[]; + color: string; + onChange: (color: string) => void; + label: string; +} + +export const CustomColorList = ({ + colors, + color, + onChange, + label, +}: CustomColorListProps) => { + const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + const btnRef = useRef<HTMLButtonElement>(null); + + useEffect(() => { + if (btnRef.current) { + btnRef.current.focus(); + } + }, [color, activeColorPickerSection]); + + return ( + <div className="color-picker-content--default"> + {colors.map((c, i) => { + return ( + <button + ref={color === c ? btnRef : undefined} + tabIndex={-1} + type="button" + className={clsx( + "color-picker__button color-picker__button--large", + { + active: color === c, + "is-transparent": c === "transparent" || !c, + }, + )} + onClick={() => { + onChange(c); + setActiveColorPickerSection("custom"); + }} + title={c} + aria-label={label} + style={{ "--swatch-color": c }} + key={i} + > + <div className="color-picker__button-outline" /> + <HotkeyLabel color={c} keyLabel={i + 1} isCustomColor /> + </button> + ); + })} + </div> + ); +}; diff --git a/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx b/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx new file mode 100644 index 0000000..145060d --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { getContrastYIQ } from "./colorPickerUtils"; + +interface HotkeyLabelProps { + color: string; + keyLabel: string | number; + isCustomColor?: boolean; + isShade?: boolean; +} +const HotkeyLabel = ({ + color, + keyLabel, + isCustomColor = false, + isShade = false, +}: HotkeyLabelProps) => { + return ( + <div + className="color-picker__button__hotkey-label" + style={{ + color: getContrastYIQ(color, isCustomColor), + }} + > + {isShade && "⇧"} + {keyLabel} + </div> + ); +}; + +export default HotkeyLabel; diff --git a/packages/excalidraw/components/ColorPicker/Picker.tsx b/packages/excalidraw/components/ColorPicker/Picker.tsx new file mode 100644 index 0000000..88d6876 --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/Picker.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState } from "react"; +import { t } from "../../i18n"; + +import type { ExcalidrawElement } from "../../element/types"; +import { ShadeList } from "./ShadeList"; + +import PickerColorList from "./PickerColorList"; +import { useAtom } from "../../editor-jotai"; +import { CustomColorList } from "./CustomColorList"; +import { colorPickerKeyNavHandler } from "./keyboardNavHandlers"; +import PickerHeading from "./PickerHeading"; +import type { ColorPickerType } from "./colorPickerUtils"; +import { + activeColorPickerSectionAtom, + getColorNameAndShadeFromColor, + getMostUsedCustomColors, + isCustomColor, +} from "./colorPickerUtils"; +import type { ColorPaletteCustom } from "../../colors"; +import { + DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, + DEFAULT_ELEMENT_STROKE_COLOR_INDEX, +} from "../../colors"; +import { KEYS } from "../../keys"; +import { EVENT } from "../../constants"; + +interface PickerProps { + color: string; + onChange: (color: string) => void; + label: string; + type: ColorPickerType; + elements: readonly ExcalidrawElement[]; + palette: ColorPaletteCustom; + updateData: (formData?: any) => void; + children?: React.ReactNode; + onEyeDropperToggle: (force?: boolean) => void; + onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; +} + +export const Picker = ({ + color, + onChange, + label, + type, + elements, + palette, + updateData, + children, + onEyeDropperToggle, + onEscape, +}: PickerProps) => { + const [customColors] = React.useState(() => { + if (type === "canvasBackground") { + return []; + } + return getMostUsedCustomColors(elements, type, palette); + }); + + const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + const colorObj = getColorNameAndShadeFromColor({ + color, + palette, + }); + + useEffect(() => { + if (!activeColorPickerSection) { + const isCustom = isCustomColor({ color, palette }); + const isCustomButNotInList = isCustom && !customColors.includes(color); + + setActiveColorPickerSection( + isCustomButNotInList + ? "hex" + : isCustom + ? "custom" + : colorObj?.shade != null + ? "shades" + : "baseColors", + ); + } + }, [ + activeColorPickerSection, + color, + palette, + setActiveColorPickerSection, + colorObj, + customColors, + ]); + + const [activeShade, setActiveShade] = useState( + colorObj?.shade ?? + (type === "elementBackground" + ? DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX + : DEFAULT_ELEMENT_STROKE_COLOR_INDEX), + ); + + useEffect(() => { + if (colorObj?.shade != null) { + setActiveShade(colorObj.shade); + } + + const keyup = (event: KeyboardEvent) => { + if (event.key === KEYS.ALT) { + onEyeDropperToggle(false); + } + }; + document.addEventListener(EVENT.KEYUP, keyup, { capture: true }); + return () => { + document.removeEventListener(EVENT.KEYUP, keyup, { capture: true }); + }; + }, [colorObj, onEyeDropperToggle]); + + const pickerRef = React.useRef<HTMLDivElement>(null); + + return ( + <div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}> + <div + ref={pickerRef} + onKeyDown={(event) => { + const handled = colorPickerKeyNavHandler({ + event, + activeColorPickerSection, + palette, + color, + onChange, + onEyeDropperToggle, + customColors, + setActiveColorPickerSection, + updateData, + activeShade, + onEscape, + }); + + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + }} + className="color-picker-content properties-content" + // to allow focusing by clicking but not by tabbing + tabIndex={-1} + > + {!!customColors.length && ( + <div> + <PickerHeading> + {t("colorPicker.mostUsedCustomColors")} + </PickerHeading> + <CustomColorList + colors={customColors} + color={color} + label={t("colorPicker.mostUsedCustomColors")} + onChange={onChange} + /> + </div> + )} + + <div> + <PickerHeading>{t("colorPicker.colors")}</PickerHeading> + <PickerColorList + color={color} + label={label} + palette={palette} + onChange={onChange} + activeShade={activeShade} + /> + </div> + + <div> + <PickerHeading>{t("colorPicker.shades")}</PickerHeading> + <ShadeList hex={color} onChange={onChange} palette={palette} /> + </div> + {children} + </div> + </div> + ); +}; diff --git a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx new file mode 100644 index 0000000..f43559d --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx @@ -0,0 +1,91 @@ +import clsx from "clsx"; +import { useAtom } from "../../editor-jotai"; +import { useEffect, useRef } from "react"; +import { + activeColorPickerSectionAtom, + colorPickerHotkeyBindings, + getColorNameAndShadeFromColor, +} from "./colorPickerUtils"; +import HotkeyLabel from "./HotkeyLabel"; +import type { ColorPaletteCustom } from "../../colors"; +import type { TranslationKeys } from "../../i18n"; +import { t } from "../../i18n"; + +interface PickerColorListProps { + palette: ColorPaletteCustom; + color: string; + onChange: (color: string) => void; + label: string; + activeShade: number; +} + +const PickerColorList = ({ + palette, + color, + onChange, + label, + activeShade, +}: PickerColorListProps) => { + const colorObj = getColorNameAndShadeFromColor({ + color: color || "transparent", + palette, + }); + const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + const btnRef = useRef<HTMLButtonElement>(null); + + useEffect(() => { + if (btnRef.current && activeColorPickerSection === "baseColors") { + btnRef.current.focus(); + } + }, [colorObj?.colorName, activeColorPickerSection]); + + return ( + <div className="color-picker-content--default"> + {Object.entries(palette).map(([key, value], index) => { + const color = + (Array.isArray(value) ? value[activeShade] : value) || "transparent"; + + const keybinding = colorPickerHotkeyBindings[index]; + const label = t( + `colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys, + null, + "", + ); + + return ( + <button + ref={colorObj?.colorName === key ? btnRef : undefined} + tabIndex={-1} + type="button" + className={clsx( + "color-picker__button color-picker__button--large", + { + active: colorObj?.colorName === key, + "is-transparent": color === "transparent" || !color, + }, + )} + onClick={() => { + onChange(color); + setActiveColorPickerSection("baseColors"); + }} + title={`${label}${ + color.startsWith("#") ? ` ${color}` : "" + } — ${keybinding}`} + aria-label={`${label} — ${keybinding}`} + style={color ? { "--swatch-color": color } : undefined} + data-testid={`color-${key}`} + key={key} + > + <div className="color-picker__button-outline" /> + <HotkeyLabel color={color} keyLabel={keybinding} /> + </button> + ); + })} + </div> + ); +}; + +export default PickerColorList; diff --git a/packages/excalidraw/components/ColorPicker/PickerHeading.tsx b/packages/excalidraw/components/ColorPicker/PickerHeading.tsx new file mode 100644 index 0000000..3999a49 --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/PickerHeading.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +const PickerHeading = ({ children }: { children: ReactNode }) => ( + <div className="color-picker__heading">{children}</div> +); + +export default PickerHeading; diff --git a/packages/excalidraw/components/ColorPicker/ShadeList.tsx b/packages/excalidraw/components/ColorPicker/ShadeList.tsx new file mode 100644 index 0000000..8d3d4cc --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/ShadeList.tsx @@ -0,0 +1,105 @@ +import clsx from "clsx"; +import { useAtom } from "../../editor-jotai"; +import { useEffect, useRef } from "react"; +import { + activeColorPickerSectionAtom, + getColorNameAndShadeFromColor, +} from "./colorPickerUtils"; +import HotkeyLabel from "./HotkeyLabel"; +import { t } from "../../i18n"; +import type { ColorPaletteCustom } from "../../colors"; + +interface ShadeListProps { + hex: string; + onChange: (color: string) => void; + palette: ColorPaletteCustom; +} + +export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => { + const colorObj = getColorNameAndShadeFromColor({ + color: hex || "transparent", + palette, + }); + + const [activeColorPickerSection, setActiveColorPickerSection] = useAtom( + activeColorPickerSectionAtom, + ); + + const btnRef = useRef<HTMLButtonElement>(null); + + useEffect(() => { + if (btnRef.current && activeColorPickerSection === "shades") { + btnRef.current.focus(); + } + }, [colorObj, activeColorPickerSection]); + + if (colorObj) { + const { colorName, shade } = colorObj; + + const shades = palette[colorName]; + + if (Array.isArray(shades)) { + return ( + <div className="color-picker-content--default shades"> + {shades.map((color, i) => ( + <button + ref={ + i === shade && activeColorPickerSection === "shades" + ? btnRef + : undefined + } + tabIndex={-1} + key={i} + type="button" + className={clsx( + "color-picker__button color-picker__button--large", + { active: i === shade }, + )} + aria-label="Shade" + title={`${colorName} - ${i + 1}`} + style={color ? { "--swatch-color": color } : undefined} + onClick={() => { + onChange(color); + setActiveColorPickerSection("shades"); + }} + > + <div className="color-picker__button-outline" /> + <HotkeyLabel color={color} keyLabel={i + 1} isShade /> + </button> + ))} + </div> + ); + } + } + + return ( + <div + className="color-picker-content--default" + style={{ position: "relative" }} + tabIndex={-1} + > + <button + type="button" + tabIndex={-1} + className="color-picker__button color-picker__button--large color-picker__button--no-focus-visible" + /> + <div + tabIndex={-1} + style={{ + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + textAlign: "center", + fontSize: "0.75rem", + }} + > + {t("colorPicker.noShades")} + </div> + </div> + ); +}; diff --git a/packages/excalidraw/components/ColorPicker/TopPicks.tsx b/packages/excalidraw/components/ColorPicker/TopPicks.tsx new file mode 100644 index 0000000..5c69d1e --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/TopPicks.tsx @@ -0,0 +1,65 @@ +import clsx from "clsx"; +import type { ColorPickerType } from "./colorPickerUtils"; +import { + DEFAULT_CANVAS_BACKGROUND_PICKS, + DEFAULT_ELEMENT_BACKGROUND_PICKS, + DEFAULT_ELEMENT_STROKE_PICKS, +} from "../../colors"; + +interface TopPicksProps { + onChange: (color: string) => void; + type: ColorPickerType; + activeColor: string; + topPicks?: readonly string[]; +} + +export const TopPicks = ({ + onChange, + type, + activeColor, + topPicks, +}: TopPicksProps) => { + let colors; + if (type === "elementStroke") { + colors = DEFAULT_ELEMENT_STROKE_PICKS; + } + + if (type === "elementBackground") { + colors = DEFAULT_ELEMENT_BACKGROUND_PICKS; + } + + if (type === "canvasBackground") { + colors = DEFAULT_CANVAS_BACKGROUND_PICKS; + } + + // this one can overwrite defaults + if (topPicks) { + colors = topPicks; + } + + if (!colors) { + console.error("Invalid type for TopPicks"); + return null; + } + + return ( + <div className="color-picker__top-picks"> + {colors.map((color: string) => ( + <button + className={clsx("color-picker__button", { + active: color === activeColor, + "is-transparent": color === "transparent" || !color, + })} + style={{ "--swatch-color": color }} + key={color} + type="button" + title={color} + onClick={() => onChange(color)} + data-testid={`color-top-pick-${color}`} + > + <div className="color-picker__button-outline" /> + </button> + ))} + </div> + ); +}; diff --git a/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts new file mode 100644 index 0000000..2733b7a --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts @@ -0,0 +1,133 @@ +import type { ExcalidrawElement } from "../../element/types"; +import type { ColorPickerColor, ColorPaletteCustom } from "../../colors"; +import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors"; +import { atom } from "../../editor-jotai"; + +export const getColorNameAndShadeFromColor = ({ + palette, + color, +}: { + palette: ColorPaletteCustom; + color: string; +}): { + colorName: ColorPickerColor; + shade: number | null; +} | null => { + for (const [colorName, colorVal] of Object.entries(palette)) { + if (Array.isArray(colorVal)) { + const shade = colorVal.indexOf(color); + if (shade > -1) { + return { colorName: colorName as ColorPickerColor, shade }; + } + } else if (colorVal === color) { + return { colorName: colorName as ColorPickerColor, shade: null }; + } + } + return null; +}; + +export const colorPickerHotkeyBindings = [ + ["q", "w", "e", "r", "t"], + ["a", "s", "d", "f", "g"], + ["z", "x", "c", "v", "b"], +].flat(); + +export const isCustomColor = ({ + color, + palette, +}: { + color: string; + palette: ColorPaletteCustom; +}) => { + const paletteValues = Object.values(palette).flat(); + return !paletteValues.includes(color); +}; + +export const getMostUsedCustomColors = ( + elements: readonly ExcalidrawElement[], + type: "elementBackground" | "elementStroke", + palette: ColorPaletteCustom, +) => { + const elementColorTypeMap = { + elementBackground: "backgroundColor", + elementStroke: "strokeColor", + }; + + const colors = elements.filter((element) => { + if (element.isDeleted) { + return false; + } + + const color = + element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"]; + + return isCustomColor({ color, palette }); + }); + + const colorCountMap = new Map<string, number>(); + colors.forEach((element) => { + const color = + element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"]; + if (colorCountMap.has(color)) { + colorCountMap.set(color, colorCountMap.get(color)! + 1); + } else { + colorCountMap.set(color, 1); + } + }); + + return [...colorCountMap.entries()] + .sort((a, b) => b[1] - a[1]) + .map((c) => c[0]) + .slice(0, MAX_CUSTOM_COLORS_USED_IN_CANVAS); +}; + +export type ActiveColorPickerSectionAtomType = + | "custom" + | "baseColors" + | "shades" + | "hex" + | null; +export const activeColorPickerSectionAtom = + atom<ActiveColorPickerSectionAtomType>(null); + +const calculateContrast = (r: number, g: number, b: number) => { + const yiq = (r * 299 + g * 587 + b * 114) / 1000; + return yiq >= 160 ? "black" : "white"; +}; + +// inspiration from https://stackoverflow.com/a/11868398 +export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => { + if (isCustomColor) { + const style = new Option().style; + style.color = bgHex; + + if (style.color) { + const rgb = style.color + .replace(/^(rgb|rgba)\(/, "") + .replace(/\)$/, "") + .replace(/\s/g, "") + .split(","); + const r = parseInt(rgb[0]); + const g = parseInt(rgb[1]); + const b = parseInt(rgb[2]); + + return calculateContrast(r, g, b); + } + } + + // TODO: ? is this wanted? + if (bgHex === "transparent") { + return "black"; + } + + const r = parseInt(bgHex.substring(1, 3), 16); + const g = parseInt(bgHex.substring(3, 5), 16); + const b = parseInt(bgHex.substring(5, 7), 16); + + return calculateContrast(r, g, b); +}; + +export type ColorPickerType = + | "canvasBackground" + | "elementBackground" + | "elementStroke"; diff --git a/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts new file mode 100644 index 0000000..7767692 --- /dev/null +++ b/packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts @@ -0,0 +1,286 @@ +import { KEYS } from "../../keys"; +import type { + ColorPickerColor, + ColorPalette, + ColorPaletteCustom, +} from "../../colors"; +import { COLORS_PER_ROW, COLOR_PALETTE } from "../../colors"; +import type { ValueOf } from "../../utility-types"; +import type { ActiveColorPickerSectionAtomType } from "./colorPickerUtils"; +import { + colorPickerHotkeyBindings, + getColorNameAndShadeFromColor, +} from "./colorPickerUtils"; + +const arrowHandler = ( + eventKey: string, + currentIndex: number | null, + length: number, +) => { + const rows = Math.ceil(length / COLORS_PER_ROW); + + currentIndex = currentIndex ?? -1; + + switch (eventKey) { + case "ArrowLeft": { + const prevIndex = currentIndex - 1; + return prevIndex < 0 ? length - 1 : prevIndex; + } + case "ArrowRight": { + return (currentIndex + 1) % length; + } + case "ArrowDown": { + const nextIndex = currentIndex + COLORS_PER_ROW; + return nextIndex >= length ? currentIndex % COLORS_PER_ROW : nextIndex; + } + case "ArrowUp": { + const prevIndex = currentIndex - COLORS_PER_ROW; + const newIndex = + prevIndex < 0 ? COLORS_PER_ROW * rows + prevIndex : prevIndex; + return newIndex >= length ? undefined : newIndex; + } + } +}; + +interface HotkeyHandlerProps { + e: React.KeyboardEvent; + colorObj: { colorName: ColorPickerColor; shade: number | null } | null; + onChange: (color: string) => void; + palette: ColorPaletteCustom; + customColors: string[]; + setActiveColorPickerSection: ( + update: React.SetStateAction<ActiveColorPickerSectionAtomType>, + ) => void; + activeShade: number; +} + +/** + * @returns true if the event was handled + */ +const hotkeyHandler = ({ + e, + colorObj, + onChange, + palette, + customColors, + setActiveColorPickerSection, + activeShade, +}: HotkeyHandlerProps): boolean => { + if (colorObj?.shade != null) { + // shift + numpad is extremely messed up on windows apparently + if ( + ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].includes(e.code) && + e.shiftKey + ) { + const newShade = Number(e.code.slice(-1)) - 1; + onChange(palette[colorObj.colorName][newShade]); + setActiveColorPickerSection("shades"); + return true; + } + } + + if (["1", "2", "3", "4", "5"].includes(e.key)) { + const c = customColors[Number(e.key) - 1]; + if (c) { + onChange(customColors[Number(e.key) - 1]); + setActiveColorPickerSection("custom"); + return true; + } + } + + if (colorPickerHotkeyBindings.includes(e.key)) { + const index = colorPickerHotkeyBindings.indexOf(e.key); + const paletteKey = Object.keys(palette)[index] as keyof ColorPalette; + const paletteValue = palette[paletteKey]; + const r = Array.isArray(paletteValue) + ? paletteValue[activeShade] + : paletteValue; + onChange(r); + setActiveColorPickerSection("baseColors"); + return true; + } + return false; +}; + +interface ColorPickerKeyNavHandlerProps { + event: React.KeyboardEvent; + activeColorPickerSection: ActiveColorPickerSectionAtomType; + palette: ColorPaletteCustom; + color: string; + onChange: (color: string) => void; + customColors: string[]; + setActiveColorPickerSection: ( + update: React.SetStateAction<ActiveColorPickerSectionAtomType>, + ) => void; + updateData: (formData?: any) => void; + activeShade: number; + onEyeDropperToggle: (force?: boolean) => void; + onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; +} + +/** + * @returns true if the event was handled + */ +export const colorPickerKeyNavHandler = ({ + event, + activeColorPickerSection, + palette, + color, + onChange, + customColors, + setActiveColorPickerSection, + updateData, + activeShade, + onEyeDropperToggle, + onEscape, +}: ColorPickerKeyNavHandlerProps): boolean => { + if (event[KEYS.CTRL_OR_CMD]) { + return false; + } + + if (event.key === KEYS.ESCAPE) { + onEscape(event); + return true; + } + + // checkt using `key` to ignore combos with Alt modifier + if (event.key === KEYS.ALT) { + onEyeDropperToggle(true); + return true; + } + + if (event.key === KEYS.I) { + onEyeDropperToggle(); + return true; + } + + const colorObj = getColorNameAndShadeFromColor({ color, palette }); + + if (event.key === KEYS.TAB) { + const sectionsMap: Record< + NonNullable<ActiveColorPickerSectionAtomType>, + boolean + > = { + custom: !!customColors.length, + baseColors: true, + shades: colorObj?.shade != null, + hex: true, + }; + + const sections = Object.entries(sectionsMap).reduce((acc, [key, value]) => { + if (value) { + acc.push(key as ActiveColorPickerSectionAtomType); + } + return acc; + }, [] as ActiveColorPickerSectionAtomType[]); + + const activeSectionIndex = sections.indexOf(activeColorPickerSection); + const indexOffset = event.shiftKey ? -1 : 1; + const nextSectionIndex = + activeSectionIndex + indexOffset > sections.length - 1 + ? 0 + : activeSectionIndex + indexOffset < 0 + ? sections.length - 1 + : activeSectionIndex + indexOffset; + + const nextSection = sections[nextSectionIndex]; + + if (nextSection) { + setActiveColorPickerSection(nextSection); + } + + if (nextSection === "custom") { + onChange(customColors[0]); + } else if (nextSection === "baseColors") { + const baseColorName = ( + Object.entries(palette) as [string, ValueOf<ColorPalette>][] + ).find(([name, shades]) => { + if (Array.isArray(shades)) { + return shades.includes(color); + } else if (shades === color) { + return name; + } + return null; + }); + + if (!baseColorName) { + onChange(COLOR_PALETTE.black); + } + } + + event.preventDefault(); + event.stopPropagation(); + + return true; + } + + if ( + hotkeyHandler({ + e: event, + colorObj, + onChange, + palette, + customColors, + setActiveColorPickerSection, + activeShade, + }) + ) { + return true; + } + + if (activeColorPickerSection === "shades") { + if (colorObj) { + const { shade } = colorObj; + const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW); + + if (newShade !== undefined) { + onChange(palette[colorObj.colorName][newShade]); + return true; + } + } + } + + if (activeColorPickerSection === "baseColors") { + if (colorObj) { + const { colorName } = colorObj; + const colorNames = Object.keys(palette) as (keyof ColorPalette)[]; + const indexOfColorName = colorNames.indexOf(colorName); + + const newColorIndex = arrowHandler( + event.key, + indexOfColorName, + colorNames.length, + ); + + if (newColorIndex !== undefined) { + const newColorName = colorNames[newColorIndex]; + const newColorNameValue = palette[newColorName]; + + onChange( + Array.isArray(newColorNameValue) + ? newColorNameValue[activeShade] + : newColorNameValue, + ); + return true; + } + } + } + + if (activeColorPickerSection === "custom") { + const indexOfColor = customColors.indexOf(color); + + const newColorIndex = arrowHandler( + event.key, + indexOfColor, + customColors.length, + ); + + if (newColorIndex !== undefined) { + const newColor = customColors[newColorIndex]; + onChange(newColor); + return true; + } + } + + return false; +}; |
