0.5.0: RUWIO rebrand, vertex bezier, animation paths, welcome focus
- Rename the app to RUWIO: title, APP_NAME, topbar logo (40px PNG), welcome dialog header and about dialog switch from the PhSparkle glyph to the real logo. Redundant name text next to the logo is removed across the board. - About dialog: logo spans the full dialog width, the bottom Close button is gone (close via header X stays). - Vertex bezier for polygons: each vertex can be Corner / Curve / Split curve via right-click. Curve mirrors tangent handles symmetrically, split curve keeps them independent. Tangents are fully animatable per keyframe — lerp mirrored across all four render pipelines. - Stage path overlay: selecting a keyframe draws the tween arc between the previous and selected keyframe with draggable per-frame dots that curve the path via quadratic bezier control points. Other segments on the same track render as dashed ghost lines so the full curve is visible. Path dots grow + show grab cursor on hover. - Vertex box-select: click-drag on empty stage while a polygon is selected boxes vertices. Shift-click toggles individual vertices into/out of the selection. Multi-vertex drag moves all selected at once; right-click on a selected vertex applies Corner/Curve/Split to all. Tangent handles only render when exactly one vertex is selected. - Welcome dialog focus: opens with the most recent project focused. Enter opens it; Tab cycles only through the dialog's real buttons (delete icons pulled out of tab order, scrollable body removed from tab order). Export-current removed from welcome (not useful on startup). - Modal focus trap: all modals now keep Tab inside the dialog. - Fill / stroke transparency toggle restores the last real color when flipped back on, with a layer-base / asset fallback chain so there's always a visible color to restore to. A watcher keeps the restore cache in sync with the sampled value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
869b67458a
commit
ddea7d348f
19 files changed
+1388
-143
No files matched your search
@@ -23,6 +23,7 @@ import {
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import Toast from '@/components/ui/Toast.vue'
|
||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||
import { expect, suite, test } from './testRunner'
|
||||
|
||||
interface MountedHandle {
|
||||
@@ -2120,4 +2121,398 @@ export function registerAllTests(): void {
|
||||
expect(center[3]).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
suite('bezier path — interpolation', () => {
|
||||
test('sampleLayer returns quadratic bezier midpoint when ctrl is set', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
// Two keyframes: (0,0) at t=0 and (100,0) at t=1 with a control at (50,100).
|
||||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||
s.setKeyframeControl(l.id, cur.id, 50, 100)
|
||||
const samp = sampleLayer(l, 0.5)
|
||||
// At u=0.5: B = 0.25*0 + 0.5*50 + 0.25*100 = 25 + 25 = 50 x; y = 0.5*100 = 50
|
||||
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
|
||||
expect(Math.abs(samp.y - 50) < 1e-6).toBe(true)
|
||||
})
|
||||
|
||||
test('bezier affects only x/y; other props remain linear', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0, opacity: 0 })
|
||||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0, opacity: 1 })
|
||||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||
s.setKeyframeControl(l.id, cur.id, 50, 200)
|
||||
const samp = sampleLayer(l, 0.5)
|
||||
expect(Math.abs(samp.opacity - 0.5) < 1e-6).toBe(true)
|
||||
})
|
||||
|
||||
test('endpoints are not affected by control point', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 0, { x: 10, y: 20 })
|
||||
s.upsertKeyframe(l.id, 2, { x: 110, y: 200 })
|
||||
const cur = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-3)!
|
||||
s.setKeyframeControl(l.id, cur.id, 500, 500)
|
||||
const start = sampleLayer(l, 0)
|
||||
const end = sampleLayer(l, 2)
|
||||
expect(start.x).toBe(10)
|
||||
expect(start.y).toBe(20)
|
||||
expect(end.x).toBe(110)
|
||||
expect(end.y).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
suite('keyframe control — store actions', () => {
|
||||
test('setKeyframeControl stores ctrlX/Y on the keyframe', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
|
||||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||
s.setKeyframeControl(l.id, kf.id, 30, 40)
|
||||
expect(kf.ctrlX).toBe(30)
|
||||
expect(kf.ctrlY).toBe(40)
|
||||
})
|
||||
|
||||
test('clearKeyframeControl removes ctrlX/Y', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 1, { x: 50, y: 50 })
|
||||
const kf = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||
s.setKeyframeControl(l.id, kf.id, 30, 40)
|
||||
s.clearKeyframeControl(l.id, kf.id)
|
||||
expect(kf.ctrlX).toBe(undefined)
|
||||
expect(kf.ctrlY).toBe(undefined)
|
||||
})
|
||||
|
||||
test('setKeyframeControlFromPoint solves so the curve passes through the target', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||
// We want the curve at u=0.5 to pass through (50, 80).
|
||||
s.setKeyframeControlFromPoint(l.id, cur.id, 0.5, 50, 80)
|
||||
const samp = sampleLayer(l, 0.5)
|
||||
expect(Math.abs(samp.x - 50) < 1e-6).toBe(true)
|
||||
expect(Math.abs(samp.y - 80) < 1e-6).toBe(true)
|
||||
})
|
||||
|
||||
test('setKeyframeControlFromPoint is a no-op for endpoints (u=0 or u=1)', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
s.upsertKeyframe(l.id, 0, { x: 0, y: 0 })
|
||||
s.upsertKeyframe(l.id, 1, { x: 100, y: 0 })
|
||||
const cur = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)!
|
||||
s.setKeyframeControlFromPoint(l.id, cur.id, 0, 10, 10)
|
||||
s.setKeyframeControlFromPoint(l.id, cur.id, 1, 99, 99)
|
||||
expect(cur.ctrlX).toBe(undefined)
|
||||
expect(cur.ctrlY).toBe(undefined)
|
||||
})
|
||||
|
||||
test('setKeyframeControlFromPoint needs a previous keyframe', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'K', width: 10, height: 10, fill: '#111' })
|
||||
const l = s.addLayer(a.id)
|
||||
// Single keyframe at t=0 only
|
||||
const kf = l.keyframes[0]
|
||||
s.setKeyframeControlFromPoint(l.id, kf.id, 0.5, 20, 20)
|
||||
expect(kf.ctrlX).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
suite('clipart store — state', () => {
|
||||
test('toggle flips open state', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
s.$reset()
|
||||
const start = s.open
|
||||
s.toggle()
|
||||
expect(s.open).toBe(!start)
|
||||
s.toggle()
|
||||
expect(s.open).toBe(start)
|
||||
})
|
||||
|
||||
test('setOpen persists to localStorage', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
s.setOpen(true)
|
||||
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('1')
|
||||
s.setOpen(false)
|
||||
expect(localStorage.getItem('animatr:clipartDrawerOpen')).toBe('0')
|
||||
})
|
||||
|
||||
test('clearFolder resets handle, folderName and items', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const s = useClipartStore()
|
||||
// simulate a loaded state
|
||||
s.folderName = 'mock'
|
||||
s.handle = {} as FileSystemDirectoryHandle
|
||||
s.items = []
|
||||
await s.clearFolder()
|
||||
expect(s.handle).toBeNull()
|
||||
expect(s.folderName).toBeNull()
|
||||
expect(s.items.length).toBe(0)
|
||||
})
|
||||
|
||||
test('supported flag matches FileSystemAccess helper', async () => {
|
||||
const { useClipartStore } = await import('@/stores/clipart')
|
||||
const { isFileSystemAccessSupported } = await import('@/lib/clipartLibrary')
|
||||
const s = useClipartStore()
|
||||
expect(s.supported).toBe(isFileSystemAccessSupported())
|
||||
})
|
||||
})
|
||||
|
||||
suite('polygon — vertex bezier actions', () => {
|
||||
function makeSquare() {
|
||||
const s = makeFreshStore()
|
||||
const asset = s.addAsset({
|
||||
kind: 'polygon',
|
||||
name: 'Sq',
|
||||
points: [
|
||||
{ x: -40, y: -40 },
|
||||
{ x: 40, y: -40 },
|
||||
{ x: 40, y: 40 },
|
||||
{ x: -40, y: 40 },
|
||||
],
|
||||
fill: '#abc',
|
||||
}) as PolygonAsset
|
||||
const layer = s.addLayer(asset.id)
|
||||
return { s, asset, layer }
|
||||
}
|
||||
|
||||
test('setPolygonVertexType curve sets type + default tangents', () => {
|
||||
const { s, asset } = makeSquare()
|
||||
s.setPolygonVertexType(asset.id, 1, 'curve')
|
||||
const p = asset.points[1]
|
||||
expect(p.type).toBe('curve')
|
||||
expect(typeof p.outX).toBe('number')
|
||||
expect(typeof p.outY).toBe('number')
|
||||
expect(typeof p.inX).toBe('number')
|
||||
expect(typeof p.inY).toBe('number')
|
||||
// Curve symmetry: in = -out
|
||||
expect(Math.abs((p.inX ?? 0) + (p.outX ?? 0)) < 1e-6).toBe(true)
|
||||
expect(Math.abs((p.inY ?? 0) + (p.outY ?? 0)) < 1e-6).toBe(true)
|
||||
})
|
||||
|
||||
test('setPolygonVertexType corner clears tangent fields', () => {
|
||||
const { s, asset } = makeSquare()
|
||||
s.setPolygonVertexType(asset.id, 2, 'curve')
|
||||
s.setPolygonVertexType(asset.id, 2, 'corner')
|
||||
const p = asset.points[2]
|
||||
expect(p.type).toBe(undefined)
|
||||
expect(p.inX).toBe(undefined)
|
||||
expect(p.inY).toBe(undefined)
|
||||
expect(p.outX).toBe(undefined)
|
||||
expect(p.outY).toBe(undefined)
|
||||
})
|
||||
|
||||
test('setPolygonVertexType accepts a list of indices', () => {
|
||||
const { s, asset } = makeSquare()
|
||||
s.setPolygonVertexType(asset.id, [0, 2], 'splitcurve')
|
||||
expect(asset.points[0].type).toBe('splitcurve')
|
||||
expect(asset.points[1].type).toBe(undefined)
|
||||
expect(asset.points[2].type).toBe('splitcurve')
|
||||
expect(asset.points[3].type).toBe(undefined)
|
||||
})
|
||||
|
||||
test('setPolygonVertexTangent writes per-keyframe (curve mirrors sides)', async () => {
|
||||
const { s, asset, layer } = makeSquare()
|
||||
s.setPolygonVertexType(asset.id, 1, 'curve')
|
||||
s.setPlayhead(0.5)
|
||||
s.setPolygonVertexTangent(asset.id, 1, 'out', 20, 10)
|
||||
await tick()
|
||||
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.5) < 1e-3)!
|
||||
const pt = kf.props.points![1]
|
||||
expect(pt.outX).toBe(20)
|
||||
expect(pt.outY).toBe(10)
|
||||
expect(pt.inX).toBe(-20)
|
||||
expect(pt.inY).toBe(-10)
|
||||
})
|
||||
|
||||
test('setPolygonVertexTangent splitcurve keeps sides independent', async () => {
|
||||
const { s, asset, layer } = makeSquare()
|
||||
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
|
||||
s.setPlayhead(0)
|
||||
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
|
||||
s.setPolygonVertexTangent(asset.id, 1, 'in', -3, 4)
|
||||
await tick()
|
||||
const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)!
|
||||
const pt = kf.props.points![1]
|
||||
expect(pt.outX).toBe(10)
|
||||
expect(pt.outY).toBe(0)
|
||||
expect(pt.inX).toBe(-3)
|
||||
expect(pt.inY).toBe(4)
|
||||
})
|
||||
|
||||
test('tangent interpolation lerps between keyframes', async () => {
|
||||
const { s, asset, layer } = makeSquare()
|
||||
s.setPolygonVertexType(asset.id, 1, 'splitcurve')
|
||||
s.setPlayhead(0)
|
||||
s.setPolygonVertexTangent(asset.id, 1, 'out', 10, 0)
|
||||
s.setPlayhead(1)
|
||||
s.setPolygonVertexTangent(asset.id, 1, 'out', 30, 0)
|
||||
await tick()
|
||||
const mid = sampleLayer(layer, 0.5)
|
||||
expect(Math.abs((mid.points?.[1].outX ?? 0) - 20) < 1e-6).toBe(true)
|
||||
})
|
||||
|
||||
test('setPolygonVertexPositions updates multiple vertices in a single kf', async () => {
|
||||
const { s, asset, layer } = makeSquare()
|
||||
s.setPlayhead(0.2)
|
||||
s.setPolygonVertexPositions(layer.id, [
|
||||
{ index: 0, x: 100, y: 100 },
|
||||
{ index: 2, x: -100, y: -100 },
|
||||
])
|
||||
await tick()
|
||||
const kf = layer.keyframes.find((k) => Math.abs(k.time - 0.2) < 1e-3)!
|
||||
expect(kf.props.points![0].x).toBe(100)
|
||||
expect(kf.props.points![0].y).toBe(100)
|
||||
expect(kf.props.points![2].x).toBe(-100)
|
||||
expect(kf.props.points![2].y).toBe(-100)
|
||||
// Others unchanged
|
||||
expect(kf.props.points![1].x).toBe(asset.points[1].x)
|
||||
expect(kf.props.points![1].y).toBe(asset.points[1].y)
|
||||
})
|
||||
|
||||
test('setPolygonVertexPositions ignores out-of-range indices', async () => {
|
||||
const { s, layer } = makeSquare()
|
||||
s.setPlayhead(0)
|
||||
s.setPolygonVertexPositions(layer.id, [
|
||||
{ index: 99, x: 0, y: 0 },
|
||||
{ index: -1, x: 0, y: 0 },
|
||||
])
|
||||
await tick()
|
||||
// No keyframe was created or changed at playhead 0 beyond the initial one
|
||||
expect(layer.keyframes.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
suite('properties — fill/stroke transparent toggle', () => {
|
||||
function findAlphaToggles(host: HTMLElement) {
|
||||
return Array.from(
|
||||
host.querySelectorAll<HTMLInputElement>('.alpha-toggle input[type="checkbox"]'),
|
||||
)
|
||||
}
|
||||
|
||||
test('fill: toggle transparent on writes "transparent" to keyframe', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'R',
|
||||
width: 50,
|
||||
height: 50,
|
||||
fill: '#ff0000',
|
||||
})
|
||||
const l = s.addLayer(a.id)
|
||||
s.selectLayer(l.id)
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
await nextTick()
|
||||
await tick()
|
||||
const toggles = findAlphaToggles(handle.host)
|
||||
const fillToggle = toggles[0]
|
||||
expect(!!fillToggle).toBe(true)
|
||||
fillToggle.click()
|
||||
await tick()
|
||||
const kf = l.keyframes[0]
|
||||
expect(kf.props.fill).toBe('transparent')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('fill: toggle off restores the previous non-transparent color', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'R',
|
||||
width: 50,
|
||||
height: 50,
|
||||
fill: '#ff0000',
|
||||
})
|
||||
const l = s.addLayer(a.id)
|
||||
s.selectLayer(l.id)
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
await nextTick()
|
||||
await tick()
|
||||
const toggles = findAlphaToggles(handle.host)
|
||||
const fillToggle = toggles[0]
|
||||
// On
|
||||
fillToggle.click()
|
||||
await tick()
|
||||
expect(l.keyframes[0].props.fill).toBe('transparent')
|
||||
// Off
|
||||
fillToggle.click()
|
||||
await tick()
|
||||
const restored = l.keyframes[0].props.fill
|
||||
expect(typeof restored).toBe('string')
|
||||
expect(restored !== 'transparent').toBe(true)
|
||||
// Should match the original fill (layer.base.fill was copied from asset)
|
||||
expect(restored).toBe('#ff0000')
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('stroke: toggle on then off restores to a visible color (auto-black fallback)', async () => {
|
||||
const s = makeFreshStore()
|
||||
// Rect without an explicit stroke — the restore should fall back to black
|
||||
const a = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'R',
|
||||
width: 50,
|
||||
height: 50,
|
||||
fill: '#123456',
|
||||
})
|
||||
const l = s.addLayer(a.id)
|
||||
s.selectLayer(l.id)
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
await nextTick()
|
||||
await tick()
|
||||
const toggles = findAlphaToggles(handle.host)
|
||||
const strokeToggle = toggles[1]
|
||||
expect(!!strokeToggle).toBe(true)
|
||||
strokeToggle.click()
|
||||
await tick()
|
||||
expect(l.keyframes[0].props.stroke).toBe('transparent')
|
||||
strokeToggle.click()
|
||||
await tick()
|
||||
const restored = l.keyframes[0].props.stroke
|
||||
expect(typeof restored).toBe('string')
|
||||
expect(restored !== 'transparent').toBe(true)
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('fill: toggle off respects a user-picked color before transparency', async () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'R',
|
||||
width: 50,
|
||||
height: 50,
|
||||
fill: '#6ea8ff',
|
||||
})
|
||||
const l = s.addLayer(a.id)
|
||||
s.selectLayer(l.id)
|
||||
// User explicitly picked green before toggling transparent
|
||||
s.upsertKeyframe(l.id, 0, { fill: '#00ff00' })
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
await nextTick()
|
||||
await tick()
|
||||
const toggles = findAlphaToggles(handle.host)
|
||||
const fillToggle = toggles[0]
|
||||
fillToggle.click()
|
||||
await tick()
|
||||
expect(l.keyframes[0].props.fill).toBe('transparent')
|
||||
fillToggle.click()
|
||||
await tick()
|
||||
// Restore should be the user's green, not the asset default blue
|
||||
expect(l.keyframes[0].props.fill).toBe('#00ff00')
|
||||
handle.unmount()
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user