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:
1 parent
b32b8a6a3d
commit
4c2b7c7c35
20 files changed
+339
-12
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"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.
Binary file not shown.
@@ -140,7 +140,7 @@ async function addSampleText() {
|
||||
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
|
||||
text,
|
||||
fontSize: 48,
|
||||
fill: '#f5d76e',
|
||||
fill: '#000000',
|
||||
})
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from '@/lib/inter
|
||||
import { importImageFile, loadImage } from '@/lib/fileLoader'
|
||||
import { useClipartStore } from '@/stores/clipart'
|
||||
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
||||
import { fontCssFamily, fontsReady } from '@/lib/fonts'
|
||||
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||
import type {
|
||||
Action,
|
||||
@@ -1303,11 +1304,17 @@ function circleConfig(
|
||||
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
|
||||
const fill = props.fill ?? asset.fill
|
||||
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)) {
|
||||
return {
|
||||
...base,
|
||||
text: asset.text,
|
||||
fontSize: asset.fontSize,
|
||||
fontFamily: family,
|
||||
offsetX: layer.offsetX ?? 0,
|
||||
offsetY: layer.offsetY ?? 0,
|
||||
fill: undefined,
|
||||
@@ -1320,6 +1327,7 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
|
||||
...base,
|
||||
text: asset.text,
|
||||
fontSize: asset.fontSize,
|
||||
fontFamily: family,
|
||||
offsetX: layer.offsetX ?? 0,
|
||||
offsetY: layer.offsetY ?? 0,
|
||||
...(layer.isMask ? {} : strokeCfg(props, asset)),
|
||||
@@ -1611,12 +1619,14 @@ function clipCircleConfig(item: RenderedClipLayer) {
|
||||
}
|
||||
|
||||
function clipTextConfig(item: RenderedClipLayer) {
|
||||
void fontsReady.value
|
||||
const asset = item.asset as Extract<Asset, { kind: 'text' }>
|
||||
const fill = item.props.fill ?? asset.fill
|
||||
const cfg: Record<string, unknown> = {
|
||||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||||
text: asset.text,
|
||||
fontSize: asset.fontSize,
|
||||
fontFamily: fontCssFamily(asset.fontFamily),
|
||||
offsetX: item.layer.offsetX ?? 0,
|
||||
offsetY: item.layer.offsetY ?? 0,
|
||||
...strokeCfg(item.props, asset),
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Action, AnimatableProps, Layer } from '@/types/scene'
|
||||
import RangeSlider from './RangeSlider.vue'
|
||||
import ActionEditor from './ActionEditor.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { AVAILABLE_FONTS } from '@/lib/fonts'
|
||||
|
||||
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||
|
||||
@@ -137,6 +138,14 @@ const textAsset = computed(() => {
|
||||
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 a = selectedAsset.value
|
||||
return a && a.kind === 'polygon' ? a : null
|
||||
@@ -484,8 +493,14 @@ function deleteSelection() {
|
||||
<span>{{ t('properties.sections.text') }}</span>
|
||||
</button>
|
||||
<div v-if="sectionOpen.text" class="group-body">
|
||||
<label>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.text') }}<input type="text" v-model="textAsset.text" /></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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ async function addText() {
|
||||
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
|
||||
text,
|
||||
fontSize: 48,
|
||||
fill: '#f5d76e',
|
||||
fill: '#000000',
|
||||
})
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -202,6 +202,11 @@ export const en = {
|
||||
appearance: 'Appearance',
|
||||
button: 'On Click',
|
||||
},
|
||||
text: {
|
||||
text: 'Text',
|
||||
fontSize: 'Font Size',
|
||||
font: 'Font',
|
||||
},
|
||||
polygonPoints: 'Points',
|
||||
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
|
||||
polygonClosed: 'Closed path (fill)',
|
||||
|
||||
+44
-4
@@ -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
|
||||
* 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)
|
||||
}
|
||||
|
||||
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(){
|
||||
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){
|
||||
if(name==='easeIn')return t*t;
|
||||
@@ -702,7 +711,7 @@ const ENGINE_SOURCE = `(function(){
|
||||
} else if(asset.kind==='text'){
|
||||
var mtOff=layer.offsetX!=null?layer.offsetX: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.fillText(asset.text,-mtOff,-mtOffY);
|
||||
} else {
|
||||
@@ -827,7 +836,7 @@ const ENGINE_SOURCE = `(function(){
|
||||
}else if(asset.kind==='text'){
|
||||
var tOffX=layer.offsetX!=null?layer.offsetX: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';
|
||||
var tFill=p.fill||asset.fill;
|
||||
if(tFill && tFill!=='transparent'){
|
||||
@@ -1330,6 +1339,22 @@ function jsonForExport(payload: ExportPayload): string {
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function sceneToLegacyProject(scene: Scene): Project {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -1415,6 +1440,19 @@ export function generateExampleHtml(payload: ExportPayload, options: ExportOptio
|
||||
const sceneBoot = generateSceneScriptBody(payload, options)
|
||||
const canvas = isProject(payload) ? payload.canvas : payload.canvas
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -1489,6 +1527,8 @@ ${sceneBoot}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
${fontHint}
|
||||
|
||||
<div class="usage">
|
||||
<h2>Embed on your own page</h2>
|
||||
<pre><!-- once per page -->
|
||||
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene, SvgAsset } from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate'
|
||||
import { fontCssFamily } from './fonts'
|
||||
|
||||
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||
const result: Record<string, HTMLImageElement> = {}
|
||||
@@ -202,7 +203,7 @@ function drawLayers(
|
||||
const offX = layer.offsetX ?? 0
|
||||
const offY = layer.offsetY ?? 0
|
||||
const fill = p.fill ?? asset.fill
|
||||
ctx.font = `${asset.fontSize}px sans-serif`
|
||||
ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
|
||||
ctx.textBaseline = 'top'
|
||||
if (fill && fill !== 'transparent') {
|
||||
ctx.fillStyle = fill
|
||||
@@ -303,7 +304,7 @@ function drawMaskShape(
|
||||
} else if (asset.kind === 'text') {
|
||||
const offX = layer.offsetX ?? 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.fillText(asset.text, -offX, -offY)
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,12 @@ import App from './App.vue'
|
||||
import './composables/useTheme'
|
||||
import './styles/global.css'
|
||||
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)
|
||||
app.use(createPinia())
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface TextAsset {
|
||||
name: string
|
||||
text: string
|
||||
fontSize: number
|
||||
/** Font id from src/lib/fonts.ts AVAILABLE_FONTS. Undefined = system sans. */
|
||||
fontFamily?: string
|
||||
fill: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.2.4'
|
||||
export const APP_VERSION = '1.2.5'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user