Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 87 additions & 60 deletions packages/excalidraw/components/ColorPicker/ColorInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons";

import { getColor } from "./ColorPicker";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import {
activeColorPickerSectionAtom,
getHexColorValidationError,
} from "./colorPickerUtils";

import type { ColorPickerType } from "./colorPickerUtils";

Expand All @@ -32,17 +35,24 @@ export const ColorInput = ({
}: ColorInputProps) => {
const device = useDevice();
const [innerValue, setInnerValue] = useState(color);
const [validationError, setValidationError] = useState<string | null>(null);
const [activeSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);

useEffect(() => {
setInnerValue(color);
setValidationError(null);
}, [color]);

const changeColor = useCallback(
(inputValue: string) => {
const value = inputValue.toLowerCase();

// Validate hex color and show error if invalid
const error = getHexColorValidationError(value);
setValidationError(error);

const color = getColor(value);

if (color) {
Expand Down Expand Up @@ -71,66 +81,83 @@ export const ColorInput = ({
}, [setEyeDropperState]);

return (
<div className="color-picker__input-label">
<div className="color-picker__input-hash">#</div>
<input
ref={activeSection === "hex" ? inputRef : undefined}
style={{ border: 0, padding: 0 }}
spellCheck={false}
className="color-picker-input"
aria-label={label}
onChange={(event) => {
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();
}}
placeholder={placeholder}
/>
{/* TODO reenable on mobile with a better UX */}
{!device.editor.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),
colorPickerType,
},
)
<div className="color-picker__input-container">
<div
className={clsx("color-picker__input-label", {
"color-picker__input-label--error": validationError,
})}
>
<div className="color-picker__input-hash">#</div>
<input
ref={activeSection === "hex" ? inputRef : undefined}
style={{
border: 0,
padding: 0,
}}
spellCheck={false}
className={clsx("color-picker-input", {
"color-picker-input--error": validationError,
})}
aria-label={label}
onChange={(event) => {
changeColor(event.target.value);
}}
value={(innerValue || "").replace(/^#/, "")}
onBlur={() => {
setInnerValue(color);
setValidationError(null);
}}
tabIndex={-1}
onFocus={() => setActiveColorPickerSection("hex")}
onKeyDown={(event) => {
if (event.key === KEYS.TAB) {
return;
} else if (event.key === KEYS.ESCAPE) {
eyeDropperTriggerRef.current?.focus();
}
title={`${t(
"labels.eyeDropper",
)} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
>
{eyeDropperIcon}
</div>
</>
event.stopPropagation();
}}
placeholder={placeholder}
/>
{/* TODO reenable on mobile with a better UX */}
{!device.editor.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),
colorPickerType,
},
)
}
title={`${t(
"labels.eyeDropper",
)} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
>
{eyeDropperIcon}
</div>
</>
)}
</div>
{validationError && (
<div className="color-picker__error-message" role="alert">
{validationError}
</div>
)}
</div>
);
Expand Down
26 changes: 26 additions & 0 deletions packages/excalidraw/components/ColorPicker/ColorPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -492,4 +492,30 @@
color: $oc-black;
}
}

// Hex color validation error styles
.color-picker__input-container {
display: flex;
flex-direction: column;
}

.color-picker__error-message {
font-size: 0.75rem;
color: #dc3545;
margin: 0.25rem 0.5rem 0;
padding: 0.25rem;
background-color: rgba(220, 53, 69, 0.1);
border-radius: 4px;
border: 1px solid rgba(220, 53, 69, 0.2);
}

.color-picker-input--error {
border-color: #dc3545 !important;
box-shadow: 0 0 0 1px #dc3545 !important;
}

.color-picker__input-label--error {
border-color: #dc3545;
box-shadow: 0 0 0 1px #dc3545;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
isValidHexColor,
getHexColorValidationError,
} from "./colorPickerUtils";

describe("Hex Color Validation", () => {
describe("isValidHexColor", () => {
it("should return true for valid hex colors", () => {
expect(isValidHexColor("fff")).toBe(true);
expect(isValidHexColor("FFF")).toBe(true);
expect(isValidHexColor("ffffff")).toBe(true);
expect(isValidHexColor("FFFFFF")).toBe(true);
expect(isValidHexColor("ffff")).toBe(true);
expect(isValidHexColor("ffffffff")).toBe(true);
expect(isValidHexColor("#fff")).toBe(true);
expect(isValidHexColor("#ffffff")).toBe(true);
expect(isValidHexColor("123")).toBe(true);
expect(isValidHexColor("abc123")).toBe(true);
});

it("should return false for invalid hex colors", () => {
expect(isValidHexColor("")).toBe(false);
expect(isValidHexColor("ff")).toBe(false);
expect(isValidHexColor("fffff")).toBe(false);
expect(isValidHexColor("fffffff")).toBe(false);
expect(isValidHexColor("123456789")).toBe(false);
expect(isValidHexColor("gggggg")).toBe(false);
expect(isValidHexColor("zzzzzz")).toBe(false);
expect(isValidHexColor("blue")).toBe(false);
expect(isValidHexColor("red")).toBe(false);
});
});

describe("getHexColorValidationError", () => {
it("should return null for valid hex colors", () => {
expect(getHexColorValidationError("fff")).toBe(null);
expect(getHexColorValidationError("ffffff")).toBe(null);
expect(getHexColorValidationError("ffff")).toBe(null);
expect(getHexColorValidationError("ffffffff")).toBe(null);
expect(getHexColorValidationError("#fff")).toBe(null);
expect(getHexColorValidationError("#ffffff")).toBe(null);
});

it("should return null for empty string", () => {
expect(getHexColorValidationError("")).toBe(null);
});

it("should return length error for invalid lengths", () => {
expect(getHexColorValidationError("f")).toBe(
"Hex code must be 3, 4, 6, or 8 characters (excluding #)",
);
expect(getHexColorValidationError("ff")).toBe(
"Hex code must be 3, 4, 6, or 8 characters (excluding #)",
);
expect(getHexColorValidationError("fffff")).toBe(
"Hex code must be 3, 4, 6, or 8 characters (excluding #)",
);
expect(getHexColorValidationError("fffffff")).toBe(
"Hex code must be 3, 4, 6, or 8 characters (excluding #)",
);
expect(getHexColorValidationError("123456789")).toBe(
"Hex code must be 3, 4, 6, or 8 characters (excluding #)",
);
});

it("should return character error for invalid characters", () => {
expect(getHexColorValidationError("gggggg")).toBe(
"Invalid characters in hex code",
);
expect(getHexColorValidationError("zzzzzz")).toBe(
"Invalid characters in hex code",
);
expect(getHexColorValidationError("blue")).toBe(
"Invalid characters in hex code",
);
expect(getHexColorValidationError("red")).toBe(
"Invalid characters in hex code",
);
});
});
});
36 changes: 36 additions & 0 deletions packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,39 @@ export type ColorPickerType =
| "canvasBackground"
| "elementBackground"
| "elementStroke";

// Hex color validation utilities
export const isValidHexColor = (hex: string): boolean => {
// Remove # if present
const cleanHex = hex.replace(/^#/, "");

// Check if it's a valid hex format (3, 4, 6, or 8 characters)
const validLengths = [3, 4, 6, 8];
if (!validLengths.includes(cleanHex.length)) {
return false;
}

// Check if all characters are valid hex digits
return /^[0-9a-fA-F]+$/.test(cleanHex);
};

export const getHexColorValidationError = (hex: string): string | null => {
if (!hex) {
return null;
}

const cleanHex = hex.replace(/^#/, "");

// Check length
const validLengths = [3, 4, 6, 8];
if (!validLengths.includes(cleanHex.length)) {
return "Hex code must be 3, 4, 6, or 8 characters (excluding #)";
}

// Check for invalid characters
if (!/^[0-9a-fA-F]+$/.test(cleanHex)) {
return "Invalid characters in hex code";
}

return null;
};