Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { cssFamilyFor as customCssFamily } from '#/lib/customFonts'
import { tokenize } from '#/lib/shiki'
import {
CODE_FONTS,
Expand All @@ -25,7 +26,7 @@ interface Props {
}

const fontFamilyFor = (font: string): string =>
CODE_FONTS.find((f) => f.value === font)?.cssFamily ?? font
CODE_FONTS.find((f) => f.value === font)?.cssFamily ?? customCssFamily(font)

const overlayColor = (hex: string): string => {
const m = hex.match(/^#?([0-9a-fA-F]{6})$/)
Expand Down
160 changes: 137 additions & 23 deletions src/components/FontCombobox.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useState, useSyncExternalStore } from 'react'
import { useRef, useState, useSyncExternalStore } from 'react'
import { LuLigature } from 'react-icons/lu'
import {
RiAddLine,
RiArrowDownSLine,
RiCheckboxBlankCircleFill,
RiCheckboxBlankCircleLine,
RiDeleteBinLine,
RiErrorWarningLine,
RiLoader4Line,
} from 'react-icons/ri'
import { LuLigature } from 'react-icons/lu'
import { Button } from '#/components/ui/button'
import {
Command,
Expand All @@ -15,31 +17,48 @@ import {
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '#/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '#/components/ui/popover'
import {
addCustomFont,
cssFamilyFor as customCssFamily,
type CustomFont,
listCustomFonts,
readFontFile,
removeCustomFont,
subscribeCustomFonts,
} from '#/lib/customFonts'
import {
type FontStatus,
getFontStatus,
subscribeFontStatus,
} from '#/lib/fonts'
import { CODE_FONTS, type CodeFont } from '#/lib/types'
import { CODE_FONTS } from '#/lib/types'

interface Props {
value: CodeFont
onChange: (next: CodeFont) => void
value: string
onChange: (next: string) => void
}

const useFontStatus = (font: CodeFont): FontStatus =>
const useFontStatus = (font: string): FontStatus =>
useSyncExternalStore(
subscribeFontStatus,
() => getFontStatus(font),
() => 'idle' as FontStatus,
)

const useCustomFonts = (): ReadonlyArray<CustomFont> =>
useSyncExternalStore(
subscribeCustomFonts,
listCustomFonts,
() => [] as ReadonlyArray<CustomFont>,
)

const StatusIcon = ({ status }: { status: FontStatus }) => {
if (status === 'loading')
return (
Expand Down Expand Up @@ -93,12 +112,14 @@ const FontRow = ({
ligatures,
selected,
onSelect,
trailing,
}: {
value: CodeFont
value: string
cssFamily: string
ligatures: boolean
selected: boolean
onSelect: () => void
trailing?: React.ReactNode
}) => {
const status = useFontStatus(value)
return (
Expand All @@ -118,14 +139,60 @@ const FontRow = ({
<Slot>
<StatusIcon status={status} />
</Slot>
{trailing}
</CommandItem>
)
}

const CurrentFontPreview = ({ value }: { value: string }) => {
const status = useFontStatus(value)
const builtIn = CODE_FONTS.find((f) => f.value === value)
const family = builtIn?.cssFamily ?? customCssFamily(value)
return (
<span className="flex min-w-0 items-center gap-2 truncate">
<span
className="truncate"
style={{ fontFamily: status === 'loaded' ? family : undefined }}
>
{value || 'Pick a font'}
</span>
<StatusIcon status={status} />
</span>
)
}

const ACCEPTED_FONT_TYPES = '.woff2,.woff,.ttf,.otf'

export function FontCombobox({ value, onChange }: Props) {
const [open, setOpen] = useState(false)
const current = CODE_FONTS.find((f) => f.value === value)
const currentStatus = useFontStatus(value)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const customFonts = useCustomFonts()

const handleFile = async (file: File): Promise<void> => {
setUploadError(null)
try {
const { defaultName, variant } = await readFontFile(file)
const name = defaultName || file.name
addCustomFont({ name, variants: [variant] })
onChange(name)
setOpen(false)
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to add font'
setUploadError(message)
}
}

const onFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const file = e.target.files?.[0]
e.target.value = ''
if (file) void handleFile(file)
}

const handleRemove = (name: string): void => {
removeCustomFont(name)
if (value === name) onChange('JetBrains Mono')
}

return (
<Popover open={open} onOpenChange={setOpen}>
Expand All @@ -136,18 +203,7 @@ export function FontCombobox({ value, onChange }: Props) {
aria-expanded={open}
className="w-full justify-between bg-transparent font-normal text-foreground"
>
<span className="flex min-w-0 items-center gap-2 truncate">
<span
className="truncate"
style={{
fontFamily:
currentStatus === 'loaded' ? current?.cssFamily : undefined,
}}
>
{current?.value ?? 'Pick a font'}
</span>
<StatusIcon status={currentStatus} />
</span>
<CurrentFontPreview value={value} />
<RiArrowDownSLine className="text-muted-foreground" />
</Button>
</PopoverTrigger>
Expand All @@ -157,9 +213,43 @@ export function FontCombobox({ value, onChange }: Props) {
>
<Command>
<CommandInput placeholder="Search font…" />
<CommandList className="max-h-72">
<CommandList className="max-h-80">
<CommandEmpty>No font found.</CommandEmpty>
<CommandGroup>
{customFonts.length > 0 && (
<>
<CommandGroup heading="Custom">
{customFonts.map((f) => (
<FontRow
key={`custom:${f.name}`}
value={f.name}
cssFamily={customCssFamily(f.name)}
ligatures={false}
selected={value === f.name}
onSelect={() => {
onChange(f.name)
setOpen(false)
}}
trailing={
<button
type="button"
aria-label={`Remove ${f.name}`}
onClick={(e) => {
e.stopPropagation()
handleRemove(f.name)
}}
onPointerDown={(e) => e.stopPropagation()}
className="ml-1 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:bg-destructive/15 hover:text-destructive"
>
<RiDeleteBinLine className="size-3.5" />
</button>
}
/>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="Built-in">
{CODE_FONTS.map((f) => (
<FontRow
key={f.value}
Expand All @@ -175,6 +265,30 @@ export function FontCombobox({ value, onChange }: Props) {
))}
</CommandGroup>
</CommandList>
<div className="border-t p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-xs"
onClick={() => fileInputRef.current?.click()}
>
<RiAddLine className="size-3.5" />
Upload local font (.woff2, .woff, .ttf, .otf)
</Button>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FONT_TYPES}
className="hidden"
onChange={onFileChange}
/>
{uploadError && (
<p className="px-2 pt-1.5 text-xs text-destructive">
{uploadError}
</p>
)}
</div>
</Command>
</PopoverContent>
</Popover>
Expand Down
133 changes: 133 additions & 0 deletions src/lib/customFonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Local custom fonts uploaded by the user. The user picks a font file from
// disk; we read the bytes, persist them to localStorage as base64, and make
// the font available to both render paths:
// * DOM preview — registered via FontFace on document.fonts
// * Satori export — surfaced through loadCodeFont in fonts.ts
//
// Storage is intentionally simple (one localStorage key, base64 payload).
// Browser monospace fonts in woff/woff2 are typically well under the ~5 MB
// localStorage quota even at a few entries. If the quota is exceeded the
// save throws and the upload is reported as failed.

export type CustomFontStyle = 'normal' | 'italic'
export type CustomFontWeight = 400 | 500

export interface CustomFontVariant {
readonly weight: CustomFontWeight
readonly style: CustomFontStyle
readonly data: string // base64-encoded font bytes
readonly mime: string
}

export interface CustomFont {
readonly name: string
readonly variants: ReadonlyArray<CustomFontVariant>
}

const STORAGE_KEY = 'code-pretty:custom-fonts:v1'

const listeners = new Set<() => void>()
let cache: ReadonlyArray<CustomFont> | undefined

const notify = (): void => {
for (const l of listeners) l()
}

const readStorage = (): ReadonlyArray<CustomFont> => {
if (typeof window === 'undefined') return []
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed as ReadonlyArray<CustomFont>
} catch {
return []
}
}

const writeStorage = (fonts: ReadonlyArray<CustomFont>): void => {
if (typeof window === 'undefined') return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(fonts))
}

export const listCustomFonts = (): ReadonlyArray<CustomFont> => {
if (cache === undefined) cache = readStorage()
return cache
}

export const subscribeCustomFonts = (cb: () => void): (() => void) => {
listeners.add(cb)
return () => {
listeners.delete(cb)
}
}

export const findCustomFont = (name: string): CustomFont | undefined =>
listCustomFonts().find((f) => f.name === name)

export const cssFamilyFor = (name: string): string =>
`"${name.replace(/"/g, '\\"')}", monospace`

const replaceAll = (next: ReadonlyArray<CustomFont>): void => {
writeStorage(next)
cache = next
notify()
}

export const addCustomFont = (font: CustomFont): void => {
const rest = listCustomFonts().filter((f) => f.name !== font.name)
replaceAll([...rest, font])
}

export const removeCustomFont = (name: string): void => {
replaceAll(listCustomFonts().filter((f) => f.name !== name))
}

const bytesToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer)
// Chunked to avoid String.fromCharCode argument-count limits on large files.
const chunkSize = 0x8000
let binary = ''
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
return btoa(binary)
}

export const base64ToBytes = (b64: string): ArrayBuffer => {
const binary = atob(b64)
const out = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
return out.buffer
}

const mimeFor = (filename: string): string => {
const ext = filename.toLowerCase().split('.').pop() ?? ''
if (ext === 'woff2') return 'font/woff2'
if (ext === 'woff') return 'font/woff'
if (ext === 'otf') return 'font/otf'
return 'font/ttf'
}

const stripExt = (filename: string): string =>
filename.replace(/\.(woff2?|otf|ttf)$/i, '').trim()

export interface ReadFileResult {
readonly defaultName: string
readonly variant: CustomFontVariant
}

export const readFontFile = async (file: File): Promise<ReadFileResult> => {
const buffer = await file.arrayBuffer()
return {
defaultName: stripExt(file.name),
variant: {
weight: 400,
style: 'normal',
data: bytesToBase64(buffer),
mime: mimeFor(file.name),
},
}
}
Loading