[Refactoring] Prettify color convertion utils

This commit is contained in:
Alexander Zinchuk 2025-10-14 03:23:20 +02:00
parent 9ea1d5c710
commit 3fd03fbe61
15 changed files with 190 additions and 182 deletions

View File

@ -22,7 +22,7 @@ import type {
MediaContent,
} from '../../types';
import { numberToHexColor } from '../../../util/colors';
import { int2hex } from '../../../util/colors';
import { pick } from '../../../util/iteratees';
import { toJSNumber } from '../../../util/numbers';
import { addDocumentToLocalDb } from '../helpers/localDb';
@ -380,10 +380,10 @@ export function buildApiBotInfo(botInfo: GramJs.BotInfo, chatId: string): ApiBot
export function buildBotAppSettings(settings: GramJs.BotAppSettings): ApiBotAppSettings {
const placeholderPath = settings.placeholderPath && buildSvgPath(settings.placeholderPath);
return {
backgroundColor: settings.backgroundColor ? numberToHexColor(settings.backgroundColor) : undefined,
backgroundDarkColor: settings.backgroundDarkColor ? numberToHexColor(settings.backgroundDarkColor) : undefined,
headerColor: settings.headerColor ? numberToHexColor(settings.headerColor) : undefined,
headerDarkColor: settings.headerDarkColor ? numberToHexColor(settings.headerDarkColor) : undefined,
backgroundColor: settings.backgroundColor ? int2hex(settings.backgroundColor) : undefined,
backgroundDarkColor: settings.backgroundDarkColor ? int2hex(settings.backgroundDarkColor) : undefined,
headerColor: settings.headerColor ? int2hex(settings.headerColor) : undefined,
headerDarkColor: settings.headerDarkColor ? int2hex(settings.headerDarkColor) : undefined,
placeholderPath,
};
}

View File

@ -12,7 +12,7 @@ import type {
ApiTypeResaleStarGifts,
} from '../../types';
import { numberToHexColor } from '../../../util/colors';
import { int2hex } from '../../../util/colors';
import { toJSNumber } from '../../../util/numbers';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { addDocumentToLocalDb } from '../helpers/localDb';
@ -131,10 +131,10 @@ export function buildApiStarGiftAttribute(attribute: GramJs.TypeStarGiftAttribut
backdropId,
name,
rarityPercent: rarityPermille / 10,
centerColor: numberToHexColor(centerColor),
edgeColor: numberToHexColor(edgeColor),
patternColor: numberToHexColor(patternColor),
textColor: numberToHexColor(textColor),
centerColor: int2hex(centerColor),
edgeColor: int2hex(edgeColor),
patternColor: int2hex(patternColor),
textColor: int2hex(textColor),
};
}

View File

@ -11,7 +11,7 @@ import type {
} from '../../types';
import { CHANNEL_ID_BASE } from '../../../config';
import { numberToHexColor } from '../../../util/colors';
import { int2hex } from '../../../util/colors';
import { buildCollectionByCallback } from '../../../util/iteratees';
type TypePeerOrInput = GramJs.TypePeer | GramJs.TypeInputPeer | GramJs.TypeInputUser | GramJs.TypeInputChannel;
@ -59,14 +59,14 @@ export function buildApiPeerColor(peerColor: GramJs.TypePeerColor): ApiPeerColor
}
function buildApiPeerColorSet(colorSet: GramJs.help.PeerColorSet) {
return colorSet.colors.map((color) => numberToHexColor(color));
return colorSet.colors.map((color) => int2hex(color));
}
function buildApiPeerProfileColorSet(colorSet: GramJs.help.PeerColorProfileSet): ApiPeerProfileColorSet {
return {
paletteColors: colorSet.paletteColors.map((color) => numberToHexColor(color)),
bgColors: colorSet.bgColors.map((color) => numberToHexColor(color)),
storyColors: colorSet.storyColors.map((color) => numberToHexColor(color)),
paletteColors: colorSet.paletteColors.map((color) => int2hex(color)),
bgColors: colorSet.bgColors.map((color) => int2hex(color)),
storyColors: colorSet.storyColors.map((color) => int2hex(color)),
};
}
@ -116,10 +116,10 @@ ApiEmojiStatusType | undefined {
title: mtpEmojiStatus.title,
slug: mtpEmojiStatus.slug,
patternDocumentId: mtpEmojiStatus.patternDocumentId.toString(),
centerColor: numberToHexColor(mtpEmojiStatus.centerColor),
edgeColor: numberToHexColor(mtpEmojiStatus.edgeColor),
patternColor: numberToHexColor(mtpEmojiStatus.patternColor),
textColor: numberToHexColor(mtpEmojiStatus.textColor),
centerColor: int2hex(mtpEmojiStatus.centerColor),
edgeColor: int2hex(mtpEmojiStatus.edgeColor),
patternColor: int2hex(mtpEmojiStatus.patternColor),
textColor: int2hex(mtpEmojiStatus.textColor),
until: mtpEmojiStatus.until,
};
}

View File

@ -15,8 +15,8 @@ import { ensureRLottie, getRLottie } from '../../lib/rlottie/RLottie.async';
import { IS_TAURI } from '../../util/browser/globalEnvironment';
import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
import { hex2rgbaObj } from '../../util/colors.ts';
import generateUniqueId from '../../util/generateUniqueId';
import { hexToRgb } from '../../util/switchTheme';
import useColorFilter from '../../hooks/stickers/useColorFilter';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
@ -116,7 +116,7 @@ const AnimatedSticker: FC<OwnProps> = ({
useSyncEffect(() => {
if (color && !shouldUseColorFilter) {
const { r, g, b } = hexToRgb(color);
const { r, g, b } = hex2rgbaObj(color);
rgbColor.current = [r, g, b];
} else {
rgbColor.current = undefined;

View File

@ -6,8 +6,8 @@ import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getStickerMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { hex2rgba, rgba2hex } from '../../../util/colors.ts';
import { preloadImage } from '../../../util/files';
import { hexToRgb } from '../../../util/switchTheme.ts';
import { REM } from '../helpers/mediaDimensions';
import useLastCallback from '../../../hooks/useLastCallback';
@ -201,17 +201,12 @@ export default memo(RadialPatternBackground);
function adjustBrightness(hex: string, delta: number) {
const factor = 1 + delta;
const rgba = hexToRgb(hex);
const darkenedRgba = [
Math.min(255, Math.round(rgba.r * factor)),
Math.min(Math.round(rgba.g * factor)),
Math.min(Math.round(rgba.b * factor)),
rgba.a ?? 1,
] as const;
const [r, g, b, a] = hex2rgba(hex);
return rgbaToHex(...darkenedRgba);
}
function rgbaToHex(r: number, g: number, b: number, a: number) {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}${Math.round(a * 255).toString(16)}`;
return rgba2hex([
Math.min(255, Math.round(r * factor)),
Math.min(255, Math.round(g * factor)),
Math.min(255, Math.round(b * factor)),
a ?? 255,
]);
}

View File

@ -99,10 +99,12 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
const currentWallpaper = loadedWallpapers && loadedWallpapers.find((wallpaper) => wallpaper.slug === slug);
if (currentWallpaper?.document.thumbnail) {
getAverageColor(currentWallpaper.document.thumbnail.dataUri)
.then((color) => {
const patternColor = getPatternColor(color);
const rgbColor = `#${rgb2hex(color)}`;
setThemeSettings({ theme: themeRef.current!, backgroundColor: rgbColor, patternColor });
.then((averageColor) => {
setThemeSettings({
theme: themeRef.current!,
backgroundColor: rgb2hex(averageColor),
patternColor: getPatternColor(averageColor),
});
});
}
}, [loadedWallpapers, setThemeSettings]);

View File

@ -13,7 +13,7 @@ import { selectTheme, selectThemeValues } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { captureEvents } from '../../../util/captureEvents';
import {
getPatternColor, hex2rgb, hsb2rgb, rgb2hex, rgb2hsb,
getPatternColor, hex2rgb, hsv2rgb, rgb2hex, rgb2hsv,
} from '../../../util/colors';
import { pick } from '../../../util/iteratees';
@ -47,7 +47,7 @@ interface CanvasRects {
};
}
const DEFAULT_HSB = rgb2hsb(hex2rgb('e6ebee'));
const DEFAULT_HSV = rgb2hsv(hex2rgb('e6ebee'));
const PREDEFINED_COLORS = [
'#e6ebee', '#b2cee1', '#008dd0', '#c6e7cb', '#c4e1a6', '#60b16e',
'#ccd0af', '#a6a997', '#7a7072', '#fdd7af', '#fdb76e', '#dd8851',
@ -68,12 +68,12 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
const huePickerRef = useRef<HTMLDivElement>();
const isFirstRunRef = useRef(true);
const [hsb, setHsb] = useState(getInitialHsb(backgroundColor));
const [hsv, setHsv] = useState(getInitialHsv(backgroundColor));
// Cache for drag handlers
const hsbRef = useRef(hsb);
const hsvRef = useRef(hsv);
useEffect(() => {
hsbRef.current = hsb;
}, [hsb]);
hsvRef.current = hsv;
}, [hsv]);
const [isDragging, markIsDragging, unmarkIsDragging] = useFlag();
const [rgbInput, setRgbInput] = useState('');
@ -102,9 +102,9 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
Math.min(Math.max(0, e.pageY! - colorRect.top + containerRef.current!.scrollTop), colorRect.height - 1),
];
const { huePosition } = hsb2positions(hsbRef.current, rectsRef.current!);
const { huePosition } = hsv2positions(hsvRef.current, rectsRef.current!);
setHsb(positions2hsb({ colorPosition, huePosition }, rectsRef.current!));
setHsv(positions2hsv({ colorPosition, huePosition }, rectsRef.current!));
markIsDragging();
return true;
@ -120,10 +120,10 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
});
function handleHueDrag(e: MouseEvent | RealTouchEvent) {
const { colorPosition } = hsb2positions(hsbRef.current, rectsRef.current!);
const { colorPosition } = hsv2positions(hsvRef.current, rectsRef.current!);
const huePosition = Math.min(Math.max(0, e.pageX! - hueRect.offsetLeft), hueRect.width - 1);
setHsb(positions2hsb({ colorPosition, huePosition }, rectsRef.current!));
setHsv(positions2hsv({ colorPosition, huePosition }, rectsRef.current!));
markIsDragging();
return true;
@ -139,15 +139,15 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
});
}, [markIsDragging, unmarkIsDragging]);
const { colorPosition = [0, 0], huePosition = 0 } = rectsRef.current ? hsb2positions(hsb, rectsRef.current) : {};
const hex = rgb2hex(hsb2rgb(hsb));
const hue = hsb[0];
const hueHex = rgb2hex(hsb2rgb([hue, 1, 1]));
const { colorPosition = [0, 0], huePosition = 0 } = rectsRef.current ? hsv2positions(hsv, rectsRef.current) : {};
const hex = rgb2hex(hsv2rgb(hsv));
const hue = hsv[0];
const hueHex = rgb2hex(hsv2rgb([hue, 1, 1]));
// Save value and update inputs when HSL changes
useEffect(() => {
const rgb = hsb2rgb(hsb);
const color = `#${rgb2hex(rgb)}`;
const rgb = hsv2rgb(hsv);
const color = rgb2hex(rgb);
setRgbInput(rgb.join(', '));
setHexInput(color);
@ -162,7 +162,7 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
});
}
isFirstRunRef.current = false;
}, [hsb, setThemeSettings]);
}, [hsv, setThemeSettings]);
// Redraw color picker when hue changes
useEffect(() => {
@ -179,7 +179,7 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
if (rgbValue.match(/^\d{1,3},\s?\d{1,3},\s?\d{1,3}$/)) {
const rgb = rgbValue.split(',').map((channel) => Number(channel.trim())) as [number, number, number];
setHsb(rgb2hsb(rgb));
setHsv(rgb2hsv(rgb));
}
e.currentTarget.value = rgbValue;
@ -189,14 +189,14 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
const hexValue = e.currentTarget.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
if (hexValue.match(/^#?[0-9a-fA-F]{6}$/)) {
setHsb(rgb2hsb(hex2rgb(hexValue.replace('#', ''))));
setHsv(rgb2hsv(hex2rgb(hexValue)));
}
e.currentTarget.value = hexValue;
}, []);
const handlePredefinedColorClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
setHsb(rgb2hsb(hex2rgb(e.currentTarget.dataset.color!.replace('#', ''))));
setHsv(rgb2hsv(hex2rgb(e.currentTarget.dataset.color!)));
}, []);
const className = buildClassName(
@ -216,14 +216,14 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
<canvas />
<div
className="handle"
style={`transform: translate(${colorPosition[0]}px, ${colorPosition[1]}px); background-color: #${hex};`}
style={`transform: translate(${colorPosition[0]}px, ${colorPosition[1]}px); background-color: ${hex};`}
/>
</div>
<div ref={huePickerRef} className="hue-picker">
<canvas />
<div
className="handle"
style={`transform: translateX(${huePosition}px); background-color: #${hueHex};`}
style={`transform: translateX(${huePosition}px); background-color: ${hueHex};`}
/>
</div>
<div className="tools">
@ -234,7 +234,7 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
<div className="predefined-colors">
{PREDEFINED_COLORS.map((color) => (
<div
className={buildClassName('predefined-color', color === `#${hex}` ? 'active' : undefined)}
className={buildClassName('predefined-color', color === hex ? 'active' : undefined)}
data-color={color}
style={`background-color: ${color};`}
onClick={handlePredefinedColorClick}
@ -245,23 +245,23 @@ const SettingsGeneralBackground: FC<OwnProps & StateProps> = ({
);
};
function getInitialHsb(backgroundColor?: string) {
return backgroundColor && backgroundColor.startsWith('#')
? rgb2hsb(hex2rgb(backgroundColor.replace('#', '')))
: DEFAULT_HSB;
function getInitialHsv(backgroundColor?: string) {
return backgroundColor?.startsWith('#')
? rgb2hsv(hex2rgb(backgroundColor))
: DEFAULT_HSV;
}
function hsb2positions(hsb: [number, number, number], rects: CanvasRects) {
function hsv2positions(hsv: [number, number, number], rects: CanvasRects) {
return {
colorPosition: [
Math.round((hsb[1]) * (rects.colorRect.width - 1)),
Math.round((1 - hsb[2]) * (rects.colorRect.height - 1)),
Math.round((hsv[1]) * (rects.colorRect.width - 1)),
Math.round((1 - hsv[2]) * (rects.colorRect.height - 1)),
],
huePosition: Math.round(hsb[0] * (rects.hueRect.width - 1)),
huePosition: Math.round(hsv[0] * (rects.hueRect.width - 1)),
};
}
function positions2hsb(
function positions2hsv(
{ colorPosition, huePosition }: { colorPosition: number[]; huePosition: number },
rects: CanvasRects,
): [number, number, number] {
@ -300,7 +300,7 @@ function drawColor(
const imgData = ctx!.createImageData(w, h);
const pixels = imgData.data;
const col = hsb2rgb([hue, 1, 1]);
const col = hsv2rgb([hue, 1, 1]);
let index = 0;
@ -334,7 +334,7 @@ function drawHue(canvas: HTMLCanvasElement) {
for (let x = 0; x < w; x++) {
const hue = x / (w - 1);
const rgb = hsb2rgb([hue, 1, 1]);
const rgb = hsv2rgb([hue, 1, 1]);
pixels[index++] = rgb[0];
pixels[index++] = rgb[1];

View File

@ -2,7 +2,7 @@ import { memo } from '../../../lib/teact/teact';
import type { IconName } from '../../../types/icons';
import { hexToRgb, lerpRgb } from '../../../util/switchTheme';
import { hex2rgbaObj, lerpRgbaObj } from '../../../util/colors.ts';
import renderText from '../../common/helpers/renderText';
import useLastCallback from '../../../hooks/useLastCallback';
@ -30,7 +30,7 @@ type OwnProps<T> = {
const COLORS = [
'#F2862D', '#EB7B4D', '#E46D72', '#DD6091', '#CC5FBA', '#B464E7',
'#9873FF', '#768DFF', '#55A5FC', '#52B0C9', '#4FBC93', '#4CC663',
].map(hexToRgb);
].map(hex2rgbaObj);
const PremiumFeatureItem = <T,>({
icon,
@ -45,7 +45,7 @@ const PremiumFeatureItem = <T,>({
const newIndex = (index / count) * COLORS.length;
const colorA = COLORS[Math.floor(newIndex)];
const colorB = COLORS[Math.ceil(newIndex)] ?? colorA;
const { r, g, b } = lerpRgb(colorA, colorB, 0.5);
const { r, g, b } = lerpRgbaObj(colorA, colorB, 0.5);
const handleClick = useLastCallback(() => {
onClick?.(section);

View File

@ -12,12 +12,12 @@ import { requestMeasure } from '../../../../lib/fasterdom/fasterdom';
import { ensureRLottie } from '../../../../lib/rlottie/RLottie.async';
import { selectCustomEmoji, selectIsAlwaysHighPriorityEmoji } from '../../../../global/selectors';
import AbsoluteVideo from '../../../../util/AbsoluteVideo';
import { hex2rgbaObj } from '../../../../util/colors.ts';
import {
addCustomEmojiInputRenderCallback,
getCustomEmojiMediaDataForInput,
} from '../../../../util/emoji/customEmojiManager';
import { round } from '../../../../util/math';
import { hexToRgb } from '../../../../util/switchTheme';
import { REM } from '../../../common/helpers/mediaDimensions';
import useColorFilter from '../../../../hooks/stickers/useColorFilter';
@ -230,7 +230,7 @@ async function createPlayer({
colorFilter?: string;
}): Promise<CustomEmojiPlayer> {
if (customEmoji.isLottie) {
const color = customEmoji.shouldUseTextColor && textColor ? hexToRgb(textColor) : undefined;
const color = customEmoji.shouldUseTextColor && textColor ? hex2rgbaObj(textColor) : undefined;
const RLottie = await ensureRLottie();
const lottie = RLottie.init(
mediaUrl,

View File

@ -21,8 +21,7 @@ import {
import { selectSharedSettings } from '../../../global/selectors/sharedState';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { getColorLuma } from '../../../util/colors';
import { hexToRgb } from '../../../util/switchTheme';
import { getColorLuma, hex2rgbaObj } from '../../../util/colors';
import windowSize from '../../../util/windowSize';
import useInterval from '../../../hooks/schedulers/useInterval';
@ -427,7 +426,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
const headerTextVar = useMemo(() => {
if (isMoreAppsTabActive) return 'color-text';
if (!headerColor) return undefined;
const { r, g, b } = hexToRgb(headerColor);
const { r, g, b } = hex2rgbaObj(headerColor);
const luma = getColorLuma([r, g, b]);
const adaptedLuma = theme === 'dark' ? 255 - luma : luma;
return adaptedLuma > LUMA_THRESHOLD ? 'color-text' : 'color-background';

View File

@ -12,7 +12,7 @@ import { requestForcedReflow, requestMutation } from '../../../lib/fasterdom/fas
import { selectRestrictedEmoji } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { convertToRGBA, getTextColor } from '../../../util/colors';
import { getTextColor, int2cssRgba } from '../../../util/colors';
import { formatTemperature } from '../../../util/formatTemperature';
import useLastCallback from '../../../hooks/useLastCallback';
@ -48,7 +48,7 @@ const MediaAreaWeather: FC<OwnProps & StateProps> = ({
const { temperatureC, color } = mediaArea;
const backgroundColor = convertToRGBA(color);
const backgroundColor = int2cssRgba(color);
const textColor = getTextColor(color);
const updateCustomSize = useLastCallback((isImmediate?: boolean) => {

View File

@ -1,8 +1,8 @@
import { useEffect } from '../../lib/teact/teact';
import { SVG_NAMESPACE } from '../../config';
import { hex2rgbaObj } from '../../util/colors.ts';
import { addSvgDefinition, removeSvgDefinition } from '../../util/svgController';
import { hexToRgb } from '../../util/switchTheme';
const SVG_MAP = new Map<string, SvgColorFilter>();
@ -14,7 +14,7 @@ class SvgColorFilter {
constructor(public color: string) {
this.filterId = `color-filter-${color.slice(1)}`;
const rgbColor = hexToRgb(color);
const rgbColor = hex2rgbaObj(color);
addSvgDefinition(
<filter color-interpolation-filters="sRGB" xmlns={SVG_NAMESPACE}>
<feColorMatrix

View File

@ -18,7 +18,7 @@ function useAverageColor(peer: ApiPeer, fallbackColor = '#00000000') {
}
const averageColor = await getAverageColor(imgBlobUrl);
setColor(`#${rgb2hex(averageColor)}`);
setColor(rgb2hex(averageColor));
})();
}, [imgBlobUrl]);

View File

@ -1,32 +1,57 @@
/* eslint-disable prefer-const */
import { preloadImage } from './files';
import { lerp } from './math.ts';
const LUMA_THRESHOLD = 128;
type Number3 = [number, number, number];
type Number4 = [number, number, number, number];
type Rgba = {
r: number;
g: number;
b: number;
a?: number;
};
function clearHex(hex: string) {
return hex.length % 2 === 0 ? hex : hex.slice(1);
}
/**
* HEX > RGB
* input: 'xxxxxx' (ex. 'ed15fa') case-insensitive
* output: [r, g, b] ([0-255, 0-255, 0-255])
*/
export function hex2rgb(param: string): [number, number, number] {
export function hex2rgb(hex: string): Number3 {
const cleanHex = clearHex(hex);
return [
parseInt(param.substring(0, 2), 16),
parseInt(param.substring(2, 4), 16),
parseInt(param.substring(4, 6), 16),
parseInt(cleanHex.substring(0, 2), 16),
parseInt(cleanHex.substring(2, 4), 16),
parseInt(cleanHex.substring(4, 6), 16),
];
}
/**
* RGB > HEX
* input: [r, g, b] ([0-255, 0-255, 0-255])
* output: 'xxxxxx' (ex. 'ff0000')
*/
export function rgb2hex(param: [number, number, number]) {
const p0 = param[0].toString(16);
const p1 = param[1].toString(16);
const p2 = param[2].toString(16);
return (p0.length === 1 ? '0' + p0 : p0) + (p1.length === 1 ? '0' + p1 : p1) + (p2.length === 1 ? '0' + p2 : p2);
export function hex2rgba(hex: string): Number4 {
const cleanHex = clearHex(hex);
return [
...hex2rgb(cleanHex),
cleanHex.length === 8 ? parseInt(cleanHex.substring(6, 8), 16) : 255,
];
}
export function hex2rgbaObj(hex: string): Rgba {
const [r, g, b, a] = hex2rgba(hex);
return { r, g, b, a };
}
export function rgb2hex([r, g, b]: Number3) {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
export function rgba2hex([r, g, b, a]: Number4) {
return `${rgb2hex([r, g, b])}${a.toString(16)}`;
}
/**
@ -40,16 +65,16 @@ export function rgb2hex(param: [number, number, number]) {
* @param Number b The blue color value
* @return Array The HSV representation
*/
export function rgb2hsb([r, g, b]: [number, number, number]): [number, number, number] {
export function rgb2hsv([r, g, b]: Number3): Number3 {
r /= 255;
g /= 255;
b /= 255;
let max = Math.max(r, g, b), min = Math.min(r, g, b);
let h!: number, s: number, v: number = max;
let d = max - min;
s = max === 0 ? 0 : d / max;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const d = max - min;
let h!: number;
const s: number = max === 0 ? 0 : d / max;
const v: number = max;
if (max === min) {
h = 0; // achromatic
@ -72,6 +97,10 @@ export function rgb2hsb([r, g, b]: [number, number, number]): [number, number, n
return [h, s, v];
}
export function rgba2hsva([r, g, b, a]: Number4): Number4 {
return [...rgb2hsv([r, g, b]), a];
}
/**
* Converts an HSV color value to RGB. Conversion formula
* adapted from http://en.wikipedia.org/wiki/HSV_color_space.
@ -83,14 +112,14 @@ export function rgb2hsb([r, g, b]: [number, number, number]): [number, number, n
* @param Number v The value
* @return Array The RGB representation
*/
export function hsb2rgb([h, s, v]: [number, number, number]): [number, number, number] {
export function hsv2rgb([h, s, v]: Number3): Number3 {
let r!: number, g!: number, b!: number;
let i = Math.floor(h * 6);
let f = h * 6 - i;
let p = v * (1 - s);
let q = v * (1 - f * s);
let t = v * (1 - (1 - f) * s);
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
@ -132,27 +161,28 @@ export function hsb2rgb([h, s, v]: [number, number, number]): [number, number, n
];
}
export async function getAverageColor(url: string): Promise<[number, number, number]> {
export function hsva2rgba([h, s, v, a]: Number4): Number4 {
return [...hsv2rgb([h, s, v]), a];
}
export async function getAverageColor(url: string): Promise<Number3> {
// Only visit every 5 pixels
const blockSize = 5;
const defaultRGB: [number, number, number] = [0, 0, 0];
const black: Number3 = [0, 0, 0];
let data;
let width;
let height;
let i = -4;
let length;
let rgb: [number, number, number] = [0, 0, 0];
const rgb: Number3 = [0, 0, 0];
let count = 0;
const canvas = document.createElement('canvas');
const context = canvas.getContext && canvas.getContext('2d');
if (!context) {
return defaultRGB;
return black;
}
const image = await preloadImage(url);
height = image.naturalHeight || image.offsetHeight || image.height;
width = image.naturalWidth || image.offsetWidth || image.width;
const height = image.naturalHeight || image.offsetHeight || image.height;
const width = image.naturalWidth || image.offsetWidth || image.width;
canvas.height = height;
canvas.width = width;
@ -161,10 +191,10 @@ export async function getAverageColor(url: string): Promise<[number, number, num
try {
data = context.getImageData(0, 0, width, height);
} catch (e) {
return defaultRGB;
return black;
}
length = data.data.length;
const length = data.data.length;
while ((i += blockSize * 4) < length) {
if (data.data[i + 3] === 0) continue; // Ignore fully transparent pixels
@ -181,50 +211,64 @@ export async function getAverageColor(url: string): Promise<[number, number, num
return rgb;
}
export function getColorLuma(rgbColor: [number, number, number]) {
const [r, g, b] = rgbColor;
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luma;
export function getColorLuma([r, g, b]: Number3) {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// https://stackoverflow.com/a/64090995
export function hsl2rgb([h, s, l]: [number, number, number]): [number, number, number] {
let a = s * Math.min(l, 1 - l);
let f = (n: number, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
export function hsl2rgb([h, s, l]: Number3): Number3 {
const a = s * Math.min(l, 1 - l);
const f = (n: number, k = (n + h / 30) % 12) => l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return [f(0), f(8), f(4)];
}
// Function was adapted from https://github.com/telegramdesktop/tdesktop/blob/35ff621b5b52f7e3553fb0f990ea13ade7101b8e/Telegram/SourceFiles/data/data_wall_paper.cpp#L518
export function getPatternColor(rgbColor: [number, number, number]) {
let [hue, saturation, value] = rgb2hsb(rgbColor);
export function getPatternColor(rgb: Number3) {
const hsv = rgb2hsv(rgb);
const [h] = hsv;
let [, s, v] = hsv;
saturation = Math.min(1, saturation + 0.05 + 0.1 * (1 - saturation));
value = value > 0.5
? Math.max(0, value * 0.65)
: Math.max(0, Math.min(1, 1 - value * 0.65));
s = Math.min(1, s + 0.05 + 0.1 * (1 - s));
v = v > 0.5
? Math.max(0, v * 0.65)
: Math.max(0, Math.min(1, 1 - v * 0.65));
const rgb = hsl2rgb([hue * 360, saturation, value]);
const hex = rgb2hex(rgb.map((c) => Math.floor(c * 255)) as [number, number, number]);
return `#${hex}66`;
const newRgb = hsl2rgb([h * 360, s, v]);
const mappedRgb = newRgb.map((c) => Math.floor(c * 255)) as Number3;
return rgba2hex([...mappedRgb, 102]);
}
export const convertToRGBA = (color: number): string => {
export function int2cssRgba(color: number): string {
const alpha = (color >> 24) & 0xff;
const red = (color >> 16) & 0xff;
const green = (color >> 8) & 0xff;
const blue = color & 0xff;
const alphaFloat = alpha / 255;
return `rgba(${red}, ${green}, ${blue}, ${alphaFloat})`;
};
}
export const numberToHexColor = (color: number): string => {
export function int2hex(color: number): string {
return `#${color.toString(16).padStart(6, '0')}`;
};
}
export const getTextColor = (color: number): string => {
export function getTextColor(color: number): string {
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
const luma = getColorLuma([r, g, b]);
return luma > LUMA_THRESHOLD ? 'black' : 'white';
};
}
export function lerpRgbaObj(start: Rgba, end: Rgba, interpolationRatio: number): Rgba {
const r = Math.round(lerp(start.r, end.r, interpolationRatio));
const g = Math.round(lerp(start.g, end.g, interpolationRatio));
const b = Math.round(lerp(start.b, end.b, interpolationRatio));
const a = start.a !== undefined
? Math.round(lerp(start.a, end.a!, interpolationRatio))
: undefined;
return { r, g, b, a };
}

View File

@ -3,19 +3,11 @@ import type { ThemeKey } from '../types';
import { requestMutation } from '../lib/fasterdom/fasterdom';
import themeColors from '../styles/themes.json';
import { animate } from './animation';
import { lerp } from './math';
type RGBAColor = {
r: number;
g: number;
b: number;
a?: number;
};
import { hex2rgbaObj, lerpRgbaObj } from './colors.ts';
let isInitialized = false;
const DECIMAL_PLACES = 3;
const HEX_COLOR_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i;
const DURATION_MS = 200;
const ENABLE_ANIMATION_DELAY_MS = 500;
const RGB_VARIABLES = new Set([
@ -34,7 +26,7 @@ const DISABLE_ANIMATION_CSS = `
const colors = (Object.keys(themeColors) as Array<keyof typeof themeColors>).map((property) => ({
property,
colors: [hexToRgb(themeColors[property][0]), hexToRgb(themeColors[property][1])],
colors: [hex2rgbaObj(themeColors[property][0]), hex2rgbaObj(themeColors[property][1])],
}));
const injectCss = (css: string) => {
@ -97,35 +89,11 @@ function transition(t: number) {
return 1 - ((1 - t) ** 3.5);
}
export function hexToRgb(hex: string): RGBAColor {
const result = HEX_COLOR_REGEX.exec(hex)!;
return {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
a: result[4] !== undefined ? parseInt(result[4], 16) : undefined,
};
}
export function lerpRgb(start: RGBAColor, end: RGBAColor, interpolationRatio: number): RGBAColor {
const r = Math.round(lerp(start.r, end.r, interpolationRatio));
const g = Math.round(lerp(start.g, end.g, interpolationRatio));
const b = Math.round(lerp(start.b, end.b, interpolationRatio));
const a = start.a !== undefined
? Math.round(lerp(start.a, end.a!, interpolationRatio))
: undefined;
return {
r, g, b, a,
};
}
function applyColorAnimationStep(startIndex: number, endIndex: number, interpolationRatio: number = 1) {
colors.forEach(({ property, colors: propertyColors }) => {
const {
r, g, b, a,
} = lerpRgb(propertyColors[startIndex], propertyColors[endIndex], interpolationRatio);
} = lerpRgbaObj(propertyColors[startIndex], propertyColors[endIndex], interpolationRatio);
const roundedA = a !== undefined ? Math.round((a / 255) * 10 ** DECIMAL_PLACES) / 10 ** DECIMAL_PLACES : undefined;