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",
|
||||
"private": true,
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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 RangeSlider from './RangeSlider.vue'
|
||||
import ActionEditor from './ActionEditor.vue'
|
||||
import FontPicker from './FontPicker.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { AVAILABLE_FONTS } from '@/lib/fonts'
|
||||
|
||||
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||
|
||||
@@ -138,11 +138,14 @@ const textAsset = computed(() => {
|
||||
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({
|
||||
get: () => textAsset.value?.fontFamily ?? 'system-sans',
|
||||
get: () => textAsset.value?.fontFamily ?? '',
|
||||
set: (id: string) => {
|
||||
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.font') }}
|
||||
<select v-model="textFontModel">
|
||||
<option v-for="f in AVAILABLE_FONTS" :key="f.id" :value="f.id">{{ f.label }}</option>
|
||||
</select>
|
||||
<FontPicker v-model="textFontModel" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+25
-15
@@ -15032,7 +15032,7 @@ export function registerAllTests(): void {
|
||||
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 a = s.addAsset({
|
||||
kind: 'text', name: 'T', text: 'hello', fontSize: 32, fill: '#000',
|
||||
@@ -15043,30 +15043,40 @@ export function registerAllTests(): void {
|
||||
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'))
|
||||
const trigger = handle.host.querySelector('.font-picker .trigger') as HTMLButtonElement | null
|
||||
expect(!!trigger).toBe(true)
|
||||
// Closed state: the label shows the default — System Sans.
|
||||
expect((trigger!.textContent ?? '').includes('System Sans')).toBe(true)
|
||||
trigger!.click()
|
||||
await nextTick()
|
||||
// Popover lives in document.body via Teleport.
|
||||
const opts = Array.from(document.querySelectorAll('.font-popover .font-option')) as HTMLButtonElement[]
|
||||
expect(opts.length).toBeGreaterThan(5)
|
||||
const labels = opts.map(o => o.textContent?.trim() ?? '')
|
||||
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()
|
||||
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'))
|
||||
// Re-open + click System Sans → clears (undefined).
|
||||
trigger!.click()
|
||||
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()
|
||||
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()
|
||||
// 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'
|
||||
Reference in new issue
Block a user