feat: eye dropper (#6615)

This commit is contained in:
David Luzar 2023-06-02 17:06:11 +02:00 committed by GitHub
parent 644685a5a8
commit 079aa72475
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 803 additions and 250 deletions

View file

@ -2,15 +2,23 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../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 | null;
color: string;
onChange: (color: string) => void;
label: string;
}
export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
const device = useDevice();
const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
@ -34,7 +42,7 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
);
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const eyeDropperTriggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputRef.current) {
@ -42,8 +50,19 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
}
}, [activeSection]);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
useEffect(() => {
return () => {
setEyeDropperState(null);
};
}, [setEyeDropperState]);
return (
<label className="color-picker__input-label">
<div className="color-picker__input-label">
<div className="color-picker__input-hash">#</div>
<input
ref={activeSection === "hex" ? inputRef : undefined}
@ -60,16 +79,48 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
}}
tabIndex={-1}
onFocus={() => setActiveColorPickerSection("hex")}
onKeyDown={(e) => {
if (e.key === KEYS.TAB) {
onKeyDown={(event) => {
if (event.key === KEYS.TAB) {
return;
} else if (event.key === KEYS.ESCAPE) {
eyeDropperTriggerRef.current?.focus();
}
if (e.key === KEYS.ESCAPE) {
divRef.current?.focus();
}
e.stopPropagation();
event.stopPropagation();
}}
/>
</label>
{/* TODO reenable on mobile with a better UX */}
{!device.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),
},
)
}
title={`${t(
"labels.eyeDropper",
)} ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
>
{eyeDropperIcon}
</div>
</>
)}
</div>
);
};

View file

@ -204,6 +204,7 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
outline: none;
}
.color-picker-content--default {

View file

@ -1,4 +1,4 @@
import { isTransparent } from "../../utils";
import { isTransparent, isWritableElement } from "../../utils";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
@ -12,12 +12,14 @@ import {
import { useDevice, useExcalidrawContainer } from "../App";
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
import PickerHeading from "./PickerHeading";
import { ColorInput } from "./ColorInput";
import { t } from "../../i18n";
import clsx from "clsx";
import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper";
import "./ColorPicker.scss";
import React from "react";
const isValidColor = (color: string) => {
const style = new Option().style;
@ -40,9 +42,9 @@ export const getColor = (color: string): string | null => {
: null;
};
export interface ColorPickerProps {
interface ColorPickerProps {
type: ColorPickerType;
color: string | null;
color: string;
onChange: (color: string) => void;
label: string;
elements: readonly ExcalidrawElement[];
@ -72,6 +74,11 @@ const ColorPickerPopupContent = ({
>) => {
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const { container } = useExcalidrawContainer();
const { isMobile, isLandscape } = useDevice();
@ -87,23 +94,42 @@ const ColorPickerPopupContent = ({
/>
</div>
);
const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
popoverRef.current
?.querySelector<HTMLDivElement>(".color-picker-content")
?.focus();
};
return (
<Popover.Portal container={container}>
<Popover.Content
ref={popoverRef}
className="focus-visible-none"
data-prevent-outside-click
onFocusOutside={(event) => {
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();
}
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
// return focus to excalidraw container
if (container) {
container.focus();
}
updateData({ openPopup: null });
e.preventDefault();
e.stopPropagation();
setActiveColorPickerSection(null);
}}
side={isMobile && !isLandscape ? "bottom" : "right"}
@ -126,10 +152,38 @@ const ColorPickerPopupContent = ({
{palette ? (
<Picker
palette={palette}
color={color || null}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
@ -158,7 +212,7 @@ const ColorPickerTrigger = ({
color,
type,
}: {
color: string | null;
color: string;
label: string;
type: ColorPickerType;
}) => {

View file

@ -6,7 +6,7 @@ import HotkeyLabel from "./HotkeyLabel";
interface CustomColorListProps {
colors: string[];
color: string | null;
color: string;
onChange: (color: string) => void;
label: string;
}

View file

@ -12,7 +12,7 @@ import PickerHeading from "./PickerHeading";
import {
ColorPickerType,
activeColorPickerSectionAtom,
getColorNameAndShadeFromHex,
getColorNameAndShadeFromColor,
getMostUsedCustomColors,
isCustomColor,
} from "./colorPickerUtils";
@ -21,9 +21,11 @@ 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 | null;
color: string;
onChange: (color: string) => void;
label: string;
type: ColorPickerType;
@ -31,6 +33,8 @@ interface PickerProps {
palette: ColorPaletteCustom;
updateData: (formData?: any) => void;
children?: React.ReactNode;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
}
export const Picker = ({
@ -42,6 +46,8 @@ export const Picker = ({
palette,
updateData,
children,
onEyeDropperToggle,
onEscape,
}: PickerProps) => {
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
@ -54,16 +60,15 @@ export const Picker = ({
activeColorPickerSectionAtom,
);
const colorObj = getColorNameAndShadeFromHex({
hex: color || "transparent",
const colorObj = getColorNameAndShadeFromColor({
color,
palette,
});
useEffect(() => {
if (!activeColorPickerSection) {
const isCustom = isCustomColor({ color, palette });
const isCustomButNotInList =
isCustom && !customColors.includes(color || "");
const isCustomButNotInList = isCustom && !customColors.includes(color);
setActiveColorPickerSection(
isCustomButNotInList
@ -95,26 +100,43 @@ export const Picker = ({
if (colorObj?.shade != null) {
setActiveShade(colorObj.shade);
}
}, [colorObj]);
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
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
colorPickerKeyNavHandler({
e,
ref={pickerRef}
onKeyDown={(event) => {
const handled = colorPickerKeyNavHandler({
event,
activeColorPickerSection,
palette,
hex: color,
color,
onChange,
onEyeDropperToggle,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
onEscape,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}}
className="color-picker-content"
// to allow focusing by clicking but not by tabbing

View file

@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
colorPickerHotkeyBindings,
getColorNameAndShadeFromHex,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors";
@ -12,7 +12,7 @@ import { t } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;
color: string | null;
color: string;
onChange: (color: string) => void;
label: string;
activeShade: number;
@ -25,8 +25,8 @@ const PickerColorList = ({
label,
activeShade,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromHex({
hex: color || "transparent",
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(

View file

@ -3,21 +3,21 @@ import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
getColorNameAndShadeFromHex,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import { ColorPaletteCustom } from "../../colors";
interface ShadeListProps {
hex: string | null;
hex: string;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromHex({
hex: hex || "transparent",
const colorObj = getColorNameAndShadeFromColor({
color: hex || "transparent",
palette,
});

View file

@ -9,7 +9,7 @@ import {
interface TopPicksProps {
onChange: (color: string) => void;
type: ColorPickerType;
activeColor: string | null;
activeColor: string;
topPicks?: readonly string[];
}

View file

@ -6,23 +6,23 @@ import {
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
} from "../../colors";
export const getColorNameAndShadeFromHex = ({
export const getColorNameAndShadeFromColor = ({
palette,
hex,
color,
}: {
palette: ColorPaletteCustom;
hex: string;
color: string;
}): {
colorName: ColorPickerColor;
shade: number | null;
} | null => {
for (const [colorName, colorVal] of Object.entries(palette)) {
if (Array.isArray(colorVal)) {
const shade = colorVal.indexOf(hex);
const shade = colorVal.indexOf(color);
if (shade > -1) {
return { colorName: colorName as ColorPickerColor, shade };
}
} else if (colorVal === hex) {
} else if (colorVal === color) {
return { colorName: colorName as ColorPickerColor, shade: null };
}
}
@ -39,12 +39,9 @@ export const isCustomColor = ({
color,
palette,
}: {
color: string | null;
color: string;
palette: ColorPaletteCustom;
}) => {
if (!color) {
return false;
}
const paletteValues = Object.values(palette).flat();
return !paletteValues.includes(color);
};

View file

@ -1,3 +1,4 @@
import { KEYS } from "../../keys";
import {
ColorPickerColor,
ColorPalette,
@ -5,12 +6,11 @@ import {
COLORS_PER_ROW,
COLOR_PALETTE,
} from "../../colors";
import { KEYS } from "../../keys";
import { ValueOf } from "../../utility-types";
import {
ActiveColorPickerSectionAtomType,
colorPickerHotkeyBindings,
getColorNameAndShadeFromHex,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
const arrowHandler = (
@ -55,6 +55,9 @@ interface HotkeyHandlerProps {
activeShade: number;
}
/**
* @returns true if the event was handled
*/
const hotkeyHandler = ({
e,
colorObj,
@ -63,7 +66,7 @@ const hotkeyHandler = ({
customColors,
setActiveColorPickerSection,
activeShade,
}: HotkeyHandlerProps) => {
}: HotkeyHandlerProps): boolean => {
if (colorObj?.shade != null) {
// shift + numpad is extremely messed up on windows apparently
if (
@ -73,6 +76,7 @@ const hotkeyHandler = ({
const newShade = Number(e.code.slice(-1)) - 1;
onChange(palette[colorObj.colorName][newShade]);
setActiveColorPickerSection("shades");
return true;
}
}
@ -81,6 +85,7 @@ const hotkeyHandler = ({
if (c) {
onChange(customColors[Number(e.key) - 1]);
setActiveColorPickerSection("custom");
return true;
}
}
@ -93,14 +98,16 @@ const hotkeyHandler = ({
: paletteValue;
onChange(r);
setActiveColorPickerSection("baseColors");
return true;
}
return false;
};
interface ColorPickerKeyNavHandlerProps {
e: React.KeyboardEvent;
event: React.KeyboardEvent;
activeColorPickerSection: ActiveColorPickerSectionAtomType;
palette: ColorPaletteCustom;
hex: string | null;
color: string;
onChange: (color: string) => void;
customColors: string[];
setActiveColorPickerSection: (
@ -108,27 +115,49 @@ interface ColorPickerKeyNavHandlerProps {
) => 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 = ({
e,
event,
activeColorPickerSection,
palette,
hex,
color,
onChange,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
}: ColorPickerKeyNavHandlerProps) => {
if (e.key === KEYS.ESCAPE || !hex) {
updateData({ openPopup: null });
return;
onEyeDropperToggle,
onEscape,
}: ColorPickerKeyNavHandlerProps): boolean => {
if (event[KEYS.CTRL_OR_CMD]) {
return false;
}
const colorObj = getColorNameAndShadeFromHex({ hex, palette });
if (event.key === KEYS.ESCAPE) {
onEscape(event);
return true;
}
if (e.key === KEYS.TAB) {
// 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
@ -147,7 +176,7 @@ export const colorPickerKeyNavHandler = ({
}, [] as ActiveColorPickerSectionAtomType[]);
const activeSectionIndex = sections.indexOf(activeColorPickerSection);
const indexOffset = e.shiftKey ? -1 : 1;
const indexOffset = event.shiftKey ? -1 : 1;
const nextSectionIndex =
activeSectionIndex + indexOffset > sections.length - 1
? 0
@ -168,8 +197,8 @@ export const colorPickerKeyNavHandler = ({
Object.entries(palette) as [string, ValueOf<ColorPalette>][]
).find(([name, shades]) => {
if (Array.isArray(shades)) {
return shades.includes(hex);
} else if (shades === hex) {
return shades.includes(color);
} else if (shades === color) {
return name;
}
return null;
@ -180,29 +209,34 @@ export const colorPickerKeyNavHandler = ({
}
}
e.preventDefault();
e.stopPropagation();
event.preventDefault();
event.stopPropagation();
return;
return true;
}
hotkeyHandler({
e,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
});
if (
hotkeyHandler({
e: event,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
})
) {
return true;
}
if (activeColorPickerSection === "shades") {
if (colorObj) {
const { shade } = colorObj;
const newShade = arrowHandler(e.key, shade, COLORS_PER_ROW);
const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW);
if (newShade !== undefined) {
onChange(palette[colorObj.colorName][newShade]);
return true;
}
}
}
@ -214,7 +248,7 @@ export const colorPickerKeyNavHandler = ({
const indexOfColorName = colorNames.indexOf(colorName);
const newColorIndex = arrowHandler(
e.key,
event.key,
indexOfColorName,
colorNames.length,
);
@ -228,15 +262,16 @@ export const colorPickerKeyNavHandler = ({
? newColorNameValue[activeShade]
: newColorNameValue,
);
return true;
}
}
}
if (activeColorPickerSection === "custom") {
const indexOfColor = customColors.indexOf(hex);
const indexOfColor = customColors.indexOf(color);
const newColorIndex = arrowHandler(
e.key,
event.key,
indexOfColor,
customColors.length,
);
@ -244,6 +279,9 @@ export const colorPickerKeyNavHandler = ({
if (newColorIndex !== undefined) {
const newColor = customColors[newColorIndex];
onChange(newColor);
return true;
}
}
return false;
};