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

+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;