diff --git a/packages/otfjs-ui/src/components/font-source-selector/font-source-selector.tsx b/packages/otfjs-ui/src/components/font-source-selector/font-source-selector.tsx new file mode 100644 index 0000000..9b64492 --- /dev/null +++ b/packages/otfjs-ui/src/components/font-source-selector/font-source-selector.tsx @@ -0,0 +1,38 @@ +export type FontSource = 'google' | 'local' + +export interface FontSourceSelectorProps { + value: FontSource + onChange: (source: FontSource) => void +} + +export function FontSourceSelector({ + value, + onChange, +}: FontSourceSelectorProps) { + return ( +
+ + +
+ ) +} diff --git a/packages/otfjs-ui/src/components/no-font-view/components/search-bar.module.css b/packages/otfjs-ui/src/components/no-font-view/components/search-bar.module.css index c000a11..8640f0f 100644 --- a/packages/otfjs-ui/src/components/no-font-view/components/search-bar.module.css +++ b/packages/otfjs-ui/src/components/no-font-view/components/search-bar.module.css @@ -1,9 +1,6 @@ .container { --color-icon: currentcolor; - grid-area: search; - place-self: center; - position: sticky; top: 20px; display: flex; diff --git a/packages/otfjs-ui/src/components/no-font-view/local-font-grid.tsx b/packages/otfjs-ui/src/components/no-font-view/local-font-grid.tsx new file mode 100644 index 0000000..e56eac5 --- /dev/null +++ b/packages/otfjs-ui/src/components/no-font-view/local-font-grid.tsx @@ -0,0 +1,326 @@ +import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' + +import type { LocalFontData } from '../../hooks/use-local-font-access' +import { useTimeoutAfterSet } from '../../hooks/use-timeout-after-set' +import { handle } from '../../shortcuts/shortcuts' +import { createElementWalkerFactory } from '../../utils/dom' +import { addListener } from '../../utils/event' +import { readAndCacheFont } from '../../utils/fetch-font' + +import styles from '../no-font-view/font-grid.module.css' + +type CellEl = HTMLButtonElement + +export interface LocalFontGridProps { + fonts: LocalFontData[] + filter?: string +} + +export const LocalFontGrid = memo(function LocalFontGrid({ + fonts, + filter, +}: LocalFontGridProps) { + const ref = useRef(null) + const getColumns = useColumns(ref) + const navigate = useNavigate() + + const filteredFonts = useMemo(() => { + if (!filter) return fonts + return fonts.filter((font) => + font.family.toLowerCase().includes(filter.toLowerCase()), + ) + }, [fonts, filter]) + + const preloadFont = async (fontData: LocalFontData) => { + try { + const blob = await fontData.blob() + const arrayBuffer = await blob.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + const fontId = await readAndCacheFont(uint8Array) + return fontId + } catch (err) { + console.error('Failed to load font:', err) + return null + } + } + + const setFocusedButton = useTimeoutAfterSet(400, (el: HTMLButtonElement) => { + const fontFamily = el.getAttribute('data-name') + const fontData = filteredFonts.find((f) => f.family === fontFamily) + if (fontData) { + void preloadFont(fontData) + } + }) + + const createWalker = useMemo( + () => + createElementWalkerFactory( + ref, + (node): node is HTMLButtonElement => node.tagName === 'BUTTON', + ), + [], + ) + + return ( +
{ + const key = handle(e) + const t = e.target as HTMLButtonElement + + switch (key.value) { + case 'H': + case '⌃P': + case 'ArrowLeft': { + return key.accept(() => { + const walker = createWalker(t) + walker.previousNode()?.focus() + }) + } + + case 'L': + case '⌃N': + case 'ArrowRight': { + return key.accept(() => { + const walker = createWalker(t) + walker.nextNode()?.focus() + }) + } + + case 'K': + case '⌃U': + case 'ArrowUp': { + return key.accept(() => { + const walker = createWalker(t) + let el: HTMLButtonElement | null = t + for (let i = 0; i < getColumns(); ++i) { + el = walker.previousNode() + } + el?.focus() + }) + } + + case 'J': + case '⌃D': + case 'ArrowDown': { + return key.accept(() => { + const walker = createWalker(t) + let el: HTMLButtonElement | null = t + for (let i = 0; i < getColumns(); ++i) { + el = walker.nextNode() + } + el?.focus() + }) + } + + case 'PageUp': { + return key.accept(() => { + let rowsToMove = 0 + const c = Array.from(ref.current!.children) + const cell = t.parentElement! + const cols = getColumns() + let i = c.findIndex((el) => el === cell) + i = i - (i % cols) + let el = c[i] as CellEl + while (0 <= i - cols * (rowsToMove + 1)) { + ++rowsToMove + el = c[i - cols * rowsToMove] as CellEl + const box = el.getBoundingClientRect() + if (box.top < 0) break + } + + el.querySelector('button')!.focus() + }) + } + + case 'PageDown': { + return key.accept(() => { + let rowsToMove = 0 + const c = Array.from(ref.current!.children) + const cell = t.parentElement! + const cols = getColumns() + let i = c.findIndex((el) => el === cell) + i = i - (i % cols) + let el = c[i] as CellEl + while (c.length > i + cols * (rowsToMove + 1)) { + ++rowsToMove + el = c[i + cols * rowsToMove] as CellEl + const box = el.getBoundingClientRect() + if (box.bottom > window.innerHeight) break + } + + el.querySelector('button')!.focus() + }) + } + + case 'Home': { + return key.accept(() => { + const c = Array.from(ref.current!.children) + const cell = t.parentElement! + const i = c.findIndex((el) => el === cell) + const cols = getColumns() + ;(c[i - (i % cols)] as CellEl).querySelector('button')!.focus() + }) + } + + case 'End': { + return key.accept(() => { + const c = Array.from(ref.current!.children) + const cell = t.parentElement! + const i = c.findIndex((el) => el === cell) + const cols = getColumns() + ;(c[i - (i % cols) + cols - 1] as CellEl) + .querySelector('button')! + .focus() + }) + } + + case '⌃Home': { + return key.accept(() => { + ;(ref.current!.firstElementChild as CellEl) + .querySelector('button')! + .focus() + }) + } + + case '⌃End': { + return key.accept(() => { + ;(ref.current!.lastElementChild as CellEl) + .querySelector('button')! + .focus() + }) + } + } + }} + onPointerDown={(e) => { + if (e.target instanceof HTMLButtonElement) { + const fontFamily = e.target.getAttribute('data-name') + const fontData = filteredFonts.find((f) => f.family === fontFamily) + if (fontData) { + void preloadFont(fontData) + } + } + }} + onFocus={(e) => { + if (e.target instanceof HTMLButtonElement) { + setFocusedButton(e.target) + } + }} + > + {filteredFonts.map((font) => ( + { + void (async () => { + const fontId = await preloadFont(font) + if (fontId !== null) { + void navigate({ to: '/', state: { fontId } }) + } + })() + }} + /> + ))} +
+ ) +}) + +interface LocalFontTileProps { + fontData: LocalFontData + onClick: () => void +} + +function LocalFontTile({ fontData, onClick }: LocalFontTileProps) { + const [previewSvg, setPreviewSvg] = useState('') + + useEffect(() => { + // Generate preview SVG for the local font + const generatePreview = async () => { + try { + const blob = await fontData.blob() + const arrayBuffer = await blob.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + + // For now, we'll use a simple text-based preview + // In a real implementation, you'd parse the font and render glyphs + const svg = ` + + + Aa + + ` + setPreviewSvg(svg) + } catch (err) { + console.error('Failed to generate preview:', err) + } + } + + void generatePreview() + }, [fontData]) + + return ( +
+ +
+ ) +} + +function useColumns(ref: React.RefObject) { + const columns = useRef(null) + + useEffect( + () => + addListener(window, 'resize', () => { + columns.current = null + }), + [], + ) + + return () => { + if (columns.current !== null) return columns.current + + let lastLeft = 0 + let count = 0 + + for (const child of ref.current!.children) { + const box = child.getBoundingClientRect() + if (box.left < lastLeft) { + columns.current = count + break + } + + lastLeft = box.left + ++count + } + + return count + } +} diff --git a/packages/otfjs-ui/src/components/no-font-view/no-font-view.module.css b/packages/otfjs-ui/src/components/no-font-view/no-font-view.module.css index 630bddd..523e913 100644 --- a/packages/otfjs-ui/src/components/no-font-view/no-font-view.module.css +++ b/packages/otfjs-ui/src/components/no-font-view/no-font-view.module.css @@ -5,6 +5,14 @@ grid-template-columns: 26px 1fr auto 1fr 26px; grid-template-rows: auto 1fr; grid-template-areas: - '. . search . .' + '. . controls . .' 'grid grid grid grid grid'; } + +.controls { + grid-area: controls; + display: flex; + align-items: center; + gap: 16px; + justify-content: center; +} diff --git a/packages/otfjs-ui/src/components/no-font-view/no-font-view.tsx b/packages/otfjs-ui/src/components/no-font-view/no-font-view.tsx index 1567afc..336d3be 100644 --- a/packages/otfjs-ui/src/components/no-font-view/no-font-view.tsx +++ b/packages/otfjs-ui/src/components/no-font-view/no-font-view.tsx @@ -1,19 +1,44 @@ -import { useDeferredValue, useState } from 'react' +import { useDeferredValue, useEffect, useState } from 'react' import fonts from '../../fonts.json' +import { + useLocalFontAccessSupport, + useLocalFonts, +} from '../../hooks/use-local-font-access' +import { + FontSource, + FontSourceSelector, +} from '../font-source-selector/font-source-selector' import { SearchBar } from './components/search-bar' import { FontGrid } from './font-grid' +import { LocalFontGrid } from './local-font-grid' import styles from './no-font-view.module.css' export function NoFontView() { const [filter, setFilter] = useState('') const deferredSearch = useDeferredValue(filter) + const [fontSource, setFontSource] = useState('google') + const isLocalFontSupported = useLocalFontAccessSupport() + const { fonts: localFonts, fetchLocalFonts } = useLocalFonts() + + useEffect(() => { + if (fontSource === 'local' && localFonts.length === 0) { + void fetchLocalFonts() + } + }, [fontSource, localFonts.length, fetchLocalFonts]) return (
- - +
+ + {isLocalFontSupported && ( + + )} +
+ {fontSource === 'google' ? + + : }
) } diff --git a/packages/otfjs-ui/src/hooks/use-local-font-access.ts b/packages/otfjs-ui/src/hooks/use-local-font-access.ts new file mode 100644 index 0000000..92e231a --- /dev/null +++ b/packages/otfjs-ui/src/hooks/use-local-font-access.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' + +export interface LocalFontData { + family: string + fullName: string + postscriptName: string + style: string + blob: () => Promise +} + +/** + * Hook to check if the Local Font Access API is supported in the current browser + */ +export function useLocalFontAccessSupport(): boolean { + const [isSupported, setIsSupported] = useState(false) + + useEffect(() => { + setIsSupported('queryLocalFonts' in window) + }, []) + + return isSupported +} + +/** + * Hook to fetch local fonts using the Local Font Access API + */ +export function useLocalFonts() { + const [fonts, setFonts] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchLocalFonts = async () => { + if (!('queryLocalFonts' in window)) { + setError(new Error('Local Font Access API not supported')) + return + } + + try { + setLoading(true) + setError(null) + + const availableFonts: FontData[] = await window.queryLocalFonts() + + setFonts(availableFonts) + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch local fonts')) + } finally { + setLoading(false) + } + } + + return { fonts, loading, error, fetchLocalFonts } +} diff --git a/packages/otfjs-ui/src/vite-env.d.ts b/packages/otfjs-ui/src/vite-env.d.ts index 11f02fe..3e05062 100644 --- a/packages/otfjs-ui/src/vite-env.d.ts +++ b/packages/otfjs-ui/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +// Local Font Access API types +interface FontData { + family: string + fullName: string + postscriptName: string + style: string + blob(): Promise +} + +interface Window { + queryLocalFonts(): Promise +}