Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581/** * Unicode text styling using Mathematical Alphanumeric Symbols (U+1D400–U+1D7FF). * * Toggle styles: bold, italic, underline, strikethrough * Font families: script (fancy), fraktur, doubleStruck, sansSerif, monospace * (mutually exclusive — enabling one replaces the previous font family) * * Underline uses U+0332 COMBINING LOW LINE appended after each character. * Strikethrough uses U+0336 COMBINING LONG STROKE OVERLAY appended after each character. * * Backwards-compatible: toggling a style off restores the original ASCII characters. */
// ── Public types ────────────────────────────────────────────────────────────
export type FontFamily = | 'none' | 'script' | 'fraktur' | 'doubleStruck' | 'sansSerif' | 'monospace';
export type TextStyle = | 'bold' | 'italic' | 'underline' | 'strikethrough' | 'stroke' | 'fancy' // alias for script | 'fraktur' | 'doubleStruck' | 'sansSerif' | 'monospace';
export interface DetectedStyles { bold: boolean; italic: boolean; underline: boolean; strikethrough: boolean; stroke: boolean; fontFamily: FontFamily;}
// ── Constants ───────────────────────────────────────────────────────────────
const COMBINING_LOW_LINE = '\u0332';const COMBINING_LONG_STROKE = '\u0336';const COMBINING_ENCLOSING_SQUARE = '\u20DE';
// ── Internal style keys (one per contiguous Unicode block) ──────────────────
type StyleKey = | 'bold' | 'italic' | 'boldItalic' | 'script' | 'boldScript' | 'fraktur' | 'boldFraktur' | 'doubleStruck' | 'sansSerif' | 'sansSerifBold' | 'sansSerifItalic' | 'sansSerifBoldItalic' | 'monospace';
interface Range { upper: number; lower: number; digits?: number;}
const RANGES: Record<StyleKey, Range> = { bold: { upper: 0x1D400, lower: 0x1D41A, digits: 0x1D7CE }, italic: { upper: 0x1D434, lower: 0x1D44E }, boldItalic: { upper: 0x1D468, lower: 0x1D482 }, script: { upper: 0, lower: 0x1D4B6 }, boldScript: { upper: 0x1D4D0, lower: 0x1D4EA }, fraktur: { upper: 0, lower: 0x1D51E }, boldFraktur: { upper: 0x1D56C, lower: 0x1D586 }, doubleStruck: { upper: 0, lower: 0x1D552, digits: 0x1D7D8 }, sansSerif: { upper: 0x1D5A0, lower: 0x1D5BA, digits: 0x1D7E2 }, sansSerifBold: { upper: 0x1D5D4, lower: 0x1D5EE, digits: 0x1D7EC }, sansSerifItalic: { upper: 0x1D608, lower: 0x1D622 }, sansSerifBoldItalic: { upper: 0x1D63C, lower: 0x1D656 }, monospace: { upper: 0x1D670, lower: 0x1D68A },};
// ── Exception arrays (uppercase / lowercase letters with holes) ─────────────
// Script uppercase — some letters live in the Letterlike Symbols blockconst SCRIPT_UPPER: readonly number[] = [ 0x1D49C, // A 𝒜 0x212C, // B ℬ 0x1D49E, // C 𝒞 0x1D49F, // D 𝒟 0x2130, // E ℰ 0x2131, // F ℱ 0x1D4A2, // G 𝒢 0x210B, // H ℋ 0x2110, // I ℐ 0x1D4A5, // J 𝒥 0x1D4A6, // K 𝒦 0x2112, // L ℒ 0x2133, // M ℳ 0x1D4A9, // N 𝒩 0x1D4AA, // O 𝒪 0x1D4AB, // P 𝒫 0x1D4AC, // Q 𝒬 0x211B, // R ℛ 0x1D4AE, // S 𝒮 0x1D4AF, // T 𝒯 0x1D4B0, // U 𝒰 0x1D4B1, // V 𝒱 0x1D4B2, // W 𝒲 0x1D4B3, // X 𝒳 0x1D4B4, // Y 𝒴 0x1D4B5, // Z 𝒵];
// Fraktur uppercase — some letters live in the Letterlike Symbols blockconst FRAKTUR_UPPER: readonly number[] = [ 0x1D504, // A 𝔄 0x1D505, // B 𝔅 0x212D, // C ℭ 0x1D507, // D 𝔇 0x1D508, // E 𝔈 0x1D509, // F 𝔉 0x1D50A, // G 𝔊 0x210C, // H ℌ 0x2111, // I ℑ 0x1D50D, // J 𝔍 0x1D50E, // K 𝔎 0x1D50F, // L 𝔏 0x1D510, // M 𝔐 0x1D511, // N 𝔑 0x1D512, // O 𝔒 0x1D513, // P 𝔓 0x1D514, // Q 𝔔 0x211C, // R ℜ 0x1D516, // S 𝔖 0x1D517, // T 𝔗 0x1D518, // U 𝔘 0x1D519, // V 𝔙 0x1D51A, // W 𝔚 0x1D51B, // X 𝔛 0x1D51C, // Y 𝔜 0x2128, // Z ℨ];
// Double-struck uppercase — several letters in the Letterlike Symbols blockconst DOUBLE_STRUCK_UPPER: readonly number[] = [ 0x1D538, // A 𝔸 0x1D539, // B 𝔹 0x2102, // C ℂ 0x1D53B, // D 𝔻 0x1D53C, // E 𝔼 0x1D53D, // F 𝔽 0x1D53E, // G 𝔾 0x210D, // H ℍ 0x1D540, // I 𝕀 0x1D541, // J 𝕁 0x1D542, // K 𝕂 0x1D543, // L 𝕃 0x1D544, // M 𝕄 0x2115, // N ℕ 0x1D546, // O 𝕆 0x2119, // P ℙ 0x211A, // Q ℚ 0x211D, // R ℝ 0x1D54A, // S 𝕊 0x1D54B, // T 𝕋 0x1D54C, // U 𝕌 0x1D54D, // V 𝕍 0x1D54E, // W 𝕎 0x1D54F, // X 𝕏 0x1D550, // Y 𝕐 0x2124, // Z ℤ];
// Italic lowercase — h is U+210E (ℎ PLANCK CONSTANT), U+1D455 is a holeconst ITALIC_LOWER: readonly number[] = [ 0x1D44E, // a 0x1D44F, // b 0x1D450, // c 0x1D451, // d 0x1D452, // e 0x1D453, // f 0x1D454, // g 0x210E, // h ℎ 0x1D456, // i 0x1D457, // j 0x1D458, // k 0x1D459, // l 0x1D45A, // m 0x1D45B, // n 0x1D45C, // o 0x1D45D, // p 0x1D45E, // q 0x1D45F, // r 0x1D460, // s 0x1D461, // t 0x1D462, // u 0x1D463, // v 0x1D464, // w 0x1D465, // x 0x1D466, // y 0x1D467, // z];
// ── Resolve (bold, italic, fontFamily) → StyleKey ──────────────────────────
function resolveStyleKey( bold: boolean, italic: boolean, fontFamily: FontFamily,): StyleKey | null { switch (fontFamily) { case 'none': if (bold && italic) return 'boldItalic'; if (bold) return 'bold'; if (italic) return 'italic'; return null; case 'script': // No script+italic or boldScript+italic blocks exist return bold ? 'boldScript' : 'script'; case 'fraktur': // No fraktur+italic block exists return bold ? 'boldFraktur' : 'fraktur'; case 'doubleStruck': // No bold/italic variants return 'doubleStruck'; case 'sansSerif': if (bold && italic) return 'sansSerifBoldItalic'; if (bold) return 'sansSerifBold'; if (italic) return 'sansSerifItalic'; return 'sansSerif'; case 'monospace': // No bold/italic variants return 'monospace'; }}
// ── Forward mapping (regular code point → styled code point) ────────────────
function toStyledCp(regularCp: number, key: StyleKey): number { const range = RANGES[key];
// Uppercase A–Z if (regularCp >= 65 && regularCp <= 90) { const idx = regularCp - 65; if (key === 'script') return SCRIPT_UPPER[idx]; if (key === 'fraktur') return FRAKTUR_UPPER[idx]; if (key === 'doubleStruck') return DOUBLE_STRUCK_UPPER[idx]; return range.upper + idx; }
// Lowercase a–z if (regularCp >= 97 && regularCp <= 122) { const idx = regularCp - 97; if (key === 'italic') return ITALIC_LOWER[idx]; return range.lower + idx; }
// Digits 0–9 if (regularCp >= 48 && regularCp <= 57 && range.digits !== undefined) { return range.digits + (regularCp - 48); }
return regularCp; // not convertible}
// ── Reverse mapping (styled code point → { regular, key }) ─────────────────
interface ReverseEntry { regular: number; key: StyleKey;}
let reverseMap: Map<number, ReverseEntry> | null = null;
function buildReverseMap(): Map<number, ReverseEntry> { const m = new Map<number, ReverseEntry>();
const addForward = (key: StyleKey) => { const range = RANGES[key];
// Uppercase if (key === 'script') { for (let i = 0; i < 26; i++) m.set(SCRIPT_UPPER[i], { regular: 65 + i, key }); } else if (key === 'fraktur') { for (let i = 0; i < 26; i++) m.set(FRAKTUR_UPPER[i], { regular: 65 + i, key }); } else if (key === 'doubleStruck') { for (let i = 0; i < 26; i++) m.set(DOUBLE_STRUCK_UPPER[i], { regular: 65 + i, key }); } else { for (let i = 0; i < 26; i++) m.set(range.upper + i, { regular: 65 + i, key }); }
// Lowercase if (key === 'italic') { for (let i = 0; i < 26; i++) m.set(ITALIC_LOWER[i], { regular: 97 + i, key }); } else { for (let i = 0; i < 26; i++) m.set(range.lower + i, { regular: 97 + i, key }); }
// Digits if (range.digits !== undefined) { for (let i = 0; i < 10; i++) m.set(range.digits + i, { regular: 48 + i, key }); } };
(Object.keys(RANGES) as StyleKey[]).forEach(addForward); return m;}
function getReverseMap(): Map<number, ReverseEntry> { if (!reverseMap) reverseMap = buildReverseMap(); return reverseMap;}
// ── StyleKey → { bold, italic, fontFamily } flags ───────────────────────────
interface StyleFlags { bold: boolean; italic: boolean; fontFamily: FontFamily;}
const STYLE_KEY_FLAGS: Record<StyleKey, StyleFlags> = { bold: { bold: true, italic: false, fontFamily: 'none' }, italic: { bold: false, italic: true, fontFamily: 'none' }, boldItalic: { bold: true, italic: true, fontFamily: 'none' }, script: { bold: false, italic: false, fontFamily: 'script' }, boldScript: { bold: true, italic: false, fontFamily: 'script' }, fraktur: { bold: false, italic: false, fontFamily: 'fraktur' }, boldFraktur: { bold: true, italic: false, fontFamily: 'fraktur' }, doubleStruck: { bold: false, italic: false, fontFamily: 'doubleStruck' }, sansSerif: { bold: false, italic: false, fontFamily: 'sansSerif' }, sansSerifBold: { bold: true, italic: false, fontFamily: 'sansSerif' }, sansSerifItalic: { bold: false, italic: true, fontFamily: 'sansSerif' }, sansSerifBoldItalic: { bold: true, italic: true, fontFamily: 'sansSerif' }, monospace: { bold: false, italic: false, fontFamily: 'monospace' },};
// ── Character-level helpers ─────────────────────────────────────────────────
function isCombining(cp: number): boolean { return (cp >= 0x0300 && cp <= 0x036F) || (cp >= 0x20DD && cp <= 0x20E0);}
/** Yield each Unicode code point with its string index. */function* eachChar(text: string): Generator<{ cp: number; idx: number; len: number }> { let i = 0; while (i < text.length) { const cp = text.codePointAt(i)!; const len = cp > 0xFFFF ? 2 : 1; yield { cp, idx: i, len }; i += len; }}
interface CharStyles { regular: number; bold: boolean; italic: boolean; fontFamily: FontFamily;}
/** Detect the styles of a single (non-combining) character. Returns null if not convertible. */function charStyles(cp: number): CharStyles | null { // Regular ASCII letter/digit if ((cp >= 65 && cp <= 90) || (cp >= 97 && cp <= 122) || (cp >= 48 && cp <= 57)) { return { regular: cp, bold: false, italic: false, fontFamily: 'none' }; } const entry = getReverseMap().get(cp); if (!entry) return null; const flags = STYLE_KEY_FLAGS[entry.key]; return { regular: entry.regular, ...flags };}
// ── Underline helpers ───────────────────────────────────────────────────────
function stripCombining(text: string): { cleaned: string; hadUnderline: boolean; hadStrikethrough: boolean; hadStroke: boolean } { let hadUnderline = false; let hadStrikethrough = false; let hadStroke = false; let result = ''; for (const { cp } of eachChar(text)) { if (cp === 0x0332) { hadUnderline = true; } else if (cp === 0x0336) { hadStrikethrough = true; } else if (cp === 0x20DE) { hadStroke = true; } else { result += String.fromCodePoint(cp); } } // Restore normal spaces where we used no-break spaces for continuous underline/strikethrough if (hadUnderline || hadStrikethrough || hadStroke) { result = result.replace(/\u00A0/g, ' '); } return { cleaned: result, hadUnderline, hadStrikethrough, hadStroke };}
// @ts-ignorefunction addUnderlines(text: string): string { let result = ''; for (const { cp } of eachChar(text)) { if (cp === 0x0020) { // Replace space with no-break space + combining low line for continuous underline result += '\u00A0' + COMBINING_LOW_LINE; } else { result += String.fromCodePoint(cp); if (!isCombining(cp)) result += COMBINING_LOW_LINE; } } return result;}
// @ts-ignorefunction addStrikethroughs(text: string): string { let result = ''; for (const { cp } of eachChar(text)) { if (cp === 0x0020) { // Replace space with no-break space + combining long stroke for continuous strikethrough result += '\u00A0' + COMBINING_LONG_STROKE; } else { result += String.fromCodePoint(cp); if (!isCombining(cp)) result += COMBINING_LONG_STROKE; } } return result;}
/** Re-apply underline, strikethrough, and stroke combining marks to text. */function addCombiningMarks(text: string, underline: boolean, strikethrough: boolean, stroke: boolean): string { if (!underline && !strikethrough && !stroke) return text; let result = ''; for (const { cp } of eachChar(text)) { if (cp === 0x0020) { result += '\u00A0'; if (underline) result += COMBINING_LOW_LINE; if (strikethrough) result += COMBINING_LONG_STROKE; if (stroke) result += COMBINING_ENCLOSING_SQUARE; } else { result += String.fromCodePoint(cp); if (!isCombining(cp)) { if (underline) result += COMBINING_LOW_LINE; if (strikethrough) result += COMBINING_LONG_STROKE; if (stroke) result += COMBINING_ENCLOSING_SQUARE; } } } return result;}
// ── Core: process every character, applying a style toggle ──────────────────
type FontFamilyStyle = 'fancy' | 'fraktur' | 'doubleStruck' | 'sansSerif' | 'monospace';
function processChars( text: string, targetStyle: 'bold' | 'italic' | FontFamilyStyle, enable: boolean,): string { const out: string[] = []; for (const { cp } of eachChar(text)) { const s = charStyles(cp); if (!s) { // Non-convertible character (space, punctuation, emoji…) — pass through out.push(String.fromCodePoint(cp)); continue; }
let { bold, italic, fontFamily } = s;
switch (targetStyle) { case 'bold': bold = enable; break; case 'italic': italic = enable; break; case 'fancy': fontFamily = enable ? 'script' : 'none'; break; case 'fraktur': fontFamily = enable ? 'fraktur' : 'none'; break; case 'doubleStruck': fontFamily = enable ? 'doubleStruck' : 'none'; break; case 'sansSerif': fontFamily = enable ? 'sansSerif' : 'none'; break; case 'monospace': fontFamily = enable ? 'monospace' : 'none'; break; }
const key = resolveStyleKey(bold, italic, fontFamily); const newCp = key ? toStyledCp(s.regular, key) : s.regular; out.push(String.fromCodePoint(newCp)); } return out.join('');}
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Map a public TextStyle to the internal FontFamily name. */export function styleToFontFamily(style: TextStyle): FontFamily { switch (style) { case 'fancy': return 'script'; case 'fraktur': return 'fraktur'; case 'doubleStruck': return 'doubleStruck'; case 'sansSerif': return 'sansSerif'; case 'monospace': return 'monospace'; default: return 'none'; }}
// ── Public API ──────────────────────────────────────────────────────────────
/** * Toggle a text style on the given string. * * Behaviour: * - Examines the **first convertible character** to decide whether to enable * or disable the style. * - Applies the decision uniformly to every character. * - Font families are mutually exclusive: enabling one replaces the previous. * - Underline is handled separately via combining low lines. */export function toggleStyle(text: string, style: TextStyle): string { // 1. Separate combining marks from the text const { cleaned, hadUnderline, hadStrikethrough, hadStroke } = stripCombining(text);
if (style === 'underline') { const newUnderline = !hadUnderline; return addCombiningMarks(cleaned, newUnderline, hadStrikethrough, hadStroke); }
if (style === 'strikethrough') { const newStrikethrough = !hadStrikethrough; return addCombiningMarks(cleaned, hadUnderline, newStrikethrough, hadStroke); }
if (style === 'stroke') { const newStroke = !hadStroke; return addCombiningMarks(cleaned, hadUnderline, hadStrikethrough, newStroke); }
// 2. Determine toggle direction from the first convertible char let enable = true; for (const { cp } of eachChar(cleaned)) { const s = charStyles(cp); if (s) { if (style === 'bold') { enable = !s.bold; } else if (style === 'italic') { enable = !s.italic; } else { // Font family style: enable if current family is different enable = s.fontFamily !== styleToFontFamily(style); } break; } }
// 3. Process every character const converted = processChars(cleaned, style as 'bold' | 'italic' | FontFamilyStyle, enable);
// 4. Restore combining marks if they were present return addCombiningMarks(converted, hadUnderline, hadStrikethrough, hadStroke);}
/** * Detect the styles present in the text based on the **first convertible * character** and whether any combining low lines are present. */export function detectStyles(text: string): DetectedStyles { const { cleaned, hadUnderline, hadStrikethrough, hadStroke } = stripCombining(text);
let bold = false; let italic = false; let fontFamily: FontFamily = 'none';
for (const { cp } of eachChar(cleaned)) { const s = charStyles(cp); if (s) { bold = s.bold; italic = s.italic; fontFamily = s.fontFamily; break; } }
return { bold, italic, underline: hadUnderline, strikethrough: hadStrikethrough, stroke: hadStroke, fontFamily };}