snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
155
packages/excalidraw/components/ColorPicker/ColorInput.tsx
Normal file
155
packages/excalidraw/components/ColorPicker/ColorInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
598
packages/excalidraw/components/ColorPicker/ColorPicker.scss
Normal file
598
packages/excalidraw/components/ColorPicker/ColorPicker.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
539
packages/excalidraw/components/ColorPicker/ColorPicker.tsx
Normal file
539
packages/excalidraw/components/ColorPicker/ColorPicker.tsx
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
27
packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx
Normal file
27
packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx
Normal 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;
|
||||
227
packages/excalidraw/components/ColorPicker/Picker.tsx
Normal file
227
packages/excalidraw/components/ColorPicker/Picker.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
132
packages/excalidraw/components/ColorPicker/PickerColorList.tsx
Normal file
132
packages/excalidraw/components/ColorPicker/PickerColorList.tsx
Normal 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
||||
<div className="color-picker__heading">{children}</div>
|
||||
);
|
||||
|
||||
export default PickerHeading;
|
||||
137
packages/excalidraw/components/ColorPicker/ShadeList.tsx
Normal file
137
packages/excalidraw/components/ColorPicker/ShadeList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
184
packages/excalidraw/components/ColorPicker/TopPicks.tsx
Normal file
184
packages/excalidraw/components/ColorPicker/TopPicks.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
107
packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
Normal file
107
packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
Normal 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";
|
||||
@@ -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;
|
||||
};
|
||||
572
packages/excalidraw/components/ColorPicker/topPicksDnD.tsx
Normal file
572
packages/excalidraw/components/ColorPicker/topPicksDnD.tsx
Normal 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],
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user