1.2.6: dedicated FontPicker component with in-line type previews
Replaces the bare <select> with a custom combobox that fits the properties panel's visual language: - Trigger looks like the other inputs (var(--bg)/var(--border)/ var(--radius-sm)) and shows the selected font's name rendered in that font itself, with a rotating caret. - Options live in a teleported popover anchored beneath the trigger. Each option renders its own label in its own typeface so the user sees what they're picking before applying it. Active row uses var(--accent-soft) to match the rest of the editor. - Click-outside closes via window pointerdown; Escape closes and returns focus to the trigger. - Storage shape simplified: textFontModel uses '' for system-sans instead of the literal 'system-sans' id, so the model field round- trips through JSON without a placeholder string ever leaking. Updated PropertiesPanel test to walk the new picker (click trigger, read options from .font-popover, click an option) instead of dispatching a select 'change' event. Picker label text doubles as the test selector — System Sans / Inter / Permanent Marker confirm the option list is wired up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4c2b7c7c35
commit
8c4f744afc
5 files changed
+258
-23
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.5",
|
"version": "1.2.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
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 selected = computed(() => fontDef(props.modelValue || undefined))
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const r = t.getBoundingClientRect()
|
||||||
|
popStyle.value = {
|
||||||
|
left: `${r.left}px`,
|
||||||
|
top: `${r.bottom + 4}px`,
|
||||||
|
minWidth: `${r.width}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</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>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
ref="popover"
|
||||||
|
class="font-popover"
|
||||||
|
:style="popStyle"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="f in AVAILABLE_FONTS"
|
||||||
|
:key="f.id"
|
||||||
|
type="button"
|
||||||
|
class="font-option"
|
||||||
|
:class="{ active: (modelValue || 'system-sans') === f.id }"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="(modelValue || 'system-sans') === f.id"
|
||||||
|
@click="pick(f.id)"
|
||||||
|
>
|
||||||
|
<PhCheck
|
||||||
|
:size="12"
|
||||||
|
class="check"
|
||||||
|
:style="{ visibility: (modelValue || 'system-sans') === f.id ? 'visible' : 'hidden' }"
|
||||||
|
/>
|
||||||
|
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
|
||||||
|
</button>
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
</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: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,8 +18,8 @@ import { sampleLayer } from '@/lib/interpolate'
|
|||||||
import type { Action, AnimatableProps, Layer } from '@/types/scene'
|
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 FontPicker from './FontPicker.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'
|
||||||
|
|
||||||
@@ -138,11 +138,14 @@ const textAsset = computed(() => {
|
|||||||
return a && a.kind === 'text' ? a : null
|
return a && a.kind === 'text' ? a : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Empty string represents the system-sans default. Stored as
|
||||||
|
// `undefined` on the asset so the model field round-trips cleanly
|
||||||
|
// through JSON without leaking a bogus 'system-sans' id.
|
||||||
const textFontModel = computed({
|
const textFontModel = computed({
|
||||||
get: () => textAsset.value?.fontFamily ?? 'system-sans',
|
get: () => textAsset.value?.fontFamily ?? '',
|
||||||
set: (id: string) => {
|
set: (id: string) => {
|
||||||
if (!textAsset.value) return
|
if (!textAsset.value) return
|
||||||
textAsset.value.fontFamily = id === 'system-sans' ? undefined : id
|
textAsset.value.fontFamily = id || undefined
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -497,9 +500,7 @@ function deleteSelection() {
|
|||||||
<label>{{ t('properties.text.fontSize') }}<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>
|
<label>
|
||||||
{{ t('properties.text.font') }}
|
{{ t('properties.text.font') }}
|
||||||
<select v-model="textFontModel">
|
<FontPicker v-model="textFontModel" />
|
||||||
<option v-for="f in AVAILABLE_FONTS" :key="f.id" :value="f.id">{{ f.label }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+25
-15
@@ -15032,7 +15032,7 @@ export function registerAllTests(): void {
|
|||||||
expect(fontCssFamily('does-not-exist')).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 () => {
|
test('PropertiesPanel renders the font picker for text assets and writes back', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const a = s.addAsset({
|
const a = s.addAsset({
|
||||||
kind: 'text', name: 'T', text: 'hello', fontSize: 32, fill: '#000',
|
kind: 'text', name: 'T', text: 'hello', fontSize: 32, fill: '#000',
|
||||||
@@ -15043,30 +15043,40 @@ export function registerAllTests(): void {
|
|||||||
const handle = mountComponent(PropertiesPanel, {})
|
const handle = mountComponent(PropertiesPanel, {})
|
||||||
try {
|
try {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const select = handle.host.querySelector('select') as HTMLSelectElement | null
|
const trigger = handle.host.querySelector('.font-picker .trigger') as HTMLButtonElement | null
|
||||||
expect(select).toBeTruthy()
|
expect(!!trigger).toBe(true)
|
||||||
const options = Array.from(select!.options).map(o => o.value)
|
// Closed state: the label shows the default — System Sans.
|
||||||
expect(options).toContain('system-sans')
|
expect((trigger!.textContent ?? '').includes('System Sans')).toBe(true)
|
||||||
expect(options).toContain('inter')
|
trigger!.click()
|
||||||
expect(options).toContain('marker')
|
await nextTick()
|
||||||
// Default selection: system-sans (asset.fontFamily is undefined).
|
// Popover lives in document.body via Teleport.
|
||||||
expect(select!.value).toBe('system-sans')
|
const opts = Array.from(document.querySelectorAll('.font-popover .font-option')) as HTMLButtonElement[]
|
||||||
// Pick Inter — the asset should mutate.
|
expect(opts.length).toBeGreaterThan(5)
|
||||||
select!.value = 'inter'
|
const labels = opts.map(o => o.textContent?.trim() ?? '')
|
||||||
select!.dispatchEvent(new Event('change'))
|
expect(labels.some(l => l.includes('System Sans'))).toBe(true)
|
||||||
|
expect(labels.some(l => l.includes('Inter'))).toBe(true)
|
||||||
|
expect(labels.some(l => l.includes('Permanent Marker'))).toBe(true)
|
||||||
|
// Click Inter.
|
||||||
|
const interOpt = opts.find(o => (o.textContent ?? '').trim() === 'Inter')!
|
||||||
|
interOpt.click()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const textAsset = s.scene.assets.find(x => x.id === a.id)!
|
const textAsset = s.scene.assets.find(x => x.id === a.id)!
|
||||||
if (textAsset.kind !== 'text') throw new Error('unexpected kind')
|
if (textAsset.kind !== 'text') throw new Error('unexpected kind')
|
||||||
expect(textAsset.fontFamily).toBe('inter')
|
expect(textAsset.fontFamily).toBe('inter')
|
||||||
// Picking system-sans clears the field (undefined, not the literal id).
|
// Re-open + click System Sans → clears (undefined).
|
||||||
select!.value = 'system-sans'
|
trigger!.click()
|
||||||
select!.dispatchEvent(new Event('change'))
|
await nextTick()
|
||||||
|
const opts2 = Array.from(document.querySelectorAll('.font-popover .font-option')) as HTMLButtonElement[]
|
||||||
|
const sysOpt = opts2.find(o => (o.textContent ?? '').trim() === 'System Sans')!
|
||||||
|
sysOpt.click()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const textAsset2 = s.scene.assets.find(x => x.id === a.id)!
|
const textAsset2 = s.scene.assets.find(x => x.id === a.id)!
|
||||||
if (textAsset2.kind !== 'text') throw new Error('unexpected kind')
|
if (textAsset2.kind !== 'text') throw new Error('unexpected kind')
|
||||||
expect(textAsset2.fontFamily === undefined).toBe(true)
|
expect(textAsset2.fontFamily === undefined).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
handle.unmount()
|
handle.unmount()
|
||||||
|
// Make sure the teleported popover doesn't leak into other tests.
|
||||||
|
document.querySelectorAll('.font-popover').forEach(el => el.remove())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.2.5'
|
export const APP_VERSION = '1.2.6'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user