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'