0.8.3: custom tooltips, editor-hide, pack script, settings reshuffle
Tooltips - New v-tooltip directive + TooltipHost floating panel replace the native title attribute across 95+ sites (topbar, timeline, sidebar, properties, stage overlays, fullscreen preview, export dialog, clipart drawer, modals). Styled dark card with a configurable label + optional help line for inline documentation; screen readers still get an aria-label when the panel is disabled. - Settings → Interface toggle (`ruwio:tooltipsEnabled`) hides the visual panel for users who know the app. IconButton now routes its title prop through v-tooltip so every existing call site is tooltip-aware without churn. Editor-hide + export filter - New `Layer.editorHidden` flag, eye toggle per layer row, skipped by the Konva stage and the mask-preview canvas path but honoured by exports and fullscreen preview (runtime untouched). - Export dialog gets a "Skip hidden tracks" switch that strips editor-hidden layers from every format (scene JS, runtime, example HTML, MP4, WebM) on opt-in. Disabled when the scene has no hidden layers. Button editor - `+` buttons appear on empty state slots in button-timeline rows and duplicate the nearest keyframe (prev by preference, next otherwise) onto that slot. New `duplicateKeyframeAt` store action carries props, easing, hold, and bezier handles across. Stage / interaction - Plain left-click on the empty content view pans the viewport, but only when the pointer isn't over a Konva shape — transformer handles, vertex dots, and selection boxes that extend past the canvas edge keep their drag. Settings reshuffle - Timeline tab merged into Document (Duration + FPS sit under a new "Timing" sub-heading). New Interface tab gathers three global prefs: Show tooltips, Show locked tracks, Asset thumbnails — driven by shared composables so the existing toolbar toggles stay in sync. Interface-tab CSS defensively resets the scoped label flex-column + uppercase rule that was making the toggle row look like an accident. Project identity + UX polish - Favicon wired up (moved to public/favicon.ico, linked from index.html); scripts/pack.sh packs the minimum Vue project (src, index.html, configs, public) into a tarball with the right version in the filename, and `npm run pack` drives it. - About dialog now credits Torsten Hoeltge. Test infra - Progress bar turns red mid-run on the first failure: runAll gets an onResult callback that TestOverlay uses to push each result into the reactive array incrementally. - mountComponent in the test harness registers v-tooltip on the isolated app instance so IconButton's aria-label contract actually fires under test. Regression tests - duplicateKeyframeAt: copy fidelity (props, easing, hold), collision refusal, unknown-id guards, separated-track targeting. - Shared composables: useTooltipPref and useAssetThumbsPref both return the same ref across calls and persist to localStorage. - Tooltip contract: IconButton ships `aria-label` (not title); live-DOM smoke tests match on aria-label selectors now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a2c8e5998a
commit
1d84c34c15
33 files changed
+1185
-128
No files matched your search
+118
-4
@@ -36,6 +36,7 @@ import IconButton from '@/components/ui/IconButton.vue'
|
||||
import Toast from '@/components/ui/Toast.vue'
|
||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||||
import { vTooltip } from '@/directives/tooltip'
|
||||
import { expect, suite, test } from './testRunner'
|
||||
|
||||
interface MountedHandle {
|
||||
@@ -88,6 +89,11 @@ function mountComponent(
|
||||
return h(component, props, slots)
|
||||
},
|
||||
})
|
||||
// Register the same global directives the real app registers in
|
||||
// main.ts — without this, anything using `v-tooltip` (IconButton,
|
||||
// the toolbar buttons, …) no-ops inside the test harness because
|
||||
// each `mountComponent` spins up an isolated app instance.
|
||||
app.directive('tooltip', vTooltip)
|
||||
app.mount(host)
|
||||
return {
|
||||
host,
|
||||
@@ -319,6 +325,68 @@ export function registerAllTests(): void {
|
||||
expect(dup.time).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test('duplicateKeyframeAt copies props + easing + hold to an explicit time', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Src', width: 50, height: 50, fill: '#abcdef' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 1, { x: 123, y: 45 })
|
||||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||||
src.easing = 'easeInOut'
|
||||
s.setKeyframeHold(l.id, src.id, true)
|
||||
|
||||
const newId = s.duplicateKeyframeAt(l.id, src.id, 3)
|
||||
expect(newId).toBeTruthy()
|
||||
const dup = l.keyframes.find((k) => k.id === newId)!
|
||||
expect(dup.time).toBe(3)
|
||||
expect(dup.props.x).toBe(123)
|
||||
expect(dup.props.y).toBe(45)
|
||||
expect(dup.easing).toBe('easeInOut')
|
||||
expect(dup.hold).toBe(true)
|
||||
// props must be a clone — mutating the duplicate must not touch the source.
|
||||
dup.props.x = 999
|
||||
expect(src.props.x).toBe(123)
|
||||
})
|
||||
|
||||
test('duplicateKeyframeAt refuses to overwrite an existing keyframe', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||||
s.upsertKeyframe(l.id, 2, { x: 20 })
|
||||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||||
const countBefore = l.keyframes.length
|
||||
const newId = s.duplicateKeyframeAt(l.id, src.id, 2)
|
||||
expect(newId).toBe(null)
|
||||
expect(l.keyframes.length).toBe(countBefore)
|
||||
})
|
||||
|
||||
test('duplicateKeyframeAt returns null for unknown layer or keyframe ids', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||||
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||||
expect(s.duplicateKeyframeAt('nope-layer', src.id, 2)).toBe(null)
|
||||
expect(s.duplicateKeyframeAt(l.id, 'nope-kf', 2)).toBe(null)
|
||||
})
|
||||
|
||||
test('duplicateKeyframeAt targets the source keyframe\'s own track (position vs main)', () => {
|
||||
// Separated layers have independent main / position / rotation
|
||||
// arrays. The duplicate must land in the same array the source
|
||||
// came from, not accidentally jump to another.
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 10, height: 10, fill: '#fff' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.setLayerSeparated(l.id, true)
|
||||
s.upsertKeyframe(l.id, 1, { x: 50, y: 60 })
|
||||
const posKf = l.positionKeyframes!.find((k) => Math.abs(k.time - 1) < 1e-6)!
|
||||
s.duplicateKeyframeAt(l.id, posKf.id, 2)
|
||||
const dup = l.positionKeyframes!.find((k) => Math.abs(k.time - 2) < 1e-6)
|
||||
expect(dup).toBeTruthy()
|
||||
// Nothing should have been added to the main array.
|
||||
expect(l.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(false)
|
||||
})
|
||||
|
||||
test('setKeyframeHold toggles hold flag', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
|
||||
@@ -1085,11 +1153,15 @@ export function registerAllTests(): void {
|
||||
|
||||
suite('ui — IconButton', () => {
|
||||
test('renders a button element', async () => {
|
||||
// IconButton now uses the custom v-tooltip directive instead of
|
||||
// the native `title` attribute. The directive still sets an
|
||||
// `aria-label` so screen readers get the tooltip text — that's
|
||||
// the stable contract this test pins.
|
||||
const handle = mountComponent(IconButton, { title: 'hi' })
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector('button')
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn!.getAttribute('title')).toBe('hi')
|
||||
expect(btn!.getAttribute('aria-label')).toBe('hi')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
@@ -1772,8 +1844,11 @@ export function registerAllTests(): void {
|
||||
})
|
||||
|
||||
test('topbar has save & recents buttons', () => {
|
||||
const saveBtn = document.querySelector('button[title^="Save project"]')
|
||||
const recentsBtn = document.querySelector('button[title="Recent projects"]')
|
||||
// Tooltips live in `aria-label` now (custom v-tooltip directive
|
||||
// replaced the native `title` attribute on every interactive
|
||||
// element).
|
||||
const saveBtn = document.querySelector('button[aria-label^="Save project"]')
|
||||
const recentsBtn = document.querySelector('button[aria-label="Recent projects"]')
|
||||
expect(saveBtn).toBeTruthy()
|
||||
expect(recentsBtn).toBeTruthy()
|
||||
})
|
||||
@@ -1798,7 +1873,7 @@ export function registerAllTests(): void {
|
||||
})
|
||||
|
||||
test('timeline has add-group button', () => {
|
||||
const btn = document.querySelector('button[title="Create a new track group"]')
|
||||
const btn = document.querySelector('button[aria-label="Create a new track group"]')
|
||||
expect(btn).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -2710,6 +2785,45 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('interface prefs — shared composables', () => {
|
||||
// Regression: the tooltip + asset-thumbs prefs are shared module-
|
||||
// level refs so the Settings dialog and the sidebar/timeline
|
||||
// toolbars stay in lock-step. The same refs also have to persist
|
||||
// to localStorage. The thumbs pref was originally an inline ref
|
||||
// inside AssetSidebar.vue — the settings tab couldn't change it
|
||||
// without this extraction.
|
||||
|
||||
test('useTooltipPref persists to localStorage and shares the ref', async () => {
|
||||
const { useTooltipPref } = await import('@/composables/useTooltipPref')
|
||||
const a = useTooltipPref()
|
||||
const b = useTooltipPref()
|
||||
expect(a.enabled).toBe(b.enabled) // same ref instance
|
||||
const initial = a.enabled.value
|
||||
a.enabled.value = !initial
|
||||
await nextTick()
|
||||
expect(localStorage.getItem('ruwio:tooltipsEnabled')).toBe(initial ? '0' : '1')
|
||||
// Both consumers see the flip because the ref is shared.
|
||||
expect(b.enabled.value).toBe(!initial)
|
||||
// Restore so other tests don't inherit a toggled state.
|
||||
a.enabled.value = initial
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
test('useAssetThumbsPref persists to localStorage and shares the ref', async () => {
|
||||
const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref')
|
||||
const a = useAssetThumbsPref()
|
||||
const b = useAssetThumbsPref()
|
||||
expect(a.enabled).toBe(b.enabled)
|
||||
const initial = a.enabled.value
|
||||
a.enabled.value = !initial
|
||||
await nextTick()
|
||||
expect(localStorage.getItem('animatr.assetThumbs')).toBe(String(!initial))
|
||||
expect(b.enabled.value).toBe(!initial)
|
||||
a.enabled.value = initial
|
||||
await nextTick()
|
||||
})
|
||||
})
|
||||
|
||||
suite('clipart drawer — grid pre-layout', () => {
|
||||
// Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on
|
||||
// clipart.open and given `flex: 1; min-width: 0` — which meant the
|
||||
|
||||
Reference in new issue
Block a user