From 6ec259a0e71174651bae95d4628138bf6fd68742 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 16:19:35 -0400 Subject: refactor: packages/ --- .../components/ColorPicker/ColorInput.tsx | 130 ++++++ .../components/ColorPicker/ColorPicker.scss | 441 +++++++++++++++++++++ .../components/ColorPicker/ColorPicker.tsx | 246 ++++++++++++ .../components/ColorPicker/CustomColorList.tsx | 63 +++ .../components/ColorPicker/HotkeyLabel.tsx | 29 ++ .../excalidraw/components/ColorPicker/Picker.tsx | 178 +++++++++ .../components/ColorPicker/PickerColorList.tsx | 91 +++++ .../components/ColorPicker/PickerHeading.tsx | 7 + .../components/ColorPicker/ShadeList.tsx | 105 +++++ .../excalidraw/components/ColorPicker/TopPicks.tsx | 65 +++ .../components/ColorPicker/colorPickerUtils.ts | 133 +++++++ .../components/ColorPicker/keyboardNavHandlers.ts | 286 +++++++++++++ 12 files changed, 1774 insertions(+) create mode 100644 packages/excalidraw/components/ColorPicker/ColorInput.tsx create mode 100644 packages/excalidraw/components/ColorPicker/ColorPicker.scss create mode 100644 packages/excalidraw/components/ColorPicker/ColorPicker.tsx create mode 100644 packages/excalidraw/components/ColorPicker/CustomColorList.tsx create mode 100644 packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx create mode 100644 packages/excalidraw/components/ColorPicker/Picker.tsx create mode 100644 packages/excalidraw/components/ColorPicker/PickerColorList.tsx create mode 100644 packages/excalidraw/components/ColorPicker/PickerHeading.tsx create mode 100644 packages/excalidraw/components/ColorPicker/ShadeList.tsx create mode 100644 packages/excalidraw/components/ColorPicker/TopPicks.tsx create mode 100644 packages/excalidraw/components/ColorPicker/colorPickerUtils.ts create mode 100644 packages/excalidraw/components/ColorPicker/keyboardNavHandlers.ts (limited to 'packages/excalidraw/components/ColorPicker') 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(null); + const eyeDropperTriggerRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [activeSection]); + + const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); + + useEffect(() => { + return () => { + setEyeDropperState(null); + }; + }, [setEyeDropperState]); + + return ( +
+
#
+ { + 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 && ( + <> +
+
+ setEyeDropperState((s) => + s + ? null + : { + keepOpenOnAlt: false, + onSelect: (color) => onChange(color), + colorPickerType, + }, + ) + } + title={`${t( + "labels.eyeDropper", + )} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `} + > + {eyeDropperIcon} +
+ + )} +
+ ); +}; 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 = ( +
+ {t("colorPicker.hexCode")} + { + onChange(color); + }} + colorPickerType={type} + /> +
+ ); + + const popoverRef = useRef(null); + + const focusPickerContent = () => { + popoverRef.current + ?.querySelector(".color-picker-content") + ?.focus(); + }; + + return ( + { + // 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 ? ( + { + 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} + + ) : ( + colorInputJSX + )} + + ); +}; + +const ColorPickerTrigger = ({ + label, + color, + type, +}: { + color: string; + label: string; + type: ColorPickerType; +}) => { + return ( + +
+ + ); +}; + +export const ColorPicker = ({ + type, + color, + onChange, + label, + elements, + palette = COLOR_PALETTE, + topPicks, + updateData, + appState, +}: ColorPickerProps) => { + return ( +
+
+ + + { + updateData({ openPopup: open ? type : null }); + }} + > + {/* serves as an active color indicator as well */} + + {/* popup content */} + {appState.openPopup === type && ( + + )} + +
+
+ ); +}; 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(null); + + useEffect(() => { + if (btnRef.current) { + btnRef.current.focus(); + } + }, [color, activeColorPickerSection]); + + return ( +
+ {colors.map((c, i) => { + return ( + + ); + })} +
+ ); +}; 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 ( +
+ {isShade && "⇧"} + {keyLabel} +
+ ); +}; + +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(null); + + return ( +
+
{ + 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 && ( +
+ + {t("colorPicker.mostUsedCustomColors")} + + +
+ )} + +
+ {t("colorPicker.colors")} + +
+ +
+ {t("colorPicker.shades")} + +
+ {children} +
+
+ ); +}; 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(null); + + useEffect(() => { + if (btnRef.current && activeColorPickerSection === "baseColors") { + btnRef.current.focus(); + } + }, [colorObj?.colorName, activeColorPickerSection]); + + return ( +
+ {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 ( + + ); + })} +
+ ); +}; + +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 }) => ( +
{children}
+); + +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(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 ( +
+ {shades.map((color, i) => ( + + ))} +
+ ); + } + } + + return ( +
+
+ ); +}; 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 ( +
+ {colors.map((color: string) => ( + + ))} +
+ ); +}; 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(); + 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(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, + ) => 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, + ) => 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, + 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][] + ).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; +}; -- cgit v1.2.3