Files
RUWIO/src/components/FullscreenPreview.vue
T
thandClaude Opus 4.7 1d84c34c15 0.8.3: custom tooltips, editor-hide, pack script, settings reshuffle
Tooltips
- New v-tooltip directive + TooltipHost floating panel replace the
  native title attribute across 95+ sites (topbar, timeline, sidebar,
  properties, stage overlays, fullscreen preview, export dialog,
  clipart drawer, modals). Styled dark card with a configurable
  label + optional help line for inline documentation; screen
  readers still get an aria-label when the panel is disabled.
- Settings → Interface toggle (`ruwio:tooltipsEnabled`) hides the
  visual panel for users who know the app. IconButton now routes
  its title prop through v-tooltip so every existing call site is
  tooltip-aware without churn.

Editor-hide + export filter
- New `Layer.editorHidden` flag, eye toggle per layer row, skipped
  by the Konva stage and the mask-preview canvas path but honoured
  by exports and fullscreen preview (runtime untouched).
- Export dialog gets a "Skip hidden tracks" switch that strips
  editor-hidden layers from every format (scene JS, runtime,
  example HTML, MP4, WebM) on opt-in. Disabled when the scene has
  no hidden layers.

Button editor
- `+` buttons appear on empty state slots in button-timeline rows
  and duplicate the nearest keyframe (prev by preference, next
  otherwise) onto that slot. New `duplicateKeyframeAt` store
  action carries props, easing, hold, and bezier handles across.

Stage / interaction
- Plain left-click on the empty content view pans the viewport,
  but only when the pointer isn't over a Konva shape — transformer
  handles, vertex dots, and selection boxes that extend past the
  canvas edge keep their drag.

Settings reshuffle
- Timeline tab merged into Document (Duration + FPS sit under a
  new "Timing" sub-heading). New Interface tab gathers three
  global prefs: Show tooltips, Show locked tracks, Asset
  thumbnails — driven by shared composables so the existing
  toolbar toggles stay in sync. Interface-tab CSS defensively
  resets the scoped label flex-column + uppercase rule that was
  making the toggle row look like an accident.

Project identity + UX polish
- Favicon wired up (moved to public/favicon.ico, linked from
  index.html); scripts/pack.sh packs the minimum Vue project
  (src, index.html, configs, public) into a tarball with the
  right version in the filename, and `npm run pack` drives it.
- About dialog now credits Torsten Hoeltge.

Test infra
- Progress bar turns red mid-run on the first failure: runAll
  gets an onResult callback that TestOverlay uses to push each
  result into the reactive array incrementally.
- mountComponent in the test harness registers v-tooltip on the
  isolated app instance so IconButton's aria-label contract
  actually fires under test.

Regression tests
- duplicateKeyframeAt: copy fidelity (props, easing, hold),
  collision refusal, unknown-id guards, separated-track targeting.
- Shared composables: useTooltipPref and useAssetThumbsPref both
  return the same ref across calls and persist to localStorage.
- Tooltip contract: IconButton ships `aria-label` (not title);
  live-DOM smoke tests match on aria-label selectors now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:57:35 +02:00

412 lines
9.4 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const frameEl = ref<HTMLDivElement | null>(null)
const rootEl = ref<HTMLDivElement | null>(null)
const progressTrackEl = ref<HTMLDivElement | null>(null)
const localTime = ref(0)
const playing = ref(false)
const showControls = ref(false)
const scrubbing = ref(false)
const HOVER_ZONE_PX = 160
let isClosing = false
let player: PlayerHandle | null = null
const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
)
const rangeIn = computed(() => store.playbackIn)
const rangeOut = computed(() => store.playbackOut)
// ---- lifecycle ----
async function startPreview() {
destroyPlayer()
await nextTick()
const el = frameEl.value
if (!el) return
playing.value = true
showControls.value = false
const start = rangeIn.value
const end = rangeOut.value
const head = store.playhead
const initialTime = head < start || head >= end ? start : head
player = mountPlayer(el, store.scene, {
loop: true,
onFrame(time: number, paused: boolean) {
localTime.value = time
playing.value = !paused
},
})
player.seek(initialTime)
requestBrowserFullscreen()
}
function stopPreview() {
destroyPlayer()
playing.value = false
showControls.value = false
exitBrowserFullscreen()
}
function destroyPlayer() {
if (player) {
player.destroy()
player = null
}
}
async function requestBrowserFullscreen() {
const el = rootEl.value
if (!el) return
if (document.fullscreenElement === el) return
try {
await el.requestFullscreen?.()
} catch {
/* user gesture missing or not supported — gracefully keep overlay */
}
}
function exitBrowserFullscreen() {
if (document.fullscreenElement) {
isClosing = true
document.exitFullscreen?.().catch(() => {
/* ignore */
})
}
}
function onFullscreenChange() {
if (!props.modelValue) return
if (document.fullscreenElement === rootEl.value) return
if (isClosing) {
isClosing = false
return
}
close()
}
function onRootMouseMove(e: MouseEvent) {
const nearBottom = e.clientY > window.innerHeight - HOVER_ZONE_PX
if (nearBottom || scrubbing.value) showControls.value = true
else showControls.value = false
}
function onRootMouseLeave() {
if (!scrubbing.value) showControls.value = false
}
// ---- transport controls ----
const progressPct = computed(() => {
const start = rangeIn.value
const end = rangeOut.value
const len = end - start
if (len <= 0) return 0
return Math.max(0, Math.min(1, (localTime.value - start) / len)) * 100
})
function seekFromEvent(e: PointerEvent) {
const el = progressTrackEl.value
if (!el || !player) return
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const start = rangeIn.value
const end = rangeOut.value
player.seek(start + ratio * (end - start))
}
function onProgressPointerDown(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
scrubbing.value = true
if (player) player.pause()
seekFromEvent(e)
}
function onProgressPointerMove(e: PointerEvent) {
if (!scrubbing.value) return
seekFromEvent(e)
}
function onProgressPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
scrubbing.value = false
}
function close() {
emit('update:modelValue', false)
}
function toggle() {
if (player) player.toggle()
}
function rewind() {
if (player) {
player.seek(rangeIn.value)
player.play()
}
}
function onKeyDown(e: KeyboardEvent) {
if (!props.modelValue) return
if (e.key === 'Escape') {
e.preventDefault()
close()
return
}
if (e.code === 'Space') {
e.preventDefault()
toggle()
}
}
watch(
() => props.modelValue,
(v) => {
if (v) startPreview()
else stopPreview()
},
)
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('fullscreenchange', onFullscreenChange)
if (props.modelValue) startPreview()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('fullscreenchange', onFullscreenChange)
destroyPlayer()
exitBrowserFullscreen()
})
const timeLabel = computed(() => {
const end = rangeOut.value || 0
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="modelValue"
ref="rootEl"
class="fs-root"
:class="{ 'controls-visible': showControls || scrubbing }"
@mousemove="onRootMouseMove"
@mouseleave="onRootMouseLeave"
@dblclick.self="close"
>
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }"></div>
<div class="fs-controls" @mousedown.stop>
<div
ref="progressTrackEl"
class="fs-progress"
@pointerdown="onProgressPointerDown"
@pointermove="onProgressPointerMove"
@pointerup="onProgressPointerUp"
@pointercancel="onProgressPointerUp"
>
<div class="fs-progress-track">
<div class="fs-progress-fill" :style="{ width: progressPct + '%' }"></div>
</div>
<div class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
</div>
<div class="fs-toolbar">
<button class="fs-btn" v-tooltip="playing ? t('fullscreen.pause') : t('fullscreen.play')" @click="toggle">
<PhPause v-if="playing" :size="15" weight="fill" />
<PhPlay v-else :size="15" weight="fill" />
</button>
<button class="fs-btn" v-tooltip="t('fullscreen.jumpToStart')" @click="rewind">
<PhSkipBack :size="15" weight="fill" />
</button>
<span class="fs-time">{{ timeLabel }}</span>
<div class="fs-spacer"></div>
<button class="fs-btn close" v-tooltip="t('fullscreen.close')" @click="close">
<PhX :size="15" weight="bold" />
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.fs-root {
position: fixed;
inset: 0;
z-index: 2500;
background: #000;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.fs-frame {
position: relative;
width: min(100vw, 100vh * (16 / 9));
max-width: 100%;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.fs-frame :deep(video) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
z-index: 0;
}
.fs-frame :deep(canvas) {
display: block;
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}
.fs-controls {
position: absolute;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(12px);
width: min(720px, calc(100vw - 48px));
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 10px;
background: rgba(20, 20, 24, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
opacity: 0;
transition: opacity 180ms ease, transform 180ms ease;
pointer-events: none;
z-index: 10;
}
.fs-root.controls-visible .fs-controls {
opacity: 1;
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
.fs-progress {
position: relative;
height: 18px;
display: flex;
align-items: center;
cursor: pointer;
touch-action: none;
user-select: none;
}
.fs-progress-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
overflow: hidden;
}
.fs-progress-fill {
height: 100%;
background: var(--accent, #6ea8ff);
border-radius: 999px;
transition: width 60ms linear;
}
.fs-progress-handle {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
background: #fff;
border: 2px solid var(--accent, #6ea8ff);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
.fs-toolbar {
display: flex;
align-items: center;
gap: 6px;
}
.fs-spacer {
flex: 1;
}
.fs-btn {
width: 30px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.85);
border-radius: 50%;
cursor: pointer;
}
.fs-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.fs-btn.close:hover {
background: rgba(255, 85, 85, 0.2);
color: #ff7a7a;
}
.fs-time {
font-variant-numeric: tabular-nums;
font-size: 11px;
color: rgba(255, 255, 255, 0.65);
padding: 0 6px;
min-width: 96px;
text-align: center;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 160ms var(--ease, ease);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>