1.2.5: text font picker + 7 bundled Google fonts + default text fill black

- New src/lib/fonts.ts: curated set (Inter, Roboto, Lora, Playfair
  Display, Bebas Neue, Source Code Pro, Permanent Marker) shipped as
  local .woff2 in public/fonts/. loadFonts() registers them via
  FontFace at app start; fontsReady ref re-triggers Konva text configs
  once fonts resolve so the first paint isn't stuck on the fallback.
- TextAsset gets optional fontFamily (id; undefined = system sans).
  PropertiesPanel adds a font dropdown next to font size.
- All five render sites read the family: CanvasStage textConfig +
  clipTextConfig (Konva), drawLayer + drawMaskShape (canvas2d),
  exportPlayer engine (uses an embedded FONT_FAMILIES map and fontFam
  helper; runtime version 27 → 28).
- Example HTML export now lists used bundled fonts and prints the
  @font-face CSS the user has to paste alongside the .woff2 files —
  fonts themselves are NOT bundled into the export to keep it small.
- Default text fill changed from yellow #f5d76e to black #000000 in
  both the main sidebar and the SVG-scope add-text path.
- 8 new tests under "text font picker — model + render + export".

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 00:32:42 +02:00
1 parent b32b8a6a3d
commit 4c2b7c7c35
20 files changed
+339 -12

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "1.2.4", "version": "1.2.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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.
Binary file not shown.
+1 -1
View File
@@ -140,7 +140,7 @@ async function addSampleText() {
name: text.length > 24 ? text.slice(0, 24) + '…' : text, name: text.length > 24 ? text.slice(0, 24) + '…' : text,
text, text,
fontSize: 48, fontSize: 48,
fill: '#f5d76e', fill: '#000000',
}) })
store.addLayer(asset.id) store.addLayer(asset.id)
} }
+10
View File
@@ -20,6 +20,7 @@ import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from '@/lib/inter
import { importImageFile, loadImage } from '@/lib/fileLoader' import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart' import { useClipartStore } from '@/stores/clipart'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer' import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
import { fontCssFamily, fontsReady } from '@/lib/fonts'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render' import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import type { import type {
Action, Action,
@@ -1303,11 +1304,17 @@ function circleConfig(
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) { function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
const fill = props.fill ?? asset.fill const fill = props.fill ?? asset.fill
const base = baseNodeConfig(layer, props) const base = baseNodeConfig(layer, props)
// Read fontsReady so this config recomputes once bundled fonts
// resolve — otherwise Konva caches a measurement made against the
// fallback family and the text is rendered too narrow.
void fontsReady.value
const family = fontCssFamily(asset.fontFamily)
if (isLayerGhostLocked(layer)) { if (isLayerGhostLocked(layer)) {
return { return {
...base, ...base,
text: asset.text, text: asset.text,
fontSize: asset.fontSize, fontSize: asset.fontSize,
fontFamily: family,
offsetX: layer.offsetX ?? 0, offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0, offsetY: layer.offsetY ?? 0,
fill: undefined, fill: undefined,
@@ -1320,6 +1327,7 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
...base, ...base,
text: asset.text, text: asset.text,
fontSize: asset.fontSize, fontSize: asset.fontSize,
fontFamily: family,
offsetX: layer.offsetX ?? 0, offsetX: layer.offsetX ?? 0,
offsetY: layer.offsetY ?? 0, offsetY: layer.offsetY ?? 0,
...(layer.isMask ? {} : strokeCfg(props, asset)), ...(layer.isMask ? {} : strokeCfg(props, asset)),
@@ -1611,12 +1619,14 @@ function clipCircleConfig(item: RenderedClipLayer) {
} }
function clipTextConfig(item: RenderedClipLayer) { function clipTextConfig(item: RenderedClipLayer) {
void fontsReady.value
const asset = item.asset as Extract<Asset, { kind: 'text' }> const asset = item.asset as Extract<Asset, { kind: 'text' }>
const fill = item.props.fill ?? asset.fill const fill = item.props.fill ?? asset.fill
const cfg: Record<string, unknown> = { const cfg: Record<string, unknown> = {
...clipBaseConfig(item.instanceId, item.layer, item.props), ...clipBaseConfig(item.instanceId, item.layer, item.props),
text: asset.text, text: asset.text,
fontSize: asset.fontSize, fontSize: asset.fontSize,
fontFamily: fontCssFamily(asset.fontFamily),
offsetX: item.layer.offsetX ?? 0, offsetX: item.layer.offsetX ?? 0,
offsetY: item.layer.offsetY ?? 0, offsetY: item.layer.offsetY ?? 0,
...strokeCfg(item.props, asset), ...strokeCfg(item.props, asset),
+17 -2
View File
@@ -19,6 +19,7 @@ import type { Action, AnimatableProps, Layer } from '@/types/scene'
import RangeSlider from './RangeSlider.vue' import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue' import ActionEditor from './ActionEditor.vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import { AVAILABLE_FONTS } from '@/lib/fonts'
const SECTION_STORAGE_KEY = 'animatr:propSections' const SECTION_STORAGE_KEY = 'animatr:propSections'
@@ -137,6 +138,14 @@ const textAsset = computed(() => {
return a && a.kind === 'text' ? a : null return a && a.kind === 'text' ? a : null
}) })
const textFontModel = computed({
get: () => textAsset.value?.fontFamily ?? 'system-sans',
set: (id: string) => {
if (!textAsset.value) return
textAsset.value.fontFamily = id === 'system-sans' ? undefined : id
},
})
const polygonAsset = computed(() => { const polygonAsset = computed(() => {
const a = selectedAsset.value const a = selectedAsset.value
return a && a.kind === 'polygon' ? a : null return a && a.kind === 'polygon' ? a : null
@@ -484,8 +493,14 @@ function deleteSelection() {
<span>{{ t('properties.sections.text') }}</span> <span>{{ t('properties.sections.text') }}</span>
</button> </button>
<div v-if="sectionOpen.text" class="group-body"> <div v-if="sectionOpen.text" class="group-body">
<label>Text<input type="text" v-model="textAsset.text" /></label> <label>{{ t('properties.text.text') }}<input type="text" v-model="textAsset.text" /></label>
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label> <label>{{ t('properties.text.fontSize') }}<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
<label>
{{ t('properties.text.font') }}
<select v-model="textFontModel">
<option v-for="f in AVAILABLE_FONTS" :key="f.id" :value="f.id">{{ f.label }}</option>
</select>
</label>
</div> </div>
</section> </section>
+1 -1
View File
@@ -77,7 +77,7 @@ async function addText() {
name: text.length > 24 ? text.slice(0, 24) + '…' : text, name: text.length > 24 ? text.slice(0, 24) + '…' : text,
text, text,
fontSize: 48, fontSize: 48,
fill: '#f5d76e', fill: '#000000',
}) })
store.addLayer(asset.id) store.addLayer(asset.id)
} }
+137
View File
@@ -15001,4 +15001,141 @@ export function registerAllTests(): void {
} }
}) })
}) })
suite('text font picker — model + render + export', () => {
test('TextAsset.fontFamily roundtrips through addAsset', () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'text', name: 'T', text: 'hi', fontSize: 24, fill: '#000', fontFamily: 'inter',
})
expect(a.kind).toBe('text')
if (a.kind !== 'text') return
expect(a.fontFamily).toBe('inter')
})
test('default text fill is black (#000000) when added via the sidebar', () => {
// Regression: defaults used to be a yellow #f5d76e which made
// first-time text invisible-ish on white canvases.
const s = makeFreshStore()
const a = s.addAsset({
kind: 'text', name: 'T', text: 'hi', fontSize: 48, fill: '#000000',
})
if (a.kind !== 'text') return
expect(a.fill).toBe('#000000')
})
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')!
expect(fontCssFamily('inter')).toBe(inter.family)
expect(fontCssFamily(undefined)).toBe(SYSTEM_SANS.family)
expect(fontCssFamily('does-not-exist')).toBe(SYSTEM_SANS.family)
})
test('PropertiesPanel renders a font dropdown for text assets and writes back', async () => {
const s = makeFreshStore()
const a = s.addAsset({
kind: 'text', name: 'T', text: 'hello', 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 select = handle.host.querySelector('select') as HTMLSelectElement | null
expect(select).toBeTruthy()
const options = Array.from(select!.options).map(o => o.value)
expect(options).toContain('system-sans')
expect(options).toContain('inter')
expect(options).toContain('marker')
// Default selection: system-sans (asset.fontFamily is undefined).
expect(select!.value).toBe('system-sans')
// Pick Inter — the asset should mutate.
select!.value = 'inter'
select!.dispatchEvent(new Event('change'))
await nextTick()
const textAsset = s.scene.assets.find(x => x.id === a.id)!
if (textAsset.kind !== 'text') throw new Error('unexpected kind')
expect(textAsset.fontFamily).toBe('inter')
// Picking system-sans clears the field (undefined, not the literal id).
select!.value = 'system-sans'
select!.dispatchEvent(new Event('change'))
await nextTick()
const textAsset2 = s.scene.assets.find(x => x.id === a.id)!
if (textAsset2.kind !== 'text') throw new Error('unexpected kind')
expect(textAsset2.fontFamily === undefined).toBe(true)
} finally {
handle.unmount()
}
})
test('drawSceneFrame sets ctx.font with the selected font family', async () => {
const { drawSceneFrame } = await import('@/lib/render')
const s = makeFreshStore()
const a = s.addAsset({
kind: 'text', name: 'T', text: 'hi', fontSize: 32, fill: '#000', fontFamily: 'lora',
})
s.addLayer(a.id)
const cv = document.createElement('canvas')
cv.width = 200
cv.height = 200
const ctx = cv.getContext('2d')!
const fontHistory: string[] = []
const realFontDesc = Object.getOwnPropertyDescriptor(
CanvasRenderingContext2D.prototype, 'font',
)
Object.defineProperty(ctx, 'font', {
configurable: true,
get() { return realFontDesc?.get?.call(this) ?? '' },
set(v: string) {
fontHistory.push(v)
realFontDesc?.set?.call(this, v)
},
})
try {
drawSceneFrame(ctx, s.scene, 0, {})
const used = fontHistory.find(f => f.includes('Lora'))
expect(!!used).toBe(true)
} finally {
if (realFontDesc) Object.defineProperty(ctx, 'font', realFontDesc)
}
})
test('generateExampleHtml shows a font hint listing assets that use bundled fonts', () => {
const s = makeFreshStore()
s.addAsset({
kind: 'text', name: 'Title', text: 'Hello', fontSize: 48, fill: '#000', fontFamily: 'playfair',
})
s.addAsset({
kind: 'text', name: 'Body', text: 'world', fontSize: 18, fill: '#333', fontFamily: 'roboto',
})
const html = generateExampleHtml(s.scene)
expect(html).toContain('Fonts used in this animation')
expect(html).toContain('Playfair Display')
expect(html).toContain('Roboto')
expect(html).toContain('@font-face')
expect(html).toContain('PlayfairDisplay-Regular.woff2')
expect(html).toContain('Roboto-Regular.woff2')
})
test('generateExampleHtml omits the font hint if no assets use bundled fonts', () => {
const s = makeFreshStore()
s.addAsset({
kind: 'text', name: 'T', text: 'hi', fontSize: 18, fill: '#000',
})
const html = generateExampleHtml(s.scene)
expect(html.includes('Fonts used in this animation')).toBe(false)
})
test('exported runtime carries a FONT_FAMILIES map and uses fontFam(asset)', async () => {
const { generateRuntimeScript } = await import('@/lib/exportPlayer')
const src = generateRuntimeScript()
expect(src).toContain('FONT_FAMILIES')
expect(src).toContain('fontFam(asset)')
// Sanity: known font ids are present in the embedded map.
expect(src).toContain('"inter"')
expect(src).toContain('"playfair"')
})
})
} }
+5
View File
@@ -202,6 +202,11 @@ export const en = {
appearance: 'Appearance', appearance: 'Appearance',
button: 'On Click', button: 'On Click',
}, },
text: {
text: 'Text',
fontSize: 'Font Size',
font: 'Font',
},
polygonPoints: 'Points', polygonPoints: 'Points',
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.', polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
polygonClosed: 'Closed path (fill)', polygonClosed: 'Closed path (fill)',
+44 -4
View File
@@ -1,4 +1,5 @@
import type { Project, Scene } from '@/types/scene' import type { Asset, Project, Scene } from '@/types/scene'
import { AVAILABLE_FONTS, fontFaceCss, fontPrimary, SYSTEM_SANS } from './fonts'
/** Input accepted by every export helper below. A Project exports all /** Input accepted by every export helper below. A Project exports all
* its scenes (jumpToScene works in the bundle); a single Scene is * its scenes (jumpToScene works in the bundle); a single Scene is
@@ -10,10 +11,18 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes) return Array.isArray((p as Partial<Project>).scenes)
} }
const RUNTIME_VERSION = '27' const RUNTIME_VERSION = '28'
const FONT_FAMILY_MAP_JSON = JSON.stringify(
Object.fromEntries(AVAILABLE_FONTS.map(f => [f.id, f.family])),
)
const ENGINE_SOURCE = `(function(){ const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return; if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
var FONT_FAMILIES = ${FONT_FAMILY_MAP_JSON};
function fontFam(asset){
return (asset && asset.fontFamily && FONT_FAMILIES[asset.fontFamily]) || FONT_FAMILIES['system-sans'] || 'sans-serif';
}
function easing(name, t){ function easing(name, t){
if(name==='easeIn')return t*t; if(name==='easeIn')return t*t;
@@ -702,7 +711,7 @@ const ENGINE_SOURCE = `(function(){
} else if(asset.kind==='text'){ } else if(asset.kind==='text'){
var mtOff=layer.offsetX!=null?layer.offsetX:0; var mtOff=layer.offsetX!=null?layer.offsetX:0;
var mtOffY=layer.offsetY!=null?layer.offsetY:0; var mtOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.font=asset.fontSize+'px sans-serif'; ctxm.font=asset.fontSize+'px '+fontFam(asset);
ctxm.textBaseline='top'; ctxm.textBaseline='top';
ctxm.fillText(asset.text,-mtOff,-mtOffY); ctxm.fillText(asset.text,-mtOff,-mtOffY);
} else { } else {
@@ -827,7 +836,7 @@ const ENGINE_SOURCE = `(function(){
}else if(asset.kind==='text'){ }else if(asset.kind==='text'){
var tOffX=layer.offsetX!=null?layer.offsetX:0; var tOffX=layer.offsetX!=null?layer.offsetX:0;
var tOffY=layer.offsetY!=null?layer.offsetY:0; var tOffY=layer.offsetY!=null?layer.offsetY:0;
ctx.font=asset.fontSize+'px sans-serif'; ctx.font=asset.fontSize+'px '+fontFam(asset);
ctx.textBaseline='top'; ctx.textBaseline='top';
var tFill=p.fill||asset.fill; var tFill=p.fill||asset.fill;
if(tFill && tFill!=='transparent'){ if(tFill && tFill!=='transparent'){
@@ -1330,6 +1339,22 @@ function jsonForExport(payload: ExportPayload): string {
return JSON.stringify(out) return JSON.stringify(out)
} }
function collectFontIds(payload: ExportPayload): string[] {
const ids = new Set<string>()
const assets: readonly Asset[] = payload.assets ?? []
for (const a of assets) {
if (a.kind === 'text' && a.fontFamily) ids.add(a.fontFamily)
}
return Array.from(ids)
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function sceneToLegacyProject(scene: Scene): Project { function sceneToLegacyProject(scene: Scene): Project {
return { return {
version: 1, version: 1,
@@ -1415,6 +1440,19 @@ export function generateExampleHtml(payload: ExportPayload, options: ExportOptio
const sceneBoot = generateSceneScriptBody(payload, options) const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}` const aspect = `${canvas.width} / ${canvas.height}`
const usedFontIds = collectFontIds(payload)
const usedFonts = usedFontIds
.map(id => AVAILABLE_FONTS.find(f => f.id === id))
.filter((f): f is typeof AVAILABLE_FONTS[number] => !!f && !!f.file)
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>
<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')}
</ul>
<pre>${escapeHtml(fontFaceCss(usedFontIds))}</pre>
</div>`
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -1489,6 +1527,8 @@ ${sceneBoot}
</script> </script>
</div> </div>
${fontHint}
<div class="usage"> <div class="usage">
<h2>Embed on your own page</h2> <h2>Embed on your own page</h2>
<pre>&lt;!-- once per page --&gt; <pre>&lt;!-- once per page --&gt;
+111
View File
@@ -0,0 +1,111 @@
// 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.
import { 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
* a re-render after fonts arrive — otherwise Konva's first paint
* would use the fallback family and never refresh. */
export const fontsReady = ref(false)
export interface FontDef {
/** Stable id stored on TextAsset.fontFamily — never rename. */
id: string
/** Human-readable label shown in the picker. */
label: string
/** CSS font-family string (with fallback) used at render time. */
family: string
/** Path to the .woff2 in /public/fonts. */
file: string
}
export const SYSTEM_SANS: FontDef = {
id: 'system-sans',
label: 'System Sans',
family:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
file: '',
}
export const AVAILABLE_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: '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: 'bebas', label: 'Bebas Neue', family: '"Bebas Neue", sans-serif', file: '/fonts/BebasNeue-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]))
export function fontDef(id: string | undefined | null): FontDef {
if (!id) return SYSTEM_SANS
return fontById.get(id) ?? SYSTEM_SANS
}
export function fontCssFamily(id: string | undefined | null): string {
return fontDef(id).family
}
/** Primary CSS family name only (no fallback list). Needed for
* 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
export function loadFonts(): Promise<void> {
if (loadPromise) return loadPromise
if (typeof document === 'undefined' || !document.fonts) {
loadPromise = Promise.resolve()
return loadPromise
}
const tasks = AVAILABLE_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 {
// 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(() => {
fontsReady.value = true
})
return loadPromise
}
/** @font-face block users can paste into their own CSS when bundling
* the example HTML — used by the export preview hint. */
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)
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}`,
)
}
return out.join('\n')
}
+3 -2
View File
@@ -1,6 +1,7 @@
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene, SvgAsset } from '@/types/scene' import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene, SvgAsset } from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene' import { BUTTON_STATE_TIMES } from '@/types/scene'
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate' import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate'
import { fontCssFamily } from './fonts'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> { export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: Record<string, HTMLImageElement> = {} const result: Record<string, HTMLImageElement> = {}
@@ -202,7 +203,7 @@ function drawLayers(
const offX = layer.offsetX ?? 0 const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0 const offY = layer.offsetY ?? 0
const fill = p.fill ?? asset.fill const fill = p.fill ?? asset.fill
ctx.font = `${asset.fontSize}px sans-serif` ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
ctx.textBaseline = 'top' ctx.textBaseline = 'top'
if (fill && fill !== 'transparent') { if (fill && fill !== 'transparent') {
ctx.fillStyle = fill ctx.fillStyle = fill
@@ -303,7 +304,7 @@ function drawMaskShape(
} else if (asset.kind === 'text') { } else if (asset.kind === 'text') {
const offX = layer.offsetX ?? 0 const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0 const offY = layer.offsetY ?? 0
ctx.font = `${asset.fontSize}px sans-serif` ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
ctx.textBaseline = 'top' ctx.textBaseline = 'top'
ctx.fillText(asset.text, -offX, -offY) ctx.fillText(asset.text, -offX, -offY)
} else { } else {
+6
View File
@@ -5,6 +5,12 @@ import App from './App.vue'
import './composables/useTheme' import './composables/useTheme'
import './styles/global.css' import './styles/global.css'
import { vTooltip } from './directives/tooltip' import { vTooltip } from './directives/tooltip'
import { loadFonts } from './lib/fonts'
// Kick off font loading immediately. Konva text needs the FontFace to
// be resolved before the first paint, so we don't await — instead the
// stage observes document.fonts.ready and re-renders.
loadFonts()
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
+2
View File
@@ -116,6 +116,8 @@ export interface TextAsset {
name: string name: string
text: string text: string
fontSize: number fontSize: number
/** Font id from src/lib/fonts.ts AVAILABLE_FONTS. Undefined = system sans. */
fontFamily?: string
fill: string fill: string
stroke?: string stroke?: string
strokeWidth?: number strokeWidth?: number
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.4' export const APP_VERSION = '1.2.5'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'