diff --git a/package.json b/package.json index 677c089..c382c7a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "1.2.4", + "version": "1.2.5", "type": "module", "scripts": { "dev": "vite", diff --git a/public/fonts/BebasNeue-Regular.woff2 b/public/fonts/BebasNeue-Regular.woff2 new file mode 100644 index 0000000..92d6a0f Binary files /dev/null and b/public/fonts/BebasNeue-Regular.woff2 differ diff --git a/public/fonts/Inter-Regular.woff2 b/public/fonts/Inter-Regular.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/public/fonts/Inter-Regular.woff2 differ diff --git a/public/fonts/Lora-Regular.woff2 b/public/fonts/Lora-Regular.woff2 new file mode 100644 index 0000000..2a40f60 Binary files /dev/null and b/public/fonts/Lora-Regular.woff2 differ diff --git a/public/fonts/PermanentMarker-Regular.woff2 b/public/fonts/PermanentMarker-Regular.woff2 new file mode 100644 index 0000000..203ccae Binary files /dev/null and b/public/fonts/PermanentMarker-Regular.woff2 differ diff --git a/public/fonts/PlayfairDisplay-Regular.woff2 b/public/fonts/PlayfairDisplay-Regular.woff2 new file mode 100644 index 0000000..141ea84 Binary files /dev/null and b/public/fonts/PlayfairDisplay-Regular.woff2 differ diff --git a/public/fonts/Roboto-Regular.woff2 b/public/fonts/Roboto-Regular.woff2 new file mode 100644 index 0000000..77e4259 Binary files /dev/null and b/public/fonts/Roboto-Regular.woff2 differ diff --git a/public/fonts/SourceCodePro-Regular.woff2 b/public/fonts/SourceCodePro-Regular.woff2 new file mode 100644 index 0000000..d1f2d84 Binary files /dev/null and b/public/fonts/SourceCodePro-Regular.woff2 differ diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue index 9184813..69760b9 100644 --- a/src/components/AssetSidebar.vue +++ b/src/components/AssetSidebar.vue @@ -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) } diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index bb589b8..586e730 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -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, 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, 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 const fill = item.props.fill ?? asset.fill const cfg: Record = { ...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), diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index 51af13c..1175b94 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -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() { {{ t('properties.sections.text') }}
- - + + +
diff --git a/src/components/SvgLayerPanel.vue b/src/components/SvgLayerPanel.vue index c8894cf..1ff91e3 100644 --- a/src/components/SvgLayerPanel.vue +++ b/src/components/SvgLayerPanel.vue @@ -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) } diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index b31a7c1..f06e088 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -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"') + }) + }) } diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index ccc4e69..1d85d33 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -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)', diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index 8ace46b..ecb933a 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -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).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() + 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, '>') +} + 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 ? '' : ` +
+

Fonts used in this animation

+

This preview uses ${SYSTEM_SANS.label} for missing fonts. To render with the correct typefaces, host the .woff2 files alongside this HTML and add the @font-face rules below to your CSS:

+
    +${usedFonts.map(f => `
  • ${fontPrimary(f.id)} — ${f.file.replace(/^\//, '')}
  • `).join('\n')} +
+
${escapeHtml(fontFaceCss(usedFontIds))}
+
` return ` @@ -1489,6 +1527,8 @@ ${sceneBoot} + ${fontHint} +

Embed on your own page

<!-- once per page -->
diff --git a/src/lib/fonts.ts b/src/lib/fonts.ts
new file mode 100644
index 0000000..1f9fb69
--- /dev/null
+++ b/src/lib/fonts.ts
@@ -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 | null = null
+
+export function loadFonts(): Promise {
+  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()
+  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')
+}
diff --git a/src/lib/render.ts b/src/lib/render.ts
index b977b98..db58b2a 100644
--- a/src/lib/render.ts
+++ b/src/lib/render.ts
@@ -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> {
   const result: Record = {}
@@ -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 {
diff --git a/src/main.ts b/src/main.ts
index 281d8f7..4ddd83c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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())
diff --git a/src/types/scene.ts b/src/types/scene.ts
index be0fd98..5c07198 100644
--- a/src/types/scene.ts
+++ b/src/types/scene.ts
@@ -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
diff --git a/src/version.ts b/src/version.ts
index 445b245..a6846ca 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '1.2.4'
+export const APP_VERSION = '1.2.5'
 export const APP_NAME = 'RUWIO'