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, app,
(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}
@ -379,7 +380,8 @@ export const actionChangeBackgroundColor = register({
app, app,
(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}

View File

@ -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 && (

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

View File

@ -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) => {
@ -166,7 +171,6 @@ const ColorPickerPopupContent = ({
updateData({ openPopup: null }); updateData({ openPopup: null });
} }
}} }}
label={label}
type={type} type={type}
elements={elements} elements={elements}
updateData={updateData} updateData={updateData}
@ -185,7 +189,7 @@ const ColorPickerTrigger = ({
color, color,
type, type,
}: { }: {
color: string; color: string | null;
label: string; label: string;
type: ColorPickerType; type: ColorPickerType;
}) => { }) => {
@ -193,8 +197,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 +209,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>
); );
}; };

View File

@ -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;
} }

View File

@ -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,9 +30,8 @@ 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;
type: ColorPickerType; type: ColorPickerType;
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
palette: ColorPaletteCustom; palette: ColorPaletteCustom;
@ -42,10 +41,11 @@ 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,
type, type,
elements, elements,
palette, palette,
@ -53,7 +53,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 +74,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 +118,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 +172,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 +180,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>
); );
}; },
);

View File

@ -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(

View File

@ -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,
}); });

View File

@ -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[];
} }

View File

@ -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);

View File

@ -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,

View File

@ -580,6 +580,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>

View File

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