0.11.1: drawer mouse trigger and clip track / audio behavior

This commit is contained in:
th committed 2026-04-23 20:31:41 +02:00
1 parent 8d987d1526
commit fa66ba69bb
9 files changed
+258 -31

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "0.11.0", "version": "0.11.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1016,7 +1016,7 @@ h4 {
background: transparent !important; background: transparent !important;
color: var(--text-dim); color: var(--text-dim);
font-style: italic; font-style: italic;
justify-content: center; justify-content: flex-start;
} }
+18 -2
View File
@@ -4,9 +4,24 @@ import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSqua
import { useClipartStore } from '@/stores/clipart' import { useClipartStore } from '@/stores/clipart'
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader' import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource' import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
import { useFullscreen } from '@/composables/useFullscreen'
import { t } from '@/i18n' import { t } from '@/i18n'
const clipart = useClipartStore() const clipart = useClipartStore()
const { isFullscreen } = useFullscreen()
// In browser-fullscreen mode the drawer auto-opens on tab hover and
// auto-closes when the mouse leaves the drawer — no click-toggle.
function onTabClick() {
if (isFullscreen.value) return
clipart.toggle()
}
function onTabEnter() {
if (isFullscreen.value) clipart.setOpen(true)
}
function onDrawerLeave() {
if (isFullscreen.value) clipart.setOpen(false)
}
onMounted(() => { onMounted(() => {
clipart.initialize() clipart.initialize()
@@ -64,11 +79,12 @@ function onItemDragStart(e: DragEvent, index: number) {
</script> </script>
<template> <template>
<aside class="clipart-drawer" :class="{ open: clipart.open }"> <aside class="clipart-drawer" :class="{ open: clipart.open }" @mouseleave="onDrawerLeave">
<button <button
class="drawer-tab" class="drawer-tab"
v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')" v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')"
@click="clipart.toggle()" @click="onTabClick"
@mouseenter="onTabEnter"
> >
<component :is="clipart.open ? PhCaretLeft : PhCaretRight" :size="14" /> <component :is="clipart.open ? PhCaretLeft : PhCaretRight" :size="14" />
</button> </button>
+16 -1
View File
@@ -4,9 +4,22 @@ import { PhCaretLeft, PhCaretRight, PhCopy, PhFilmSlate, PhPlus, PhTrash } from
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog } from '@/components/ui' import { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useFullscreen } from '@/composables/useFullscreen'
const store = useSceneStore() const store = useSceneStore()
const open = ref(false) const open = ref(false)
const { isFullscreen } = useFullscreen()
// In browser-fullscreen mode the drawer auto-opens on tab hover and
// auto-closes when the mouse leaves the drawer — there's no chrome to
// host a toggle button comfortably. In regular view the click-toggle
// behavior stays.
function onTabEnter() {
if (isFullscreen.value) open.value = true
}
function onDrawerLeave() {
if (isFullscreen.value) open.value = false
}
interface MenuState { interface MenuState {
pageId: string pageId: string
@@ -17,6 +30,7 @@ const menu = ref<MenuState | null>(null)
const menuRef = ref<HTMLDivElement | null>(null) const menuRef = ref<HTMLDivElement | null>(null)
function toggle() { function toggle() {
if (isFullscreen.value) return
open.value = !open.value open.value = !open.value
} }
@@ -86,11 +100,12 @@ watch(menu, (m) => {
</script> </script>
<template> <template>
<aside class="scene-drawer" :class="{ open }"> <aside class="scene-drawer" :class="{ open }" @mouseleave="onDrawerLeave">
<button <button
class="drawer-tab" class="drawer-tab"
v-tooltip="open ? t('scenes.close') : t('scenes.open')" v-tooltip="open ? t('scenes.close') : t('scenes.open')"
@click="toggle" @click="toggle"
@mouseenter="onTabEnter"
> >
<component :is="open ? PhCaretRight : PhCaretLeft" :size="14" /> <component :is="open ? PhCaretRight : PhCaretLeft" :size="14" />
</button> </button>
+160 -21
View File
@@ -1835,6 +1835,24 @@ function clipPeaks(clip: AudioClip): Float32Array | undefined {
* the clip's current width (resizes on zoom + trim changes). Cheap * the clip's current width (resizes on zoom + trim changes). Cheap
* because peaks are cached and the draw itself is a few hundred * because peaks are cached and the draw itself is a few hundred
* fillRects. */ * fillRects. */
function waveformColorFor(clip: AudioClip): string | undefined {
// Honor the track color so tinted audio tracks get a matching
// waveform fill. Accept #rgb/#rrggbb and append a fixed 55% alpha
// (matches the default sky-blue tint). Non-hex values fall back to
// the drawWaveform default.
const track = audioTrackById.value.get(clip.audioTrackId)
const color = track?.color
if (!color) return undefined
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(color)
if (short) {
const [, r, g, b] = short
return `#${r}${r}${g}${g}${b}${b}8c`
}
const full = /^#([0-9a-f]{6})$/i.exec(color)
if (full) return `#${full[1]}8c`
return color
}
function renderWaveformCanvas(clip: AudioClip) { function renderWaveformCanvas(clip: AudioClip) {
return (raw: Element | ComponentPublicInstance | null) => { return (raw: Element | ComponentPublicInstance | null) => {
if (!raw || !(raw instanceof HTMLCanvasElement)) return if (!raw || !(raw instanceof HTMLCanvasElement)) return
@@ -1852,7 +1870,7 @@ function renderWaveformCanvas(clip: AudioClip) {
if (el.height !== pxH) el.height = pxH if (el.height !== pxH) el.height = pxH
el.style.width = cssW + 'px' el.style.width = cssW + 'px'
el.style.height = cssH + 'px' el.style.height = cssH + 'px'
drawWaveform(el, peaks, { pixelRatio: pr }) drawWaveform(el, peaks, { pixelRatio: pr, color: waveformColorFor(clip) })
} }
} }
@@ -2099,7 +2117,78 @@ function onAudioClipPointerUp(e: PointerEvent) {
} }
audioClipMove.value = null audioClipMove.value = null
audioClipGhost.value = null audioClipGhost.value = null
if (wasMoved) e.preventDefault() }
interface AudioClipTrimState {
clipId: string
side: 'start' | 'end'
pointerId: number
startX: number
initialStartTime: number
initialTrimStart: number
initialTrimEnd: number
}
const audioClipTrim = ref<AudioClipTrimState | null>(null)
function onAudioClipHandlePointerDown(
e: PointerEvent,
clip: AudioClip,
side: 'start' | 'end',
) {
if (e.button !== 0) return
const track = audioTrackById.value.get(clip.audioTrackId)
if (track?.locked) return
e.preventDefault()
e.stopPropagation()
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
audioClipTrim.value = {
clipId: clip.id,
side,
pointerId: e.pointerId,
startX: e.clientX,
initialStartTime: clip.startTime,
initialTrimStart: clip.trimStart ?? 0,
initialTrimEnd: clip.trimEnd ?? 0,
}
}
function onAudioClipHandlePointerMove(e: PointerEvent) {
const tr = audioClipTrim.value
if (!tr || tr.pointerId !== e.pointerId) return
const pps = effectivePxPerSec.value
if (pps <= 0) return
const rawDelta = (e.clientX - tr.startX) / pps
const clip = store.scene.audioClips?.find((c) => c.id === tr.clipId)
if (!clip) return
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset) return
const MIN_VISIBLE = 0.05
const maxBudget = asset.duration - MIN_VISIBLE
if (tr.side === 'start') {
// Clamp locally so overshooting the natural start doesn't slide
// the whole clip — matches the regular-clip handle behavior.
const minDelta = Math.max(-tr.initialTrimStart, -tr.initialStartTime)
const maxDelta = maxBudget - tr.initialTrimStart - tr.initialTrimEnd
const delta = Math.min(Math.max(rawDelta, minDelta), maxDelta)
store.trimAudioClip(tr.clipId, {
trimStart: tr.initialTrimStart + delta,
startTime: tr.initialStartTime + delta,
})
} else {
store.trimAudioClip(tr.clipId, {
trimEnd: tr.initialTrimEnd - rawDelta,
})
}
}
function onAudioClipHandlePointerUp(e: PointerEvent) {
const tr = audioClipTrim.value
if (!tr || tr.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
audioClipTrim.value = null
} }
// -------- Button asset edit bar -------------------------------------- // -------- Button asset edit bar --------------------------------------
@@ -2253,17 +2342,29 @@ function onClipHandlePointerMove(e: PointerEvent) {
if (!t || t.pointerId !== e.pointerId) return if (!t || t.pointerId !== e.pointerId) return
const pps = effectivePxPerSec.value const pps = effectivePxPerSec.value
if (pps <= 0) return if (pps <= 0) return
const deltaSec = (e.clientX - t.startX) / pps const rawDelta = (e.clientX - t.startX) / pps
if (t.side === 'start') { if (t.side === 'start') {
// shift block right = trim more from start + shift startTime // Clamp the delta locally so pulling the handle past the natural
// start doesn't shift startTime — previously trimStart clamped to
// 0 in the store but startTime still moved, which slid the whole
// clip. Bounds: trimStart stays in [0, maxBudget - trimEnd] and
// we also can't shift startTime below 0.
const inst = store.scene.clipInstances?.find((ci) => ci.id === t.instanceId)
const clip = inst ? store.scene.clips?.find((c) => c.id === inst.clipId) : null
if (!clip) return
const MIN_VISIBLE = 0.05
const maxBudget = clip.duration - MIN_VISIBLE
const minDelta = Math.max(-t.initialTrimStart, -t.initialStartTime)
const maxDelta = maxBudget - t.initialTrimStart - t.initialTrimEnd
const delta = Math.min(Math.max(rawDelta, minDelta), maxDelta)
store.trimClipInstance(t.instanceId, { store.trimClipInstance(t.instanceId, {
trimStart: t.initialTrimStart + deltaSec, trimStart: t.initialTrimStart + delta,
startTime: t.initialStartTime + deltaSec, startTime: t.initialStartTime + delta,
}) })
} else { } else {
// shift right edge right = less trimEnd (reveal more) // shift right edge right = less trimEnd (reveal more)
store.trimClipInstance(t.instanceId, { store.trimClipInstance(t.instanceId, {
trimEnd: t.initialTrimEnd - deltaSec, trimEnd: t.initialTrimEnd - rawDelta,
}) })
} }
} }
@@ -3310,7 +3411,12 @@ onBeforeUnmount(() => {
v-for="clip in audioClipsByTrack[track.audioTrackId] ?? []" v-for="clip in audioClipsByTrack[track.audioTrackId] ?? []"
:key="clip.id" :key="clip.id"
class="audio-clip-block" class="audio-clip-block"
:class="{ moving: audioClipMove?.clipId === clip.id }" :class="{
moving: audioClipMove?.clipId === clip.id,
'trimmed-start': (clip.trimStart ?? 0) > 0,
'trimmed-end': (clip.trimEnd ?? 0) > 0,
trimming: audioClipTrim?.clipId === clip.id,
}"
:style="audioClipBlockStyle(clip)" :style="audioClipBlockStyle(clip)"
v-tooltip="audioClipTooltip(clip)" v-tooltip="audioClipTooltip(clip)"
@pointerdown="onAudioClipPointerDown($event, clip)" @pointerdown="onAudioClipPointerDown($event, clip)"
@@ -3319,6 +3425,17 @@ onBeforeUnmount(() => {
@pointercancel="onAudioClipPointerUp" @pointercancel="onAudioClipPointerUp"
@contextmenu="onAudioClipContextMenu($event, clip)" @contextmenu="onAudioClipContextMenu($event, clip)"
> >
<span
class="clip-handle start"
draggable="false"
@pointerdown="onAudioClipHandlePointerDown($event, clip, 'start')"
@pointermove="onAudioClipHandlePointerMove"
@pointerup="onAudioClipHandlePointerUp"
@pointercancel="onAudioClipHandlePointerUp"
@dragstart.prevent.stop
@dblclick.stop
@click.stop
/>
<canvas <canvas
v-if="clipPeaks(clip)" v-if="clipPeaks(clip)"
class="audio-clip-wave" class="audio-clip-wave"
@@ -3327,6 +3444,17 @@ onBeforeUnmount(() => {
<span class="audio-clip-label"> <span class="audio-clip-label">
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }} {{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
</span> </span>
<span
class="clip-handle end"
draggable="false"
@pointerdown="onAudioClipHandlePointerDown($event, clip, 'end')"
@pointermove="onAudioClipHandlePointerMove"
@pointerup="onAudioClipHandlePointerUp"
@pointercancel="onAudioClipHandlePointerUp"
@dragstart.prevent.stop
@dblclick.stop
@click.stop
/>
</span> </span>
<span <span
v-if="audioClipGhost && audioClipGhost.targetTrackId === track.audioTrackId" v-if="audioClipGhost && audioClipGhost.targetTrackId === track.audioTrackId"
@@ -4811,15 +4939,15 @@ onBeforeUnmount(() => {
width: 9px; width: 9px;
height: 9px; height: 9px;
border-radius: 50%; border-radius: 50%;
background: #f59e0b; background: var(--track-color, #f59e0b);
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.35); box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.35);
} }
.script-frame-count { .script-frame-count {
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
color: #f59e0b; color: var(--track-color, #f59e0b);
background: rgba(245, 158, 11, 0.15); background: color-mix(in srgb, var(--track-color, #f59e0b) 18%, transparent);
border-radius: 999px; border-radius: 999px;
padding: 0 5px; padding: 0 5px;
line-height: 13px; line-height: 13px;
@@ -4833,8 +4961,8 @@ onBeforeUnmount(() => {
height: 30px; height: 30px;
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(14, 165, 233, 0.08) 0%, color-mix(in srgb, var(--track-color, #0ea5e9) 8%, transparent) 0%,
rgba(14, 165, 233, 0.02) 100% color-mix(in srgb, var(--track-color, #0ea5e9) 2%, transparent) 100%
); );
} }
@@ -4876,10 +5004,10 @@ onBeforeUnmount(() => {
bottom: 4px; bottom: 4px;
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(14, 165, 233, 0.35) 0%, color-mix(in srgb, var(--track-color, #0ea5e9) 35%, transparent) 0%,
rgba(14, 165, 233, 0.15) 100% color-mix(in srgb, var(--track-color, #0ea5e9) 15%, transparent) 100%
); );
border: 1px solid rgba(14, 165, 233, 0.6); border: 1px solid color-mix(in srgb, var(--track-color, #0ea5e9) 60%, transparent);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5034,29 +5162,40 @@ onBeforeUnmount(() => {
right: 2px; right: 2px;
} }
.clip-block:hover .clip-handle { .clip-block:hover .clip-handle,
.audio-clip-block:hover .clip-handle {
opacity: 1; opacity: 1;
} }
.clip-block.trimmed-start .clip-handle.start, .clip-block.trimmed-start .clip-handle.start,
.clip-block.trimmed-end .clip-handle.end { .clip-block.trimmed-end .clip-handle.end,
.audio-clip-block.trimmed-start .clip-handle.start,
.audio-clip-block.trimmed-end .clip-handle.end {
opacity: 0.75; opacity: 0.75;
} }
.clip-block.trimmed-start .clip-handle.start::after, .clip-block.trimmed-start .clip-handle.start::after,
.clip-block.trimmed-end .clip-handle.end::after { .clip-block.trimmed-end .clip-handle.end::after,
.audio-clip-block.trimmed-start .clip-handle.start::after,
.audio-clip-block.trimmed-end .clip-handle.end::after {
background: #ffc857; background: #ffc857;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
} }
.clip-block.trimming .clip-handle { .clip-block.trimming .clip-handle,
.audio-clip-block.trimming .clip-handle {
opacity: 1; opacity: 1;
} }
.clip-block.trimming { .clip-block.trimming,
.audio-clip-block.trimming {
box-shadow: 0 0 0 1px #ffc857; box-shadow: 0 0 0 1px #ffc857;
} }
.track.audio-track.locked .clip-handle {
display: none;
}
.clip-block.moving { .clip-block.moving {
box-shadow: 0 0 0 1px var(--track-color, var(--accent)), 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 0 0 1px var(--track-color, var(--accent)), 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: grabbing; cursor: grabbing;
+45
View File
@@ -0,0 +1,45 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
// Tracks whether the window is in browser fullscreen — covers both the
// Fullscreen API (element.requestFullscreen) and the F11-style display
// mode that Firefox uses without setting fullscreenElement. The drawers
// switch to hover-open behavior when this is true.
const isFullscreen = ref(false)
let listeners = 0
let mql: MediaQueryList | null = null
function evaluate() {
const apiFs = !!document.fullscreenElement
const mqFs = mql?.matches === true
isFullscreen.value = apiFs || mqFs
}
function onFullscreenChange() {
evaluate()
}
function onMqChange() {
evaluate()
}
export function useFullscreen() {
onMounted(() => {
if (listeners === 0) {
mql = window.matchMedia('(display-mode: fullscreen)')
mql.addEventListener('change', onMqChange)
document.addEventListener('fullscreenchange', onFullscreenChange)
evaluate()
}
listeners += 1
})
onBeforeUnmount(() => {
listeners -= 1
if (listeners === 0) {
document.removeEventListener('fullscreenchange', onFullscreenChange)
mql?.removeEventListener('change', onMqChange)
mql = null
}
})
return { isFullscreen }
}
+4 -4
View File
@@ -69,7 +69,7 @@ export const en = {
thumbsToggle: 'Thumbs', thumbsToggle: 'Thumbs',
imageLabel: 'Image', imageLabel: 'Image',
addLayerTooltip: 'Insert as layer (or drag to stage)', addLayerTooltip: 'Insert as layer (or drag to stage)',
noImages: 'No images imported', noImages: 'No images imported yet',
clips: 'Clips', clips: 'Clips',
createClipTooltip: 'Create new clip', createClipTooltip: 'Create new clip',
clipLayerCount: '{n} layers', clipLayerCount: '{n} layers',
@@ -88,7 +88,7 @@ export const en = {
buttons: 'Buttons', buttons: 'Buttons',
noButtons: 'No buttons yet', noButtons: 'No buttons yet',
audio: 'Audio', audio: 'Audio',
noAudio: 'No audio imported', noAudio: 'No audio imported yet',
deleteAudioDialog: { deleteAudioDialog: {
title: 'Delete audio?', title: 'Delete audio?',
message: 'Remove "{name}" from the asset library. Any clips on the timeline that reference it will also be removed.', message: 'Remove "{name}" from the asset library. Any clips on the timeline that reference it will also be removed.',
@@ -996,7 +996,7 @@ export const de: DeepPartial<Messages> = {
thumbsToggle: 'Thumbs', thumbsToggle: 'Thumbs',
imageLabel: 'Bild', imageLabel: 'Bild',
addLayerTooltip: 'Als Ebene einfügen oder auf die Bühne ziehen', addLayerTooltip: 'Als Ebene einfügen oder auf die Bühne ziehen',
noImages: 'Keine Bilder importiert', noImages: 'Noch keine Bilder importiert',
clips: 'Clips', clips: 'Clips',
createClipTooltip: 'Neuen Clip anlegen', createClipTooltip: 'Neuen Clip anlegen',
clipLayerCount: '{n} Ebenen', clipLayerCount: '{n} Ebenen',
@@ -1015,7 +1015,7 @@ export const de: DeepPartial<Messages> = {
buttons: 'Buttons', buttons: 'Buttons',
noButtons: 'Noch keine Buttons', noButtons: 'Noch keine Buttons',
audio: 'Audio', audio: 'Audio',
noAudio: 'Kein Audio importiert', noAudio: 'Noch kein Audio importiert',
deleteAudioDialog: { deleteAudioDialog: {
title: 'Audio löschen?', title: 'Audio löschen?',
message: message:
+12
View File
@@ -1010,6 +1010,18 @@ export const useSceneStore = defineStore('scene', {
scriptTrack.color = color scriptTrack.color = color
scriptTrack.colorOverridden = true scriptTrack.colorOverridden = true
} }
return
}
const audioTrack = this.scene.audioTracks?.find((t) => t.id === trackId)
if (audioTrack) {
// Audio tracks are never inside a group either.
if (color === null) {
delete audioTrack.color
delete audioTrack.colorOverridden
} else {
audioTrack.color = color
audioTrack.colorOverridden = true
}
} }
}, },
shiftKeyframesPosition( shiftKeyframesPosition(
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.11.0' export const APP_VERSION = '0.11.1'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'