rewrite color picker to support no (mixed) colors & fix focus handling
This commit is contained in:
parent
a7b4b08e86
commit
f832bf9fde
@ -335,7 +335,8 @@ export const actionChangeStrokeColor = register({
|
|||||||
appState,
|
appState,
|
||||||
(element) => element.strokeColor,
|
(element) => element.strokeColor,
|
||||||
true,
|
true,
|
||||||
appState.currentItemStrokeColor,
|
(hasSelection) =>
|
||||||
|
!hasSelection ? appState.currentItemStrokeColor : null,
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@ -353,6 +354,10 @@ export const actionChangeBackgroundColor = register({
|
|||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (!value.currentItemBackgroundColor) {
|
if (!value.currentItemBackgroundColor) {
|
||||||
return {
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...value,
|
||||||
|
},
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -409,7 +414,8 @@ export const actionChangeBackgroundColor = register({
|
|||||||
appState,
|
appState,
|
||||||
(element) => element.backgroundColor,
|
(element) => element.backgroundColor,
|
||||||
true,
|
true,
|
||||||
appState.currentItemBackgroundColor,
|
(hasSelection) =>
|
||||||
|
!hasSelection ? appState.currentItemBackgroundColor : null,
|
||||||
)}
|
)}
|
||||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
|
@ -19,6 +19,7 @@ interface ColorInputProps {
|
|||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
colorPickerType: ColorPickerType;
|
colorPickerType: ColorPickerType;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColorInput = ({
|
export const ColorInput = ({
|
||||||
@ -26,6 +27,7 @@ export const ColorInput = ({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
colorPickerType,
|
colorPickerType,
|
||||||
|
placeholder,
|
||||||
}: ColorInputProps) => {
|
}: ColorInputProps) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const [innerValue, setInnerValue] = useState(color);
|
const [innerValue, setInnerValue] = useState(color);
|
||||||
@ -93,6 +95,7 @@ export const ColorInput = ({
|
|||||||
}
|
}
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}}
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
{/* TODO reenable on mobile with a better UX */}
|
{/* TODO reenable on mobile with a better UX */}
|
||||||
{!device.editor.isMobile && (
|
{!device.editor.isMobile && (
|
||||||
|
@ -69,6 +69,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker__button-outline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
svg {
|
||||||
|
color: var(--color-gray-60);
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
.color-picker__button-outline {
|
.color-picker__button-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -18,6 +18,7 @@ import { useExcalidrawContainer } from "../App";
|
|||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { PropertiesPopover } from "../PropertiesPopover";
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
|
import { slashIcon } from "../icons";
|
||||||
|
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
@ -54,7 +55,11 @@ export const getColor = (color: string): string | null => {
|
|||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
color: string;
|
/**
|
||||||
|
* null indicates no color should be displayed as active
|
||||||
|
* (e.g. when multiple shapes selected with different colors)
|
||||||
|
*/
|
||||||
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@ -91,22 +96,21 @@ const ColorPickerPopupContent = ({
|
|||||||
<div>
|
<div>
|
||||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||||
<ColorInput
|
<ColorInput
|
||||||
color={color}
|
color={color || ""}
|
||||||
label={label}
|
label={label}
|
||||||
onChange={(color) => {
|
onChange={(color) => {
|
||||||
onChange(color);
|
onChange(color);
|
||||||
}}
|
}}
|
||||||
colorPickerType={type}
|
colorPickerType={type}
|
||||||
|
placeholder={t("colorPicker.color")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const colorPickerContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const focusPickerContent = () => {
|
const focusPickerContent = () => {
|
||||||
popoverRef.current
|
colorPickerContentRef.current?.focus();
|
||||||
?.querySelector<HTMLDivElement>(".color-picker-content")
|
|
||||||
?.focus();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -133,6 +137,7 @@ const ColorPickerPopupContent = ({
|
|||||||
>
|
>
|
||||||
{palette ? (
|
{palette ? (
|
||||||
<Picker
|
<Picker
|
||||||
|
ref={colorPickerContentRef}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
color={color}
|
color={color}
|
||||||
onChange={(changedColor) => {
|
onChange={(changedColor) => {
|
||||||
@ -185,7 +190,7 @@ const ColorPickerTrigger = ({
|
|||||||
color,
|
color,
|
||||||
type,
|
type,
|
||||||
}: {
|
}: {
|
||||||
color: string;
|
color: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
}) => {
|
}) => {
|
||||||
@ -193,8 +198,9 @@ const ColorPickerTrigger = ({
|
|||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx("color-picker__button active-color properties-trigger", {
|
className={clsx("color-picker__button active-color properties-trigger", {
|
||||||
"is-transparent": color === "transparent" || !color,
|
"is-transparent": !color || color === "transparent",
|
||||||
"has-outline": !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
"has-outline":
|
||||||
|
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
||||||
})}
|
})}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
style={color ? { "--swatch-color": color } : undefined}
|
style={color ? { "--swatch-color": color } : undefined}
|
||||||
@ -204,7 +210,7 @@ const ColorPickerTrigger = ({
|
|||||||
: t("labels.showBackground")
|
: t("labels.showBackground")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline" />
|
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -8,7 +8,7 @@ import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
|||||||
|
|
||||||
interface CustomColorListProps {
|
interface CustomColorListProps {
|
||||||
colors: string[];
|
colors: string[];
|
||||||
color: string;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useImperativeHandle, useState } from "react";
|
||||||
|
|
||||||
import { EVENT } from "@excalidraw/common";
|
import { EVENT } from "@excalidraw/common";
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
|||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
|
|
||||||
interface PickerProps {
|
interface PickerProps {
|
||||||
color: string;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
@ -42,7 +42,9 @@ interface PickerProps {
|
|||||||
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Picker = ({
|
export const Picker = React.forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
@ -53,7 +55,9 @@ export const Picker = ({
|
|||||||
children,
|
children,
|
||||||
onEyeDropperToggle,
|
onEyeDropperToggle,
|
||||||
onEscape,
|
onEscape,
|
||||||
}: PickerProps) => {
|
}: PickerProps,
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const [customColors] = React.useState(() => {
|
const [customColors] = React.useState(() => {
|
||||||
if (type === "canvasBackground") {
|
if (type === "canvasBackground") {
|
||||||
return [];
|
return [];
|
||||||
@ -72,12 +76,12 @@ export const Picker = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeColorPickerSection) {
|
if (!activeColorPickerSection) {
|
||||||
const isCustom = isCustomColor({ color, palette });
|
const isCustom = !!color && isCustomColor({ color, palette });
|
||||||
const isCustomButNotInList = isCustom && !customColors.includes(color);
|
const isCustomButNotInList = isCustom && !customColors.includes(color);
|
||||||
|
|
||||||
setActiveColorPickerSection(
|
setActiveColorPickerSection(
|
||||||
isCustomButNotInList
|
isCustomButNotInList
|
||||||
? "hex"
|
? null
|
||||||
: isCustom
|
: isCustom
|
||||||
? "custom"
|
? "custom"
|
||||||
: colorObj?.shade != null
|
: colorObj?.shade != null
|
||||||
@ -116,9 +120,14 @@ export const Picker = ({
|
|||||||
document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
|
document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
|
||||||
};
|
};
|
||||||
}, [colorObj, onEyeDropperToggle]);
|
}, [colorObj, onEyeDropperToggle]);
|
||||||
|
|
||||||
const pickerRef = React.useRef<HTMLDivElement>(null);
|
const pickerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => pickerRef.current!);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pickerRef?.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
|
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
|
||||||
<div
|
<div
|
||||||
@ -165,7 +174,6 @@ export const Picker = ({
|
|||||||
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
|
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
|
||||||
<PickerColorList
|
<PickerColorList
|
||||||
color={color}
|
color={color}
|
||||||
label={label}
|
|
||||||
palette={palette}
|
palette={palette}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
activeShade={activeShade}
|
activeShade={activeShade}
|
||||||
@ -174,10 +182,11 @@ export const Picker = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
||||||
<ShadeList hex={color} onChange={onChange} palette={palette} />
|
<ShadeList color={color} onChange={onChange} palette={palette} />
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
@ -17,9 +17,8 @@ import type { TranslationKeys } from "../../i18n";
|
|||||||
|
|
||||||
interface PickerColorListProps {
|
interface PickerColorListProps {
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
color: string;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
|
||||||
activeShade: number;
|
activeShade: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,11 +26,10 @@ const PickerColorList = ({
|
|||||||
palette,
|
palette,
|
||||||
color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
label,
|
|
||||||
activeShade,
|
activeShade,
|
||||||
}: PickerColorListProps) => {
|
}: PickerColorListProps) => {
|
||||||
const colorObj = getColorNameAndShadeFromColor({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
color: color || "transparent",
|
color,
|
||||||
palette,
|
palette,
|
||||||
});
|
});
|
||||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||||
|
@ -13,14 +13,14 @@ import {
|
|||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
|
|
||||||
interface ShadeListProps {
|
interface ShadeListProps {
|
||||||
hex: string;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
|
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
|
||||||
const colorObj = getColorNameAndShadeFromColor({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
color: hex || "transparent",
|
color: color || "transparent",
|
||||||
palette,
|
palette,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ import type { ColorPickerType } from "./colorPickerUtils";
|
|||||||
interface TopPicksProps {
|
interface TopPicksProps {
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
activeColor: string;
|
activeColor: string | null;
|
||||||
topPicks?: readonly string[];
|
topPicks?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,11 +11,14 @@ export const getColorNameAndShadeFromColor = ({
|
|||||||
color,
|
color,
|
||||||
}: {
|
}: {
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
color: string;
|
color: string | null;
|
||||||
}): {
|
}): {
|
||||||
colorName: ColorPickerColor;
|
colorName: ColorPickerColor;
|
||||||
shade: number | null;
|
shade: number | null;
|
||||||
} | null => {
|
} | null => {
|
||||||
|
if (!color) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
for (const [colorName, colorVal] of Object.entries(palette)) {
|
for (const [colorName, colorVal] of Object.entries(palette)) {
|
||||||
if (Array.isArray(colorVal)) {
|
if (Array.isArray(colorVal)) {
|
||||||
const shade = colorVal.indexOf(color);
|
const shade = colorVal.indexOf(color);
|
||||||
|
@ -109,7 +109,7 @@ interface ColorPickerKeyNavHandlerProps {
|
|||||||
event: React.KeyboardEvent;
|
event: React.KeyboardEvent;
|
||||||
activeColorPickerSection: ActiveColorPickerSectionAtomType;
|
activeColorPickerSection: ActiveColorPickerSectionAtomType;
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
color: string;
|
color: string | null;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
customColors: string[];
|
customColors: string[];
|
||||||
setActiveColorPickerSection: (
|
setActiveColorPickerSection: (
|
||||||
@ -270,7 +270,7 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeColorPickerSection === "custom") {
|
if (activeColorPickerSection === "custom") {
|
||||||
const indexOfColor = customColors.indexOf(color);
|
const indexOfColor = color != null ? customColors.indexOf(color) : 0;
|
||||||
|
|
||||||
const newColorIndex = arrowHandler(
|
const newColorIndex = arrowHandler(
|
||||||
event.key,
|
event.key,
|
||||||
|
@ -595,6 +595,14 @@ export const bucketFillIcon = createIcon(
|
|||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// simple / icon
|
||||||
|
export const slashIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path d="M6 18l12 -12" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const ExportImageIcon = createIcon(
|
export const ExportImageIcon = createIcon(
|
||||||
<g strokeWidth="1.25">
|
<g strokeWidth="1.25">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
@ -558,6 +558,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"colorPicker": {
|
"colorPicker": {
|
||||||
|
"color": "Color",
|
||||||
"mostUsedCustomColors": "Most used custom colors",
|
"mostUsedCustomColors": "Most used custom colors",
|
||||||
"colors": "Colors",
|
"colors": "Colors",
|
||||||
"shades": "Shades",
|
"shades": "Shades",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user