feat: show empty active color if no common color (#9506)

This commit is contained in:
David Luzar 2025-05-11 15:07:57 +02:00 committed by GitHub
parent 51dbd4831b
commit c4c064982f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 193 additions and 155 deletions

View File

@ -333,7 +333,8 @@ export const actionChangeStrokeColor = register({
app,
(element) => element.strokeColor,
true,
appState.currentItemStrokeColor,
(hasSelection) =>
!hasSelection ? appState.currentItemStrokeColor : null,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
elements={elements}
@ -379,7 +380,8 @@ export const actionChangeBackgroundColor = register({
app,
(element) => element.backgroundColor,
true,
appState.currentItemBackgroundColor,
(hasSelection) =>
!hasSelection ? appState.currentItemBackgroundColor : null,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
elements={elements}

View File

@ -19,6 +19,7 @@ interface ColorInputProps {
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}
export const ColorInput = ({
@ -26,6 +27,7 @@ export const ColorInput = ({
onChange,
label,
colorPickerType,
placeholder,
}: ColorInputProps) => {
const device = useDevice();
const [innerValue, setInnerValue] = useState(color);
@ -93,6 +95,7 @@ export const ColorInput = ({
}
event.stopPropagation();
}}
placeholder={placeholder}
/>
{/* TODO reenable on mobile with a better UX */}
{!device.editor.isMobile && (

View File

@ -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 {
.color-picker__button-outline {
position: absolute;

View File

@ -18,6 +18,7 @@ import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { slashIcon } from "../icons";
import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker";
@ -54,7 +55,11 @@ export const getColor = (color: string): string | null => {
interface ColorPickerProps {
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;
label: string;
elements: readonly ExcalidrawElement[];
@ -91,22 +96,21 @@ const ColorPickerPopupContent = ({
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
<ColorInput
color={color}
color={color || ""}
label={label}
onChange={(color) => {
onChange(color);
}}
colorPickerType={type}
placeholder={t("colorPicker.color")}
/>
</div>
);
const popoverRef = useRef<HTMLDivElement>(null);
const colorPickerContentRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
popoverRef.current
?.querySelector<HTMLDivElement>(".color-picker-content")
?.focus();
colorPickerContentRef.current?.focus();
};
return (
@ -133,6 +137,7 @@ const ColorPickerPopupContent = ({
>
{palette ? (
<Picker
ref={colorPickerContentRef}
palette={palette}
color={color}
onChange={(changedColor) => {
@ -166,7 +171,6 @@ const ColorPickerPopupContent = ({
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
updateData={updateData}
@ -185,7 +189,7 @@ const ColorPickerTrigger = ({
color,
type,
}: {
color: string;
color: string | null;
label: string;
type: ColorPickerType;
}) => {
@ -193,8 +197,9 @@ const ColorPickerTrigger = ({
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
"is-transparent": !color || color === "transparent",
"has-outline":
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
})}
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
@ -204,7 +209,7 @@ const ColorPickerTrigger = ({
: t("labels.showBackground")
}
>
<div className="color-picker__button-outline" />
<div className="color-picker__button-outline">{!color && slashIcon}</div>
</Popover.Trigger>
);
};

View File

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

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useImperativeHandle, useState } from "react";
import { EVENT } from "@excalidraw/common";
@ -30,9 +30,8 @@ import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import type { ColorPickerType } from "./colorPickerUtils";
interface PickerProps {
color: string;
color: string | null;
onChange: (color: string) => void;
label: string;
type: ColorPickerType;
elements: readonly ExcalidrawElement[];
palette: ColorPaletteCustom;
@ -42,142 +41,150 @@ interface PickerProps {
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);
export const Picker = React.forwardRef(
(
{
color,
onChange,
type,
elements,
palette,
updateData,
children,
onEyeDropperToggle,
onEscape,
}: PickerProps,
ref,
) => {
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
};
document.addEventListener(EVENT.KEYUP, keyup, { capture: true });
return () => {
document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
};
}, [colorObj, onEyeDropperToggle]);
return getMostUsedCustomColors(elements, type, palette);
});
const pickerRef = React.useRef<HTMLDivElement>(null);
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
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,
});
const colorObj = getColorNameAndShadeFromColor({
color,
palette,
});
useEffect(() => {
if (!activeColorPickerSection) {
const isCustom = !!color && isCustomColor({ color, palette });
const isCustomButNotInList = isCustom && !customColors.includes(color);
setActiveColorPickerSection(
isCustomButNotInList
? null
: 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);
useImperativeHandle(ref, () => pickerRef.current!);
useEffect(() => {
pickerRef?.current?.focus();
}, []);
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>
)}
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}
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
<PickerColorList
color={color}
label={t("colorPicker.mostUsedCustomColors")}
palette={palette}
onChange={onChange}
activeShade={activeShade}
/>
</div>
)}
<div>
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
<PickerColorList
color={color}
label={label}
palette={palette}
onChange={onChange}
activeShade={activeShade}
/>
<div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList color={color} onChange={onChange} palette={palette} />
</div>
{children}
</div>
<div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList hex={color} onChange={onChange} palette={palette} />
</div>
{children}
</div>
</div>
);
};
);
},
);

View File

@ -17,9 +17,8 @@ import type { TranslationKeys } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;
color: string;
color: string | null;
onChange: (color: string) => void;
label: string;
activeShade: number;
}
@ -27,11 +26,10 @@ const PickerColorList = ({
palette,
color,
onChange,
label,
activeShade,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
color,
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(

View File

@ -13,14 +13,14 @@ import {
} from "./colorPickerUtils";
interface ShadeListProps {
hex: string;
color: string | null;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color: hex || "transparent",
color: color || "transparent",
palette,
});

View File

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

View File

@ -11,11 +11,14 @@ export const getColorNameAndShadeFromColor = ({
color,
}: {
palette: ColorPaletteCustom;
color: string;
color: string | null;
}): {
colorName: ColorPickerColor;
shade: number | null;
} | null => {
if (!color) {
return null;
}
for (const [colorName, colorVal] of Object.entries(palette)) {
if (Array.isArray(colorVal)) {
const shade = colorVal.indexOf(color);

View File

@ -109,7 +109,7 @@ interface ColorPickerKeyNavHandlerProps {
event: React.KeyboardEvent;
activeColorPickerSection: ActiveColorPickerSectionAtomType;
palette: ColorPaletteCustom;
color: string;
color: string | null;
onChange: (color: string) => void;
customColors: string[];
setActiveColorPickerSection: (
@ -270,7 +270,7 @@ export const colorPickerKeyNavHandler = ({
}
if (activeColorPickerSection === "custom") {
const indexOfColor = customColors.indexOf(color);
const indexOfColor = color != null ? customColors.indexOf(color) : 0;
const newColorIndex = arrowHandler(
event.key,

View File

@ -580,6 +580,14 @@ export const bucketFillIcon = createIcon(
tablerIconProps,
);
// simple / icon
export const slashIcon = createIcon(
<g strokeWidth={1.5}>
<path d="M6 18l12 -12" />
</g>,
tablerIconProps,
);
export const ExportImageIcon = createIcon(
<g strokeWidth="1.25">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>

View File

@ -556,6 +556,7 @@
}
},
"colorPicker": {
"color": "Color",
"mostUsedCustomColors": "Most used custom colors",
"colors": "Colors",
"shades": "Shades",