From c2b81b0fb4c5dcaadf833a892a5344b8bf1ae0b0 Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 24 Apr 2026 16:40:36 +0200
Subject: [PATCH] 1.0.0: delete-group cascade + native-size APNG preview
First stable release. Highlights in this bump:
- Group context menu gains a proper danger "Delete group" entry
that tears down the group AND every track it contains (layers,
clip tracks, script tracks, audio tracks) along with their
dependent content. "Ungroup" keeps its old meaning as a separate
non-danger item, so the two operations stop being confusable.
New removeTrackGroupWithChildren store action clears any
selection pointing inside the group before dispatching per-kind
removes.
- APNG preview page now renders the export at its native pixel
size: the .stage + use explicit width/height in pixels
instead of a max-960 aspect-ratio box with object-fit: contain.
A 4K export looks 4K; the body scrolls if the window is
smaller.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
src/components/Timeline.vue | 16 +++-
src/devtools/tests.ts | 177 +++++++++++++++++++++++++++++++++++-
src/i18n/messages.ts | 1 +
src/lib/apngExport.ts | 14 +--
src/stores/scene.ts | 56 ++++++++++++
src/version.ts | 2 +-
7 files changed, 256 insertions(+), 12 deletions(-)
diff --git a/package.json b/package.json
index f49d1ec..c9b0dee 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "0.18.3",
+ "version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index cbbb426..c0972e5 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -10,6 +10,7 @@ import {
PhDotsSixVertical,
PhFilmStrip,
PhFolder,
+ PhFolderMinus,
PhFolderPlus,
PhLock,
PhLockOpen,
@@ -2751,11 +2752,18 @@ function deleteTrack() {
} else if (trackMenu.value.kind === 'script') {
store.removeScriptTrack(trackMenu.value.trackId)
} else {
- store.removeTrackGroup(trackMenu.value.trackId)
+ // Group: delete the group AND every track inside it.
+ store.removeTrackGroupWithChildren(trackMenu.value.trackId)
}
trackMenu.value = null
}
+function ungroupFromMenu() {
+ if (!trackMenu.value || trackMenu.value.kind !== 'group') return
+ store.removeTrackGroup(trackMenu.value.trackId)
+ trackMenu.value = null
+}
+
function duplicateTrackFromMenu() {
if (!trackMenu.value) return
const newId = store.duplicateTrack(trackMenu.value.trackId)
@@ -4033,6 +4041,10 @@ onBeforeUnmount(() => {
{{ t('timeline.renameGroup') }}
+
@@ -4136,7 +4148,7 @@ onBeforeUnmount(() => {
? t('timeline.deleteScriptTrack')
: trackMenu.kind === 'audio'
? t('timeline.deleteAudioTrack')
- : t('timeline.ungroup')
+ : t('timeline.deleteGroup')
}}
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 7f87565..e25e92b 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -1068,8 +1068,11 @@ export function registerAllTests(): void {
const html = generateApngPreviewHtml(dataUrl, 320, 180)
// Payload is embedded so the exported HTML works as a single file.
expect(html).toContain(dataUrl)
- // Aspect ratio drives the stage box.
- expect(html).toContain('aspect-ratio: 320 / 180')
+ // Stage + image render at the canvas's original pixel size —
+ // no auto-fit / aspect-ratio box so a 4K export looks 4K.
+ expect(html).toContain('width: 320px')
+ expect(html).toContain('height: 180px')
+ expect(html.includes('aspect-ratio')).toBe(false)
// Checkerboard + toggle buttons are present.
expect(html).toContain('.stage.checker')
expect(html).toContain('data-bg="checker"')
@@ -6241,6 +6244,172 @@ export function registerAllTests(): void {
})
})
+ suite('scene store — removeTrackGroupWithChildren', () => {
+ test('deletes the group + every nested layer', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const l1 = s.addLayer(a.id)!
+ const l2 = s.addLayer(a.id)!
+ s.selectLayer(l1.id)
+ s.toggleLayerSelection(l2.id)
+ const groupId = s.groupSelectedLayers('G')!
+ expect(s.scene.layers.length).toBe(2)
+ expect(s.scene.groups!.length).toBe(1)
+ s.removeTrackGroupWithChildren(groupId)
+ // Group and children are gone, trackOrder empty.
+ expect(s.scene.groups!.find((g) => g.id === groupId)).toBe(undefined)
+ expect(s.scene.layers.find((x) => x.id === l1.id)).toBe(undefined)
+ expect(s.scene.layers.find((x) => x.id === l2.id)).toBe(undefined)
+ expect(s.scene.trackOrder!.includes(groupId)).toBe(false)
+ })
+
+ test('drops clip-track children and their instances', () => {
+ const s = makeFreshStore()
+ const clip = s.addClip('C')
+ clip.duration = 2
+ const track = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(clip.id, 0, track.id)!
+ const g = s.addTrackGroup('G', track.id)
+ g.childIds.push(track.id)
+ const order = s.scene.trackOrder!
+ const ti = order.indexOf(track.id)
+ if (ti >= 0) order.splice(ti, 1)
+ s.selectClipInstance(ci.id)
+ s.removeTrackGroupWithChildren(g.id)
+ // Track + instance gone, selection cleared, group gone.
+ expect(s.scene.clipTracks!.find((t) => t.id === track.id)).toBe(undefined)
+ expect(s.scene.clipInstances!.find((x) => x.id === ci.id)).toBe(undefined)
+ expect(s.scene.groups!.find((gg) => gg.id === g.id)).toBe(undefined)
+ expect(s.selectedClipInstanceId).toBe(null)
+ })
+
+ test('clears layer selection that pointed inside the group', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.selectLayer(l.id)
+ const groupId = s.groupSelectedLayers('G')!
+ s.selectLayer(l.id)
+ expect(s.selectedLayerId).toBe(l.id)
+ s.removeTrackGroupWithChildren(groupId)
+ expect(s.selectedLayerId).toBe(null)
+ expect(s.selectedLayerIds).toEqual([])
+ })
+
+ test('leaves sibling tracks outside the group untouched', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const inside = s.addLayer(a.id)!
+ s.selectLayer(inside.id)
+ const groupId = s.groupSelectedLayers('G')!
+ // Create another top-level layer AFTER grouping so it stays
+ // outside the group.
+ const outside = s.addLayer(a.id)!
+ s.removeTrackGroupWithChildren(groupId)
+ expect(s.scene.layers.find((x) => x.id === outside.id)).toBeTruthy()
+ expect(s.scene.trackOrder!.includes(outside.id)).toBe(true)
+ })
+ })
+
+ suite('ui — Timeline group delete menu', () => {
+ test('right-click on a group shows a "Delete group" danger item', async () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.selectLayer(l.id)
+ s.groupSelectedLayers('G1')!
+ const handle = mountComponent(Timeline, {})
+ try {
+ await nextTick()
+ const row = handle.host.querySelector(
+ '.track.group-track .track-name',
+ ) as HTMLElement
+ row.dispatchEvent(
+ new MouseEvent('contextmenu', {
+ bubbles: true, cancelable: true, clientX: 10, clientY: 10,
+ }),
+ )
+ await nextTick()
+ const menu = handle.host.querySelector('.kf-menu') as HTMLElement
+ expect(menu).toBeTruthy()
+ const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
+ const del = items.find((b) => b.classList.contains('danger'))
+ expect(del).toBeTruthy()
+ expect(del!.textContent?.includes(i18nT('timeline.deleteGroup'))).toBe(true)
+ // Ungroup is still there as a separate (non-danger) option.
+ const ungroup = items.find((b) => b.textContent?.includes(i18nT('timeline.ungroup')))
+ expect(ungroup).toBeTruthy()
+ expect(ungroup!.classList.contains('danger')).toBe(false)
+ } finally {
+ handle.unmount()
+ }
+ })
+
+ test('clicking Delete group removes the group and its children', async () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.selectLayer(l.id)
+ const groupId = s.groupSelectedLayers('G1')!
+ const handle = mountComponent(Timeline, {})
+ try {
+ await nextTick()
+ const row = handle.host.querySelector(
+ '.track.group-track .track-name',
+ ) as HTMLElement
+ row.dispatchEvent(
+ new MouseEvent('contextmenu', {
+ bubbles: true, cancelable: true, clientX: 10, clientY: 10,
+ }),
+ )
+ await nextTick()
+ const menu = handle.host.querySelector('.kf-menu') as HTMLElement
+ const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
+ const del = items.find((b) => b.classList.contains('danger'))!
+ del.click()
+ await nextTick()
+ expect(s.scene.groups!.find((g) => g.id === groupId)).toBe(undefined)
+ expect(s.scene.layers.find((x) => x.id === l.id)).toBe(undefined)
+ } finally {
+ handle.unmount()
+ }
+ })
+
+ test('clicking Ungroup preserves the children but removes the wrapper', async () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.selectLayer(l.id)
+ const groupId = s.groupSelectedLayers('G1')!
+ const handle = mountComponent(Timeline, {})
+ try {
+ await nextTick()
+ const row = handle.host.querySelector(
+ '.track.group-track .track-name',
+ ) as HTMLElement
+ row.dispatchEvent(
+ new MouseEvent('contextmenu', {
+ bubbles: true, cancelable: true, clientX: 10, clientY: 10,
+ }),
+ )
+ await nextTick()
+ const menu = handle.host.querySelector('.kf-menu') as HTMLElement
+ const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
+ const ungroup = items.find(
+ (b) => b.textContent?.includes(i18nT('timeline.ungroup')) && !b.classList.contains('danger'),
+ )!
+ ungroup.click()
+ await nextTick()
+ // Group gone, layer stays.
+ expect(s.scene.groups!.find((g) => g.id === groupId)).toBe(undefined)
+ expect(s.scene.layers.find((x) => x.id === l.id)).toBeTruthy()
+ expect(s.scene.trackOrder!.includes(l.id)).toBe(true)
+ } finally {
+ handle.unmount()
+ }
+ })
+ })
+
suite('ui — Timeline eye button stays clickable on locked tracks', () => {
test('locked layer row keeps the eye-btn pointer-events auto', async () => {
// Regression: `.track.locked .track-name { pointer-events: none }`
@@ -11182,6 +11351,10 @@ export function registerAllTests(): void {
expect(confirm === 'timeline.renameScriptTrackDialog.confirm').toBe(false)
})
+ test('deleteGroup is translated', () => {
+ expect(i18nT('timeline.deleteGroup') === 'timeline.deleteGroup').toBe(false)
+ })
+
test('renameLayer + renameClipTrack are translated', () => {
expect(i18nT('timeline.renameLayer') === 'timeline.renameLayer').toBe(false)
expect(i18nT('timeline.renameClipTrack') === 'timeline.renameClipTrack').toBe(false)
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 7ea36c3..b41734a 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -301,6 +301,7 @@ export const en = {
deleteClipTrack: 'Delete clip track',
deleteScriptTrack: 'Delete script track',
deleteAudioTrack: 'Delete audio track',
+ deleteGroup: 'Delete group',
separate: 'Separate Position and Rotation',
rotation: 'Rotation',
position: 'Position',
diff --git a/src/lib/apngExport.ts b/src/lib/apngExport.ts
index 4e7b95f..cd07a49 100644
--- a/src/lib/apngExport.ts
+++ b/src/lib/apngExport.ts
@@ -86,7 +86,6 @@ export function generateApngPreviewHtml(
width: number,
height: number,
): string {
- const aspect = `${width} / ${height}`
return `
@@ -115,13 +114,17 @@ export function generateApngPreviewHtml(
}
h1 { font-size: 18px; font-weight: 600; margin: 0; }
p { margin: 0; color: #6e6e73; font-size: 12px; text-align: center; }
+ /* Original pixel dimensions — scaling the preview muddies the
+ final result, so keep 1:1. If the export is larger than the
+ viewport the body scrolls. */
.stage {
- width: min(100%, 960px);
- aspect-ratio: ${aspect};
+ width: ${width}px;
+ height: ${height}px;
border-radius: 6px;
box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
overflow: hidden;
position: relative;
+ flex-shrink: 0;
}
/* Checkerboard tiling. Two diagonal linear gradients stacked so
the tiles meet at their midpoints — the classic transparency
@@ -140,9 +143,8 @@ export function generateApngPreviewHtml(
.stage.solid-light { background: #ffffff; }
.stage img {
display: block;
- width: 100%;
- height: 100%;
- object-fit: contain;
+ width: ${width}px;
+ height: ${height}px;
user-select: none;
-webkit-user-drag: none;
}
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index 543458b..dc548bb 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -3023,6 +3023,62 @@ export const useSceneStore = defineStore('scene', {
if (idx >= 0) order.splice(idx, 1, ...group.childIds)
this.activeScene.groups = this.activeScene.groups.filter((g) => g.id !== id)
},
+ /** Delete a group AND every track nested inside it. Dispatches
+ * the per-kind remove action for each child so keyframes, clip
+ * instances, script frames and audio clips are cleaned up along
+ * with their owning track. Selection state (layer + clip-
+ * instance) is cleared up-front since the individual remove
+ * actions don't know about each other. */
+ removeTrackGroupWithChildren(id: string) {
+ const group = this.scene.groups?.find((g) => g.id === id)
+ if (!group) return
+ const childIds = new Set(group.childIds)
+ // Clear any selection that lives inside this group so the
+ // Properties panel + Konva transformer don't point at tracks
+ // we're about to yank.
+ if (this.selectedLayerId && childIds.has(this.selectedLayerId)) {
+ this.selectedLayerId = null
+ this.selectedLayerIds = []
+ } else if (this.selectedLayerIds.length) {
+ this.selectedLayerIds = this.selectedLayerIds.filter((lid) => !childIds.has(lid))
+ if (!this.selectedLayerIds.length) this.selectedLayerId = null
+ }
+ if (this.selectedClipInstanceId) {
+ const inst = this.scene.clipInstances?.find(
+ (ci) => ci.id === this.selectedClipInstanceId,
+ )
+ if (inst && childIds.has(inst.clipTrackId)) {
+ this.selectedClipInstanceId = null
+ }
+ }
+ // Dispatch the right remove action per child. Each call cleans
+ // up its own dependents (layer keyframes come along for free;
+ // clip-tracks drop their instances; script tracks drop their
+ // frames; audio tracks drop their clips).
+ for (const cid of childIds) {
+ if (this.activeLayers.find((l) => l.id === cid)) {
+ this.removeLayer(cid)
+ continue
+ }
+ if (this.scene.clipTracks?.some((t) => t.id === cid)) {
+ this.removeClipTrack(cid)
+ continue
+ }
+ if (this.scene.scriptTracks?.some((t) => t.id === cid)) {
+ this.removeScriptTrack(cid)
+ continue
+ }
+ if (this.activeScene.audioTracks.some((t) => t.id === cid)) {
+ this.removeAudioTrack(cid)
+ continue
+ }
+ }
+ // Finally drop the group itself. trackOrder still has the
+ // group id; the per-child removes left it alone because they
+ // only match direct ids, not nested ones.
+ this.activeScene.groups = this.activeScene.groups.filter((g) => g.id !== id)
+ this.activeScene.trackOrder = this.activeScene.trackOrder.filter((tid) => tid !== id)
+ },
renameGroup(id: string, name: string) {
const group = this.scene.groups?.find((g) => g.id === id)
if (!group) return
diff --git a/src/version.ts b/src/version.ts
index 4abde99..098c9fb 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '0.18.3'
+export const APP_VERSION = '1.0.0'
export const APP_NAME = 'RUWIO'
|