dragonflight/services/editor/apps/image/src/components/ui/FontPicker.tsx
Zac b68f0c6aba feat(editor): integrate openreel-video as services/editor with MAM hooks
Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
2026-05-17 21:44:37 -04:00

170 lines
6 KiB
TypeScript

import { useState, useEffect, useRef, useMemo } from 'react';
import { Search, Check, ChevronDown, Loader2 } from 'lucide-react';
import {
getPopularFonts,
filterFonts,
loadGoogleFont,
isFontLoaded,
FONT_CATEGORIES,
type GoogleFont,
} from '../../services/fonts-service';
interface FontPickerProps {
value: string;
onChange: (fontFamily: string) => void;
}
export function FontPicker({ value, onChange }: FontPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const [loadingFont, setLoadingFont] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const fonts = useMemo(() => getPopularFonts(), []);
const filteredFonts = useMemo(() => filterFonts(fonts, category, search), [fonts, category, search]);
useEffect(() => {
loadGoogleFont(value);
}, [value]);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
useEffect(() => {
if (isOpen && listRef.current) {
filteredFonts.slice(0, 10).forEach((font) => {
if (!isFontLoaded(font.family)) {
loadGoogleFont(font.family, ['400']);
}
});
}
}, [isOpen, filteredFonts]);
const handleSelect = async (font: GoogleFont) => {
setLoadingFont(font.family);
try {
await loadGoogleFont(font.family, font.variants.slice(0, 4));
onChange(font.family);
setIsOpen(false);
setSearch('');
} finally {
setLoadingFont(null);
}
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const container = e.currentTarget;
const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (scrollBottom < 200) {
const startIndex = Math.floor(container.scrollTop / 40);
const endIndex = Math.min(startIndex + 15, filteredFonts.length);
filteredFonts.slice(startIndex, endIndex).forEach((font) => {
if (!isFontLoaded(font.family)) {
loadGoogleFont(font.family, ['400']);
}
});
}
};
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs bg-background border border-input rounded-md hover:border-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary transition-colors"
>
<span style={{ fontFamily: value }} className="truncate">
{value}
</span>
<ChevronDown size={12} className={`text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute z-50 top-full left-0 mt-1 w-64 bg-popover border border-border rounded-lg shadow-lg overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
<div className="p-2 border-b border-border">
<div className="relative">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search fonts..."
className="w-full pl-7 pr-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
/>
</div>
</div>
<div className="flex flex-wrap gap-1 p-2 border-b border-border">
{FONT_CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => setCategory(cat.id)}
className={`px-2 py-0.5 text-[10px] rounded-full transition-colors ${
category === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
{cat.name}
</button>
))}
</div>
<div
ref={listRef}
onScroll={handleScroll}
className="max-h-64 overflow-y-auto"
>
{filteredFonts.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
No fonts found
</div>
) : (
filteredFonts.map((font) => (
<button
key={font.family}
onClick={() => handleSelect(font)}
disabled={loadingFont === font.family}
className={`w-full flex items-center justify-between px-3 py-2 text-left hover:bg-accent transition-colors ${
value === font.family ? 'bg-accent/50' : ''
}`}
>
<div className="flex-1 min-w-0">
<span
style={{ fontFamily: font.family }}
className="block text-sm truncate"
>
{font.family}
</span>
<span className="text-[10px] text-muted-foreground capitalize">
{font.category}
</span>
</div>
{loadingFont === font.family ? (
<Loader2 size={14} className="animate-spin text-muted-foreground" />
) : value === font.family ? (
<Check size={14} className="text-primary" />
) : null}
</button>
))
)}
</div>
</div>
)}
</div>
);
}