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:
thandClaude Opus 4.7 committed 2026-04-20 12:12:38 +02:00
1 parent 869b67458a
commit ddea7d348f
19 files changed
+1388 -143

No files matched your search

+6
View File
@@ -9,3 +9,9 @@ dist-ssr
# Local clipart working folder
/clipart/
# Logo working files (final PNGs live under src/assets/)
/logo.png
/logo.xcf
/logo_v2.xcf
/ruwio.png
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Animatr</title>
<title>RUWIO</title>
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+37 -9
View File
@@ -52,7 +52,16 @@
if(a.points && b.points && a.points.length===b.points.length){
var lp=[];
for(var pi=0;pi<a.points.length;pi++){
lp.push({x:lerp(a.points[pi].x,b.points[pi].x,t),y:lerp(a.points[pi].y,b.points[pi].y,t)});
var pa=a.points[pi], pb=b.points[pi];
var mp={x:lerp(pa.x,pb.x,t),y:lerp(pa.y,pb.y,t)};
var keys=['inX','inY','outX','outY'];
for(var ki=0;ki<keys.length;ki++){
var K=keys[ki];
if(pa[K]!=null && pb[K]!=null) mp[K]=lerp(pa[K],pb[K],t);
else if(pb[K]!=null) mp[K]=pb[K];
else if(pa[K]!=null) mp[K]=pa[K];
}
lp.push(mp);
}
o.points=lp;
} else if(b.points) o.points=b.points;
@@ -108,6 +117,31 @@
var canvas=document.getElementById('c');
var ctx=canvas.getContext('2d');
var scene=null, images={};
function tracePolyPath(tctx, sp, ap, offX, offY, closed){
var n=sp.length; if(n<2) return;
tctx.beginPath();
tctx.moveTo(sp[0].x-offX, sp[0].y-offY);
var endI=closed?n:n-1;
for(var ti=0;ti<endI;ti++){
var aPos=sp[ti];
var bIdx=(ti+1)%n;
var bPos=sp[bIdx];
var aA=ap?ap[ti]:null;
var bA=ap?ap[bIdx]:null;
var aHas=aA&&aA.type&&aA.type!=='corner';
var bHas=bA&&bA.type&&bA.type!=='corner';
if(aHas||bHas){
var aox=aHas?((aPos.outX!=null?aPos.outX:(aA.outX||0))):0;
var aoy=aHas?((aPos.outY!=null?aPos.outY:(aA.outY||0))):0;
var bix=bHas?((bPos.inX!=null?bPos.inX:(bA.inX||0))):0;
var biy=bHas?((bPos.inY!=null?bPos.inY:(bA.inY||0))):0;
tctx.bezierCurveTo(aPos.x+aox-offX,aPos.y+aoy-offY,bPos.x+bix-offX,bPos.y+biy-offY,bPos.x-offX,bPos.y-offY);
} else {
tctx.lineTo(bPos.x-offX,bPos.y-offY);
}
}
if(closed) tctx.closePath();
}
function drawMaskShape(ctxm, layer, am, time){
var asset=am[layer.assetId];
if(!asset) return;
@@ -138,10 +172,7 @@
var mpPts=p.points||asset.points||[];
var mpClosed=asset.closed!==false;
if(mpClosed && mpPts.length>=3){
ctxm.beginPath();
ctxm.moveTo(mpPts[0].x-mpX,mpPts[0].y-mpY);
for(var mpi=1;mpi<mpPts.length;mpi++) ctxm.lineTo(mpPts[mpi].x-mpX,mpPts[mpi].y-mpY);
ctxm.closePath();
tracePolyPath(ctxm,mpPts,asset.points,mpX,mpY,true);
ctxm.fill();
}
}
@@ -205,10 +236,7 @@
var pNeeded=pClosed?3:2;
if(pPts.length>=pNeeded){
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
ctx.beginPath();
ctx.moveTo(pPts[0].x-pOffX,pPts[0].y-pOffY);
for(var pI=1;pI<pPts.length;pI++) ctx.lineTo(pPts[pI].x-pOffX,pPts[pI].y-pOffY);
if(pClosed) ctx.closePath();
tracePolyPath(ctx,pPts,asset.points,pOffX,pOffY,pClosed);
if(pClosed && fill && fill!=='transparent'){
ctx.fillStyle=fill;
ctx.fill();
+12 -3
View File
@@ -26,6 +26,7 @@ import PropertiesPanel from './components/PropertiesPanel.vue'
import Timeline from './components/Timeline.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import ruwioLogo from './assets/logo.png'
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
import { t } from './i18n'
import { useHistory } from './composables/useHistory'
@@ -297,8 +298,7 @@ onBeforeUnmount(() => {
<header class="topbar">
<div class="brand-area">
<button class="brand" :title="t('about.openTooltip')" @click="aboutOpen = true">
<PhSparkle :size="16" weight="fill" class="brand-icon" />
Animatr
<img :src="ruwioLogo" alt="RUWIO" class="brand-logo" />
</button>
</div>
<div class="transport">
@@ -400,7 +400,6 @@ onBeforeUnmount(() => {
@open="openFromRecent"
@new="() => { currentProjectId = null; currentProjectName = null }"
@import="importFromFile"
@export="exportProjectFile"
/>
<Toast :visible="!!loadError" variant="error">
<strong>{{ t('app.loadFailed') }}</strong>
@@ -501,10 +500,20 @@ onBeforeUnmount(() => {
.topbar-right {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.brand-logo {
height: 40px;
width: auto;
object-fit: contain;
display: block;
user-select: none;
-webkit-user-drag: none;
}
.export-btn {
padding: 6px 12px;
border: 1px solid var(--accent);
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

+14 -60
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { PhSparkle } from '@phosphor-icons/vue'
import { Modal } from '@/components/ui'
import { APP_NAME, APP_VERSION } from '@/version'
import { t } from '@/i18n'
import ruwioLogo from '@/assets/logo.png'
defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -30,14 +30,9 @@ const shortcuts: Shortcut[] = [
@update:model-value="emit('update:modelValue', $event)"
>
<div class="about">
<div class="hero">
<div class="logo">
<PhSparkle :size="28" weight="fill" />
</div>
<div class="hero-text">
<div class="app-name">{{ APP_NAME }}</div>
<div class="version">{{ t('about.versionLabel') }} {{ APP_VERSION }}</div>
</div>
<img :src="ruwioLogo" alt="RUWIO" class="logo" />
<div class="meta">
<span class="version">{{ t('about.versionLabel') }} {{ APP_VERSION }}</span>
</div>
<p class="tagline">{{ t('about.tagline') }}</p>
@@ -52,11 +47,6 @@ const shortcuts: Shortcut[] = [
</ul>
</section>
</div>
<template #footer>
<button class="btn primary" @click="emit('update:modelValue', false)">
{{ t('about.close') }}
</button>
</template>
</Modal>
</template>
@@ -65,37 +55,22 @@ const shortcuts: Shortcut[] = [
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
.hero {
display: flex;
align-items: center;
gap: 14px;
}
.logo {
width: 52px;
height: 52px;
width: 100%;
height: auto;
max-height: 160px;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.meta {
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-soft);
color: var(--accent);
border-radius: 12px;
flex-shrink: 0;
}
.hero-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.app-name {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
margin-top: -4px;
}
.version {
@@ -153,25 +128,4 @@ kbd {
color: var(--text-dim-strong);
}
.btn {
padding: 6px 14px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--panel-alt);
color: var(--text);
cursor: pointer;
}
.btn.primary {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.btn.primary:hover {
background: var(--accent-soft-strong);
}
</style>
+489 -16
View File
@@ -7,7 +7,7 @@ import { sampleLayer } from '@/lib/interpolate'
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type { AnimatableProps, Asset, Layer } from '@/types/scene'
import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { expandedTrackOrder } from '@/lib/sceneOrder'
@@ -90,6 +90,7 @@ const backgroundConfig = computed(() => ({
width: store.scene.canvas.width,
height: store.scene.canvas.height,
fill: store.scene.canvas.background,
listening: false,
}))
const canvasBorderConfig = computed(() => ({
@@ -288,6 +289,14 @@ type KonvaCtx = {
arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void
moveTo: (x: number, y: number) => void
lineTo: (x: number, y: number) => void
bezierCurveTo: (
c1x: number,
c1y: number,
c2x: number,
c2y: number,
x: number,
y: number,
) => void
fillStrokeShape: (shape: unknown) => void
save: () => void
restore: () => void
@@ -380,6 +389,49 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
return cfg
}
function traceKonvaPolyPath(
ctx: KonvaCtx,
samplePoints: { x: number; y: number }[],
assetPoints: Point[],
offX: number,
offY: number,
closed: boolean,
) {
const n = samplePoints.length
if (n < 2) return
ctx.beginPath()
ctx.moveTo(samplePoints[0].x - offX, samplePoints[0].y - offY)
const endI = closed ? n : n - 1
for (let i = 0; i < endI; i++) {
const aPos = samplePoints[i]
const bIdx = (i + 1) % n
const bPos = samplePoints[bIdx]
const aAsset = assetPoints[i] as Point | undefined
const bAsset = assetPoints[bIdx] as Point | undefined
const aHas = aAsset && aAsset.type && aAsset.type !== 'corner'
const bHas = bAsset && bAsset.type && bAsset.type !== 'corner'
if (aHas || bHas) {
const aSp = samplePoints[i] as Point
const bSp = samplePoints[bIdx] as Point
const aox = aHas ? aSp.outX ?? aAsset!.outX ?? 0 : 0
const aoy = aHas ? aSp.outY ?? aAsset!.outY ?? 0 : 0
const bix = bHas ? bSp.inX ?? bAsset!.inX ?? 0 : 0
const biy = bHas ? bSp.inY ?? bAsset!.inY ?? 0 : 0
ctx.bezierCurveTo(
aPos.x + aox - offX,
aPos.y + aoy - offY,
bPos.x + bix - offX,
bPos.y + biy - offY,
bPos.x - offX,
bPos.y - offY,
)
} else {
ctx.lineTo(bPos.x - offX, bPos.y - offY)
}
}
if (closed) ctx.closePath()
}
function polygonConfig(
layer: Layer,
asset: Extract<Asset, { kind: 'polygon' }>,
@@ -398,6 +450,7 @@ function polygonConfig(
for (const p of pts) {
flat.push(p.x - offX, p.y - offY)
}
const hasCurves = asset.points.some((p) => p.type && p.type !== 'corner')
const cfg: Record<string, unknown> = {
...baseNodeConfig(layer, props),
points: flat,
@@ -414,18 +467,12 @@ function polygonConfig(
cfg.stroke = undefined
cfg.strokeWidth = 0
}
// Widen hit area on edges so double-click to insert a vertex is easy
cfg.hitStrokeWidth = Math.max(strokeWidth, 14 / zoom.value)
if (blur > 0) {
if (blur > 0 || hasCurves) {
cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => {
ctx.save()
ctx.setAttr('filter', `blur(${blur}px)`)
ctx.beginPath()
if (pts.length > 0) {
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
}
if (closed) ctx.closePath()
if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`)
traceKonvaPolyPath(ctx, pts, asset.points, offX, offY, closed)
ctx.fillStrokeShape(shape)
ctx.restore()
}
@@ -662,13 +709,14 @@ const polygonVertexHandles = computed<VertexHandle[]>(() => {
})
function vertexHandleConfig(h: VertexHandle) {
const selected = selectedVertexIndices.value.has(h.index)
return {
x: h.x,
y: h.y,
radius: 5 / zoom.value,
radius: (selected ? 6 : 5) / zoom.value,
stroke: '#6ea8ff',
strokeWidth: 1.5 / zoom.value,
fill: '#ffffff',
strokeWidth: (selected ? 2 : 1.5) / zoom.value,
fill: selected ? '#6ea8ff' : '#ffffff',
draggable: true,
name: 'vertex-handle',
}
@@ -691,20 +739,320 @@ function worldToPolygonLocal(layer: Layer, wx: number, wy: number): { x: number;
}
}
function onVertexDragMove(e: Konva.KonvaEventObject<DragEvent>, index: number) {
interface VertexDragState {
layerId: string
indices: number[]
dragStartLocal: { x: number; y: number }
startPositions: Map<number, { x: number; y: number }>
}
const vertexDragState = ref<VertexDragState | null>(null)
function onVertexClick(e: Konva.KonvaEventObject<MouseEvent>, index: number) {
const native = e.evt as MouseEvent
if (native.shiftKey) {
const next = new Set(selectedVertexIndices.value)
if (next.has(index)) next.delete(index)
else next.add(index)
selectedVertexIndices.value = next
} else {
selectedVertexIndices.value = new Set([index])
}
e.cancelBubble = true
}
function onVertexDragStart(_e: Konva.KonvaEventObject<DragEvent>, index: number) {
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
// If the dragged vertex wasn't in the selection, reduce selection to just that one
const current = selectedVertexIndices.value
if (!current.has(index)) {
selectedVertexIndices.value = new Set([index])
}
const indices = Array.from(selectedVertexIndices.value)
const sampled = sampleLayer(layer, store.playhead)
const asset = assetMap.value.get(layer.assetId)
const pts = sampled.points ?? (asset && asset.kind === 'polygon' ? asset.points : [])
const start = new Map<number, { x: number; y: number }>()
for (const i of indices) {
const p = pts[i]
if (p) start.set(i, { x: p.x, y: p.y })
}
const dragStart = start.get(index) ?? { x: 0, y: 0 }
vertexDragState.value = {
layerId: id,
indices,
dragStartLocal: dragStart,
startPositions: start,
}
}
function onVertexDragMove(e: Konva.KonvaEventObject<DragEvent>, index: number) {
const state = vertexDragState.value
if (!state) return
const layer = store.activeLayers.find((l) => l.id === state.layerId)
if (!layer) return
const node = e.target
const local = worldToPolygonLocal(layer, node.x(), node.y())
store.movePolygonVertex(id, index, local.x, local.y)
const currentLocal = worldToPolygonLocal(layer, node.x(), node.y())
const dx = currentLocal.x - state.dragStartLocal.x
const dy = currentLocal.y - state.dragStartLocal.y
const updates: { index: number; x: number; y: number }[] = []
for (const i of state.indices) {
const start = state.startPositions.get(i)
if (!start) continue
updates.push({ index: i, x: start.x + dx, y: start.y + dy })
}
// Keep the dragged node itself in sync with the applied delta (in case Konva's internal node drifts)
const target = state.startPositions.get(index)
if (target) {
updates.push({ index, x: target.x + dx, y: target.y + dy })
}
store.setPolygonVertexPositions(state.layerId, updates)
}
function onVertexDragEnd(e: Konva.KonvaEventObject<DragEvent>, index: number) {
onVertexDragMove(e, index)
vertexDragState.value = null
}
interface VertexMenuState {
screenX: number
screenY: number
assetId: string
layerId: string
indices: number[]
currentType: 'corner' | 'curve' | 'splitcurve'
}
const vertexMenu = ref<VertexMenuState | null>(null)
function onVertexContextMenu(e: Konva.KonvaEventObject<PointerEvent>, index: number) {
e.evt.preventDefault()
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return
const rect = container.value?.getBoundingClientRect()
if (!rect) return
// If the right-clicked vertex is part of an existing multi-selection,
// apply the menu action to all selected. Otherwise focus on this vertex only.
const sel = selectedVertexIndices.value
const indices = sel.has(index) ? Array.from(sel) : [index]
if (!sel.has(index)) selectedVertexIndices.value = new Set([index])
const point = asset.points[index]
vertexMenu.value = {
screenX: e.evt.clientX - rect.left,
screenY: e.evt.clientY - rect.top,
assetId: asset.id,
layerId: layer.id,
indices,
currentType: point?.type ?? 'corner',
}
}
function applyVertexType(type: 'corner' | 'curve' | 'splitcurve') {
const m = vertexMenu.value
if (!m) return
store.setPolygonVertexType(m.assetId, m.indices, type)
vertexMenu.value = null
}
function closeVertexMenu() {
vertexMenu.value = null
}
interface TangentHandle {
key: string
layerId: string
assetId: string
vertexIndex: number
side: 'in' | 'out'
vertexWx: number
vertexWy: number
handleWx: number
handleWy: number
}
const polygonTangentHandles = computed<TangentHandle[]>(() => {
const id = store.selectedLayerId
if (!id) return []
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || layer.locked) return []
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return []
// Only show tangent handles when exactly one vertex is selected
const vsel = selectedVertexIndices.value
if (vsel.size !== 1) return []
const onlyIndex = vsel.values().next().value!
const sampled = sampleLayer(layer, store.playhead)
const live = liveLayerPose.value
const cx = live && live.layerId === id ? live.x : sampled.x
const cy = live && live.layerId === id ? live.y : sampled.y
const rot = live && live.layerId === id ? live.rotation : sampled.rotation
const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX
const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY
const offX = layer.offsetX ?? 0
const offY = layer.offsetY ?? 0
const pts = sampled.points ?? asset.points
const theta = (rot * Math.PI) / 180
const c = Math.cos(theta)
const s = Math.sin(theta)
const out: TangentHandle[] = []
for (let i = 0; i < asset.points.length; i++) {
if (i !== onlyIndex) continue
const ap = asset.points[i]
if (!ap.type || ap.type === 'corner') continue
const p = pts[i] as Point
const vx = p.x - offX
const vy = p.y - offY
const localVertex = { x: vx * scX, y: vy * scY }
const vertexWx = cx + localVertex.x * c - localVertex.y * s
const vertexWy = cy + localVertex.x * s + localVertex.y * c
for (const side of ['in', 'out'] as const) {
const dx =
(side === 'in' ? p.inX ?? ap.inX : p.outX ?? ap.outX) ?? 0
const dy =
(side === 'in' ? p.inY ?? ap.inY : p.outY ?? ap.outY) ?? 0
const tx = (p.x + dx - offX) * scX
const ty = (p.y + dy - offY) * scY
const handleWx = cx + tx * c - ty * s
const handleWy = cy + tx * s + ty * c
out.push({
key: `tan-${layer.id}-${i}-${side}`,
layerId: layer.id,
assetId: asset.id,
vertexIndex: i,
side,
vertexWx,
vertexWy,
handleWx,
handleWy,
})
}
}
return out
})
const polygonTangentLines = computed(() =>
polygonTangentHandles.value.map((h) => ({
key: `tl-${h.key}`,
config: {
points: [h.vertexWx, h.vertexWy, h.handleWx, h.handleWy],
stroke: '#6ea8ff',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
listening: false,
opacity: 0.6,
},
})),
)
function tangentHandleConfig(h: TangentHandle) {
return {
x: h.handleWx,
y: h.handleWy,
radius: 3.5 / zoom.value,
hitStrokeWidth: 10 / zoom.value,
fill: '#6ea8ff',
stroke: '#ffffff',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
draggable: true,
name: 'tangent-handle',
}
}
function onTangentDrag(e: Konva.KonvaEventObject<DragEvent>, h: TangentHandle) {
const layer = store.activeLayers.find((l) => l.id === h.layerId)
if (!layer) return
const asset = assetMap.value.get(h.assetId)
if (!asset || asset.kind !== 'polygon') return
const node = e.target
// Convert node world position to asset-local vertex offset
const handleLocal = worldToPolygonLocal(layer, node.x(), node.y())
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
const vp = pts[h.vertexIndex]
if (!vp) return
const dx = handleLocal.x - vp.x
const dy = handleLocal.y - vp.y
store.setPolygonVertexTangent(h.assetId, h.vertexIndex, h.side, dx, dy)
}
const selectedVertexIndices = ref<Set<number>>(new Set())
const selectedPolygonLayer = computed(() => {
const id = store.selectedLayerId
if (!id) return null
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer) return null
const asset = assetMap.value.get(layer.assetId)
if (!asset || asset.kind !== 'polygon') return null
return { layer, asset }
})
watch(
() => store.selectedLayerId,
() => {
selectedVertexIndices.value = new Set()
closeVertexMenu()
},
)
const boxSelect = ref<{
startX: number
startY: number
curX: number
curY: number
} | null>(null)
const boxSelectRectPx = computed(() => {
const b = boxSelect.value
if (!b) return null
const x0 = Math.min(b.startX, b.curX)
const y0 = Math.min(b.startY, b.curY)
const x1 = Math.max(b.startX, b.curX)
const y1 = Math.max(b.startY, b.curY)
// Convert world coords to screen coords for DOM overlay
const sx0 = x0 * zoom.value + viewX.value
const sy0 = y0 * zoom.value + viewY.value
const sx1 = x1 * zoom.value + viewX.value
const sy1 = y1 * zoom.value + viewY.value
return {
left: Math.min(sx0, sx1),
top: Math.min(sy0, sy1),
width: Math.abs(sx1 - sx0),
height: Math.abs(sy1 - sy0),
}
})
function finishBoxSelect() {
const b = boxSelect.value
boxSelect.value = null
if (!b) return
const sel = selectedPolygonLayer.value
if (!sel) return
const { layer, asset } = sel
const sampled = sampleLayer(layer, store.playhead)
const pts = sampled.points ?? asset.points
const minX = Math.min(b.startX, b.curX)
const maxX = Math.max(b.startX, b.curX)
const minY = Math.min(b.startY, b.curY)
const maxY = Math.max(b.startY, b.curY)
const indices = new Set<number>()
for (let i = 0; i < pts.length; i++) {
const world = transformLocalToWorld(layer, sampled, pts[i])
if (world.x >= minX && world.x <= maxX && world.y >= minY && world.y <= maxY) {
indices.add(i)
}
}
selectedVertexIndices.value = indices
}
const draggingPivot = ref(false)
@@ -1028,6 +1376,24 @@ function pointerToWorld(e: Konva.KonvaEventObject<MouseEvent>) {
}
function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
// Box-select for vertices when a polygon layer is selected and the
// click lands on the empty stage (not a shape/handle).
if (
!store.drawingMode &&
selectedPolygonLayer.value &&
e.target === e.target.getStage()
) {
const native = e.evt as MouseEvent
if (native.button === 0 && !native.ctrlKey) {
const wp = pointerToWorld(e)
if (wp) {
boxSelect.value = { startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y }
if (!native.shiftKey) selectedVertexIndices.value = new Set()
closeVertexMenu()
return
}
}
}
if (store.drawingMode === 'freeform') {
const native = e.evt as MouseEvent
// Middle-click or Ctrl+click belong to the pan gesture, not drawing
@@ -1066,7 +1432,9 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
if (target.findAncestor?.('.pivot-handle')) return
if (target.findAncestor?.('.rotation-handle')) return
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
if (target.name?.() === 'tangent-handle') return
if (target.name?.() === 'path-frame-dot') return
if (vertexMenu.value) vertexMenu.value = null
if (target === target.getStage()) {
store.selectLayer(null)
return
@@ -1081,6 +1449,11 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
}
function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
if (boxSelect.value) {
const wp = pointerToWorld(e)
if (wp) boxSelect.value = { ...boxSelect.value, curX: wp.x, curY: wp.y }
return
}
if (store.drawingMode !== 'freeform') {
drawingPreview.value = null
return
@@ -1092,6 +1465,10 @@ function handleStageMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
drawingPreview.value = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp
}
function handleStageMouseUp() {
if (boxSelect.value) finishBoxSelect()
}
function onContainerDblClick(e: MouseEvent) {
const rect = container.value?.getBoundingClientRect()
if (!rect) return
@@ -1631,6 +2008,7 @@ onBeforeUnmount(() => {
:config="stageConfig"
@mousedown="handleStageMouseDown"
@mousemove="handleStageMouseMove"
@mouseup="handleStageMouseUp"
>
<v-layer>
<v-group :config="viewConfig">
@@ -1733,8 +2111,23 @@ onBeforeUnmount(() => {
v-for="h in polygonVertexHandles"
:key="`vh-${h.layerId}-${h.index}`"
:config="vertexHandleConfig(h)"
@click="(e: Konva.KonvaEventObject<MouseEvent>) => onVertexClick(e, h.index)"
@dragstart="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragStart(e, h.index)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragMove(e, h.index)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onVertexDragEnd(e, h.index)"
@contextmenu="(e: Konva.KonvaEventObject<PointerEvent>) => onVertexContextMenu(e, h.index)"
/>
<v-line
v-for="tl in polygonTangentLines"
:key="tl.key"
:config="tl.config"
/>
<v-circle
v-for="th in polygonTangentHandles"
:key="th.key"
:config="tangentHandleConfig(th)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onTangentDrag(e, th)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onTangentDrag(e, th)"
/>
<v-line
v-for="g in animationGhostSegments"
@@ -1766,6 +2159,16 @@ onBeforeUnmount(() => {
ref="previewCanvasEl"
class="mask-preview-canvas"
></canvas>
<div
v-if="boxSelectRectPx"
class="box-select"
:style="{
left: boxSelectRectPx.left + 'px',
top: boxSelectRectPx.top + 'px',
width: boxSelectRectPx.width + 'px',
height: boxSelectRectPx.height + 'px',
}"
></div>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
@@ -1799,6 +2202,35 @@ onBeforeUnmount(() => {
</div>
<div class="dims">{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}</div>
<div
v-if="vertexMenu"
class="vertex-menu"
:style="{ left: vertexMenu.screenX + 'px', top: vertexMenu.screenY + 'px' }"
@mouseleave="closeVertexMenu"
>
<button
class="vertex-menu-item"
:class="{ active: vertexMenu.currentType === 'corner' }"
@click="applyVertexType('corner')"
>
{{ t('canvas.vertexCorner') }}
</button>
<button
class="vertex-menu-item"
:class="{ active: vertexMenu.currentType === 'curve' }"
@click="applyVertexType('curve')"
>
{{ t('canvas.vertexCurve') }}
</button>
<button
class="vertex-menu-item"
:class="{ active: vertexMenu.currentType === 'splitcurve' }"
@click="applyVertexType('splitcurve')"
>
{{ t('canvas.vertexSplitCurve') }}
</button>
</div>
</div>
</template>
@@ -1814,6 +2246,39 @@ onBeforeUnmount(() => {
cursor: crosshair;
}
.vertex-menu {
position: absolute;
display: flex;
flex-direction: column;
min-width: 140px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.3));
z-index: 20;
padding: 4px;
}
.vertex-menu-item {
text-align: left;
padding: 6px 10px;
font-size: 12px;
border: none;
background: transparent;
color: var(--text);
border-radius: var(--radius-sm);
cursor: pointer;
}
.vertex-menu-item:hover {
background: var(--panel-alt);
}
.vertex-menu-item.active {
background: var(--accent-soft);
color: var(--accent);
}
.mask-preview-canvas {
position: absolute;
inset: 0;
@@ -1821,6 +2286,14 @@ onBeforeUnmount(() => {
background: var(--bg);
}
.box-select {
position: absolute;
pointer-events: none;
border: 1px dashed var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
z-index: 15;
}
.dims {
position: absolute;
bottom: 10px;
+63 -9
View File
@@ -103,15 +103,29 @@ const canStroke = computed(() => {
return k === 'polygon' || k === 'rect' || k === 'circle' || k === 'text'
})
// Remember the last non-transparent stroke color so we can restore it when
// the user flips "Transparent" back off. Updates whenever the sampled stroke
// has a real color, regardless of whether the user set it via picker, keyframe
// interpolation or layer selection.
const lastStrokeColor = ref<string>('#000000')
watch(
() => [sampled.value?.stroke, selectedAsset.value?.id] as const,
([s]) => {
if (s && s !== 'transparent') lastStrokeColor.value = s
},
{ immediate: true },
)
const strokeColor = computed<string>({
get: () => {
if (sampled.value?.stroke) return sampled.value.stroke
const a = selectedAsset.value
if (a && 'stroke' in a && a.stroke) return a.stroke
return '#000000'
const s = sampled.value?.stroke
if (s && s !== 'transparent') return s
return lastStrokeColor.value
},
set: (v: string) => {
if (!selected.value) return
if (v !== 'transparent') lastStrokeColor.value = v
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: v })
},
})
@@ -223,20 +237,44 @@ const canFill = computed(() => {
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
})
const lastFillColor = ref<string>('#000000')
watch(
() => [sampled.value?.fill, selectedAsset.value?.id] as const,
([f]) => {
if (f && f !== 'transparent') lastFillColor.value = f
},
{ immediate: true },
)
const fillColor = computed<string>({
get: () => {
const samp = sampled.value?.fill
if (samp && samp !== 'transparent') return samp
const a = selectedAsset.value
if (a && 'fill' in a && a.fill && a.fill !== 'transparent') return a.fill
return '#000000'
return lastFillColor.value
},
set: (v: string) => {
if (!selected.value) return
if (v !== 'transparent') lastFillColor.value = v
store.upsertKeyframe(selected.value.id, store.playhead, { fill: v })
},
})
function resolveRestoreFill(): string {
// Prefer the cached last non-transparent color, but fall back to the layer
// base or asset fill so that the restore is visibly different from
// 'transparent' even if the user never picked a color via the picker.
if (lastFillColor.value && lastFillColor.value !== 'transparent') {
return lastFillColor.value
}
const layer = selected.value
const base = layer?.base?.fill
if (base && base !== 'transparent') return base
const a = selectedAsset.value
if (a && 'fill' in a && a.fill && a.fill !== 'transparent') return a.fill
return '#6ea8ff'
}
const fillTransparent = computed<boolean>({
get: () => {
const samp = sampled.value?.fill
@@ -250,11 +288,25 @@ const fillTransparent = computed<boolean>({
if (v) {
store.upsertKeyframe(selected.value.id, store.playhead, { fill: 'transparent' })
} else {
store.upsertKeyframe(selected.value.id, store.playhead, { fill: fillColor.value })
const color = resolveRestoreFill()
lastFillColor.value = color
store.upsertKeyframe(selected.value.id, store.playhead, { fill: color })
}
},
})
function resolveRestoreStroke(): string {
if (lastStrokeColor.value && lastStrokeColor.value !== 'transparent') {
return lastStrokeColor.value
}
const layer = selected.value
const base = layer?.base?.stroke
if (base && base !== 'transparent') return base
const a = selectedAsset.value
if (a && 'stroke' in a && a.stroke && a.stroke !== 'transparent') return a.stroke
return '#000000'
}
const strokeTransparent = computed<boolean>({
get: () => {
const s = sampled.value?.stroke
@@ -268,7 +320,9 @@ const strokeTransparent = computed<boolean>({
if (v) {
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: 'transparent' })
} else {
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: strokeColor.value })
const color = resolveRestoreStroke()
lastStrokeColor.value = color
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: color })
}
},
})
+52 -19
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { nextTick, onMounted, ref, watch } from 'vue'
import {
PhDownloadSimple,
PhFileArrowUp,
PhFolderOpen,
PhPlus,
PhTrash,
} from '@phosphor-icons/vue'
import ruwioLogo from '@/assets/logo.png'
import {
deleteStoredProject,
listRecentProjects,
@@ -21,11 +21,12 @@ const emit = defineEmits<{
'open': [project: StoredProject]
'new': []
'import': []
'export': []
}>()
const recents = ref<StoredProject[]>([])
const loading = ref(false)
const recentRefs = ref<HTMLElement[]>([])
const newBtnRef = ref<HTMLButtonElement | null>(null)
async function reload() {
loading.value = true
@@ -34,6 +35,16 @@ async function reload() {
} finally {
loading.value = false
}
await nextTick()
focusInitial()
}
function focusInitial() {
if (recentRefs.value[0]) {
recentRefs.value[0].focus()
} else if (newBtnRef.value) {
newBtnRef.value.focus()
}
}
function formatDate(ts: number): string {
@@ -89,10 +100,6 @@ function importFile() {
emit('update:modelValue', false)
}
function exportFile() {
emit('export')
}
function close() {
emit('update:modelValue', false)
}
@@ -117,7 +124,7 @@ watch(
>
<template #header>
<div class="hdr-text">
<h2>{{ t('welcome.heading') }}</h2>
<img :src="ruwioLogo" alt="RUWIO" class="hdr-logo" />
<p class="sub">{{ t('welcome.subheading') }}</p>
</div>
</template>
@@ -128,10 +135,18 @@ watch(
</div>
<ul v-else class="recents">
<li
v-for="p in recents"
v-for="(p, i) in recents"
:key="p.id"
:ref="
(el) => {
if (el) recentRefs[i] = el as HTMLElement
}
"
class="recent"
tabindex="0"
@click="openProject(p)"
@keydown.enter.prevent="openProject(p)"
@keydown.space.prevent="openProject(p)"
>
<div class="thumb">
<img v-if="p.thumbnail" :src="p.thumbnail" alt="" />
@@ -145,6 +160,7 @@ watch(
</div>
<button
class="del"
tabindex="-1"
:title="t('welcome.deleteTooltip')"
@click="removeProject($event, p)"
>
@@ -154,7 +170,7 @@ watch(
</ul>
</div>
<template #footer>
<button class="ftr-btn" @click="startFresh">
<button ref="newBtnRef" class="ftr-btn" @click="startFresh">
<PhPlus :size="13" weight="bold" />
{{ t('welcome.newProject') }}
</button>
@@ -162,23 +178,28 @@ watch(
<PhFileArrowUp :size="13" />
{{ t('welcome.importFile') }}
</button>
<button class="ftr-btn" @click="exportFile">
<PhDownloadSimple :size="13" />
{{ t('welcome.exportCurrent') }}
</button>
</template>
</Modal>
</template>
<style scoped>
.hdr-text h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
.hdr-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.hdr-logo {
height: 48px;
width: auto;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.sub {
margin: 2px 0 0;
margin: 0;
font-size: 11px;
color: var(--text-dim);
}
@@ -223,6 +244,12 @@ watch(
border-color: var(--border-strong);
}
.recent:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.thumb {
width: 44px;
height: 44px;
@@ -303,4 +330,10 @@ watch(
background: var(--panel-hover);
border-color: var(--border-strong);
}
.ftr-btn:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
</style>
+53 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { PhX } from '@phosphor-icons/vue'
import { t } from '@/i18n'
@@ -24,6 +24,8 @@ const props = withDefaults(
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const dialogRef = ref<HTMLElement | null>(null)
function close() {
if (!props.dismissable) return
emit('update:modelValue', false)
@@ -34,12 +36,58 @@ function onBackdropMouseDown(e: MouseEvent) {
if (e.target === e.currentTarget) close()
}
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
function collectFocusable(): HTMLElement[] {
const root = dialogRef.value
if (!root) return []
const nodes = Array.from(
root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
)
return nodes.filter((n) => {
if (n.hasAttribute('disabled')) return false
if (n.getAttribute('aria-hidden') === 'true') return false
// hidden via offsetParent check (covers display:none and detached)
if (n.offsetParent === null && n !== document.activeElement) return false
return true
})
}
function handleTab(e: KeyboardEvent) {
const root = dialogRef.value
if (!root) return
const focusables = collectFocusable()
if (focusables.length === 0) {
e.preventDefault()
return
}
const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement as HTMLElement | null
const insideDialog = active && root.contains(active)
if (e.shiftKey) {
if (!insideDialog || active === first) {
e.preventDefault()
last.focus()
}
} else {
if (!insideDialog || active === last) {
e.preventDefault()
first.focus()
}
}
}
function onKeyDown(e: KeyboardEvent) {
if (!props.modelValue) return
if (!props.closeOnEsc) return
if (e.key === 'Escape') {
if (e.key === 'Escape' && props.closeOnEsc) {
e.preventDefault()
close()
return
}
if (e.key === 'Tab') {
handleTab(e)
}
}
@@ -51,7 +99,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="modelValue" class="backdrop" @mousedown="onBackdropMouseDown">
<div class="dialog" :class="`size-${size}`" role="dialog" aria-modal="true">
<div ref="dialogRef" class="dialog" :class="`size-${size}`" role="dialog" aria-modal="true">
<header v-if="title || $slots.header" class="hdr">
<slot name="header">
<h2>{{ title }}</h2>
@@ -66,7 +114,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<PhX :size="14" weight="bold" />
</button>
</header>
<div class="body">
<div class="body" tabindex="-1">
<slot />
</div>
<footer v-if="$slots.footer" class="ftr">
+395
View File
@@ -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()
})
})
}
+3
View File
@@ -214,6 +214,9 @@ export const en = {
cropOff: 'Show everything',
maskPreviewOn: 'Preview masks (render view)',
maskPreviewOff: 'Back to edit view',
vertexCorner: 'Corner',
vertexCurve: 'Curve (smooth)',
vertexSplitCurve: 'Split curve',
},
settings: {
+38 -9
View File
@@ -50,7 +50,16 @@ const ENGINE_SOURCE = `(function(){
if(a.points&&b.points&&a.points.length===b.points.length){
var lp=[];
for(var lpi=0;lpi<a.points.length;lpi++){
lp.push({x:lerp(a.points[lpi].x,b.points[lpi].x,t),y:lerp(a.points[lpi].y,b.points[lpi].y,t)});
var pa=a.points[lpi], pb=b.points[lpi];
var mp={x:lerp(pa.x,pb.x,t),y:lerp(pa.y,pb.y,t)};
var keys=['inX','inY','outX','outY'];
for(var ki=0;ki<keys.length;ki++){
var K=keys[ki];
if(pa[K]!=null && pb[K]!=null) mp[K]=lerp(pa[K],pb[K],t);
else if(pb[K]!=null) mp[K]=pb[K];
else if(pa[K]!=null) mp[K]=pa[K];
}
lp.push(mp);
}
out.points=lp;
} else if(b.points) out.points=b.points;
@@ -162,6 +171,32 @@ const ENGINE_SOURCE = `(function(){
draw(t);
requestAnimationFrame(frame);
}
function tracePolyPath(tctx,sp,ap,offX,offY,closed){
var n=sp.length;
if(n<2) return;
tctx.beginPath();
tctx.moveTo(sp[0].x-offX,sp[0].y-offY);
var endI=closed?n:n-1;
for(var ti=0;ti<endI;ti++){
var aPos=sp[ti];
var bIdx=(ti+1)%n;
var bPos=sp[bIdx];
var aA=ap?ap[ti]:null;
var bA=ap?ap[bIdx]:null;
var aHas=aA&&aA.type&&aA.type!=='corner';
var bHas=bA&&bA.type&&bA.type!=='corner';
if(aHas||bHas){
var aox=aHas?((aPos.outX!=null?aPos.outX:(aA.outX||0))):0;
var aoy=aHas?((aPos.outY!=null?aPos.outY:(aA.outY||0))):0;
var bix=bHas?((bPos.inX!=null?bPos.inX:(bA.inX||0))):0;
var biy=bHas?((bPos.inY!=null?bPos.inY:(bA.inY||0))):0;
tctx.bezierCurveTo(aPos.x+aox-offX,aPos.y+aoy-offY,bPos.x+bix-offX,bPos.y+biy-offY,bPos.x-offX,bPos.y-offY);
} else {
tctx.lineTo(bPos.x-offX,bPos.y-offY);
}
}
if(closed) tctx.closePath();
}
function drawMaskShape(ctxm,layer,time){
var asset=assetMap[layer.assetId];
if(!asset) return;
@@ -194,10 +229,7 @@ const ENGINE_SOURCE = `(function(){
var mpPts=p.points||asset.points||[];
var mpClosed=asset.closed!==false;
if(mpClosed && mpPts.length>=3){
ctxm.beginPath();
ctxm.moveTo(mpPts[0].x-mpOffX,mpPts[0].y-mpOffY);
for(var mpI=1;mpI<mpPts.length;mpI++)ctxm.lineTo(mpPts[mpI].x-mpOffX,mpPts[mpI].y-mpOffY);
ctxm.closePath();
tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true);
ctxm.fill();
}
}
@@ -288,10 +320,7 @@ const ENGINE_SOURCE = `(function(){
var pNeeded=pClosed?3:2;
if(pPts.length>=pNeeded){
if(pBlur>0) ctx.filter='blur('+pBlur+'px)';
ctx.beginPath();
ctx.moveTo(pPts[0].x-pOffX,pPts[0].y-pOffY);
for(var pI=1;pI<pPts.length;pI++)ctx.lineTo(pPts[pI].x-pOffX,pPts[pI].y-pOffY);
if(pClosed) ctx.closePath();
tracePolyPath(ctx,pPts,asset.points,pOffX,pOffY,pClosed);
var pFill=p.fill||asset.fill;
if(pClosed && pFill && pFill!=='transparent'){
ctx.fillStyle=pFill;
+9 -1
View File
@@ -71,7 +71,15 @@ function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): Animatabl
if (a.points && b.points && a.points.length === b.points.length) {
out.points = a.points.map((pa, i) => {
const pb = b.points![i]
return { x: lerp(pa.x, pb.x, t), y: lerp(pa.y, pb.y, t) }
const merged: typeof pa = { x: lerp(pa.x, pb.x, t), y: lerp(pa.y, pb.y, t) }
for (const key of ['inX', 'inY', 'outX', 'outY'] as const) {
const av = pa[key]
const bv = pb[key]
if (av !== undefined && bv !== undefined) merged[key] = lerp(av, bv, t)
else if (bv !== undefined) merged[key] = bv
else if (av !== undefined) merged[key] = av
}
return merged
})
} else if (b.points) {
out.points = b.points.map((p) => ({ ...p }))
+45 -8
View File
@@ -80,6 +80,49 @@ function clearShadow(ctx: CanvasRenderingContext2D) {
ctx.shadowOffsetY = 0
}
export function tracePolygonPath(
ctx: CanvasRenderingContext2D,
samplePoints: { x: number; y: number }[],
assetPoints: import('@/types/scene').Point[],
offX: number,
offY: number,
closed: boolean,
): void {
const n = samplePoints.length
if (n < 2) return
ctx.beginPath()
ctx.moveTo(samplePoints[0].x - offX, samplePoints[0].y - offY)
const endI = closed ? n : n - 1
for (let i = 0; i < endI; i++) {
const aPos = samplePoints[i]
const bIdx = (i + 1) % n
const bPos = samplePoints[bIdx]
const aAsset = assetPoints[i]
const bAsset = assetPoints[bIdx]
const aHas = aAsset && aAsset.type && aAsset.type !== 'corner'
const bHas = bAsset && bAsset.type && bAsset.type !== 'corner'
if (aHas || bHas) {
const aSp = samplePoints[i] as import('@/types/scene').Point
const bSp = samplePoints[bIdx] as import('@/types/scene').Point
const aoutX = aHas ? (aSp.outX ?? aAsset.outX ?? 0) : 0
const aoutY = aHas ? (aSp.outY ?? aAsset.outY ?? 0) : 0
const binX = bHas ? (bSp.inX ?? bAsset.inX ?? 0) : 0
const binY = bHas ? (bSp.inY ?? bAsset.inY ?? 0) : 0
ctx.bezierCurveTo(
aPos.x + aoutX - offX,
aPos.y + aoutY - offY,
bPos.x + binX - offX,
bPos.y + binY - offY,
bPos.x - offX,
bPos.y - offY,
)
} else {
ctx.lineTo(bPos.x - offX, bPos.y - offY)
}
}
if (closed) ctx.closePath()
}
function drawLayers(
ctx: CanvasRenderingContext2D,
layers: Layer[],
@@ -168,10 +211,7 @@ function drawLayers(
const needed = closed ? 3 : 2
if (pts.length >= needed) {
if (blur > 0) ctx.filter = `blur(${blur}px)`
ctx.beginPath()
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
if (closed) ctx.closePath()
tracePolygonPath(ctx, pts, asset.points, offX, offY, closed)
if (closed && fill && fill !== 'transparent') {
ctx.fillStyle = fill
ctx.fill()
@@ -242,10 +282,7 @@ function drawMaskShape(
const pts = p.points ?? asset.points
const closed = asset.closed !== false
if (closed && pts.length >= 3) {
ctx.beginPath()
ctx.moveTo(pts[0].x - offX, pts[0].y - offY)
for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY)
ctx.closePath()
tracePolygonPath(ctx, pts, asset.points, offX, offY, true)
ctx.fill()
}
}
+161
View File
@@ -718,6 +718,167 @@ export const useSceneStore = defineStore('scene', {
if (layerId) this.removeLayer(layerId)
if (assetId) this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
},
setPolygonVertexType(
assetId: string,
indexOrIndices: number | number[],
type: 'corner' | 'curve' | 'splitcurve',
) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
const list = Array.isArray(indexOrIndices) ? indexOrIndices : [indexOrIndices]
for (const index of list) {
if (index < 0 || index >= asset.points.length) continue
const point = asset.points[index]
if (type === 'corner') {
delete point.type
delete point.inX
delete point.inY
delete point.outX
delete point.outY
continue
}
const n = asset.points.length
const closed = asset.closed !== false
let prev: { x: number; y: number } | null = null
let next: { x: number; y: number } | null = null
if (index > 0) prev = asset.points[index - 1]
else if (closed) prev = asset.points[n - 1]
if (index < n - 1) next = asset.points[index + 1]
else if (closed) next = asset.points[0]
const dirX = (next?.x ?? point.x) - (prev?.x ?? point.x)
const dirY = (next?.y ?? point.y) - (prev?.y ?? point.y)
const k = 0.25
const baseOutX = dirX * k
const baseOutY = dirY * k
point.type = type
if (point.outX === undefined || point.outY === undefined) {
point.outX = baseOutX
point.outY = baseOutY
}
if (point.inX === undefined || point.inY === undefined) {
point.inX = -baseOutX
point.inY = -baseOutY
}
}
},
setPolygonVertexPositions(
layerId: string,
updates: { index: number; x: number; y: number }[],
time?: number,
) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const asset = this.scene.assets.find((a) => a.id === layer.assetId)
if (!asset || asset.kind !== 'polygon') return
if (updates.length === 0) return
const t = time ?? this.playhead
// Backfill any existing keyframes that have points-array but lack
// matching entries / tangents for consistency with movePolygonVertex.
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (kf.props.points) continue
const samp = sampleLayer(layer, kf.time)
const basePts = samp.points ?? asset.points
kf.props = {
...kf.props,
points: basePts.map((p) => ({ ...p })),
}
}
}
const sampled = sampleLayer(layer, t)
const current = sampled.points ?? asset.points
const updateMap = new Map<number, { x: number; y: number }>()
for (const u of updates) {
if (u.index < 0 || u.index >= current.length) continue
updateMap.set(u.index, u)
}
const next = current.map((p, i) => {
const u = updateMap.get(i)
if (!u) return { ...p }
return { ...p, x: u.x, y: u.y }
})
this.upsertKeyframe(layerId, t, { points: next })
},
setPolygonVertexTangent(
assetId: string,
index: number,
side: 'in' | 'out',
dx: number,
dy: number,
) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
if (index < 0 || index >= asset.points.length) return
const type = asset.points[index].type
if (!type || type === 'corner') return
const t = this.playhead
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
// Backfill any existing keyframes that have points but lack tangents
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
kf.props = {
...kf.props,
points: kf.props.points.map((p, i) => {
const ap = asset.points[i]
if (!ap) return { ...p }
return {
...p,
inX: p.inX ?? ap.inX,
inY: p.inY ?? ap.inY,
outX: p.outX ?? ap.outX,
outY: p.outY ?? ap.outY,
}
}),
}
}
}
const sampled = sampleLayer(layer, t)
const basePts = sampled.points ?? asset.points
const next = basePts.map((p, i) => {
const out: typeof p = { ...p }
if (i !== index) return out
if (side === 'in') {
out.inX = dx
out.inY = dy
if (type === 'curve') {
out.outX = -dx
out.outY = -dy
}
} else {
out.outX = dx
out.outY = dy
if (type === 'curve') {
out.inX = -dx
out.inY = -dy
}
}
return out
})
this.upsertKeyframe(layer.id, t, { points: next })
}
// Also keep asset-level defaults in sync so new keyframes start from
// the latest edit when no kf.points is present yet.
const basePoint = asset.points[index]
if (side === 'in') {
basePoint.inX = dx
basePoint.inY = dy
if (type === 'curve') {
basePoint.outX = -dx
basePoint.outY = -dy
}
} else {
basePoint.outX = dx
basePoint.outY = dy
if (type === 'curve') {
basePoint.inX = -dx
basePoint.inY = -dy
}
}
},
reversePolygonPoints(assetId: string) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
+7
View File
@@ -1,8 +1,15 @@
export type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
export type PointType = 'corner' | 'curve' | 'splitcurve'
export interface Point {
x: number
y: number
type?: PointType
inX?: number
inY?: number
outX?: number
outY?: number
}
export interface AnimatableProps {
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.4.0'
export const APP_NAME = 'Animatr'
export const APP_VERSION = '0.5.0'
export const APP_NAME = 'RUWIO'