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>
381 lines
8.9 KiB
Vue
381 lines
8.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
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
|
|
// shape: TextAsset.fontFamily is undefined for the default.
|
|
modelValue: string
|
|
}>()
|
|
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
|
|
}
|
|
|
|
function pick(id: string) {
|
|
// Storage convention: 'system-sans' clears the override.
|
|
emit('update:modelValue', id === 'system-sans' ? '' : id)
|
|
open.value = false
|
|
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
|
|
if (!t) return
|
|
if (trigger.value?.contains(t)) return
|
|
if (popover.value?.contains(t)) return
|
|
open.value = false
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (!open.value) return
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
open.value = false
|
|
trigger.value?.focus()
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('pointerdown', onWindowPointerDown, true)
|
|
window.addEventListener('keydown', onKeydown)
|
|
})
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('pointerdown', onWindowPointerDown, true)
|
|
window.removeEventListener('keydown', onKeydown)
|
|
})
|
|
|
|
const popStyle = ref<Record<string, string>>({})
|
|
watch(open, async (isOpen) => {
|
|
if (!isOpen) return
|
|
await Promise.resolve()
|
|
const t = trigger.value
|
|
if (!t) return
|
|
const r = t.getBoundingClientRect()
|
|
popStyle.value = {
|
|
left: `${r.left}px`,
|
|
top: `${r.bottom + 4}px`,
|
|
minWidth: `${r.width}px`,
|
|
}
|
|
})
|
|
|
|
function isActive(id: string): boolean {
|
|
return (props.modelValue || 'system-sans') === id
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="font-picker">
|
|
<button
|
|
ref="trigger"
|
|
type="button"
|
|
class="trigger"
|
|
:class="{ open }"
|
|
:aria-expanded="open"
|
|
@click="toggle"
|
|
>
|
|
<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"
|
|
ref="popover"
|
|
class="font-popover"
|
|
:style="popStyle"
|
|
role="listbox"
|
|
>
|
|
<div class="font-popover-section">
|
|
<button
|
|
v-for="f in BUILTIN_FONTS"
|
|
: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>
|
|
</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>
|
|
|
|
<style scoped>
|
|
.font-picker {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.trigger {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
width: 100%;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text);
|
|
padding: 6px 8px;
|
|
font-size: 12px;
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
|
|
.trigger:hover {
|
|
border-color: var(--border-strong, var(--border));
|
|
}
|
|
|
|
.trigger.open,
|
|
.trigger:focus-visible {
|
|
border-color: var(--accent);
|
|
outline: none;
|
|
}
|
|
|
|
.trigger .label {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.trigger .caret {
|
|
flex-shrink: 0;
|
|
color: var(--text-dim);
|
|
transition: transform 0.15s ease;
|
|
}
|
|
|
|
.trigger.open .caret {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.hidden-file-input {
|
|
position: absolute;
|
|
width: 0;
|
|
height: 0;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* Popover is teleported to <body>, so the styles need to be global. */
|
|
.font-popover {
|
|
position: fixed;
|
|
z-index: 1000;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
|
padding: 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
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;
|
|
gap: 6px;
|
|
width: 100%;
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text);
|
|
padding: 6px 8px;
|
|
font-size: 13px;
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
|
|
.font-popover .font-option:hover {
|
|
background: var(--panel-hover, var(--panel-alt));
|
|
}
|
|
|
|
.font-popover .font-option.active {
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.font-popover .check {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.font-popover .option-label {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
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>
|