0.12.0: document drawer redesign, PWA install, clip/audio context menus + push-cascade

- Document drawer slides out from under the topbar (no tab, no hover
  peek); opens via project-name button, closes on outside-click or ESC.
  Layout: inputs row + divider + preset row, presets user-managed via
  popup and persisted in localStorage.
- PWA manifest + minimal service worker so Chrome installs RUWIO as a
  standalone app — suppresses the native fullscreen exit overlay.
- Clips and audio clips get right-click context menus (Duplicate /
  Delete) via new `duplicateClipInstance` / `duplicateAudioClip` store
  actions.
- Clip moves cascade non-overlap in both directions; `setAudioClipPosition`
  now pushes backward as well as forward. New `setClipInstancePosition`
  gives clip instances the same behaviour.
- Library drags carry timeline-shaped ghost previews (audio pill, clip
  pill, button thumb) instead of the library row; clip lane renders a
  dashed drop-target ghost and rejects window-overlap drops.
- Scene / Clipart drawer tabs disappear in fullscreen; the 24 px they
  occupied is reclaimed for the stage. Play button sits on the exact
  horizontal center via a 3-column grid in the transport cluster.
- Tests: context menu + push-cascade + duplicate action coverage for
  both clip kinds; DocumentDrawer suite rewritten for the slide-out
  model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 07:41:26 +02:00
1 parent fa66ba69bb
commit 354dd8a4f0
25 files changed
+2294 -405

No files matched your search

+53 -8
View File
@@ -1,10 +1,15 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSquare, PhMusicNote } from '@phosphor-icons/vue'
import { useClipartStore } from '@/stores/clipart'
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
import {
clearAudioDragSource,
installAudioClipDragImage,
setAudioDragSource,
} from '@/composables/useAudioDragSource'
import { useFullscreen } from '@/composables/useFullscreen'
import { dialogState } from '@/components/ui'
import { t } from '@/i18n'
const clipart = useClipartStore()
@@ -23,12 +28,31 @@ function onDrawerLeave() {
if (isFullscreen.value) clipart.setOpen(false)
}
// ESC closes the drawer when opened via click. Skipped in fullscreen
// (hover-leave handles it) and while a dialog is on top.
function onDocKeyDown(e: KeyboardEvent) {
if (!clipart.open || isFullscreen.value) return
if (e.key !== 'Escape') return
if (dialogState.value) return
e.preventDefault()
clipart.setOpen(false)
}
watch(
() => clipart.open,
(v) => {
if (v) document.addEventListener('keydown', onDocKeyDown)
else document.removeEventListener('keydown', onDocKeyDown)
},
)
onMounted(() => {
clipart.initialize()
})
onBeforeUnmount(() => {
clipart.disposeItems()
document.removeEventListener('keydown', onDocKeyDown)
})
function onItemDragStart(e: DragEvent, index: number) {
@@ -44,6 +68,7 @@ function onItemDragStart(e: DragEvent, index: number) {
// file decodes, so seed with 0 (ghost renders at minimum width)
// and upgrade asynchronously. dragend clears both.
setAudioDragSource({ duration: 0, name: item.name })
installAudioClipDragImage(e, { name: item.name })
const el = e.currentTarget as HTMLElement
el.addEventListener('dragend', clearAudioDragSource, { once: true })
void (async () => {
@@ -55,8 +80,6 @@ function onItemDragStart(e: DragEvent, index: number) {
/* leave the placeholder width — still a visible hint */
}
})()
// No image ghost for audio — browsers render the row itself as the
// default drag preview, which is what we want.
return
}
// Use the actual image as drag ghost
@@ -79,7 +102,11 @@ function onItemDragStart(e: DragEvent, index: number) {
</script>
<template>
<aside class="clipart-drawer" :class="{ open: clipart.open }" @mouseleave="onDrawerLeave">
<aside
class="clipart-drawer"
:class="{ open: clipart.open, fullscreen: isFullscreen }"
@mouseleave="onDrawerLeave"
>
<button
class="drawer-tab"
v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')"
@@ -192,16 +219,34 @@ function onItemDragStart(e: DragEvent, index: number) {
transition: opacity 140ms var(--ease);
}
.clipart-drawer:hover .drawer-tab :deep(svg),
.clipart-drawer.open .drawer-tab :deep(svg) {
.clipart-drawer:hover:not(.fullscreen) .drawer-tab :deep(svg),
.clipart-drawer.open:not(.fullscreen) .drawer-tab :deep(svg) {
opacity: 1;
}
.clipart-drawer:hover .drawer-tab {
.clipart-drawer:hover:not(.fullscreen) .drawer-tab {
background: var(--panel-alt);
color: var(--text);
}
.clipart-drawer.fullscreen .drawer-tab {
cursor: default;
background: transparent !important;
}
/* Once the fullscreen hover opens the drawer, the tab column has
served its purpose (hover-detect) — reclaim its 24px so the panel
uses the full width. The aside itself still catches mouseleave. */
.clipart-drawer.fullscreen.open .drawer-tab {
display: none;
}
/* Tab column is gone in fullscreen — reclaim its 24 px so the stage
area grows by the same amount instead of leaving a blank strip. */
.clipart-drawer.fullscreen.open {
width: 400px;
}
.clipart-drawer.open .drawer-tab {
flex: 0 0 24px;
width: 24px;