1.2.7: 6 more bundled fonts + custom-font upload + layerActiveAt parity

Built-in additions: Montserrat, Nunito, Figtree, Gabarito (sans),
Archivo Black (heavy display), Press Start 2P (pixel/retro).

Custom font upload (lib/fonts.ts):
- uploadCustomFont(file) reads .woff2/.woff/.ttf/.otf via FileReader,
  registers a FontFace early so a corrupt file fails before storage,
  then persists {id, label, dataUrl} to localStorage under
  'animatr:customFonts'. Quota errors surface a friendly message.
- removeCustomFont(id) drops the FontFace + storage entry. If the
  asset still pointed at the deleted font, the picker resets it to
  System Sans on the calling side.
- AVAILABLE_FONTS becomes a ComputedRef combining BUILTIN_FONTS with
  the reactive customFontsState. fontFamilyMapSnapshot() rebuilds the
  {id → CSS family} map per export so user-uploaded fonts make it
  into the exported runtime.

FontPicker UI:
- Two sections divided by a thin rule — built-ins on top, "Your fonts"
  below — plus an "Upload font…" entry at the bottom that opens a
  hidden file input. Custom rows show a trash button on hover.
- Auto-selects a freshly uploaded font.

Engine source becomes a function (engineSource()) instead of a const,
so each export materializes the latest FONT_FAMILY map. ENGINE_TEMPLATE
holds the static skeleton with a __FONT_FAMILY_MAP__ placeholder.

Example HTML hint:
- Built-in fonts list their .woff2 path; custom fonts get a
  "locally uploaded; @font-face below has the file inlined as a data
  URL" line. The fontFaceCss snippet inlines data URLs for custom
  entries so it's self-contained.

Bug fixes (player layerActiveAt):
- Mirrors interpolate.ts isLayerActiveAt now. The previous
  `if(total<=1) return true` short-circuit made layers with a single
  future keyframe visible at t=0; the lastHold→Infinity heuristic
  ignored end keyframes entirely. Both regressions affected the
  fullscreen preview and standalone exports. Replaced with a strict
  scan: min = earliest non-end kf, max = earliest 'end' kf or +Inf.
  Static layers (no normal kfs) stay always-visible.

Tests (5 new under "text font picker — model + render + export"):
- 6 new built-in ids in FONT_FAMILIES map.
- uploadCustomFont registers + persists + appears in AVAILABLE_FONTS;
  removeCustomFont reverses it. Stubs FontFace and document.fonts.add
  for the test environment via a stubFontFaceForTest() helper.
- uploadCustomFont rejects unsupported extensions.
- Exported runtime FONT_FAMILIES includes uploaded custom font ids.
- FontPicker popover shows the Upload font… entry.
- New "exported runtime layerActiveAt mirrors editor isLayerActiveAt"
  extracts the function from engineSource() and exercises five
  scenarios (single future kf, two normal kfs no end, end kf cuts
  off, static layer always-visible, end kf wins over separated tracks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-26 13:40:26 +02:00
1 parent 8c4f744afc
commit 27d6a47b72
12 files changed
+578 -66

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.6",
"version": "1.2.7",
"type": "module",
"scripts": {
"dev": "vite",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+166 -10
View File
@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhCaretDown, PhCheck } from '@phosphor-icons/vue'
import { AVAILABLE_FONTS, fontDef } from '@/lib/fonts'
import { PhCaretDown, PhCheck, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import {
AVAILABLE_FONTS,
BUILTIN_FONTS,
fontDef,
removeCustomFont,
uploadCustomFont,
} from '@/lib/fonts'
const props = defineProps<{
// Empty string = system-sans (no override). Mirrors the storage
@@ -13,9 +19,13 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const open = ref(false)
const trigger = ref<HTMLButtonElement | null>(null)
const popover = ref<HTMLDivElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const errorMsg = ref<string | null>(null)
const selected = computed(() => fontDef(props.modelValue || undefined))
const customFonts = computed(() => AVAILABLE_FONTS.value.filter(f => f.custom))
function toggle() {
open.value = !open.value
}
@@ -27,6 +37,35 @@ function pick(id: string) {
trigger.value?.focus()
}
function onUploadClick() {
errorMsg.value = null
fileInput.value?.click()
}
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// Reset value so picking the same file twice still triggers change.
input.value = ''
if (!file) return
try {
const newFont = await uploadCustomFont(file)
// Auto-select the freshly uploaded font.
emit('update:modelValue', newFont.id)
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : 'Upload failed.'
}
}
function onDelete(id: string, e: MouseEvent) {
e.stopPropagation()
// If the currently selected font is the one being deleted, fall
// back to system sans — otherwise the asset is left pointing at a
// dead id and silently renders in the fallback family.
if (props.modelValue === id) emit('update:modelValue', '')
removeCustomFont(id)
}
function onWindowPointerDown(e: PointerEvent) {
if (!open.value) return
const t = e.target as Node | null
@@ -54,12 +93,9 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
})
// Anchor the popover beneath the trigger using the trigger's
// post-layout rect. Re-measure when it opens.
const popStyle = ref<Record<string, string>>({})
watch(open, async (isOpen) => {
if (!isOpen) return
// Wait a tick so the popover element exists before measuring.
await Promise.resolve()
const t = trigger.value
if (!t) return
@@ -70,6 +106,10 @@ watch(open, async (isOpen) => {
minWidth: `${r.width}px`,
}
})
function isActive(id: string): boolean {
return (props.modelValue || 'system-sans') === id
}
</script>
<template>
@@ -85,6 +125,13 @@ watch(open, async (isOpen) => {
<span class="label" :style="{ fontFamily: selected.family }">{{ selected.label }}</span>
<PhCaretDown :size="12" class="caret" />
</button>
<input
ref="fileInput"
type="file"
accept=".woff2,.woff,.ttf,.otf,font/woff2,font/woff,font/ttf,font/otf"
class="hidden-file-input"
@change="onFileChange"
/>
<Teleport to="body">
<div
v-if="open"
@@ -93,24 +140,65 @@ watch(open, async (isOpen) => {
:style="popStyle"
role="listbox"
>
<div class="font-popover-section">
<button
v-for="f in AVAILABLE_FONTS"
v-for="f in BUILTIN_FONTS"
:key="f.id"
type="button"
class="font-option"
:class="{ active: (modelValue || 'system-sans') === f.id }"
:class="{ active: isActive(f.id) }"
role="option"
:aria-selected="(modelValue || 'system-sans') === f.id"
:aria-selected="isActive(f.id)"
@click="pick(f.id)"
>
<PhCheck
:size="12"
class="check"
:style="{ visibility: (modelValue || 'system-sans') === f.id ? 'visible' : 'hidden' }"
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
/>
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
</button>
</div>
<div v-if="customFonts.length > 0" class="font-popover-divider"></div>
<div v-if="customFonts.length > 0" class="font-popover-section">
<div class="section-title">Your fonts</div>
<button
v-for="f in customFonts"
:key="f.id"
type="button"
class="font-option"
:class="{ active: isActive(f.id) }"
role="option"
:aria-selected="isActive(f.id)"
@click="pick(f.id)"
>
<PhCheck
:size="12"
class="check"
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
/>
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
<button
type="button"
class="delete-custom"
title="Remove this font"
@click="onDelete(f.id, $event)"
>
<PhTrash :size="12" />
</button>
</button>
</div>
<div class="font-popover-divider"></div>
<button type="button" class="font-option upload-btn" @click="onUploadClick">
<PhUploadSimple :size="12" class="check" />
<span class="option-label">Upload font…</span>
</button>
<div v-if="errorMsg" class="upload-error">{{ errorMsg }}</div>
</div>
</Teleport>
</div>
</template>
@@ -165,6 +253,14 @@ watch(open, async (isOpen) => {
.trigger.open .caret {
transform: rotate(180deg);
}
.hidden-file-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
<style>
@@ -180,10 +276,31 @@ watch(open, async (isOpen) => {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 320px;
max-height: 360px;
overflow-y: auto;
}
.font-popover-section {
display: flex;
flex-direction: column;
gap: 1px;
}
.font-popover-divider {
height: 1px;
background: var(--border);
margin: 4px 2px;
}
.font-popover .section-title {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
padding: 4px 8px 2px;
}
.font-popover .font-option {
display: flex;
align-items: center;
@@ -221,4 +338,43 @@ watch(open, async (isOpen) => {
text-overflow: ellipsis;
white-space: nowrap;
}
.font-popover .delete-custom {
flex-shrink: 0;
width: 22px;
height: 22px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
opacity: 0;
}
.font-popover .font-option:hover .delete-custom {
opacity: 1;
}
.font-popover .delete-custom:hover {
color: var(--danger, #ff6b6b);
border-color: var(--danger, #ff6b6b);
}
.font-popover .upload-btn {
color: var(--text-dim);
}
.font-popover .upload-btn:hover {
color: var(--text);
}
.font-popover .upload-error {
font-size: 11px;
color: var(--danger, #ff6b6b);
padding: 4px 8px;
}
</style>
+177 -2
View File
@@ -141,6 +141,31 @@ function tick(ms = 40): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
/** Replaces window.FontFace and document.fonts.add with stubs so
* uploadCustomFont can run without a real woff2 parser. Returns a
* restore function — call it from a finally block. */
function stubFontFaceForTest(): () => void {
const realFontFace = (window as unknown as { FontFace?: unknown }).FontFace
class StubFontFace {
family: string
constructor(family: string) { this.family = family }
load() { return Promise.resolve(this) }
}
;(window as unknown as { FontFace: unknown }).FontFace = StubFontFace
// document.fonts.add() type-checks its argument against the real
// FontFace, which our stub fails. Replace with a no-op for the
// duration of the test.
const fontSet = document.fonts as unknown as { add: (f: unknown) => void }
const realAdd = fontSet.add
fontSet.add = () => {}
return () => {
if (realFontFace !== undefined) {
(window as unknown as { FontFace: unknown }).FontFace = realFontFace
}
fontSet.add = realAdd
}
}
export function registerAllTests(): void {
suite('scene store — defaults', () => {
test('starts with empty scene, playhead 0, not playing', () => {
@@ -15025,8 +15050,8 @@ export function registerAllTests(): void {
})
test('fontCssFamily resolves known ids and falls back to system sans', async () => {
const { fontCssFamily, AVAILABLE_FONTS, SYSTEM_SANS } = await import('@/lib/fonts')
const inter = AVAILABLE_FONTS.find(f => f.id === 'inter')!
const { fontCssFamily, BUILTIN_FONTS, SYSTEM_SANS } = await import('@/lib/fonts')
const inter = BUILTIN_FONTS.find(f => f.id === 'inter')!
expect(fontCssFamily('inter')).toBe(inter.family)
expect(fontCssFamily(undefined)).toBe(SYSTEM_SANS.family)
expect(fontCssFamily('does-not-exist')).toBe(SYSTEM_SANS.family)
@@ -15138,6 +15163,56 @@ export function registerAllTests(): void {
expect(html.includes('Fonts used in this animation')).toBe(false)
})
test('exported runtime layerActiveAt mirrors the editor isLayerActiveAt', async () => {
// Regression: the player previously short-circuited
// `if(total<=1) return true` — a layer with one keyframe at t=2
// would render at t=0 (visible too early). It also ignored
// 'end' keyframes, and dropped layers with two normal kfs after
// the last kf instead of holding them to +Infinity. Both bugs
// showed up in fullscreen-preview AND in standalone exports.
const { generateRuntimeScript } = await import('@/lib/exportPlayer')
const src = generateRuntimeScript()
const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/)
expect(!!match).toBe(true)
const body = match![1]
// Build a callable from the function body so we can poke the
// runtime's gate logic directly without mounting a player.
const layerActiveAt = new Function('layer', 'time', body) as (layer: unknown, time: number) => boolean
// (a) Single normal kf at t=2 → invisible before t=2.
const a = { keyframes: [{ time: 2, kind: 'normal' }] }
expect(layerActiveAt(a, 0)).toBe(false)
expect(layerActiveAt(a, 1.9)).toBe(false)
expect(layerActiveAt(a, 2)).toBe(true)
expect(layerActiveAt(a, 5)).toBe(true)
// (b) Two normal kfs, no end kf → visible to +Infinity.
const b = { keyframes: [{ time: 0, kind: 'normal' }, { time: 1, kind: 'normal' }] }
expect(layerActiveAt(b, 0)).toBe(true)
expect(layerActiveAt(b, 0.5)).toBe(true)
expect(layerActiveAt(b, 5)).toBe(true)
expect(layerActiveAt(b, 1000)).toBe(true)
// (c) Normal kf at t=0 + end kf at t=2 → invisible at and after t=2.
const c = { keyframes: [{ time: 0, kind: 'normal' }, { time: 2, kind: 'end' }] }
expect(layerActiveAt(c, 0)).toBe(true)
expect(layerActiveAt(c, 1)).toBe(true)
expect(layerActiveAt(c, 2)).toBe(false)
expect(layerActiveAt(c, 3)).toBe(false)
// (d) Static layer with zero keyframes → always visible.
const d = { keyframes: [] }
expect(layerActiveAt(d, 0)).toBe(true)
expect(layerActiveAt(d, 999)).toBe(true)
// (e) End kf is honored even when it's only on the main track —
// separated position kfs alone should not push max past the end.
const e = {
keyframes: [{ time: 5, kind: 'end' }],
positionKeyframes: [{ time: 0, kind: 'normal' }, { time: 4, kind: 'normal' }],
rotationKeyframes: [],
}
expect(layerActiveAt(e, 0)).toBe(true)
expect(layerActiveAt(e, 4.99)).toBe(true)
expect(layerActiveAt(e, 5)).toBe(false)
expect(layerActiveAt(e, 6)).toBe(false)
})
test('exported runtime carries a FONT_FAMILIES map and uses fontFam(asset)', async () => {
const { generateRuntimeScript } = await import('@/lib/exportPlayer')
const src = generateRuntimeScript()
@@ -15146,6 +15221,106 @@ export function registerAllTests(): void {
// Sanity: known font ids are present in the embedded map.
expect(src).toContain('"inter"')
expect(src).toContain('"playfair"')
// The 6 newly-added built-ins all show up too.
expect(src).toContain('"montserrat"')
expect(src).toContain('"nunito"')
expect(src).toContain('"archivo-black"')
expect(src).toContain('"figtree"')
expect(src).toContain('"press-start"')
expect(src).toContain('"gabarito"')
})
test('uploadCustomFont registers, persists to localStorage, and exposes the font', async () => {
const fonts = await import('@/lib/fonts')
// Reset state: nuke any leftovers from a previous test run.
const before = localStorage.getItem('animatr:customFonts')
localStorage.removeItem('animatr:customFonts')
const restore = stubFontFaceForTest()
try {
// Crude .woff2 stub: a single byte is enough since the FontFace
// stub doesn't actually parse it.
const file = new File([new Uint8Array([0])], 'MyFancy.woff2', { type: 'font/woff2' })
const def = await fonts.uploadCustomFont(file)
expect(def.custom).toBe(true)
expect(def.label).toBe('MyFancy')
expect(def.id.startsWith('custom-')).toBe(true)
// Reflected in AVAILABLE_FONTS reactively.
const list = fonts.AVAILABLE_FONTS.value
expect(list.some(f => f.id === def.id)).toBe(true)
// Persisted to localStorage in the documented blob shape.
const raw = localStorage.getItem('animatr:customFonts')!
const parsed = JSON.parse(raw)
expect(parsed.version).toBe(1)
expect(Array.isArray(parsed.fonts)).toBe(true)
expect(parsed.fonts[0].id).toBe(def.id)
expect(parsed.fonts[0].dataUrl.startsWith('data:')).toBe(true)
// removeCustomFont reverses both the live state and the
// localStorage entry.
fonts.removeCustomFont(def.id)
expect(fonts.AVAILABLE_FONTS.value.some(f => f.id === def.id)).toBe(false)
const rawAfter = localStorage.getItem('animatr:customFonts')!
expect(JSON.parse(rawAfter).fonts.length).toBe(0)
} finally {
restore()
if (before === null) localStorage.removeItem('animatr:customFonts')
else localStorage.setItem('animatr:customFonts', before)
}
})
test('uploadCustomFont rejects unsupported file extensions', async () => {
const fonts = await import('@/lib/fonts')
const file = new File([new Uint8Array([0])], 'evil.exe', { type: 'application/octet-stream' })
let caught: Error | null = null
try {
await fonts.uploadCustomFont(file)
} catch (err) {
caught = err as Error
}
expect(caught !== null).toBe(true)
expect(/Unsupported font format/.test(caught?.message ?? '')).toBe(true)
})
test('exported runtime FONT_FAMILIES map includes uploaded custom fonts', async () => {
const fonts = await import('@/lib/fonts')
const { generateRuntimeScript } = await import('@/lib/exportPlayer')
const before = localStorage.getItem('animatr:customFonts')
localStorage.removeItem('animatr:customFonts')
const restore = stubFontFaceForTest()
try {
const file = new File([new Uint8Array([0])], 'BrandFont.woff2', { type: 'font/woff2' })
const def = await fonts.uploadCustomFont(file)
const src = generateRuntimeScript()
expect(src).toContain(def.id)
expect(src).toContain('RUWIO Custom')
fonts.removeCustomFont(def.id)
} finally {
restore()
if (before === null) localStorage.removeItem('animatr:customFonts')
else localStorage.setItem('animatr:customFonts', before)
}
})
test('FontPicker popover shows an Upload font… entry', async () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'text', name: 'T', text: 'hi', fontSize: 32, fill: '#000',
})
const layer = s.addLayer(a.id)!
s.selectedLayerId = layer.id
s.selectedLayerIds = [layer.id]
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const trigger = handle.host.querySelector('.font-picker .trigger') as HTMLButtonElement
trigger.click()
await nextTick()
const upload = document.querySelector('.font-popover .upload-btn') as HTMLButtonElement | null
expect(!!upload).toBe(true)
expect((upload!.textContent ?? '').toLowerCase().includes('upload')).toBe(true)
} finally {
handle.unmount()
document.querySelectorAll('.font-popover').forEach(el => el.remove())
}
})
})
}
+46 -25
View File
@@ -1,5 +1,5 @@
import type { Asset, Project, Scene } from '@/types/scene'
import { AVAILABLE_FONTS, fontFaceCss, fontPrimary, SYSTEM_SANS } from './fonts'
import { AVAILABLE_FONTS, fontFaceCss, fontFamilyMapSnapshot, fontPrimary, SYSTEM_SANS } from './fonts'
/** Input accepted by every export helper below. A Project exports all
* its scenes (jumpToScene works in the bundle); a single Scene is
@@ -13,13 +13,17 @@ function isProject(p: ExportPayload): p is Project {
const RUNTIME_VERSION = '28'
const FONT_FAMILY_MAP_JSON = JSON.stringify(
Object.fromEntries(AVAILABLE_FONTS.map(f => [f.id, f.family])),
)
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
* and are dynamic) make it into the exported runtime. */
function engineSource(): string {
const fontMapJson = JSON.stringify(fontFamilyMapSnapshot())
return ENGINE_TEMPLATE.replace('__FONT_FAMILY_MAP__', fontMapJson)
}
const ENGINE_SOURCE = `(function(){
const ENGINE_TEMPLATE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
var FONT_FAMILIES = ${FONT_FAMILY_MAP_JSON};
var FONT_FAMILIES = __FONT_FAMILY_MAP__;
function fontFam(asset){
return (asset && asset.fontFamily && FONT_FAMILIES[asset.fontFamily]) || FONT_FAMILIES['system-sans'] || 'sans-serif';
}
@@ -726,25 +730,35 @@ const ENGINE_SOURCE = `(function(){
}
ctxm.restore();
}
// Mirror of isLayerActiveAt + layerActiveRange in interpolate.ts.
// A layer is "alive" from its earliest NORMAL kf onward, ending
// either at the earliest 'end' kf or at +Infinity when none exists.
// Layers without any normal kf are static (always visible).
// End kfs only live on .keyframes (the main track).
function layerActiveAt(layer,time){
var main=layer.keyframes||[];
var pos=layer.positionKeyframes||[];
var rot=layer.rotationKeyframes||[];
var total=main.length+pos.length+rot.length;
if(total<=1) return true;
var mn=Infinity, mx=-Infinity, lastHold=false;
function scan(arr){
var foundNormal=false, mn=Infinity;
function scanNormal(arr){
for(var i=0;i<arr.length;i++){
var k=arr[i], t=k.time;
if(t<mn) mn=t;
if(t>mx){ mx=t; lastHold=!!k.hold; }
else if(t===mx && k.hold){ lastHold=true; }
var k=arr[i];
if(k.kind==='end') continue;
foundNormal=true;
if(k.time<mn) mn=k.time;
}
}
scan(main); scan(pos); scan(rot);
if(lastHold) mx=Infinity;
scanNormal(main); scanNormal(pos); scanNormal(rot);
if(!foundNormal) return true;
var endTime=null;
for(var ei=0;ei<main.length;ei++){
var ek=main[ei];
if(ek.kind!=='end') continue;
if(endTime===null || ek.time<endTime) endTime=ek.time;
}
var mx=(endTime===null)?Infinity:endTime;
var EPS=1e-6;
return time>=mn-EPS && time<=mx+EPS;
return time>=mn-EPS && time<mx-EPS;
}
// Button-asset visibility: a still-frame (hold) kf shows ONLY on
// its own state frame, not on the state frames that follow. Mirrors
@@ -1265,7 +1279,7 @@ let cachedApi: any = null
function ensureEngine() {
if (cachedApi) return
// eslint-disable-next-line no-eval
;(0, eval)(ENGINE_SOURCE)
;(0, eval)(engineSource())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cachedApi = (window as any).AnimatrPlayer
}
@@ -1393,7 +1407,7 @@ export interface ExportOptions {
export function generateRuntimeScript(): string {
return (
'/* RUWIO runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE
engineSource()
)
}
@@ -1411,7 +1425,7 @@ export function generateSceneScript(payload: ExportPayload, options: ExportOptio
export function generateBundledScript(payload: ExportPayload, options: ExportOptions = {}): string {
return (
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE +
engineSource() +
'\n' +
generateSceneScriptBody(payload, options)
)
@@ -1436,20 +1450,27 @@ export function downloadBundledExport(payload: ExportPayload, filename = 'animat
}
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = ENGINE_SOURCE
const runtime = engineSource()
const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}`
const usedFontIds = collectFontIds(payload)
const fonts = AVAILABLE_FONTS.value
const usedFonts = usedFontIds
.map(id => AVAILABLE_FONTS.find(f => f.id === id))
.filter((f): f is typeof AVAILABLE_FONTS[number] => !!f && !!f.file)
.map(id => fonts.find(f => f.id === id))
.filter((f): f is typeof fonts[number] => !!f && !!f.file)
const builtinUsed = usedFonts.filter(f => !f.custom)
const customUsed = usedFonts.filter(f => f.custom)
const fontHint = usedFonts.length === 0 ? '' : `
<div class="usage">
<h2>Fonts used in this animation</h2>
<p style="margin-bottom: 8px;">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:</p>
<p style="margin-bottom: 8px;">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:</p>
<ul style="margin: 0 0 12px 18px; padding: 0; font-size: 12px; color: #6e6e73;">
${usedFonts.map(f => ` <li>${fontPrimary(f.id)} — <code>${f.file.replace(/^\//, '')}</code></li>`).join('\n')}
${builtinUsed.map(f => ` <li>${fontPrimary(f.id)} — <code>${f.file.replace(/^\//, '')}</code></li>`).join('\n')}${
customUsed.length > 0
? '\n' + customUsed.map(f => ` <li>${escapeHtml(f.label)} — <em>locally uploaded; the @font-face below has the file inlined as a data URL</em></li>`).join('\n')
: ''
}
</ul>
<pre>${escapeHtml(fontFaceCss(usedFontIds))}</pre>
</div>`
+187 -27
View File
@@ -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<CustomFontStored[]>(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<FontDef[]>(() => {
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<void> | null = null
function registerFontFace(family: string, src: string, format?: string): Promise<void> {
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<void> {
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<string> {
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<FontDef> {
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<FontFace>)
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<string, string> {
const out: Record<string, string> = {}
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<string>()
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
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')
}
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.6'
export const APP_VERSION = '1.2.7'
export const APP_NAME = 'RUWIO'