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 = `
+
+ `
+ 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
+}