mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
feat: Add color swatch hover card (#13192)
* feat: Add color swatch hover card * refactor
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import type { ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "../../styles";
|
||||
import type { EditorNotice } from "../types";
|
||||
import { ColorPreview } from "./ColorPreview";
|
||||
|
||||
interface Props {
|
||||
/** The CSS color to preview, in its original notation. */
|
||||
color: string;
|
||||
/** Whether the card is currently shown. */
|
||||
open: boolean;
|
||||
/** Called when the card is dismissed by Radix, for example on Escape. */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called when the pointer enters the card. */
|
||||
onMouseEnter: () => void;
|
||||
/** Called when the pointer leaves the card. */
|
||||
onMouseLeave: () => void;
|
||||
/** Callback used to surface a notice to the user. */
|
||||
onNotice?: EditorNotice;
|
||||
/** The swatch the card is anchored to. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hover card shown alongside a color swatch. It lives in its own module so
|
||||
* that its browser-only dependency on Radix is loaded lazily and stays out of
|
||||
* the editor schema graph, which is also imported on the server.
|
||||
*
|
||||
* @returns the popover wrapping the provided swatch.
|
||||
*/
|
||||
export default function ColorHoverCard({
|
||||
color,
|
||||
open,
|
||||
onOpenChange,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onNotice,
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverPrimitive.Trigger asChild>{children}</PopoverPrimitive.Trigger>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
asChild
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Card onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
|
||||
<ColorPreview color={color} onNotice={onNotice} />
|
||||
</Card>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const Card = styled.div`
|
||||
/* Sized to the widest notation so that no value has to wrap. */
|
||||
width: max-content;
|
||||
min-width: 180px;
|
||||
padding: 8px;
|
||||
z-index: ${depths.modal};
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,150 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { darken } from "polished";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "../../styles";
|
||||
import { toColorFormats } from "../../utils/color";
|
||||
import type { EditorNotice } from "../types";
|
||||
|
||||
interface Props {
|
||||
/** The CSS color to preview, in its original notation. */
|
||||
color: string;
|
||||
/** Callback used to surface a notice to the user. */
|
||||
onNotice?: EditorNotice;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contents of the color hover card – a large preview of the color above the
|
||||
* equivalent hex, rgb, and hsl notations. Clicking a notation copies it.
|
||||
*/
|
||||
export function ColorPreview({ color, onNotice }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const preview = useMemo(() => {
|
||||
try {
|
||||
return {
|
||||
formats: toColorFormats(color),
|
||||
// A darker shade of the color itself keeps the edge from reading as a
|
||||
// separate element, whatever the color.
|
||||
borderColor: darken(0.1, color),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
copy(event.currentTarget.value);
|
||||
onNotice?.(t("Copied to clipboard"));
|
||||
},
|
||||
[onNotice, t]
|
||||
);
|
||||
|
||||
if (!preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { formats, borderColor } = preview;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Preview style={{ borderColor }}>
|
||||
<PreviewColor style={{ backgroundColor: color }} />
|
||||
</Preview>
|
||||
<Formats>
|
||||
{(["hex", "rgb", "hsl"] as const).map((format) => (
|
||||
<Format
|
||||
key={format}
|
||||
type="button"
|
||||
value={formats[format]}
|
||||
title={t("Click to copy")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Label>{format.toUpperCase()}</Label>
|
||||
<Value>{formats[format]}</Value>
|
||||
</Format>
|
||||
))}
|
||||
</Formats>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Preview = styled.div`
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
/* Checkerboard so that translucent colors read as translucent. */
|
||||
background-color: ${s("background")};
|
||||
background-image:
|
||||
linear-gradient(45deg, ${s("inputBorder")} 25%, transparent 25%),
|
||||
linear-gradient(-45deg, ${s("inputBorder")} 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, ${s("inputBorder")} 75%),
|
||||
linear-gradient(-45deg, transparent 75%, ${s("inputBorder")} 75%);
|
||||
background-size: 12px 12px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 6px,
|
||||
6px -6px,
|
||||
-6px 0px;
|
||||
`;
|
||||
|
||||
const PreviewColor = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const Formats = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* The negative bottom margin keeps 8px below the last row of text. */
|
||||
margin: 4px 0 -4px;
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: ${s("textTertiary")};
|
||||
transition: color 100ms ease;
|
||||
`;
|
||||
|
||||
const Value = styled.span`
|
||||
font-family: ${s("fontFamilyMono")};
|
||||
font-size: 12px;
|
||||
color: ${s("textSecondary")};
|
||||
white-space: nowrap;
|
||||
user-select: all;
|
||||
transition: color 100ms ease;
|
||||
`;
|
||||
|
||||
const Format = styled.button`
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover {
|
||||
${Label} {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
${Value} {
|
||||
color: ${s("text")};
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,10 +1,27 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import type { EditorNotice } from "../types";
|
||||
|
||||
// Loaded lazily so its browser-only dependency on Radix doesn't enter the
|
||||
// editor schema's static import graph, which is also used on the server.
|
||||
const ColorHoverCard = lazy(() => import("./ColorHoverCard"));
|
||||
|
||||
/** Time in ms the pointer must rest on the swatch before the card opens. */
|
||||
const OPEN_DELAY = 400;
|
||||
|
||||
/** Time in ms the card stays open after the pointer leaves, to allow travel. */
|
||||
const CLOSE_DELAY = 200;
|
||||
|
||||
interface Props {
|
||||
/** The CSS color the swatch represents, in its original notation. */
|
||||
color: string;
|
||||
@@ -16,10 +33,31 @@ interface Props {
|
||||
|
||||
/**
|
||||
* A small colored circle rendered after a CSS color inside inline code. Clicking
|
||||
* it copies the color to the clipboard.
|
||||
* it copies the color to the clipboard, hovering reveals a larger preview and
|
||||
* the color translated into other notations.
|
||||
*/
|
||||
export function ColorSwatch({ color, luminance, onNotice }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const scheduleOpen = useCallback((value: boolean, delay: number) => {
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => setOpen(value), delay);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimeout(timeout.current), []);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setHovered(true);
|
||||
scheduleOpen(true, OPEN_DELAY);
|
||||
}, [scheduleOpen]);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
() => scheduleOpen(false, CLOSE_DELAY),
|
||||
[scheduleOpen]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback((event: MouseEvent) => {
|
||||
// Prevent the editor from moving the cursor into the code mark on click.
|
||||
@@ -36,27 +74,46 @@ export function ColorSwatch({ color, luminance, onNotice }: Props) {
|
||||
[color, onNotice, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Swatch
|
||||
const trigger = (
|
||||
<SwatchTarget
|
||||
aria-hidden="true"
|
||||
title={t("Click to copy")}
|
||||
$luminance={luminance}
|
||||
style={{ backgroundColor: color }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
>
|
||||
<Swatch $luminance={luminance} style={{ backgroundColor: color }} />
|
||||
</SwatchTarget>
|
||||
);
|
||||
|
||||
// A document can contain many swatches, so the card is only loaded and
|
||||
// mounted once the pointer has actually reached one.
|
||||
if (!hovered) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={trigger}>
|
||||
<ColorHoverCard
|
||||
color={color}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onNotice={onNotice}
|
||||
>
|
||||
{trigger}
|
||||
</ColorHoverCard>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const Swatch = styled.span<{ $luminance: number }>`
|
||||
display: inline-block;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
margin-left: 0.3em;
|
||||
vertical-align: -0.05em;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-clip: padding-box;
|
||||
cursor: var(--pointer);
|
||||
transition: transform 100ms ease;
|
||||
|
||||
/* Outline colors that would otherwise blend into the current background. */
|
||||
@@ -65,12 +122,25 @@ const Swatch = styled.span<{ $luminance: number }>`
|
||||
css`
|
||||
outline: 1px solid ${props.theme.codeBorder};
|
||||
`}
|
||||
`;
|
||||
|
||||
&:hover {
|
||||
/**
|
||||
* Wraps the swatch at a fixed size so that scaling on hover doesn't move the
|
||||
* bounds the hover card is positioned against.
|
||||
*/
|
||||
const SwatchTarget = styled.span`
|
||||
display: inline-block;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
margin-left: 0.3em;
|
||||
vertical-align: -0.05em;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover ${Swatch} {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
&:active ${Swatch} {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { validateColorHex } from "./color";
|
||||
import { toColorFormats, validateColorHex } from "./color";
|
||||
|
||||
describe("validateColorHex", () => {
|
||||
it("accepts 3-digit hex", () => {
|
||||
@@ -54,3 +54,49 @@ describe("validateColorHex", () => {
|
||||
expect(validateColorHex("#fff;")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toColorFormats", () => {
|
||||
it("translates hex into rgb and hsl", () => {
|
||||
expect(toColorFormats("#ff0000")).toEqual({
|
||||
hex: "#FF0000",
|
||||
rgb: "rgb(255, 0, 0)",
|
||||
hsl: "hsl(0, 100%, 50%)",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns shorthand hex without expanding it", () => {
|
||||
expect(toColorFormats("#fff")).toEqual({
|
||||
hex: "#FFF",
|
||||
rgb: "rgb(255, 255, 255)",
|
||||
hsl: "hsl(0, 0%, 100%)",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the input notation unchanged", () => {
|
||||
expect(toColorFormats("hsl(68, 69%, 45%)").hsl).toBe("hsl(68, 69%, 45%)");
|
||||
expect(toColorFormats("rgba(0,0,0,.25)").rgb).toBe("rgba(0,0,0,.25)");
|
||||
expect(toColorFormats("hsla(0, 0%, 0%, 0.253)").hsl).toBe(
|
||||
"hsla(0, 0%, 0%, 0.253)"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves alpha across notations", () => {
|
||||
expect(toColorFormats("#ff000080")).toEqual({
|
||||
hex: "#FF000080",
|
||||
rgb: "rgba(255, 0, 0, 0.5)",
|
||||
hsl: "hsla(0, 100%, 50%, 0.5)",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts rgb and hsl input", () => {
|
||||
expect(toColorFormats("rgb(0, 0, 255)").hex).toBe("#0000FF");
|
||||
expect(toColorFormats("hsl(120, 100%, 50%)").hex).toBe("#00FF00");
|
||||
expect(toColorFormats("rgba(0, 0, 0, 0.25)").hsl).toBe(
|
||||
"hsla(0, 0%, 0%, 0.25)"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for values that are not colors", () => {
|
||||
expect(() => toColorFormats("rgb(foo)")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+52
-1
@@ -1,5 +1,5 @@
|
||||
import md5 from "crypto-js/md5";
|
||||
import { darken, parseToRgb } from "polished";
|
||||
import { darken, parseToHsl, parseToRgb } from "polished";
|
||||
import theme from "../styles/theme";
|
||||
import type { RgbaColor } from "polished/lib/types/color";
|
||||
|
||||
@@ -67,6 +67,57 @@ export const rgbaToHex = ({ red, green, blue, alpha }: RgbaColor): string => {
|
||||
return "#" + toHex(red) + toHex(green) + toHex(blue) + alphaHex;
|
||||
};
|
||||
|
||||
export interface ColorFormats {
|
||||
/** The color in uppercase hex notation, with an alpha pair when translucent. */
|
||||
hex: string;
|
||||
/** The color in `rgb()` notation, or `rgba()` when translucent. */
|
||||
rgb: string;
|
||||
/** The color in `hsl()` notation, or `hsla()` when translucent. */
|
||||
hsl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a CSS color into the equivalent hex, rgb, and hsl notations. The
|
||||
* notation the color was given in is returned unchanged, so that no precision
|
||||
* is lost to a round trip through another color space.
|
||||
*
|
||||
* @param color - a color string in any notation understood by polished.
|
||||
* @returns the same color expressed in each notation.
|
||||
* @throws if the string is not a valid CSS color.
|
||||
*/
|
||||
export const toColorFormats = (color: string): ColorFormats => {
|
||||
const rgb = parseToRgb(color);
|
||||
const hsl = parseToHsl(color);
|
||||
const alpha = "alpha" in rgb && rgb.alpha !== undefined ? rgb.alpha : 1;
|
||||
|
||||
const hue = round(hsl.hue);
|
||||
const saturation = round(hsl.saturation * 100);
|
||||
const lightness = round(hsl.lightness * 100);
|
||||
const opacity = round(alpha, 2);
|
||||
|
||||
const formats: ColorFormats = {
|
||||
hex: rgbaToHex({ ...rgb, alpha }).toUpperCase(),
|
||||
rgb:
|
||||
alpha < 1
|
||||
? `rgba(${rgb.red}, ${rgb.green}, ${rgb.blue}, ${opacity})`
|
||||
: `rgb(${rgb.red}, ${rgb.green}, ${rgb.blue})`,
|
||||
hsl:
|
||||
alpha < 1
|
||||
? `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`
|
||||
: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
|
||||
};
|
||||
|
||||
if (validateColorHex(color)) {
|
||||
formats.hex = color.toUpperCase();
|
||||
} else if (/^rgba?\(/i.test(color)) {
|
||||
formats.rgb = color;
|
||||
} else if (/^hsla?\(/i.test(color)) {
|
||||
formats.hsl = color;
|
||||
}
|
||||
|
||||
return formats;
|
||||
};
|
||||
|
||||
interface PresetColor {
|
||||
hex: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user