0.11.1: drawer mouse trigger and clip track / audio behavior
This commit is contained in:
1 parent
8d987d1526
commit
fa66ba69bb
9 files changed
+258
-31
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1016,7 +1016,7 @@ h4 {
|
||||
background: transparent !important;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,24 @@ import { PhArrowClockwise, PhCaretLeft, PhCaretRight, PhFolderOpen, PhImagesSqua
|
||||
import { useClipartStore } from '@/stores/clipart'
|
||||
import { readAudioDuration, readAsDataUrl } from '@/lib/fileLoader'
|
||||
import { clearAudioDragSource, setAudioDragSource } from '@/composables/useAudioDragSource'
|
||||
import { useFullscreen } from '@/composables/useFullscreen'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
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(() => {
|
||||
clipart.initialize()
|
||||
@@ -64,11 +79,12 @@ function onItemDragStart(e: DragEvent, index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="clipart-drawer" :class="{ open: clipart.open }">
|
||||
<aside class="clipart-drawer" :class="{ open: clipart.open }" @mouseleave="onDrawerLeave">
|
||||
<button
|
||||
class="drawer-tab"
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -4,9 +4,22 @@ import { PhCaretLeft, PhCaretRight, PhCopy, PhFilmSlate, PhPlus, PhTrash } from
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
import { useFullscreen } from '@/composables/useFullscreen'
|
||||
|
||||
const store = useSceneStore()
|
||||
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 {
|
||||
pageId: string
|
||||
@@ -17,6 +30,7 @@ const menu = ref<MenuState | null>(null)
|
||||
const menuRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
function toggle() {
|
||||
if (isFullscreen.value) return
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
@@ -86,11 +100,12 @@ watch(menu, (m) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="scene-drawer" :class="{ open }">
|
||||
<aside class="scene-drawer" :class="{ open }" @mouseleave="onDrawerLeave">
|
||||
<button
|
||||
class="drawer-tab"
|
||||
v-tooltip="open ? t('scenes.close') : t('scenes.open')"
|
||||
@click="toggle"
|
||||
@mouseenter="onTabEnter"
|
||||
>
|
||||
<component :is="open ? PhCaretRight : PhCaretLeft" :size="14" />
|
||||
</button>
|
||||
|
||||
+160
-21
@@ -1835,6 +1835,24 @@ function clipPeaks(clip: AudioClip): Float32Array | undefined {
|
||||
* the clip's current width (resizes on zoom + trim changes). Cheap
|
||||
* because peaks are cached and the draw itself is a few hundred
|
||||
* 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) {
|
||||
return (raw: Element | ComponentPublicInstance | null) => {
|
||||
if (!raw || !(raw instanceof HTMLCanvasElement)) return
|
||||
@@ -1852,7 +1870,7 @@ function renderWaveformCanvas(clip: AudioClip) {
|
||||
if (el.height !== pxH) el.height = pxH
|
||||
el.style.width = cssW + '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
|
||||
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 --------------------------------------
|
||||
@@ -2253,17 +2342,29 @@ function onClipHandlePointerMove(e: PointerEvent) {
|
||||
if (!t || t.pointerId !== e.pointerId) return
|
||||
const pps = effectivePxPerSec.value
|
||||
if (pps <= 0) return
|
||||
const deltaSec = (e.clientX - t.startX) / pps
|
||||
const rawDelta = (e.clientX - t.startX) / pps
|
||||
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, {
|
||||
trimStart: t.initialTrimStart + deltaSec,
|
||||
startTime: t.initialStartTime + deltaSec,
|
||||
trimStart: t.initialTrimStart + delta,
|
||||
startTime: t.initialStartTime + delta,
|
||||
})
|
||||
} else {
|
||||
// shift right edge right = less trimEnd (reveal more)
|
||||
store.trimClipInstance(t.instanceId, {
|
||||
trimEnd: t.initialTrimEnd - deltaSec,
|
||||
trimEnd: t.initialTrimEnd - rawDelta,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3310,7 +3411,12 @@ onBeforeUnmount(() => {
|
||||
v-for="clip in audioClipsByTrack[track.audioTrackId] ?? []"
|
||||
:key="clip.id"
|
||||
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)"
|
||||
v-tooltip="audioClipTooltip(clip)"
|
||||
@pointerdown="onAudioClipPointerDown($event, clip)"
|
||||
@@ -3319,6 +3425,17 @@ onBeforeUnmount(() => {
|
||||
@pointercancel="onAudioClipPointerUp"
|
||||
@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
|
||||
v-if="clipPeaks(clip)"
|
||||
class="audio-clip-wave"
|
||||
@@ -3327,6 +3444,17 @@ onBeforeUnmount(() => {
|
||||
<span class="audio-clip-label">
|
||||
{{ audioAssetById.get(clip.audioAssetId)?.name ?? '—' }}
|
||||
</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
|
||||
v-if="audioClipGhost && audioClipGhost.targetTrackId === track.audioTrackId"
|
||||
@@ -4811,15 +4939,15 @@ onBeforeUnmount(() => {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
background: var(--track-color, #f59e0b);
|
||||
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.script-frame-count {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: var(--track-color, #f59e0b);
|
||||
background: color-mix(in srgb, var(--track-color, #f59e0b) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0 5px;
|
||||
line-height: 13px;
|
||||
@@ -4833,8 +4961,8 @@ onBeforeUnmount(() => {
|
||||
height: 30px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(14, 165, 233, 0.08) 0%,
|
||||
rgba(14, 165, 233, 0.02) 100%
|
||||
color-mix(in srgb, var(--track-color, #0ea5e9) 8%, transparent) 0%,
|
||||
color-mix(in srgb, var(--track-color, #0ea5e9) 2%, transparent) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4876,10 +5004,10 @@ onBeforeUnmount(() => {
|
||||
bottom: 4px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(14, 165, 233, 0.35) 0%,
|
||||
rgba(14, 165, 233, 0.15) 100%
|
||||
color-mix(in srgb, var(--track-color, #0ea5e9) 35%, transparent) 0%,
|
||||
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);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5034,29 +5162,40 @@ onBeforeUnmount(() => {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.clip-block:hover .clip-handle {
|
||||
.clip-block:hover .clip-handle,
|
||||
.audio-clip-block:hover .clip-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.clip-block.trimming {
|
||||
.clip-block.trimming,
|
||||
.audio-clip-block.trimming {
|
||||
box-shadow: 0 0 0 1px #ffc857;
|
||||
}
|
||||
|
||||
.track.audio-track.locked .clip-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clip-block.moving {
|
||||
box-shadow: 0 0 0 1px var(--track-color, var(--accent)), 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
cursor: grabbing;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export const en = {
|
||||
thumbsToggle: 'Thumbs',
|
||||
imageLabel: 'Image',
|
||||
addLayerTooltip: 'Insert as layer (or drag to stage)',
|
||||
noImages: 'No images imported',
|
||||
noImages: 'No images imported yet',
|
||||
clips: 'Clips',
|
||||
createClipTooltip: 'Create new clip',
|
||||
clipLayerCount: '{n} layers',
|
||||
@@ -88,7 +88,7 @@ export const en = {
|
||||
buttons: 'Buttons',
|
||||
noButtons: 'No buttons yet',
|
||||
audio: 'Audio',
|
||||
noAudio: 'No audio imported',
|
||||
noAudio: 'No audio imported yet',
|
||||
deleteAudioDialog: {
|
||||
title: 'Delete audio?',
|
||||
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',
|
||||
imageLabel: 'Bild',
|
||||
addLayerTooltip: 'Als Ebene einfügen oder auf die Bühne ziehen',
|
||||
noImages: 'Keine Bilder importiert',
|
||||
noImages: 'Noch keine Bilder importiert',
|
||||
clips: 'Clips',
|
||||
createClipTooltip: 'Neuen Clip anlegen',
|
||||
clipLayerCount: '{n} Ebenen',
|
||||
@@ -1015,7 +1015,7 @@ export const de: DeepPartial<Messages> = {
|
||||
buttons: 'Buttons',
|
||||
noButtons: 'Noch keine Buttons',
|
||||
audio: 'Audio',
|
||||
noAudio: 'Kein Audio importiert',
|
||||
noAudio: 'Noch kein Audio importiert',
|
||||
deleteAudioDialog: {
|
||||
title: 'Audio löschen?',
|
||||
message:
|
||||
|
||||
@@ -1010,6 +1010,18 @@ export const useSceneStore = defineStore('scene', {
|
||||
scriptTrack.color = color
|
||||
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(
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.11.0'
|
||||
export const APP_VERSION = '0.11.1'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user