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
@@ -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
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "animatr",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+37
-9
@@ -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
@@ -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 |
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
Reference in new issue
Block a user