mirror of
https://github.com/CompeyDev/fxtwitter-docker.git
synced 2025-04-04 10:00:55 +01:00
Full-ish implementation
This commit is contained in:
parent
6094c9df98
commit
c0cb1b73d2
9 changed files with 467 additions and 27 deletions
|
@ -4,5 +4,6 @@
|
|||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 90,
|
||||
"arrowParens": "avoid"
|
||||
"arrowParens": "avoid",
|
||||
"quoteProps": "consistent"
|
||||
}
|
||||
|
|
|
@ -1,25 +1,44 @@
|
|||
const fakeChromeVersion = '103';
|
||||
|
||||
export const Constants = {
|
||||
REDIRECT_URL: 'https://github.com/dangeredwolf',
|
||||
REDIRECT_URL: 'https://twitter.com/dangeredwolf',
|
||||
TWITTER_ROOT: 'https://twitter.com',
|
||||
TWITTER_API_ROOT: 'https://api.twitter.com',
|
||||
GUEST_BEARER_TOKEN: `Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA`,
|
||||
GUEST_FETCH_PARAMETERS: [
|
||||
'cards_platform=Web-12',
|
||||
'include_cards=1',
|
||||
'include_ext_alt_text=true',
|
||||
'include_quote_count=true',
|
||||
'include_reply_count=1',
|
||||
'tweet_mode=extended',
|
||||
'include_ext_media_color=true',
|
||||
'include_ext_media_availability=true',
|
||||
'include_ext_sensitive_media_warning=true',
|
||||
'simple_quoted_tweet=true',
|
||||
].join('&'),
|
||||
BASE_HEADERS: {
|
||||
'sec-ch-ua': `".Not/A)Brand";v="99", "Google Chrome";v="${fakeChromeVersion}", "Chromium";v="${fakeChromeVersion}"`,
|
||||
DNT: `1`,
|
||||
'DNT': `1`,
|
||||
'x-twitter-client-language': `en`,
|
||||
'sec-ch-ua-mobile': `?0`,
|
||||
'content-type': `application/x-www-form-urlencoded`,
|
||||
'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${fakeChromeVersion}.0.0.0 Safari/537.36`,
|
||||
'x-twitter-active-user': `yes`,
|
||||
'sec-ch-ua-platform': `"Windows"`,
|
||||
Accept: `*/*`,
|
||||
Origin: `https://twitter.com`,
|
||||
'Accept': `*/*`,
|
||||
'Origin': `https://twitter.com`,
|
||||
'Sec-Fetch-Site': `same-site`,
|
||||
'Sec-Fetch-Mode': `cors`,
|
||||
'Sec-Fetch-Dest': `empty`,
|
||||
Referer: `https://twitter.com/`,
|
||||
'Referer': `https://twitter.com/`,
|
||||
'Accept-Encoding': `gzip, deflate, br`,
|
||||
'Accept-Language': `en`,
|
||||
},
|
||||
RESPONSE_HEADERS: {
|
||||
'content-type': 'text/html;charset=UTF-8',
|
||||
"x-powered-by": 'Black Magic',
|
||||
// 'cache-control': 'max-age=1'
|
||||
},
|
||||
DEFAULT_COLOR: '#10A3FF'
|
||||
};
|
||||
|
|
|
@ -1,19 +1,42 @@
|
|||
import { Constants } from '../constants';
|
||||
|
||||
export const fetchUsingGuest = async (
|
||||
screenName: string,
|
||||
status: string
|
||||
): Promise<any> => {
|
||||
const activate = await fetch(`${Constants.TWITTER_ROOT}/1.1/guest/activate.json`, {
|
||||
export const fetchUsingGuest = async (status: string): Promise<TweetPartial> => {
|
||||
const csrfToken = crypto.randomUUID().replace(/-/g, ''); // Generate a random CSRF token, this doesn't matter, Twitter just cares that header and cookie match
|
||||
|
||||
let headers: { [header: string]: string } = {
|
||||
Authorization: Constants.GUEST_BEARER_TOKEN,
|
||||
...Constants.BASE_HEADERS,
|
||||
};
|
||||
|
||||
const activate = await fetch(`${Constants.TWITTER_API_ROOT}/1.1/guest/activate.json`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...Constants.BASE_HEADERS,
|
||||
Authorization: `Bearer ${Constants.GUEST_BEARER_TOKEN}`,
|
||||
},
|
||||
headers: headers,
|
||||
body: '',
|
||||
});
|
||||
|
||||
console.log(activate.json());
|
||||
const activateJson = (await activate.json()) as { guest_token: string };
|
||||
const guestToken = activateJson.guest_token;
|
||||
|
||||
return activate.json();
|
||||
headers['Cookie'] = `guest_id=v1%3A${guestToken}; ct0=${csrfToken};`;
|
||||
headers['x-csrf-token'] = csrfToken;
|
||||
headers['x-twitter-active-user'] = 'yes';
|
||||
headers['x-guest-token'] = guestToken;
|
||||
|
||||
const conversation = (await (
|
||||
await fetch(
|
||||
`${Constants.TWITTER_ROOT}/i/api/2/timeline/conversation/${status}.json?${Constants.GUEST_FETCH_PARAMETERS}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
}
|
||||
)
|
||||
).json()) as TimelineBlobPartial;
|
||||
|
||||
console.log(conversation);
|
||||
|
||||
const tweet = conversation?.globalObjects?.tweets?.[status] || {};
|
||||
|
||||
tweet.user = conversation?.globalObjects?.users?.[tweet.user_id_str] || {};
|
||||
|
||||
return tweet;
|
||||
};
|
||||
|
|
18
src/html.ts
18
src/html.ts
|
@ -2,10 +2,18 @@ import { Strings } from './strings';
|
|||
|
||||
export const Html = {
|
||||
BASE_HTML: `<!DOCTYPE html>
|
||||
<!-- 👋 Hello robots and hackers -->
|
||||
<html {lang}>
|
||||
<head>
|
||||
{headers}
|
||||
</head>
|
||||
`,
|
||||
<!-- ███████████ ▐█▌ ███ ███
|
||||
███ █ ███ ███
|
||||
███ ███ ███
|
||||
█████▌ ███ ███ ███ ███ ███ ███ ███ ██████ ██████ ▐████▌ ███▐███
|
||||
███ ██▌ ▐█▌▐█▌ ███ ███ ███ ███ ███ ███ ███ ▐█▌ ▐█▌ ███▌
|
||||
███ ███ ▐██▌ ███ ███ ███ ███ ███ ███ ███ ████████ ███
|
||||
███ ▐██▌ ▐█▌▐█▌ ███ ▐██▌ ███ ▐██▌ ███ ▐██▌ ▐██▌ ▐█▌ ███
|
||||
█████▌ ███ ███ ███ ▐█████████▌ ███ ▐████ ▐███ ▐████ ███
|
||||
███
|
||||
███ The best way to embed tweets.
|
||||
███ A work in progress by @dangeredwolf
|
||||
-->
|
||||
<head>{headers}</head>`,
|
||||
};
|
||||
|
|
42
src/poll.ts
Normal file
42
src/poll.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
const barLength = 30;
|
||||
|
||||
export const renderPoll = async (card: TweetCard): Promise<string> => {
|
||||
let str = '\n\n';
|
||||
const values = card.binding_values;
|
||||
|
||||
console.log('rendering poll on ', card);
|
||||
|
||||
let choices: { [label: string]: number } = {};
|
||||
let totalVotes = 0;
|
||||
|
||||
if (typeof values !== "undefined" && typeof values.choice1_count !== "undefined" && typeof values.choice2_count !== "undefined") {
|
||||
choices[values.choice1_label?.string_value || ''] = parseInt(values.choice1_count.string_value);
|
||||
totalVotes += parseInt(values.choice1_count.string_value);
|
||||
choices[values.choice2_label?.string_value || ''] = parseInt(values.choice2_count.string_value);
|
||||
totalVotes += parseInt(values.choice2_count.string_value);
|
||||
if (typeof values.choice3_count !== "undefined") {
|
||||
choices[values.choice3_label?.string_value || ''] = parseInt(values.choice3_count.string_value);
|
||||
totalVotes += parseInt(values.choice3_count.string_value);
|
||||
}
|
||||
if (typeof values.choice4_count !== "undefined") {
|
||||
choices[values.choice4_label?.string_value || ''] = parseInt(values.choice4_count.string_value);
|
||||
totalVotes += parseInt(values.choice4_count.string_value);
|
||||
}
|
||||
} else {
|
||||
console.log('no choices found', values);
|
||||
}
|
||||
console.log(choices);
|
||||
|
||||
for (const [label, votes] of Object.entries(choices)) {
|
||||
// render bar
|
||||
const bar = '█'.repeat(Math.floor(votes / totalVotes * barLength));
|
||||
str += `${bar}
|
||||
${label} (${Math.floor(votes / totalVotes * 100)}%)
|
||||
`;
|
||||
}
|
||||
|
||||
str += `\n${totalVotes} votes`;
|
||||
|
||||
console.log(str);
|
||||
return str;
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { Router } from 'itty-router';
|
||||
import { Constants } from './constants';
|
||||
import { fetchUsingGuest } from './drivers/guest';
|
||||
import { handleStatus } from './status';
|
||||
|
||||
/*
|
||||
Useful little function to format strings for us
|
||||
|
@ -23,10 +24,52 @@ String.prototype.format = function (options: any) {
|
|||
|
||||
const router = Router();
|
||||
|
||||
router.get('/:handle/status/:id', async (request: any) => {
|
||||
const { handle, id } = request.params;
|
||||
return new Response(await fetchUsingGuest(handle, id), { status: 200 });
|
||||
});
|
||||
const statusRequest = async (request: any) => {
|
||||
const { handle, id, mediaNumber } = request.params;
|
||||
const url = new URL(request.url);
|
||||
const userAgent = request.headers.get('User-Agent');
|
||||
|
||||
if (userAgent.match(/bot/ig) !== null) {
|
||||
return new Response(await handleStatus(handle, id, mediaNumber), {
|
||||
headers: {
|
||||
'content-type': 'text/html;charset=UTF-8',
|
||||
},
|
||||
status: 200
|
||||
});
|
||||
} else {
|
||||
return Response.redirect(`${Constants.TWITTER_ROOT}${url.pathname}`, 302);
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:handle/status/:id', statusRequest);
|
||||
router.get('/:handle/status/:id/photo/:mediaNumber', statusRequest);
|
||||
router.get('/:handle/status/:id/video/:mediaNumber', statusRequest);
|
||||
router.get('/:handle/statuses/:id', statusRequest);
|
||||
router.get('/:handle/statuses/:id/photo/:mediaNumber', statusRequest);
|
||||
router.get('/:handle/statuses/:id/video/:mediaNumber', statusRequest);
|
||||
|
||||
router.get('/owoembed', async (request: any) => {
|
||||
console.log("THE OWOEMBED HAS BEEN ACCESSED!!!!!!!!!");
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
let text = searchParams.get('text') || 'Twitter';
|
||||
|
||||
const test = {
|
||||
"author_name":text,
|
||||
"author_url":"https://twitter.com/AquosTheWolf/status/1547447632284553216",
|
||||
"provider_name":"pxTwitter",
|
||||
"provider_url":"https://github.com/dangeredwolf/pxtwitter",
|
||||
"title":"test",
|
||||
"type":"link",
|
||||
"version":"1.0"
|
||||
}
|
||||
return new Response(JSON.stringify(test), {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
status: 200
|
||||
});
|
||||
})
|
||||
|
||||
router.all('*', async request => {
|
||||
return Response.redirect(Constants.REDIRECT_URL);
|
||||
|
|
175
src/status.ts
175
src/status.ts
|
@ -1 +1,174 @@
|
|||
export const handleStatus = (screenName: string, status: number) => {};
|
||||
import { Constants } from "./constants";
|
||||
import { fetchUsingGuest } from "./drivers/guest";
|
||||
import { Html } from "./html";
|
||||
import { renderPoll } from "./poll";
|
||||
import { rgbToHex } from "./utils";
|
||||
|
||||
const colorFromPalette = (palette: MediaPlaceholderColor[]) => {
|
||||
for (let i = 0; i < palette.length; i++) {
|
||||
const rgb = palette[i].rgb;
|
||||
|
||||
// We need vibrant colors, grey backgrounds won't do!
|
||||
if (rgb.red + rgb.green + rgb.blue < 120) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return rgbToHex(rgb.red, rgb.green, rgb.blue);
|
||||
}
|
||||
|
||||
return Constants.DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
export const handleStatus = async (handle: string, id: string, mediaNumber?: number): Promise<string> => {
|
||||
const tweet = await fetchUsingGuest(id);
|
||||
console.log(tweet);
|
||||
|
||||
// Try to deep link to mobile apps, just like Twitter does
|
||||
let headers: string[] = [
|
||||
`<meta property="og:site_name" content="Twitter" />`,
|
||||
`<meta property="fb:app_id" content="2231777543"/>`,
|
||||
`<meta content="twitter://status?id=${id}" property="al:ios:url"/>`,
|
||||
`<meta content="333903271" property="al:ios:app_store_id"/>`,
|
||||
`<meta content="Twitter" property="al:ios:app_name"/>`,
|
||||
`<meta content="twitter://status?id=${id}" property="al:android:url"/>`,
|
||||
`<meta content="com.twitter.android" property="al:android:package"/>`,
|
||||
`<meta content="Twitter" property="al:android:app_name"/>`,
|
||||
];
|
||||
|
||||
// Fallback for if Tweet did not load
|
||||
if (typeof tweet.full_text === "undefined") {
|
||||
headers.push(
|
||||
`<meta content="Twitter" property="og:title"/>`,
|
||||
`<meta content="Tweet failed to load :(" property="og:description"/>`
|
||||
);
|
||||
|
||||
return Html.BASE_HTML.format({
|
||||
lang: '',
|
||||
headers: headers.join(''),
|
||||
tweet: JSON.stringify(tweet),
|
||||
});
|
||||
}
|
||||
|
||||
let text = tweet.full_text;
|
||||
const user = tweet.user;
|
||||
const screenName = user?.screen_name || '';
|
||||
const name = user?.name || '';
|
||||
|
||||
let authorText = 'Twitter';
|
||||
|
||||
// This is used to chop off the end if it's like pic.twitter.com or something
|
||||
if (tweet.display_text_range) {
|
||||
const [start, end] = tweet.display_text_range;
|
||||
// We ignore start because it cuts off reply handles
|
||||
text = text.substring(0, end + 1);
|
||||
}
|
||||
|
||||
if (tweet.card) {
|
||||
text += await renderPoll(tweet.card);
|
||||
}
|
||||
|
||||
// Replace t.co links with their full counterparts
|
||||
if (typeof tweet.entities?.urls !== 'undefined') {
|
||||
tweet.entities?.urls.forEach((url: TcoExpansion) => {
|
||||
text = text.replace(url.url, url.expanded_url);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof tweet.extended_entities?.media === 'undefined' && typeof tweet.entities?.media === 'undefined') {
|
||||
let palette = user?.profile_image_extensions_media_color?.palette;
|
||||
let colorOverride: string = Constants.DEFAULT_COLOR;
|
||||
|
||||
// for loop for palettes
|
||||
if (palette) {
|
||||
colorOverride = colorFromPalette(palette);
|
||||
}
|
||||
|
||||
headers.push(
|
||||
`<meta content="${colorOverride}" property="theme-color"/>`,
|
||||
`<meta property="og:image" content="${user?.profile_image_url_https}"/>`,
|
||||
`<meta name="twitter:card" content="tweet"/>`,
|
||||
`<meta name="twitter:title" content="${name} (@${screenName})"/>`,
|
||||
`<meta name="twitter:image" content="0"/>`,
|
||||
`<meta name="twitter:creator" content="@${name}"/>`,
|
||||
`<meta content="${text}" property="og:description"/>`
|
||||
);
|
||||
} else {
|
||||
let media = tweet.extended_entities?.media || tweet.entities?.media || [];
|
||||
|
||||
let firstMedia = media[0];
|
||||
|
||||
let palette = firstMedia?.ext_media_color?.palette;
|
||||
let colorOverride: string = Constants.DEFAULT_COLOR;
|
||||
let pushedCardType = false;
|
||||
|
||||
// for loop for palettes
|
||||
if (palette) {
|
||||
colorOverride = colorFromPalette(palette);
|
||||
}
|
||||
|
||||
headers.push(
|
||||
`<meta content="${colorOverride}" property="theme-color"/>`
|
||||
)
|
||||
|
||||
const processMedia = (media: TweetMedia) => {
|
||||
if (media.type === 'photo') {
|
||||
headers.push(
|
||||
`<meta name="twitter:image" content="${media.media_url_https}"/>`
|
||||
);
|
||||
|
||||
if (!pushedCardType) {
|
||||
headers.push(`<meta name="twitter:card" content="summary_large_image"/>`);
|
||||
pushedCardType = true;
|
||||
}
|
||||
} else if (media.type === 'video') {
|
||||
headers.push(
|
||||
`<meta name="twitter:image" content="${media.media_url_https}"/>`
|
||||
);
|
||||
|
||||
authorText = encodeURIComponent(text);
|
||||
|
||||
// Find the variant with the highest bitrate
|
||||
let bestVariant = media.video_info?.variants?.reduce?.((a, b) => (a.bitrate || 0) > (b.bitrate || 0) ? a : b);
|
||||
headers.push(
|
||||
`<meta name="twitter:card" content="player"/>`,
|
||||
`<meta name="twitter:player:stream" content="${bestVariant?.url}"/>`,
|
||||
`<meta name="twitter:player:stream:content_type" content="${bestVariant?.content_type}" />`,
|
||||
`<meta name="twitter:player:height" content="${media.original_info.height}"/>`,
|
||||
`<meta name="twitter:player:width" content="${media.original_info.height}"/>`,
|
||||
`<meta name="og:video" content="${bestVariant?.url}"/>`,
|
||||
`<meta name="og:video:secure_url" content="${bestVariant?.url}"/>`,
|
||||
`<meta name="og:video:height" content="${media.original_info.height}"/>`,
|
||||
`<meta name="og:video:width" content="${media.original_info.height}"/>`,
|
||||
`<meta name="og:video:type" content="${bestVariant?.content_type}" />`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// You can specify a specific photo in the URL
|
||||
if (typeof mediaNumber === "number" && media[mediaNumber]) {
|
||||
processMedia(media[mediaNumber]);
|
||||
} else {
|
||||
// I wish Telegram respected multiple photos in a tweet
|
||||
// media.forEach(media => processMedia(media));
|
||||
processMedia(firstMedia);
|
||||
}
|
||||
|
||||
headers.push(
|
||||
`<meta content="${name} (@${screenName})" property="og:title"/>`,
|
||||
`<meta content="${text}" property="og:description"/>`
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof tweet.in_reply_to_screen_name !== "undefined") {
|
||||
authorText = `↪️ @${tweet.in_reply_to_screen_name}`;
|
||||
}
|
||||
|
||||
headers.push(`<link rel="alternate" href="https://pxtwitter.com/owoembed?text=${authorText}" type="application/json+oembed" title="${name}">`)
|
||||
|
||||
console.log(JSON.stringify(tweet))
|
||||
|
||||
return Html.BASE_HTML.format({
|
||||
lang: `lang="${tweet.lang || 'en'}"`,
|
||||
headers: headers.join('')
|
||||
});
|
||||
};
|
||||
|
|
122
src/tweetTypes.ts
Normal file
122
src/tweetTypes.ts
Normal file
|
@ -0,0 +1,122 @@
|
|||
type TimelineBlobPartial = {
|
||||
globalObjects: {
|
||||
tweets: {
|
||||
[tweetId: string]: TweetPartial;
|
||||
};
|
||||
users: {
|
||||
[userId: string]: UserPartial;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type TweetMediaSize = {
|
||||
w: number;
|
||||
h: number;
|
||||
resize: 'crop' | 'fit';
|
||||
};
|
||||
|
||||
type TweetMediaFormat = {
|
||||
bitrate: number;
|
||||
content_type: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type TcoExpansion = {
|
||||
display_url: string;
|
||||
expanded_url: string;
|
||||
indices: [number, number];
|
||||
url: string;
|
||||
};
|
||||
|
||||
type TweetMedia = {
|
||||
additional_media_info: { monetizable: boolean };
|
||||
display_url: string;
|
||||
expanded_url: string;
|
||||
ext_media_color?: {
|
||||
palette?: MediaPlaceholderColor[]
|
||||
};
|
||||
id_str: string;
|
||||
indices: [number, number];
|
||||
media_key: string;
|
||||
media_url: string;
|
||||
media_url_https: string;
|
||||
original_info: { width: number; height: number };
|
||||
sizes: {
|
||||
thumb: TweetMediaSize;
|
||||
large: TweetMediaSize;
|
||||
medium: TweetMediaSize;
|
||||
small: TweetMediaSize;
|
||||
};
|
||||
type: 'photo' | 'video';
|
||||
url: string;
|
||||
video_info?: {
|
||||
aspect_ratio: [number, number];
|
||||
duration_millis: number;
|
||||
variants: TweetMediaFormat[];
|
||||
};
|
||||
};
|
||||
|
||||
type CardValue = {
|
||||
type: 'BOOLEAN' | 'STRING',
|
||||
boolean_value: boolean,
|
||||
string_value: string,
|
||||
}
|
||||
|
||||
type TweetCard = {
|
||||
binding_values: {
|
||||
card_url: CardValue;
|
||||
choice1_count?: CardValue,
|
||||
choice2_count?: CardValue,
|
||||
choice3_count?: CardValue,
|
||||
choice4_count?: CardValue,
|
||||
choice1_label?: CardValue,
|
||||
choice2_label?: CardValue,
|
||||
choice3_label?: CardValue,
|
||||
choice4_label?: CardValue,
|
||||
counts_are_final?: CardValue,
|
||||
duration_minutes?: CardValue,
|
||||
end_datetime_utc?: CardValue,
|
||||
},
|
||||
name: string
|
||||
}
|
||||
|
||||
type TweetPartial = {
|
||||
card?: TweetCard;
|
||||
conversation_id_str: string;
|
||||
created_at: string; // date string
|
||||
display_text_range: [number, number];
|
||||
entities: { urls?: TcoExpansion[], media?: TweetMedia[] };
|
||||
extended_entities: { media?: TweetMedia[] };
|
||||
favorite_count: number;
|
||||
in_reply_to_screen_name?: string;
|
||||
in_reply_to_status_id_str?: string;
|
||||
in_reply_to_user_id_str?: string;
|
||||
id_str: string;
|
||||
lang: string;
|
||||
possibly_sensitive_editable: boolean;
|
||||
retweet_count: number;
|
||||
quote_count: number;
|
||||
reply_count: number;
|
||||
source: string;
|
||||
full_text: string;
|
||||
user_id_str: string;
|
||||
user?: UserPartial;
|
||||
};
|
||||
|
||||
type UserPartial = {
|
||||
id_str: string;
|
||||
name: string;
|
||||
screen_name: string;
|
||||
profile_image_url_https: string;
|
||||
profile_image_extensions_media_color?: {
|
||||
palette?: MediaPlaceholderColor[]
|
||||
};
|
||||
}
|
||||
|
||||
type MediaPlaceholderColor = {
|
||||
rgb: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
}
|
||||
}
|
9
src/utils.ts
Normal file
9
src/utils.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
// https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
|
||||
|
||||
const componentToHex = (component: number) => {
|
||||
let hex = component.toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
}
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number) =>
|
||||
`#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`;
|
Loading…
Add table
Reference in a new issue