Fonts used in this animation
- This preview uses ${SYSTEM_SANS.label} for missing fonts. To render with the correct typefaces, host the .woff2 files alongside this HTML and add the @font-face rules below to your CSS:
+ This preview uses ${SYSTEM_SANS.label} for missing fonts. To render with the correct typefaces, host the font files alongside this HTML and add the @font-face rules below to your CSS:
-${usedFonts.map(f => ` - ${fontPrimary(f.id)} —
${f.file.replace(/^\//, '')} `).join('\n')}
+${builtinUsed.map(f => ` - ${fontPrimary(f.id)} —
${f.file.replace(/^\//, '')} `).join('\n')}${
+ customUsed.length > 0
+ ? '\n' + customUsed.map(f => ` - ${escapeHtml(f.label)} — locally uploaded; the @font-face below has the file inlined as a data URL
`).join('\n')
+ : ''
+ }
${escapeHtml(fontFaceCss(usedFontIds))}
`
diff --git a/src/lib/fonts.ts b/src/lib/fonts.ts
index 1f9fb69..3fa7bf2 100644
--- a/src/lib/fonts.ts
+++ b/src/lib/fonts.ts
@@ -1,10 +1,11 @@
// Curated set of Google Fonts shipped with the editor as local
-// .woff2 files (in /public/fonts). Adding a new entry needs three
-// things: (1) drop the .woff2 next to the others, (2) add an entry
-// here, (3) update FONT_FACE_CSS so the export player's HTML preview
-// can hint users at @font-face declarations they need to bundle.
+// .woff2 files (in /public/fonts), plus user-uploaded fonts kept in
+// localStorage as data-URLs. Adding a built-in needs three things:
+// (1) drop the .woff2 next to the others, (2) add an entry to
+// BUILTIN_FONTS, (3) the example-HTML hint picks it up automatically.
+// Custom (user-uploaded) fonts flow through uploadCustomFont().
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
/** Reactive flag that flips to true once all bundled fonts have
* resolved. Read it inside a computed/watch on the canvas to force
@@ -19,8 +20,11 @@ export interface FontDef {
label: string
/** CSS font-family string (with fallback) used at render time. */
family: string
- /** Path to the .woff2 in /public/fonts. */
+ /** Path to the .woff2 (built-ins) or data-URL (custom). Empty for
+ * the system-sans fallback. */
file: string
+ /** True for user-uploaded fonts. Built-ins are false/undefined. */
+ custom?: boolean
}
export const SYSTEM_SANS: FontDef = {
@@ -31,22 +35,93 @@ export const SYSTEM_SANS: FontDef = {
file: '',
}
-export const AVAILABLE_FONTS: FontDef[] = [
+export const BUILTIN_FONTS: FontDef[] = [
SYSTEM_SANS,
{ id: 'inter', label: 'Inter', family: '"Inter", sans-serif', file: '/fonts/Inter-Regular.woff2' },
{ id: 'roboto', label: 'Roboto', family: '"Roboto", sans-serif', file: '/fonts/Roboto-Regular.woff2' },
+ { id: 'montserrat', label: 'Montserrat', family: '"Montserrat", sans-serif', file: '/fonts/Montserrat-Regular.woff2' },
+ { id: 'nunito', label: 'Nunito', family: '"Nunito", sans-serif', file: '/fonts/Nunito-Regular.woff2' },
+ { id: 'figtree', label: 'Figtree', family: '"Figtree", sans-serif', file: '/fonts/Figtree-Regular.woff2' },
+ { id: 'gabarito', label: 'Gabarito', family: '"Gabarito", sans-serif', file: '/fonts/Gabarito-Regular.woff2' },
{ id: 'lora', label: 'Lora', family: '"Lora", serif', file: '/fonts/Lora-Regular.woff2' },
{ id: 'playfair', label: 'Playfair Display', family: '"Playfair Display", serif', file: '/fonts/PlayfairDisplay-Regular.woff2' },
+ { id: 'archivo-black', label: 'Archivo Black', family: '"Archivo Black", sans-serif', file: '/fonts/ArchivoBlack-Regular.woff2' },
{ id: 'bebas', label: 'Bebas Neue', family: '"Bebas Neue", sans-serif', file: '/fonts/BebasNeue-Regular.woff2' },
+ { id: 'press-start', label: 'Press Start 2P', family: '"Press Start 2P", monospace', file: '/fonts/PressStart2P-Regular.woff2' },
{ id: 'source-code', label: 'Source Code Pro', family: '"Source Code Pro", monospace', file: '/fonts/SourceCodePro-Regular.woff2' },
{ id: 'marker', label: 'Permanent Marker', family: '"Permanent Marker", cursive', file: '/fonts/PermanentMarker-Regular.woff2' },
]
-const fontById = new Map(AVAILABLE_FONTS.map(f => [f.id, f]))
+// ----- Custom (user-uploaded) fonts -----------------------------------------
+
+const CUSTOM_FONTS_KEY = 'animatr:customFonts'
+const CUSTOM_ID_PREFIX = 'custom-'
+
+interface CustomFontStored {
+ id: string
+ label: string
+ /** data:font/woff2;base64,... — also accepts ttf / otf data URLs. */
+ dataUrl: string
+}
+
+interface CustomFontsBlob {
+ version: 1
+ fonts: CustomFontStored[]
+}
+
+function readCustomFontsFromStorage(): CustomFontStored[] {
+ if (typeof localStorage === 'undefined') return []
+ try {
+ const raw = localStorage.getItem(CUSTOM_FONTS_KEY)
+ if (!raw) return []
+ const parsed = JSON.parse(raw) as CustomFontsBlob
+ if (parsed?.version !== 1 || !Array.isArray(parsed.fonts)) return []
+ return parsed.fonts.filter(f => f && typeof f.id === 'string' && typeof f.dataUrl === 'string')
+ } catch {
+ return []
+ }
+}
+
+function persistCustomFonts(fonts: CustomFontStored[]): void {
+ if (typeof localStorage === 'undefined') return
+ const blob: CustomFontsBlob = { version: 1, fonts }
+ localStorage.setItem(CUSTOM_FONTS_KEY, JSON.stringify(blob))
+}
+
+const customFontsState = ref(readCustomFontsFromStorage())
+
+function customFamilyName(id: string): string {
+ // Sanitize so the id can be embedded in a font-family identifier
+ // without escaping. Custom ids only ever come from cryptoId() so
+ // they're already safe, but we belt-and-suspenders for safety.
+ return `RUWIO Custom ${id.replace(/[^a-zA-Z0-9_-]/g, '')}`
+}
+
+function customToDef(c: CustomFontStored): FontDef {
+ const fam = customFamilyName(c.id)
+ return {
+ id: c.id,
+ label: c.label,
+ family: `"${fam}", sans-serif`,
+ file: c.dataUrl,
+ custom: true,
+ }
+}
+
+/** Reactive list of all available fonts: built-ins + user-uploaded. */
+export const AVAILABLE_FONTS = computed(() => {
+ return [...BUILTIN_FONTS, ...customFontsState.value.map(customToDef)]
+})
+
+function fontByIdSnapshot(id: string): FontDef | undefined {
+ for (const f of BUILTIN_FONTS) if (f.id === id) return f
+ const c = customFontsState.value.find(x => x.id === id)
+ return c ? customToDef(c) : undefined
+}
export function fontDef(id: string | undefined | null): FontDef {
if (!id) return SYSTEM_SANS
- return fontById.get(id) ?? SYSTEM_SANS
+ return fontByIdSnapshot(id) ?? SYSTEM_SANS
}
export function fontCssFamily(id: string | undefined | null): string {
@@ -57,55 +132,140 @@ export function fontCssFamily(id: string | undefined | null): string {
* ctx.font shorthand and FontFace registration. */
export function fontPrimary(id: string | undefined | null): string {
const f = fontDef(id).family
- // Family is either '"Quoted Name", fallback' or a bare list.
- // Split on the first comma; trim outer quotes.
const head = f.split(',', 1)[0]?.trim() ?? f
return head.replace(/^"(.*)"$/, '$1')
}
let loadPromise: Promise | null = null
+function registerFontFace(family: string, src: string, format?: string): Promise {
+ if (typeof document === 'undefined' || !document.fonts) return Promise.resolve()
+ const srcSpec = format ? `url(${src}) format('${format}')` : `url(${src})`
+ const face = new FontFace(family, srcSpec, {
+ style: 'normal',
+ weight: '400',
+ display: 'swap',
+ })
+ return face.load().then(loaded => {
+ document.fonts.add(loaded)
+ })
+}
+
export function loadFonts(): Promise {
if (loadPromise) return loadPromise
if (typeof document === 'undefined' || !document.fonts) {
loadPromise = Promise.resolve()
return loadPromise
}
- const tasks = AVAILABLE_FONTS
+ const builtinTasks = BUILTIN_FONTS
.filter(f => f.file)
- .map(async f => {
- try {
- const face = new FontFace(fontPrimary(f.id), `url(${f.file}) format('woff2')`, {
- style: 'normal',
- weight: '400',
- display: 'swap',
- })
- const loaded = await face.load()
- document.fonts.add(loaded)
- } catch {
+ .map(f =>
+ registerFontFace(fontPrimary(f.id), f.file, 'woff2').catch(() => {
// Best-effort. Failure to load a single font shouldn't block
// the editor — text falls back to the next family in the list.
- }
- })
- loadPromise = Promise.all(tasks).then(() => {
+ }),
+ )
+ const customTasks = customFontsState.value.map(c =>
+ registerFontFace(customFamilyName(c.id), c.dataUrl).catch(() => {}),
+ )
+ loadPromise = Promise.all([...builtinTasks, ...customTasks]).then(() => {
fontsReady.value = true
})
return loadPromise
}
+// ----- Upload / remove API --------------------------------------------------
+
+function cryptoId(): string {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, '')
+ return 'x' + Math.random().toString(36).slice(2) + Date.now().toString(36)
+}
+
+function dataUrlFor(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(String(reader.result))
+ reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'))
+ reader.readAsDataURL(file)
+ })
+}
+
+const ACCEPTED_EXT = /\.(woff2?|ttf|otf)$/i
+
+export async function uploadCustomFont(file: File): Promise {
+ if (!ACCEPTED_EXT.test(file.name)) {
+ throw new Error('Unsupported font format. Use .woff2, .woff, .ttf, or .otf.')
+ }
+ const dataUrl = await dataUrlFor(file)
+ const id = `${CUSTOM_ID_PREFIX}${cryptoId()}`
+ const label = file.name.replace(ACCEPTED_EXT, '')
+ // Register with FontFace before persisting so a corrupt file fails
+ // loud instead of poisoning localStorage.
+ await registerFontFace(customFamilyName(id), dataUrl)
+ const next: CustomFontStored = { id, label, dataUrl }
+ const all = [...customFontsState.value, next]
+ try {
+ persistCustomFonts(all)
+ } catch (err) {
+ // Quota: report a friendly message but keep the live registration
+ // (the font still works for this session, it just won't survive
+ // a reload).
+ throw new Error(
+ 'Could not save font to local storage — likely quota full. The font is loaded for this session only.',
+ )
+ }
+ customFontsState.value = all
+ return customToDef(next)
+}
+
+export function removeCustomFont(id: string): void {
+ const all = customFontsState.value.filter(f => f.id !== id)
+ if (all.length === customFontsState.value.length) return
+ persistCustomFonts(all)
+ customFontsState.value = all
+ // Drop the FontFace too so a freshly added font with the same id
+ // isn't shadowed (in practice the id is random so this is defensive).
+ if (typeof document !== 'undefined' && document.fonts) {
+ const fam = customFamilyName(id)
+ const faces = Array.from(document.fonts as unknown as Iterable)
+ for (const f of faces) if (f.family === fam) document.fonts.delete(f)
+ }
+}
+
+export function isCustomFontId(id: string | undefined | null): boolean {
+ return !!id && id.startsWith(CUSTOM_ID_PREFIX)
+}
+
+/** Snapshot of {id → CSS family} used by the export player engine.
+ * Built fresh per export so user-uploaded fonts get included. */
+export function fontFamilyMapSnapshot(): Record {
+ const out: Record = {}
+ for (const f of BUILTIN_FONTS) out[f.id] = f.family
+ for (const c of customFontsState.value) out[c.id] = customToDef(c).family
+ return out
+}
+
/** @font-face block users can paste into their own CSS when bundling
- * the example HTML — used by the export preview hint. */
+ * the example HTML — used by the export preview hint. Custom fonts
+ * are emitted with the data-URL inlined so the snippet is self-
+ * contained even though the export itself doesn't bundle them. */
export function fontFaceCss(ids: string[]): string {
const seen = new Set()
const out: string[] = []
for (const id of ids) {
if (seen.has(id)) continue
seen.add(id)
- const f = fontById.get(id)
+ const f = fontByIdSnapshot(id)
if (!f || !f.file) continue
- out.push(
- `@font-face {\n font-family: '${fontPrimary(f.id)}';\n src: url('.${f.file}') format('woff2');\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`,
- )
+ if (f.custom) {
+ out.push(
+ `@font-face {\n font-family: '${customFamilyName(f.id)}';\n src: url('${f.file}');\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`,
+ )
+ } else {
+ out.push(
+ `@font-face {\n font-family: '${fontPrimary(f.id)}';\n src: url('.${f.file}') format('woff2');\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`,
+ )
+ }
}
return out.join('\n')
}
diff --git a/src/version.ts b/src/version.ts
index 580b2d8..f7e7c6a 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '1.2.6'
+export const APP_VERSION = '1.2.7'
export const APP_NAME = 'RUWIO'