snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS, normalizeInputColor } from "@excalidraw/common";
import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import { useEditorInterface } from "../App";
import { activeEyeDropperAtom } from "../EyeDropper";
import { eyeDropperIcon } from "../icons";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
export const ColorInput = ({
color,
onChange,
label,
colorPickerType,
placeholder,
}: {
color: string;
onChange: (color: string) => void;
label: string;
colorPickerType: ColorPickerType;
placeholder?: string;
}) => {
const editorInterface = useEditorInterface();
const [innerValue, setInnerValue] = useState(color);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [activeSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
useEffect(() => {
setInnerValue(color);
}, [color]);
const changeColor = useCallback(
(inputValue: string) => {
const value = inputValue.toLowerCase().trim();
const color = normalizeInputColor(value);
if (color) {
onChange(color);
setErrorMessage(null);
} else if (value.length === 0) {
setErrorMessage(null);
} else if (/^#?[0-9a-f]+$/.test(value)) {
setErrorMessage(t("colorPicker.invalidHexLength"));
} else {
setErrorMessage(t("colorPicker.invalidColor"));
}
setInnerValue(value);
},
[onChange],
);
const inputRef = useRef<HTMLInputElement>(null);
const eyeDropperTriggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [activeSection]);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
useEffect(() => {
return () => {
setEyeDropperState(null);
};
}, [setEyeDropperState]);
return (
<div className="color-picker__input-label-container">
<div
className={clsx("color-picker__input-label", {
"has-error": errorMessage,
})}
>
<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}
aria-invalid={!!errorMessage}
onChange={(event) => {
changeColor(event.target.value);
}}
value={(innerValue || "").replace(/^#/, "")}
onBlur={() => {
setInnerValue(color);
setErrorMessage(null);
}}
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 */}
{editorInterface.formFactor !== "phone" && (
<>
<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>
{errorMessage && (
<div className="color-picker__error-message" role="alert">
{errorMessage}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,598 @@
@use "sass:color";
@use "../../css/variables.module" as *;
.excalidraw {
--color-outline: var(--color-gray-20);
&.theme--dark {
--color-outline: var(--color-gray-80);
}
.focus-visible-none {
&:focus-visible {
outline: none !important;
}
}
.color-picker__title {
padding: 0 0.5rem;
font-size: 0.875rem;
text-align: left;
}
.color-picker__heading {
padding: 0 0.5rem;
font-size: 0.75rem;
text-align: left;
}
.color-picker__context-menu {
background: var(--popup-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
padding: 0.25rem;
}
.color-picker__context-menu-item {
font-size: 0.75rem;
padding: 0.375rem 0.625rem;
border-radius: var(--border-radius-md);
color: var(--text-primary-color);
cursor: pointer;
outline: none;
user-select: none;
// :hover as well — radix only sets data-highlighted once the item gets
// focus via pointermove, which leaves dead frames (e.g. menu opening
// under a stationary cursor)
&[data-highlighted],
&:not([data-disabled]):hover {
background: var(--button-hover-bg);
}
&[data-disabled] {
opacity: 0.5;
pointer-events: none;
}
}
.color-picker__tip {
padding: 0 0.5rem;
font-size: 0.6875rem;
line-height: 1.4;
text-align: left;
color: var(--color-gray-60);
}
.color-picker-container {
display: grid;
grid-template-columns: 1fr 20px 1.625rem;
padding: 0.25rem 0px;
align-items: center;
@include isMobile {
max-width: 11rem;
}
&.color-picker-container--no-top-picks {
display: flex;
justify-content: center;
grid-template-columns: unset;
}
}
.color-picker__top-picks {
display: flex;
justify-content: space-between;
align-items: center;
}
.color-picker__button {
--radius: 4px;
--size: 1.375rem;
&.has-outline {
box-shadow: 0 0 0 1px var(--color-outline);
}
padding: 0;
margin: 0;
width: var(--size);
height: var(--size);
border: 0;
border-radius: var(--radius);
background-color: var(--swatch-color);
background-position: left center;
position: relative;
font-family: inherit;
box-sizing: border-box;
&:hover:not(.active).color-picker__button--large {
box-shadow: inset 0 0 0 1px var(--color-primary);
}
&:active {
box-shadow: 0 0 0 1px var(--color-primary) !important;
}
&:active.color-picker__button--large {
box-shadow: none !important;
&.has-outline {
box-shadow: 0 0 0 1px var(--color-outline) !important;
}
}
.color-picker__button-outline {
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--color-gray-60);
width: 1.25rem;
height: 1.25rem;
}
}
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active:not(:active) {
.color-picker__button-outline {
position: absolute;
--offset: -1px;
top: var(--offset);
left: var(--offset);
right: var(--offset);
bottom: var(--offset);
box-shadow: 0 0 0 1px var(--color-primary-darkest);
z-index: 1; // due hover state so this has preference
border-radius: var(--radius);
}
}
&:focus-visible {
outline: none;
&::after {
content: "";
position: absolute;
top: -4px;
right: -4px;
bottom: -4px;
left: -4px;
border: 3px solid var(--focus-highlight-color);
border-radius: calc(var(--radius) + 1px);
}
&.active {
.color-picker__button-outline {
display: none;
}
}
}
&--large {
--radius: 6px;
width: 1.875rem;
height: 1.875rem;
}
&.is-transparent {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==");
}
&--no-focus-visible {
border: 0;
&::after {
display: none;
}
&:focus-visible {
outline: none !important;
}
}
&.active-color {
border-radius: calc(var(--radius) + 1px);
width: 1.625rem;
height: 1.625rem;
}
&.compact-sizing {
width: var(--mobile-action-button-size);
height: var(--mobile-action-button-size);
}
&.mobile-border {
border: 1px solid var(--mobile-color-border);
}
}
.color-picker__button__hotkey-label {
position: absolute;
right: 4px;
bottom: 2px;
font-size: 10px;
font-family: monospace;
font-weight: 500;
}
.color-picker {
background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: color.adjust(#000, $alpha: -0.75) 0 1px 4px;
border-radius: 4px;
position: absolute;
:root[dir="ltr"] & {
left: -5.5px;
}
:root[dir="rtl"] & {
right: -5.5px;
}
}
.color-picker-control-container {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
column-gap: 0.5rem;
}
.color-picker-control-container + .popover {
position: static;
}
.color-picker-popover-container {
margin-top: -0.25rem;
:root[dir="ltr"] & {
margin-left: 0.5rem;
}
:root[dir="rtl"] & {
margin-left: -3rem;
}
}
.color-picker-triangle {
width: 0;
height: 0;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
position: absolute;
top: 10px;
:root[dir="ltr"] & {
transform: rotate(270deg);
left: -14px;
}
:root[dir="rtl"] & {
transform: rotate(90deg);
right: -14px;
}
}
.color-picker-triangle-shadow {
border-color: transparent transparent color.adjust(#000, $alpha: -0.9);
:root[dir="ltr"] & {
left: -14px;
}
:root[dir="rtl"] & {
right: -16px;
}
}
.color-picker-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
outline: none;
}
.color-picker-content--default {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(5, 1.875rem);
grid-gap: 0.25rem;
border-radius: 4px;
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
.color-picker-content--canvas {
display: flex;
flex-direction: column;
padding: 0.25rem;
&-title {
color: $color-gray-6;
font-size: 12px;
padding: 0 0.25rem;
}
&-colors {
padding: 0.5rem 0;
}
}
.color-picker-content .color-input-container {
grid-column: 1 / span 5;
}
.color-picker-hash {
height: var(--default-button-size);
flex-shrink: 0;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
border: 1px solid var(--default-border-color);
border-right: 0;
box-sizing: border-box;
:root[dir="ltr"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
color: var(--input-label-color);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.color-input-container {
display: flex;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
}
.color-picker__input-label-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.color-picker__input-label {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 8px;
align-items: center;
border: 1px solid var(--default-border-color);
border-radius: 8px;
padding: 0 12px;
margin: 8px;
box-sizing: border-box;
&:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-lg);
}
&.has-error {
border-color: var(--color-danger, #e03131);
&:focus-within {
box-shadow: 0 0 0 1px var(--color-danger, #e03131);
}
}
}
.color-picker__input-hash {
padding: 0 0.25rem;
}
.color-picker__error-message {
color: var(--color-danger, #e03131);
font-size: 0.75rem;
padding: 0 8px 4px 8px;
text-align: left;
line-height: 1.3;
}
.color-picker-input {
box-sizing: border-box;
width: 100%;
margin: 0;
font-size: 0.875rem;
font-family: inherit;
background-color: transparent;
color: var(--text-primary-color);
border: 0;
outline: none;
height: var(--default-button-size);
border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem;
padding-left: 0.25rem;
appearance: none;
&:focus-visible {
box-shadow: none;
}
}
// top-picks drag & drop (pinning + reordering)
// ---------------------------------------------------------------------------
.color-picker__top-picks {
// anchor for the animated drop-target outline
position: relative;
}
.color-picker__top-picks .color-picker__button {
// the strip is tiny and never scrolls — allow touch-dragging picks
touch-action: none;
}
.color-picker__top-picks.is-dnd-active {
.color-picker__button {
// no hover/tooltips while a drag is in flight; hit-testing is
// coordinate-based so this doesn't affect dropping
pointer-events: none;
transition: transform 0.14s ease, opacity 0.14s ease,
box-shadow 0.14s ease;
}
}
// "marching ants" outline hinting that the strip accepts the drop —
// SVG because CSS dashed outlines/borders can't animate their dash offset
.color-picker__top-picks__dnd-outline {
position: absolute;
top: -5px;
left: -5px;
width: calc(100% + 10px);
height: calc(100% + 10px);
pointer-events: none;
overflow: visible;
rect {
x: 0.5px;
y: 0.5px;
rx: 6px;
width: calc(100% - 1px);
height: calc(100% - 1px);
fill: none;
stroke: var(--color-primary);
stroke-width: 1;
stroke-dasharray: 4 4;
animation: excalidraw-color-dnd-march 0.8s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
@keyframes excalidraw-color-dnd-march {
to {
// one full dash+gap period per cycle → seamless loop
stroke-dashoffset: -8;
}
}
// the pick being reordered — its ghost is following the pointer
.color-picker__button.is-dnd-source {
opacity: 0.3;
}
// slot a dragged swatch would replace
.color-picker__button.is-dnd-target {
transform: scale(1.2);
box-shadow: 0 0 0 1px var(--color-primary-darkest);
}
// this color is already pinned — drop not allowed
.color-picker__button.is-dnd-duplicate {
animation: excalidraw-color-dnd-duplicate 0.45s ease-in-out infinite
alternate;
}
@keyframes excalidraw-color-dnd-duplicate {
from {
box-shadow: 0 0 0 2px var(--color-danger, #e03131);
transform: scale(1);
}
to {
box-shadow: 0 0 0 3px var(--color-danger, #e03131);
transform: scale(1.12);
}
}
}
// drag & drop ghost — appended to document.body (outside the .excalidraw
// scope) so it can follow the pointer without overflow clipping
.excalidraw-color-dnd-ghost {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
pointer-events: none;
will-change: transform;
// NOTE transform is deliberately not transitioned here (tracks the pointer
// every frame); --dropping adds it for the fly-to-target animation
transition: width 0.15s ease, height 0.15s ease;
&__swatch {
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
transition: transform 0.15s ease, opacity 0.15s ease, box-shadow 0.15s ease;
&.is-transparent {
background-color: #fff;
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==");
background-position: left center;
}
}
&--lifted &__swatch {
transform: scale(1.2);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.08);
}
&--over &__swatch {
transform: scale(1.05);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.08);
}
&--blocked &__swatch {
opacity: 0.4;
transform: scale(0.85);
}
&--dropping {
transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1), width 0.18s ease,
height 0.18s ease;
}
&--dropping &__swatch {
transform: scale(1);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
&--fade &__swatch {
opacity: 0;
}
}
body.excalidraw-color-dnd-active {
user-select: none !important;
-webkit-user-select: none !important;
&,
* {
cursor: grabbing !important;
}
}

View File

@@ -0,0 +1,539 @@
import { Popover } from "radix-ui";
import clsx from "clsx";
import { memo, useRef, useEffect } from "react";
import {
applyDarkModeFilter,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
isWritableElement,
THEME,
} from "@excalidraw/common";
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import { useApp, useExcalidrawContainer, useStylesPanelMode } from "../App";
import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { slashIcon, strokeIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import {
ColorPickerDnDContext,
useColorPickerDnD,
useTopPicksDnD,
} from "./topPicksDnD";
import "./ColorPicker.scss";
import type { ColorPickerType } from "./colorPickerUtils";
import type { AppState, UIAppState } from "../../types";
interface ColorPickerProps {
type: ColorPickerType;
/**
* 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[];
appState: UIAppState;
palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple;
updateData: (formData?: any) => void;
/** palette colors to hide from the popup, keeping hotkey positions */
excludedColors?: readonly string[];
/** allow users to customize the top picks strip by drag & dropping colors
* from the picker popup onto it. The value names the
* `appState.colorTopPicks` slot the customization is stored in */
customizableTopPicks?: keyof AppState["colorTopPicks"];
}
const ColorPickerPopupContent = ({
type,
color,
onChange,
label,
elements,
palette = COLOR_PALETTE,
updateData,
getOpenPopup,
appState,
excludedColors,
}: Pick<
ColorPickerProps,
| "type"
| "color"
| "onChange"
| "label"
| "elements"
| "palette"
| "updateData"
| "appState"
| "excludedColors"
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
const { container } = useExcalidrawContainer();
const app = useApp();
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isMobileMode = stylesPanelMode === "mobile";
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
const colorInputJSX = (
<div>
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
<ColorInput
color={color || ""}
label={label}
onChange={(color) => {
onChange(color);
}}
colorPickerType={type}
placeholder={t("colorPicker.color")}
/>
</div>
);
const colorPickerContentRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
colorPickerContentRef.current?.focus();
};
return (
<PropertiesPopover
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => {
const target = event.target;
// focus moving into the top-picks context menu — let the menu keep
// it (stealing it back would clear the menu's focus-driven
// `data-highlighted` styling and break its keyboard navigation)
if (
target instanceof app.ownerWindow.HTMLElement &&
target.closest(".color-picker__context-menu")
) {
event.preventDefault();
return;
}
// refocus due to eye dropper
if (!isWritableElement(event.target)) {
if (
target instanceof app.ownerWindow.HTMLElement &&
target.matches(":active")
) {
// the focus escaped because a button outside is being pressed
// (e.g. a top pick) — stealing focus mid-press makes the browser
// drop the button's activation (`:active` styling), so wait for
// the release
app.ownerWindow.addEventListener("pointerup", focusPickerContent, {
once: true,
});
} else {
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();
}
}}
onClose={() => {
// only clear if we're still the active popup (avoid racing with switch)
if (getOpenPopup() === type) {
updateData({ openPopup: null });
}
setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
app.ownerWindow.setTimeout(() => {
const textEditor = app.ownerDocument.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
>
{palette ? (
<Picker
ref={colorPickerContentRef}
palette={palette}
excludedColors={excludedColors}
color={color}
theme={appState.theme}
onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition(app.ownerDocument)
: null;
onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection, app.ownerDocument);
}
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
colorPickerType: type,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
colorPickerType: type,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else {
// close explicitly on Escape
updateData({ openPopup: null });
}
}}
type={type}
elements={elements}
updateData={updateData}
showTitle={isCompactMode}
showHotKey={!isMobileMode}
>
{colorInputJSX}
</Picker>
) : (
colorInputJSX
)}
</PropertiesPopover>
);
};
const isColorPickerPopup = (
popup: AppState["openPopup"],
): popup is ColorPickerType =>
popup === "elementStroke" ||
popup === "elementBackground" ||
popup === "canvasBackground";
const ColorPickerTrigger = ({
label,
color,
type,
theme,
mode = "background",
onToggle,
editingTextElement,
}: {
color: string | null;
label: string;
type: ColorPickerType;
theme: Theme;
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
}) => {
const app = useApp();
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isMobileMode = stylesPanelMode === "mobile";
const dnd = useColorPickerDnD();
const displayColor = color
? applyDarkModeFilter(color, theme === THEME.DARK)
: null;
const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur(app.ownerDocument);
}
onToggle();
};
return (
<Popover.Trigger
type="button"
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": !color || color === "transparent",
"has-outline":
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
"compact-sizing": isCompactMode,
"mobile-border": isMobileMode,
})}
aria-label={label}
style={displayColor ? { "--swatch-color": displayColor } : undefined}
title={
type === "elementStroke"
? t("labels.showStroke")
: t("labels.showBackground")
}
data-openpopup={type}
onClick={handleClick}
// the active-color swatch can be dragged onto the top-picks strip to
// pin the current (possibly custom) color
onPointerDown={
dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined
}
>
<div className="color-picker__button-outline">{!color && slashIcon}</div>
{isCompactMode && color && mode === "stroke" && (
<div className="color-picker__button-background">
<span
style={{
color:
displayColor &&
isColorDark(displayColor, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{strokeIcon}
</span>
</div>
)}
</Popover.Trigger>
);
};
const ColorPickerComponent = ({
type,
color,
onChange,
label,
elements,
palette = COLOR_PALETTE,
topPicks,
updateData,
appState,
excludedColors,
customizableTopPicks,
}: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
const stylesPanelMode = useStylesPanelMode();
const isCompactMode = stylesPanelMode !== "full";
const isTopPicksCustomizable = !!customizableTopPicks && !isCompactMode;
// user-pinned picks trump the (host-provided or default) baseline
const customTopPicks =
isTopPicksCustomizable && customizableTopPicks
? appState.colorTopPicks?.[customizableTopPicks]
: null;
// fully-resolved picks currently displayed in the strip — the baseline the
// drag & drop customization starts from
const effectiveTopPicks = customTopPicks?.length
? customTopPicks
: topPicks ??
(type === "elementStroke"
? DEFAULT_ELEMENT_STROKE_PICKS
: DEFAULT_ELEMENT_BACKGROUND_PICKS);
const dnd = useTopPicksDnD({
enabled: isTopPicksCustomizable,
picks: effectiveTopPicks,
onPicksChange: (picks) => {
if (!customizableTopPicks) {
return;
}
// NOTE the comparator comparing `appState.colorTopPicks` is what keeps
// this captured spread fresh — see `areColorPickerPropsEqual`
updateData({
colorTopPicks: {
...appState.colorTopPicks,
[customizableTopPicks]: picks,
},
});
},
});
return (
<ColorPickerDnDContext.Provider value={isTopPicksCustomizable ? dnd : null}>
<div
role="dialog"
aria-modal="true"
className={clsx("color-picker-container", {
"color-picker-container--no-top-picks": isCompactMode,
})}
>
{!isCompactMode && (
<TopPicks
theme={appState.theme}
activeColor={color}
onChange={(pickedColor) => {
onChange(pickedColor);
// if another color-picker popup is open, follow the user's
// focus over to this picker (same as clicking its trigger)
if (
isColorPickerPopup(appState.openPopup) &&
appState.openPopup !== type
) {
// deferred: each updateData spreads the full pre-commit
// appState, so issuing this in the same task would clobber
// the color change committed by `onChange` above
setTimeout(() => updateData({ openPopup: type }), 0);
}
}}
type={type}
topPicks={customTopPicks?.length ? customTopPicks : topPicks}
isCustomized={!!customTopPicks?.length}
onReset={
isTopPicksCustomizable && customizableTopPicks
? () => {
updateData({
colorTopPicks: {
...appState.colorTopPicks,
[customizableTopPicks]: null,
},
});
}
: undefined
}
/>
)}
{!isCompactMode && <ButtonSeparator />}
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {
if (open) {
updateData({ openPopup: type });
}
}}
>
{/* serves as an active color indicator as well */}
<ColorPickerTrigger
color={color}
label={label}
type={type}
theme={appState.theme}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) {
// toggle off on same trigger
updateData({ openPopup: null });
} else if (appState.openPopup) {
updateData({ openPopup: type });
} else {
// open this one
updateData({ openPopup: type });
}
}}
/>
{/* popup content */}
{appState.openPopup === type && (
<ColorPickerPopupContent
type={type}
color={color}
onChange={onChange}
label={label}
elements={elements}
palette={palette}
excludedColors={excludedColors}
updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
/>
)}
</Popover.Root>
</div>
</ColorPickerDnDContext.Provider>
);
};
/**
* ColorPicker renders on every styles-panel render (selection churn during
* box-select and the like), while its actual inputs are a handful of resolved
* primitives and reference-stable objects — cheap `===` checks, no deep
* comparisons:
* - `palette` / `topPicks` / `excludedColors` are module constants,
* - `appState.colorTopPicks` identity changes exactly when its content does.
*
* Function props (`onChange`, `updateData`) are deliberately ignored — they
* are recreated every render but semantically stable (they read fresh state
* at call time). Same for `elements`, which is only sampled once when the
* popup opens (most-used custom colors).
*/
const areColorPickerPropsEqual = (
prev: ColorPickerProps,
next: ColorPickerProps,
) => {
// the open popup is interactive (hotkeys, eye dropper, most-used custom
// colors) — never memoize it, including the open/close transitions
if (
prev.appState.openPopup === prev.type ||
next.appState.openPopup === next.type
) {
return false;
}
return (
prev.type === next.type &&
prev.color === next.color &&
prev.label === next.label &&
// popup open/close is rare, and the strip's click handler branches on
// which popup is open (popup-switching) — keep its closure fresh
prev.appState.openPopup === next.appState.openPopup &&
prev.palette === next.palette &&
prev.topPicks === next.topPicks &&
prev.excludedColors === next.excludedColors &&
prev.customizableTopPicks === next.customizableTopPicks &&
prev.appState.theme === next.appState.theme &&
prev.appState.colorTopPicks === next.appState.colorTopPicks &&
// the trigger tweaks its click behavior while a text element is edited
!!prev.appState.editingTextElement === !!next.appState.editingTextElement
);
};
export const ColorPicker = memo(ColorPickerComponent, areColorPickerPropsEqual);

View File

@@ -0,0 +1,77 @@
import clsx from "clsx";
import { useEffect, useRef } from "react";
import { applyDarkModeFilter, THEME } from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai";
import HotkeyLabel from "./HotkeyLabel";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useColorPickerDnD } from "./topPicksDnD";
interface CustomColorListProps {
theme: Theme;
colors: string[];
color: string | null;
onChange: (color: string) => void;
label: string;
}
export const CustomColorList = ({
theme,
colors,
color,
onChange,
label,
}: CustomColorListProps) => {
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const dnd = useColorPickerDnD();
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (btnRef.current) {
btnRef.current.focus();
}
}, [color, activeColorPickerSection]);
return (
<div className="color-picker-content--default">
{colors.map((c, i) => {
const displayColor = applyDarkModeFilter(c, theme === THEME.DARK);
return (
<button
ref={color === c ? btnRef : undefined}
tabIndex={-1}
type="button"
className={clsx(
"color-picker__button color-picker__button--large has-outline",
{
active: color === c,
"is-transparent": c === "transparent" || !c,
},
)}
onClick={() => {
onChange(c);
setActiveColorPickerSection("custom");
}}
onPointerDown={
dnd ? (event) => dnd.startSwatchDrag(event, c) : undefined
}
title={c}
aria-label={label}
style={{ "--swatch-color": displayColor }}
key={i}
>
<div className="color-picker__button-outline" />
<HotkeyLabel color={displayColor} keyLabel={i + 1} />
</button>
);
})}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import React from "react";
import { isColorDark } from "@excalidraw/common";
interface HotkeyLabelProps {
color: string;
keyLabel: string | number;
isShade?: boolean;
}
const HotkeyLabel = ({
color,
keyLabel,
isShade = false,
}: HotkeyLabelProps) => {
return (
<div
className="color-picker__button__hotkey-label"
style={{
color: isColorDark(color) ? "#fff" : "#000",
}}
>
{isShade && "⇧"}
{keyLabel}
</div>
);
};
export default HotkeyLabel;

View File

@@ -0,0 +1,227 @@
import React, { useEffect, useImperativeHandle, useState } from "react";
import { EVENT } from "@excalidraw/common";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
KEYS,
} from "@excalidraw/common";
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import type { ColorPaletteCustom } from "@excalidraw/common";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import { CustomColorList } from "./CustomColorList";
import PickerColorList from "./PickerColorList";
import PickerHeading from "./PickerHeading";
import { ShadeList } from "./ShadeList";
import {
activeColorPickerSectionAtom,
getColorNameAndShadeFromColor,
getMostUsedCustomColors,
isCustomColor,
} from "./colorPickerUtils";
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import { useColorPickerDnD } from "./topPicksDnD";
import type { ColorPickerType } from "./colorPickerUtils";
interface PickerProps {
theme: Theme;
color: string | null;
onChange: (color: string) => void;
type: ColorPickerType;
elements: readonly ExcalidrawElement[];
palette: ColorPaletteCustom;
updateData: (formData?: any) => void;
children?: React.ReactNode;
showTitle?: boolean;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
showHotKey?: boolean;
excludedColors?: readonly string[];
}
export const Picker = React.forwardRef(
(
{
theme,
color,
onChange,
type,
elements,
palette,
updateData,
children,
showTitle,
onEyeDropperToggle,
onEscape,
showHotKey = true,
excludedColors,
}: PickerProps,
ref,
) => {
const title = showTitle
? type === "elementStroke"
? t("labels.stroke")
: type === "elementBackground"
? t("labels.background")
: null
: null;
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
return [];
}
return getMostUsedCustomColors(elements, type, palette);
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const dnd = useColorPickerDnD();
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,
excludedColors,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}}
className="color-picker-content properties-content"
// to allow focusing by clicking but not by tabbing
tabIndex={-1}
>
{title && <div className="color-picker__title">{title}</div>}
{!!customColors.length && (
<div>
<PickerHeading>
{t("colorPicker.mostUsedCustomColors")}
</PickerHeading>
<CustomColorList
theme={theme}
colors={customColors}
color={color}
label={t("colorPicker.mostUsedCustomColors")}
onChange={onChange}
/>
</div>
)}
<div>
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
<PickerColorList
theme={theme}
color={color}
palette={palette}
onChange={onChange}
activeShade={activeShade}
showHotKey={showHotKey}
excludedColors={excludedColors}
/>
</div>
<div>
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
<ShadeList
theme={theme}
color={color}
onChange={onChange}
palette={palette}
showHotKey={showHotKey}
/>
</div>
{children}
{/* dnd context is only provided when top picks are customizable */}
{dnd && (
<div className="color-picker__tip">
{t("colorPicker.topPicksTip")}
</div>
)}
</div>
</div>
);
},
);

View File

@@ -0,0 +1,132 @@
import clsx from "clsx";
import { useEffect, useRef } from "react";
import {
applyDarkModeFilter,
isColorDark,
THEME,
type ColorPaletteCustom,
} from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import HotkeyLabel from "./HotkeyLabel";
import {
activeColorPickerSectionAtom,
colorPickerHotkeyBindings,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import { useColorPickerDnD } from "./topPicksDnD";
import type { TranslationKeys } from "../../i18n";
interface PickerColorListProps {
theme: Theme;
palette: ColorPaletteCustom;
color: string | null;
onChange: (color: string) => void;
activeShade: number;
showHotKey?: boolean;
/**
* palette colors to hide. Hidden entries keep their position in the hotkey
* order (their hotkey goes dead instead of remapping the colors after them).
*/
excludedColors?: readonly string[];
}
const PickerColorList = ({
theme,
palette,
color,
onChange,
activeShade,
showHotKey = true,
excludedColors,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color,
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const dnd = useColorPickerDnD();
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (btnRef.current && activeColorPickerSection === "baseColors") {
btnRef.current.focus();
}
}, [colorObj?.colorName, activeColorPickerSection]);
return (
<div className="color-picker-content--default">
{Object.entries(palette).map(([key, value], index) => {
const color =
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
if (excludedColors?.includes(color)) {
// invisible placeholder so the remaining colors keep their exact
// grid positions (and hotkeys) — muscle memory stays intact
return (
<div
key={key}
className="color-picker__button color-picker__button--large"
style={{ visibility: "hidden" }}
aria-hidden="true"
/>
);
}
const keybinding = colorPickerHotkeyBindings[index];
const displayColor = applyDarkModeFilter(color, theme === THEME.DARK);
const label = t(
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
null,
"",
);
return (
<button
ref={colorObj?.colorName === key ? btnRef : undefined}
tabIndex={-1}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
{
"has-outline": !isColorDark(color, 255),
active: colorObj?.colorName === key,
"is-transparent": color === "transparent" || !color,
},
)}
onClick={() => {
onChange(color);
setActiveColorPickerSection("baseColors");
}}
onPointerDown={
dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined
}
title={`${label}${
color.startsWith("#") ? ` ${color}` : ""
} — ${keybinding}`}
aria-label={`${label} — ${keybinding}`}
style={color ? { "--swatch-color": displayColor } : undefined}
data-testid={`color-${key}`}
key={key}
>
<div className="color-picker__button-outline" />
{showHotKey && (
<HotkeyLabel color={displayColor} keyLabel={keybinding} />
)}
</button>
);
})}
</div>
);
};
export default PickerColorList;

View File

@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
const PickerHeading = ({ children }: { children: ReactNode }) => (
<div className="color-picker__heading">{children}</div>
);
export default PickerHeading;

View File

@@ -0,0 +1,137 @@
import clsx from "clsx";
import { useEffect, useRef } from "react";
import {
applyDarkModeFilter,
THEME,
type ColorPaletteCustom,
} from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { useAtom } from "../../editor-jotai";
import { t } from "../../i18n";
import HotkeyLabel from "./HotkeyLabel";
import {
activeColorPickerSectionAtom,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import { useColorPickerDnD } from "./topPicksDnD";
interface ShadeListProps {
theme: Theme;
color: string | null;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
showHotKey?: boolean;
}
export const ShadeList = ({
theme,
color,
onChange,
palette,
showHotKey,
}: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
const dnd = useColorPickerDnD();
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (btnRef.current && activeColorPickerSection === "shades") {
btnRef.current.focus();
}
}, [colorObj, activeColorPickerSection]);
if (colorObj) {
const { colorName, shade } = colorObj;
const shades = palette[colorName];
if (Array.isArray(shades)) {
return (
<div className="color-picker-content--default shades">
{shades.map((color, i) => {
const displayColor = applyDarkModeFilter(
color,
theme === THEME.DARK,
);
return (
<button
ref={
i === shade && activeColorPickerSection === "shades"
? btnRef
: undefined
}
tabIndex={-1}
key={i}
type="button"
className={clsx(
"color-picker__button color-picker__button--large",
{
active: i === shade,
},
)}
aria-label="Shade"
title={`${colorName} - ${i + 1}`}
style={color ? { "--swatch-color": displayColor } : undefined}
onClick={() => {
onChange(color);
setActiveColorPickerSection("shades");
}}
onPointerDown={
dnd ? (event) => dnd.startSwatchDrag(event, color) : undefined
}
>
<div className="color-picker__button-outline" />
{showHotKey && (
<HotkeyLabel color={displayColor} keyLabel={i + 1} isShade />
)}
</button>
);
})}
</div>
);
}
}
return (
<div
className="color-picker-content--default"
style={{ position: "relative" }}
tabIndex={-1}
>
<button
type="button"
tabIndex={-1}
className="color-picker__button color-picker__button--large color-picker__button--no-focus-visible"
/>
<div
tabIndex={-1}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
fontSize: "0.75rem",
}}
>
{t("colorPicker.noShades")}
</div>
</div>
);
};

View File

@@ -0,0 +1,184 @@
import clsx from "clsx";
import { ContextMenu } from "radix-ui";
import {
applyDarkModeFilter,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
THEME,
} from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { t } from "../../i18n";
import { useExcalidrawContainer } from "../App";
import { useColorPickerDnD } from "./topPicksDnD";
import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps {
theme: Theme;
onChange: (color: string) => void;
type: ColorPickerType;
activeColor: string | null;
topPicks?: readonly string[];
/** present when the strip is user-customizable — enables the right-click
* context menu resetting the strip to its default picks */
onReset?: () => void;
/** whether custom picks are currently applied (enables the reset item) */
isCustomized?: boolean;
}
export const TopPicks = ({
theme,
onChange,
type,
activeColor,
topPicks,
onReset,
isCustomized,
}: TopPicksProps) => {
const dnd = useColorPickerDnD();
const dragState = dnd?.dragState ?? null;
const { container } = useExcalidrawContainer();
let colors;
if (type === "elementStroke") {
colors = DEFAULT_ELEMENT_STROKE_PICKS;
}
if (type === "elementBackground") {
colors = DEFAULT_ELEMENT_BACKGROUND_PICKS;
}
if (type === "canvasBackground") {
colors = DEFAULT_CANVAS_BACKGROUND_PICKS;
}
// this one can overwrite defaults
if (topPicks) {
colors = topPicks;
}
if (!colors) {
console.error("Invalid type for TopPicks");
return null;
}
// live preview of the reorder result — every pick translates to the slot
// it would occupy if dropped right now
const getReorderOffset = (index: number) => {
if (
!dragState ||
dragState.origin.kind !== "pick" ||
dragState.overIndex === null
) {
return 0;
}
const from = dragState.origin.index;
const to = dragState.overIndex;
if (from === to) {
return 0;
}
let newIndex = index;
if (index === from) {
newIndex = to;
} else {
if (index > from) {
newIndex -= 1;
}
if (newIndex >= to) {
newIndex += 1;
}
}
return (newIndex - index) * dragState.slotSpan;
};
const strip = (
<div
className={clsx("color-picker__top-picks", {
"is-dnd-active": !!dragState,
})}
ref={dnd?.setStripEl}
>
{dragState && (
<svg
className="color-picker__top-picks__dnd-outline"
aria-hidden="true"
>
<rect />
</svg>
)}
{colors.map((color: string, index: number) => {
const reorderOffset = getReorderOffset(index);
const displayColor = applyDarkModeFilter(color, theme === THEME.DARK);
return (
<button
className={clsx("color-picker__button", {
active: color === activeColor,
"is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(
color,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
),
"is-dnd-source":
dragState?.origin.kind === "pick" &&
dragState.origin.index === index,
"is-dnd-target":
dragState?.origin.kind === "swatch" &&
dragState.overIndex === index,
"is-dnd-duplicate": dragState?.duplicateIndex === index,
})}
style={{
"--swatch-color": displayColor,
transform: reorderOffset
? `translateX(${reorderOffset}px)`
: undefined,
}}
key={color}
type="button"
title={color}
onClick={() => onChange(color)}
onPointerDown={
dnd
? (event) => dnd.startPickDrag(event, index, color)
: undefined
}
data-testid={`color-top-pick-${color}`}
data-top-pick-index={index}
>
<div className="color-picker__button-outline" />
</button>
);
})}
</div>
);
if (!onReset) {
return strip;
}
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>{strip}</ContextMenu.Trigger>
<ContextMenu.Portal container={container}>
<ContextMenu.Content
className="color-picker__context-menu"
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
>
<ContextMenu.Item
className="color-picker__context-menu-item"
disabled={!isCustomized}
onSelect={onReset}
>
{t("colorPicker.resetTopPicks")}
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
);
};

View File

@@ -0,0 +1,107 @@
import {
isTransparent,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { ColorPickerColor, ColorPaletteCustom } from "@excalidraw/common";
import { atom } from "../../editor-jotai";
export const getColorNameAndShadeFromColor = ({
palette,
color,
}: {
palette: ColorPaletteCustom;
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);
if (shade > -1) {
return { colorName: colorName as ColorPickerColor, shade };
}
} else if (colorVal === color) {
return { colorName: colorName as ColorPickerColor, shade: null };
}
}
return null;
};
export const colorPickerHotkeyBindings = [
["q", "w", "e", "r", "t"],
["a", "s", "d", "f", "g"],
["z", "x", "c", "v", "b"],
].flat();
export const isCustomColor = ({
color,
palette,
}: {
color: string;
palette: ColorPaletteCustom;
}) => {
const paletteValues = Object.values(palette).flat();
return !paletteValues.includes(color);
};
export const getMostUsedCustomColors = (
elements: readonly ExcalidrawElement[],
type: "elementBackground" | "elementStroke",
palette: ColorPaletteCustom,
) => {
const elementColorTypeMap = {
elementBackground: "backgroundColor",
elementStroke: "strokeColor",
};
const colors = elements.filter((element) => {
if (element.isDeleted) {
return false;
}
const color =
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
// fully-transparent colors are "no color", not a reusable custom color
// (matters for palettes that exclude `transparent`, e.g. bucket fill)
return !isTransparent(color) && isCustomColor({ color, palette });
});
const colorCountMap = new Map<string, number>();
colors.forEach((element) => {
const color =
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
if (colorCountMap.has(color)) {
colorCountMap.set(color, colorCountMap.get(color)! + 1);
} else {
colorCountMap.set(color, 1);
}
});
return [...colorCountMap.entries()]
.sort((a, b) => b[1] - a[1])
.map((c) => c[0])
.slice(0, MAX_CUSTOM_COLORS_USED_IN_CANVAS);
};
export type ActiveColorPickerSectionAtomType =
| "custom"
| "baseColors"
| "shades"
| "hex"
| null;
export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null);
export type ColorPickerType =
| "canvasBackground"
| "elementBackground"
| "elementStroke";

View File

@@ -0,0 +1,318 @@
import { COLORS_PER_ROW, COLOR_PALETTE, KEYS } from "@excalidraw/common";
import type {
ColorPickerColor,
ColorPalette,
ColorPaletteCustom,
} from "@excalidraw/common";
import type { ValueOf } from "@excalidraw/common/utility-types";
import {
colorPickerHotkeyBindings,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import type { ActiveColorPickerSectionAtomType } from "./colorPickerUtils";
const arrowHandler = (
eventKey: string,
currentIndex: number | null,
length: number,
) => {
const rows = Math.ceil(length / COLORS_PER_ROW);
currentIndex = currentIndex ?? -1;
switch (eventKey) {
case "ArrowLeft": {
const prevIndex = currentIndex - 1;
return prevIndex < 0 ? length - 1 : prevIndex;
}
case "ArrowRight": {
return (currentIndex + 1) % length;
}
case "ArrowDown": {
const nextIndex = currentIndex + COLORS_PER_ROW;
return nextIndex >= length ? currentIndex % COLORS_PER_ROW : nextIndex;
}
case "ArrowUp": {
const prevIndex = currentIndex - COLORS_PER_ROW;
const newIndex =
prevIndex < 0 ? COLORS_PER_ROW * rows + prevIndex : prevIndex;
return newIndex >= length ? undefined : newIndex;
}
}
};
interface HotkeyHandlerProps {
e: React.KeyboardEvent;
colorObj: { colorName: ColorPickerColor; shade: number | null } | null;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
customColors: string[];
setActiveColorPickerSection: (
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
) => void;
activeShade: number;
excludedColors?: readonly string[];
}
/**
* @returns true if the event was handled
*/
const hotkeyHandler = ({
e,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
excludedColors,
}: HotkeyHandlerProps): boolean => {
if (colorObj?.shade != null) {
// shift + numpad is extremely messed up on windows apparently
if (
["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].includes(e.code) &&
e.shiftKey
) {
const newShade = Number(e.code.slice(-1)) - 1;
onChange(palette[colorObj.colorName][newShade]);
setActiveColorPickerSection("shades");
return true;
}
}
if (["1", "2", "3", "4", "5"].includes(e.key)) {
const c = customColors[Number(e.key) - 1];
if (c) {
onChange(customColors[Number(e.key) - 1]);
setActiveColorPickerSection("custom");
return true;
}
}
if (colorPickerHotkeyBindings.includes(e.key)) {
const index = colorPickerHotkeyBindings.indexOf(e.key);
const paletteKey = Object.keys(palette)[index] as keyof ColorPalette;
const paletteValue = palette[paletteKey];
const r = Array.isArray(paletteValue)
? paletteValue[activeShade]
: paletteValue;
// hotkeys of excluded (hidden) or absent palette entries are dead, not
// remapped — this keeps every other color on its usual key. Still
// HANDLED: within the modal's keyboard scope the key is deliberately
// inert, and reporting it unhandled would let it escape to global
// shortcuts (`q` toggles the tool lock)
if (r == null || excludedColors?.includes(r)) {
return true;
}
onChange(r);
setActiveColorPickerSection("baseColors");
return true;
}
return false;
};
interface ColorPickerKeyNavHandlerProps {
event: React.KeyboardEvent;
activeColorPickerSection: ActiveColorPickerSectionAtomType;
palette: ColorPaletteCustom;
color: string | null;
onChange: (color: string) => void;
customColors: string[];
setActiveColorPickerSection: (
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
) => void;
updateData: (formData?: any) => void;
activeShade: number;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
excludedColors?: readonly string[];
}
/**
* @returns true if the event was handled
*/
export const colorPickerKeyNavHandler = ({
event,
activeColorPickerSection,
palette,
color,
onChange,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
onEyeDropperToggle,
onEscape,
excludedColors,
}: ColorPickerKeyNavHandlerProps): boolean => {
if (event[KEYS.CTRL_OR_CMD]) {
return false;
}
if (event.key === KEYS.ESCAPE) {
onEscape(event);
return true;
}
// 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
> = {
custom: !!customColors.length,
baseColors: true,
shades: colorObj?.shade != null,
hex: true,
};
const sections = Object.entries(sectionsMap).reduce((acc, [key, value]) => {
if (value) {
acc.push(key as ActiveColorPickerSectionAtomType);
}
return acc;
}, [] as ActiveColorPickerSectionAtomType[]);
const activeSectionIndex = sections.indexOf(activeColorPickerSection);
const indexOffset = event.shiftKey ? -1 : 1;
const nextSectionIndex =
activeSectionIndex + indexOffset > sections.length - 1
? 0
: activeSectionIndex + indexOffset < 0
? sections.length - 1
: activeSectionIndex + indexOffset;
const nextSection = sections[nextSectionIndex];
if (nextSection) {
setActiveColorPickerSection(nextSection);
}
if (nextSection === "custom") {
onChange(customColors[0]);
} else if (nextSection === "baseColors") {
const baseColorName = (
Object.entries(palette) as [string, ValueOf<ColorPalette>][]
).find(([name, shades]) => {
if (Array.isArray(shades)) {
return shades.includes(color);
} else if (shades === color) {
return name;
}
return null;
});
if (!baseColorName) {
onChange(COLOR_PALETTE.black);
}
}
event.preventDefault();
event.stopPropagation();
return true;
}
if (
hotkeyHandler({
e: event,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
excludedColors,
})
) {
return true;
}
if (activeColorPickerSection === "shades") {
if (colorObj) {
const { shade } = colorObj;
const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW);
if (newShade !== undefined) {
onChange(palette[colorObj.colorName][newShade]);
return true;
}
}
}
if (activeColorPickerSection === "baseColors") {
if (colorObj) {
const { colorName } = colorObj;
const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
const indexOfColorName = colorNames.indexOf(colorName);
let newColorIndex = arrowHandler(
event.key,
indexOfColorName,
colorNames.length,
);
// step over excluded (hidden) palette entries, continuing in the
// arrow's direction
let guard = 0;
while (newColorIndex !== undefined && guard++ < colorNames.length) {
const value = palette[colorNames[newColorIndex]];
const resolved = Array.isArray(value) ? value[activeShade] : value;
if (!excludedColors?.includes(resolved)) {
break;
}
newColorIndex = arrowHandler(
event.key,
newColorIndex,
colorNames.length,
);
}
if (newColorIndex !== undefined) {
const newColorName = colorNames[newColorIndex];
const newColorNameValue = palette[newColorName];
onChange(
Array.isArray(newColorNameValue)
? newColorNameValue[activeShade]
: newColorNameValue,
);
return true;
}
}
}
if (activeColorPickerSection === "custom") {
const indexOfColor = color != null ? customColors.indexOf(color) : 0;
const newColorIndex = arrowHandler(
event.key,
indexOfColor,
customColors.length,
);
if (newColorIndex !== undefined) {
const newColor = customColors[newColorIndex];
onChange(newColor);
return true;
}
}
return false;
};

View File

@@ -0,0 +1,572 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { colorToHex, isTransparent } from "@excalidraw/common";
/**
* Custom (non-native) drag & drop for pinning colors to the color-picker
* top-picks strip and reordering the strip itself.
*
* Implemented with pointer events + a manually rendered "ghost" swatch so we
* control the visuals fully (native HTML5 dnd flickers and forces ugly
* ghosting/cursors).
*/
const GHOST_CLASS = "excalidraw-color-dnd-ghost";
const BODY_CLASS = "excalidraw-color-dnd-active";
const DRAG_THRESHOLD = 10;
/** a fast sloppy click can travel many px — releases faster than this stay
* clicks; the drag only starts once the pointer is held this long */
const DRAG_TIME_THRESHOLD_MS = 100;
/** value-equality of colors — normalizes notation (`#fff` vs `#ffffff` vs
* `white`) so visually identical colors can't occupy multiple pick slots */
const isSameColor = (a: string, b: string) => {
if (a.toLowerCase() === b.toLowerCase()) {
return true;
}
const aHex = colorToHex(a);
return aHex !== null && aHex === colorToHex(b);
};
type DragOrigin =
// a color dragged from the picker popup (palette/shades/custom) or the
// active-color trigger — dropping replaces the hovered pick
| { kind: "swatch" }
// a pick dragged from the top-picks strip itself — dropping reorders
| { kind: "pick"; index: number };
export type TopPicksDragState = {
color: string;
origin: DragOrigin;
/** hovered strip slot — the slot to replace (swatch drags) or the final
* position (pick reorders). null while the pointer is outside the strip */
overIndex: number | null;
/** index of an already-pinned identical color that blocks the drop */
duplicateIndex: number | null;
/** signed distance between strip slot centers (for reorder preview) */
slotSpan: number;
} | null;
type DragSession = {
pointerId: number;
startX: number;
startY: number;
startTime: number;
lastX: number;
lastY: number;
/** pending delayed activation (spatial threshold crossed before the
* temporal one) */
activationTimer: number | null;
color: string;
origin: DragOrigin;
sourceEl: HTMLElement;
sourceRect: DOMRect;
activated: boolean;
ghost: HTMLDivElement | null;
ghostW: number;
ghostH: number;
slotRects: DOMRect[];
slotSpan: number;
hitRect: { left: number; right: number; top: number; bottom: number } | null;
overIndex: number | null;
duplicateIndex: number | null;
};
export type ColorPickerDnD = {
dragState: TopPicksDragState;
startSwatchDrag: (event: React.PointerEvent, color: string | null) => void;
startPickDrag: (
event: React.PointerEvent,
index: number,
color: string,
) => void;
setStripEl: (el: HTMLDivElement | null) => void;
};
export const ColorPickerDnDContext = createContext<ColorPickerDnD | null>(null);
export const useColorPickerDnD = () => useContext(ColorPickerDnDContext);
export const useTopPicksDnD = ({
enabled,
picks,
onPicksChange,
}: {
enabled: boolean;
picks: readonly string[];
onPicksChange: (picks: string[]) => void;
}): ColorPickerDnD => {
const [dragState, setDragState] = useState<TopPicksDragState>(null);
const stripRef = useRef<HTMLDivElement | null>(null);
const latestRef = useRef({ enabled, picks, onPicksChange });
latestRef.current = { enabled, picks, onPicksChange };
const controller = useMemo(() => {
let session: DragSession | null = null;
const publish = () => {
if (session?.activated) {
setDragState({
color: session.color,
origin: session.origin,
overIndex: session.overIndex,
duplicateIndex: session.duplicateIndex,
slotSpan: session.slotSpan,
});
} else {
setDragState(null);
}
};
const measureStrip = () => {
const strip = stripRef.current;
if (!strip || !session) {
return false;
}
const buttons = Array.from(
strip.querySelectorAll<HTMLElement>("[data-top-pick-index]"),
).sort(
(a, b) =>
Number(a.dataset.topPickIndex) - Number(b.dataset.topPickIndex),
);
if (!buttons.length) {
return false;
}
session.slotRects = buttons.map((button) =>
button.getBoundingClientRect(),
);
const [first, second] = session.slotRects;
// strip is evenly spaced (space-between); sign flips under RTL
session.slotSpan = second ? second.left - first.left : first.width + 4;
const stripRect = strip.getBoundingClientRect();
const padX = Math.max(Math.abs(session.slotSpan) / 2, 10);
session.hitRect = {
left: stripRect.left - padX,
right: stripRect.right + padX,
top: stripRect.top - 14,
bottom: stripRect.bottom + 14,
};
return true;
};
const positionGhost = (x: number, y: number) => {
if (session?.ghost) {
session.ghost.style.transform = `translate(${
x - session.ghostW / 2
}px, ${y - session.ghostH / 2}px)`;
}
};
const setGhostSize = (
width: number,
height: number,
x: number,
y: number,
) => {
if (!session?.ghost) {
return;
}
if (session.ghostW !== width || session.ghostH !== height) {
session.ghostW = width;
session.ghostH = height;
session.ghost.style.width = `${width}px`;
session.ghost.style.height = `${height}px`;
positionGhost(x, y);
}
};
const activate = (x: number, y: number) => {
if (!session) {
return;
}
const { sourceRect, sourceEl, color } = session;
const ghost = document.createElement("div");
ghost.className = GHOST_CLASS;
const swatch = document.createElement("div");
swatch.className = `${GHOST_CLASS}__swatch`;
if (isTransparent(color)) {
swatch.classList.add("is-transparent");
} else {
// swatches render the theme-adjusted color (dark mode remaps colors
// rather than CSS-filtering them) — sample the rendered color so the
// ghost matches what the user picked up
const rendered = getComputedStyle(sourceEl).backgroundColor;
swatch.style.backgroundColor =
rendered && rendered !== "rgba(0, 0, 0, 0)" ? rendered : color;
}
ghost.appendChild(swatch);
document.body.appendChild(ghost);
session.ghost = ghost;
session.ghostW = sourceRect.width;
session.ghostH = sourceRect.height;
ghost.style.width = `${sourceRect.width}px`;
ghost.style.height = `${sourceRect.height}px`;
positionGhost(x, y);
// let the spawn frame paint at rest, then "lift" (scale-up transition)
requestAnimationFrame(() => {
// unless the drag already ended — don't restyle a ghost that's
// mid-flight in its release animation (or already removed)
if (session?.ghost === ghost) {
ghost.classList.add(`${GHOST_CLASS}--lifted`);
}
});
document.body.classList.add(BODY_CLASS);
session.activated = true;
publish();
};
const hitTest = (x: number, y: number) => {
if (!session?.hitRect) {
return;
}
const { hitRect, slotRects } = session;
let overIndex: number | null = null;
let duplicateIndex: number | null = null;
if (
x >= hitRect.left &&
x <= hitRect.right &&
y >= hitRect.top &&
y <= hitRect.bottom
) {
let best = 0;
let bestDistance = Infinity;
slotRects.forEach((rect, index) => {
const distance = Math.abs(x - (rect.left + rect.width / 2));
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
});
if (session.origin.kind === "swatch") {
const duplicate = latestRef.current.picks.findIndex((pick) =>
isSameColor(pick, session!.color),
);
if (duplicate !== -1) {
duplicateIndex = duplicate;
} else {
overIndex = best;
}
} else {
overIndex = best;
}
}
if (
overIndex !== session.overIndex ||
duplicateIndex !== session.duplicateIndex
) {
session.overIndex = overIndex;
session.duplicateIndex = duplicateIndex;
const { ghost } = session;
if (ghost) {
ghost.classList.toggle(`${GHOST_CLASS}--over`, overIndex !== null);
ghost.classList.toggle(
`${GHOST_CLASS}--blocked`,
duplicateIndex !== null,
);
}
publish();
}
// over the strip the ghost morphs to slot size to preview the landing
if (overIndex !== null) {
const rect = session.slotRects[overIndex];
setGhostSize(rect.width, rect.height, x, y);
} else {
setGhostSize(session.sourceRect.width, session.sourceRect.height, x, y);
}
};
const releaseGhost = (target: { rect: DOMRect | null }) => {
if (!session?.ghost) {
return;
}
const { ghost } = session;
session.ghost = null;
ghost.classList.add(`${GHOST_CLASS}--dropping`);
const rect = target.rect;
if (rect) {
ghost.style.width = `${rect.width}px`;
ghost.style.height = `${rect.height}px`;
ghost.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
}
window.setTimeout(() => {
ghost.classList.add(`${GHOST_CLASS}--fade`);
}, 160);
window.setTimeout(() => {
ghost.remove();
}, 340);
};
// kill any in-flight (or retargetable) reorder-preview transitions before
// the drag state is torn down. Running CSS transitions survive both the
// removal of the `transition` property (spec: transition-* changes don't
// affect running transitions) and React's keyed DOM reorder on commit —
// the browser retargets them, which made the picks visibly re-shift on
// drop. Clearing the transforms with transitions disabled (+ forced
// reflow) cancels them for good, so the commit paints the final order
// directly.
const settleStripInstantly = () => {
const strip = stripRef.current;
if (!strip || !session?.activated) {
return;
}
const buttons = Array.from(
strip.querySelectorAll<HTMLElement>("[data-top-pick-index]"),
);
for (const button of buttons) {
button.style.transition = "none";
button.style.transform = "none";
}
// flush the non-animated state...
void strip.offsetWidth;
// ...then let the stylesheet govern transitions again (next drag)
for (const button of buttons) {
button.style.transition = "";
}
};
const suppressNextClick = () => {
const suppress = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("click", suppress, { capture: true, once: true });
window.setTimeout(() => {
window.removeEventListener("click", suppress, { capture: true });
}, 100);
};
const removeListeners = () => {
window.removeEventListener("pointermove", onPointerMove, true);
window.removeEventListener("pointerup", onPointerUp, true);
window.removeEventListener("pointercancel", onPointerCancel, true);
window.removeEventListener("keydown", onKeyDown, true);
};
const dispose = () => {
removeListeners();
document.body.classList.remove(BODY_CLASS);
if (session?.activationTimer != null) {
window.clearTimeout(session.activationTimer);
}
session = null;
setDragState(null);
};
const cancelDrag = (animate: boolean) => {
if (!session) {
return;
}
settleStripInstantly();
if (session.ghost) {
if (animate) {
releaseGhost({ rect: session.sourceRect });
} else {
session.ghost.remove();
session.ghost = null;
}
}
dispose();
};
const tryActivate = (x: number, y: number) => {
if (!session || session.activated) {
return;
}
if (!measureStrip()) {
dispose();
return;
}
activate(x, y);
hitTest(x, y);
};
const onPointerMove = (event: PointerEvent) => {
if (!session || event.pointerId !== session.pointerId) {
return;
}
session.lastX = event.clientX;
session.lastY = event.clientY;
if (!session.activated) {
if (
Math.hypot(
event.clientX - session.startX,
event.clientY - session.startY,
) < DRAG_THRESHOLD
) {
// back inside the click tolerance — a delayed activation scheduled
// while we were beyond it no longer applies (wiggle-and-return
// must stay a click)
if (session.activationTimer !== null) {
window.clearTimeout(session.activationTimer);
session.activationTimer = null;
}
return;
}
const elapsed = performance.now() - session.startTime;
if (elapsed < DRAG_TIME_THRESHOLD_MS) {
// spatial threshold crossed, temporal not yet — likely a fast
// sloppy click. Wait out the rest of the grace period; the timeout
// covers "flick then hold still", where no further moves fire
if (session.activationTimer === null) {
session.activationTimer = window.setTimeout(() => {
if (session) {
session.activationTimer = null;
// the pointer may have returned inside the tolerance after
// the last event we saw — never activate from within it
if (
Math.hypot(
session.lastX - session.startX,
session.lastY - session.startY,
) >= DRAG_THRESHOLD
) {
tryActivate(session.lastX, session.lastY);
}
}
}, DRAG_TIME_THRESHOLD_MS - elapsed);
}
return;
}
tryActivate(event.clientX, event.clientY);
if (!session?.activated) {
return;
}
}
event.preventDefault();
positionGhost(event.clientX, event.clientY);
hitTest(event.clientX, event.clientY);
};
const onPointerUp = (event: PointerEvent) => {
if (!session || event.pointerId !== session.pointerId) {
return;
}
if (!session.activated) {
// never became a drag — let the regular click happen
dispose();
return;
}
event.preventDefault();
event.stopPropagation();
suppressNextClick();
const { overIndex, origin, color, slotRects } = session;
const { picks, onPicksChange } = latestRef.current;
settleStripInstantly();
if (overIndex !== null) {
if (origin.kind === "swatch") {
if (!picks.some((pick) => isSameColor(pick, color))) {
const next = [...picks];
next[overIndex] = color;
onPicksChange(next);
}
} else if (origin.index !== overIndex) {
const next = [...picks];
const [moved] = next.splice(origin.index, 1);
next.splice(overIndex, 0, moved);
onPicksChange(next);
}
releaseGhost({ rect: slotRects[overIndex] });
} else {
releaseGhost({ rect: session.sourceRect });
}
dispose();
};
const onPointerCancel = (event: PointerEvent) => {
if (session && event.pointerId === session.pointerId) {
cancelDrag(true);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (session?.activated && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancelDrag(true);
}
};
const begin = (
event: React.PointerEvent,
color: string | null,
origin: DragOrigin,
) => {
if (
!latestRef.current.enabled ||
session ||
event.button !== 0 ||
!color
) {
return;
}
session = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startTime: performance.now(),
lastX: event.clientX,
lastY: event.clientY,
activationTimer: null,
color,
origin,
sourceEl: event.currentTarget as HTMLElement,
sourceRect: (
event.currentTarget as HTMLElement
).getBoundingClientRect(),
activated: false,
ghost: null,
ghostW: 0,
ghostH: 0,
slotRects: [],
slotSpan: 0,
hitRect: null,
overIndex: null,
duplicateIndex: null,
};
window.addEventListener("pointermove", onPointerMove, true);
window.addEventListener("pointerup", onPointerUp, true);
window.addEventListener("pointercancel", onPointerCancel, true);
window.addEventListener("keydown", onKeyDown, true);
};
return {
begin,
destroy: () => cancelDrag(false),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// cancel a drag in flight if the picker unmounts mid-drag
useEffect(() => () => controller.destroy(), [controller]);
return useMemo(
() => ({
dragState,
startSwatchDrag: (event, color) =>
controller.begin(event, color, { kind: "swatch" }),
startPickDrag: (event, index, color) =>
controller.begin(event, color, { kind: "pick", index }),
setStripEl: (el) => {
stripRef.current = el;
},
}),
[dragState, controller],
);
};