From ddea7d348f8f640f342b7163cf5b413198383873 Mon Sep 17 00:00:00 2001 From: th Date: Mon, 20 Apr 2026 12:12:38 +0200 Subject: [PATCH] 0.5.0: RUWIO rebrand, vertex bezier, animation paths, welcome focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 6 + index.html | 2 +- package.json | 2 +- server/render.html | 46 ++- src/App.vue | 15 +- src/assets/logo.png | Bin 0 -> 178169 bytes src/components/AboutDialog.vue | 74 +---- src/components/CanvasStage.vue | 505 ++++++++++++++++++++++++++++- src/components/PropertiesPanel.vue | 72 +++- src/components/WelcomeDialog.vue | 71 ++-- src/components/ui/Modal.vue | 58 +++- src/devtools/tests.ts | 395 ++++++++++++++++++++++ src/i18n/messages.ts | 3 + src/lib/exportPlayer.ts | 47 ++- src/lib/interpolate.ts | 10 +- src/lib/render.ts | 53 ++- src/stores/scene.ts | 161 +++++++++ src/types/scene.ts | 7 + src/version.ts | 4 +- 19 files changed, 1388 insertions(+), 143 deletions(-) create mode 100644 src/assets/logo.png diff --git a/.gitignore b/.gitignore index 2c3ce18..acd4d89 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/index.html b/index.html index 17c27fe..686d998 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Animatr + RUWIO
diff --git a/package.json b/package.json index 0d394ff..8ed67e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "animatr", "private": true, - "version": "0.4.0", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/render.html b/server/render.html index 7f91efa..a5aa5d4 100644 --- a/server/render.html +++ b/server/render.html @@ -52,7 +52,16 @@ if(a.points && b.points && a.points.length===b.points.length){ var lp=[]; for(var pi=0;pi=3){ - ctxm.beginPath(); - ctxm.moveTo(mpPts[0].x-mpX,mpPts[0].y-mpY); - for(var mpi=1;mpi=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 {
@@ -400,7 +400,6 @@ onBeforeUnmount(() => { @open="openFromRecent" @new="() => { currentProjectId = null; currentProjectName = null }" @import="importFromFile" - @export="exportProjectFile" /> {{ t('app.loadFailed') }} @@ -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); diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..65ba07accf74c484ae3c9b4a07cf67d67b5325f8 GIT binary patch literal 178169 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 -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)" >
-
- -
-
{{ APP_NAME }}
-
{{ t('about.versionLabel') }} {{ APP_VERSION }}
-
+ +
+ {{ t('about.versionLabel') }} {{ APP_VERSION }}

{{ t('about.tagline') }}

@@ -52,11 +47,6 @@ const shortcuts: Shortcut[] = [
- @@ -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); -} diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 08c95d0..4cafcd2 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -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, 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, @@ -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 = { ...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(() => { }) 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, index: number) { +interface VertexDragState { + layerId: string + indices: number[] + dragStartLocal: { x: number; y: number } + startPositions: Map +} + +const vertexDragState = ref(null) + +function onVertexClick(e: Konva.KonvaEventObject, 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, 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() + 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, 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, 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(null) + +function onVertexContextMenu(e: Konva.KonvaEventObject, 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(() => { + 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, 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>(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() + 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) { } function handleStageMouseDown(e: Konva.KonvaEventObject) { + // 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) { 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) { } function handleStageMouseMove(e: Konva.KonvaEventObject) { + 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) { 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" > @@ -1733,8 +2111,23 @@ onBeforeUnmount(() => { v-for="h in polygonVertexHandles" :key="`vh-${h.layerId}-${h.index}`" :config="vertexHandleConfig(h)" + @click="(e: Konva.KonvaEventObject) => onVertexClick(e, h.index)" + @dragstart="(e: Konva.KonvaEventObject) => onVertexDragStart(e, h.index)" @dragmove="(e: Konva.KonvaEventObject) => onVertexDragMove(e, h.index)" @dragend="(e: Konva.KonvaEventObject) => onVertexDragEnd(e, h.index)" + @contextmenu="(e: Konva.KonvaEventObject) => onVertexContextMenu(e, h.index)" + /> + + { ref="previewCanvasEl" class="mask-preview-canvas" > +
@@ -1799,6 +2202,35 @@ onBeforeUnmount(() => {
{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}
+ +
+ + + +
@@ -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; diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index 6297fd5..bfc2cf3 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -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('#000000') + +watch( + () => [sampled.value?.stroke, selectedAsset.value?.id] as const, + ([s]) => { + if (s && s !== 'transparent') lastStrokeColor.value = s + }, + { immediate: true }, +) + const strokeColor = computed({ 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('#000000') + +watch( + () => [sampled.value?.fill, selectedAsset.value?.id] as const, + ([f]) => { + if (f && f !== 'transparent') lastFillColor.value = f + }, + { immediate: true }, +) + const fillColor = computed({ 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({ get: () => { const samp = sampled.value?.fill @@ -250,11 +288,25 @@ const fillTransparent = computed({ 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({ get: () => { const s = sampled.value?.stroke @@ -268,7 +320,9 @@ const strokeTransparent = computed({ 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 }) } }, }) diff --git a/src/components/WelcomeDialog.vue b/src/components/WelcomeDialog.vue index a284f34..4560a5f 100644 --- a/src/components/WelcomeDialog.vue +++ b/src/components/WelcomeDialog.vue @@ -1,12 +1,12 @@