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>
325 lines
7.6 KiB
Vue
325 lines
7.6 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import {
|
|
PhArrowDown,
|
|
PhArrowUp,
|
|
PhPlus,
|
|
PhTrash,
|
|
} from '@phosphor-icons/vue'
|
|
import { useSceneStore } from '@/stores/scene'
|
|
import { t } from '@/i18n'
|
|
import type { Action } from '@/types/scene'
|
|
|
|
const props = defineProps<{ actions: Action[] }>()
|
|
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
|
|
|
|
const store = useSceneStore()
|
|
|
|
const markerNames = computed(() =>
|
|
(store.scene.markers ?? []).map((m) => m.name),
|
|
)
|
|
|
|
const ACTION_TYPES: Action['type'][] = [
|
|
'play',
|
|
'pause',
|
|
'rewindToLoop',
|
|
'jumpTo',
|
|
'jumpToFrame',
|
|
'jumpToMarker',
|
|
'script',
|
|
]
|
|
|
|
function actionTypeLabel(type: Action['type']): string {
|
|
return t(`actions.type.${type}`)
|
|
}
|
|
|
|
function makeDefault(type: Action['type']): Action {
|
|
switch (type) {
|
|
case 'play':
|
|
case 'pause':
|
|
case 'rewindToLoop':
|
|
return { type }
|
|
case 'jumpTo':
|
|
return { type: 'jumpTo', time: 0 }
|
|
case 'jumpToFrame':
|
|
return { type: 'jumpToFrame', frame: 0 }
|
|
case 'jumpToMarker':
|
|
return { type: 'jumpToMarker', name: markerNames.value[0] ?? '' }
|
|
case 'script':
|
|
return { type: 'script', source: '' }
|
|
}
|
|
}
|
|
|
|
function setAt(index: number, next: Action) {
|
|
const copy = [...props.actions]
|
|
copy[index] = next
|
|
emit('update:actions', copy)
|
|
}
|
|
|
|
function addAction() {
|
|
emit('update:actions', [...props.actions, makeDefault('pause')])
|
|
}
|
|
|
|
function removeAt(index: number) {
|
|
const copy = [...props.actions]
|
|
copy.splice(index, 1)
|
|
emit('update:actions', copy)
|
|
}
|
|
|
|
function moveUp(index: number) {
|
|
if (index === 0) return
|
|
const copy = [...props.actions]
|
|
const tmp = copy[index - 1]
|
|
copy[index - 1] = copy[index]
|
|
copy[index] = tmp
|
|
emit('update:actions', copy)
|
|
}
|
|
|
|
function moveDown(index: number) {
|
|
if (index === props.actions.length - 1) return
|
|
const copy = [...props.actions]
|
|
const tmp = copy[index + 1]
|
|
copy[index + 1] = copy[index]
|
|
copy[index] = tmp
|
|
emit('update:actions', copy)
|
|
}
|
|
|
|
function onTypeChange(index: number, newType: Action['type']) {
|
|
if (props.actions[index].type === newType) return
|
|
setAt(index, makeDefault(newType))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="action-editor">
|
|
<div v-if="!actions.length" class="empty">{{ t('actions.empty') }}</div>
|
|
<ol v-else class="list">
|
|
<li v-for="(a, i) in actions" :key="i" class="row">
|
|
<div class="head">
|
|
<span class="idx">{{ i + 1 }}</span>
|
|
<select
|
|
class="type-select"
|
|
:value="a.type"
|
|
@change="onTypeChange(i, ($event.target as HTMLSelectElement).value as Action['type'])"
|
|
>
|
|
<option v-for="type in ACTION_TYPES" :key="type" :value="type">
|
|
{{ actionTypeLabel(type) }}
|
|
</option>
|
|
</select>
|
|
<button class="icon-btn" v-tooltip="t('actions.moveUp')" :disabled="i === 0" @click="moveUp(i)">
|
|
<PhArrowUp :size="13" />
|
|
</button>
|
|
<button class="icon-btn" v-tooltip="t('actions.moveDown')" :disabled="i === actions.length - 1" @click="moveDown(i)">
|
|
<PhArrowDown :size="13" />
|
|
</button>
|
|
<button class="icon-btn danger" v-tooltip="t('actions.remove')" @click="removeAt(i)">
|
|
<PhTrash :size="13" />
|
|
</button>
|
|
</div>
|
|
<div v-if="a.type === 'jumpTo'" class="params">
|
|
<label>
|
|
{{ t('actions.param.time') }}
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
:value="a.time"
|
|
@input="setAt(i, { type: 'jumpTo', time: Number(($event.target as HTMLInputElement).value) })"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div v-else-if="a.type === 'jumpToFrame'" class="params">
|
|
<label>
|
|
{{ t('actions.param.frame') }}
|
|
<input
|
|
type="number"
|
|
step="1"
|
|
min="0"
|
|
:value="a.frame"
|
|
@input="setAt(i, { type: 'jumpToFrame', frame: Math.round(Number(($event.target as HTMLInputElement).value)) })"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div v-else-if="a.type === 'jumpToMarker'" class="params">
|
|
<label>
|
|
{{ t('actions.param.marker') }}
|
|
<select
|
|
:value="a.name"
|
|
@change="setAt(i, { type: 'jumpToMarker', name: ($event.target as HTMLSelectElement).value })"
|
|
>
|
|
<option v-if="!markerNames.length" value="" disabled>{{ t('actions.noMarkers') }}</option>
|
|
<option v-for="name in markerNames" :key="name" :value="name">{{ name }}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div v-else-if="a.type === 'script'" class="params">
|
|
<label class="block">
|
|
{{ t('actions.param.source') }}
|
|
<textarea
|
|
rows="3"
|
|
:value="a.source"
|
|
@input="setAt(i, { type: 'script', source: ($event.target as HTMLTextAreaElement).value })"
|
|
></textarea>
|
|
</label>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
<button class="add-btn" @click="addAction">
|
|
<PhPlus :size="12" weight="bold" />
|
|
{{ t('actions.add') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.action-editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.empty {
|
|
padding: 10px;
|
|
text-align: center;
|
|
color: var(--text-dim);
|
|
font-style: italic;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.row {
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--panel-alt);
|
|
padding: 6px 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.idx {
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
min-width: 14px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.type-select {
|
|
flex: 1;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-size: 12px;
|
|
border-radius: var(--radius-sm);
|
|
padding: 3px 6px;
|
|
}
|
|
|
|
.icon-btn {
|
|
width: 22px;
|
|
height: 22px;
|
|
padding: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.icon-btn:hover:not(:disabled) {
|
|
color: var(--text);
|
|
background: var(--panel-hover);
|
|
}
|
|
|
|
.icon-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: default;
|
|
}
|
|
|
|
.icon-btn.danger:hover {
|
|
background: var(--danger-soft);
|
|
color: var(--danger);
|
|
}
|
|
|
|
.params {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.params label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.params label.bool {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.params label.block {
|
|
flex: 1 1 100%;
|
|
}
|
|
|
|
.params input[type='number'],
|
|
.params select,
|
|
.params textarea {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-size: 12px;
|
|
border-radius: var(--radius-sm);
|
|
padding: 3px 6px;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.params textarea {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 11px;
|
|
resize: vertical;
|
|
min-height: 48px;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.add-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 10px;
|
|
background: var(--panel);
|
|
border: 1px dashed var(--border-strong);
|
|
color: var(--text);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 12px;
|
|
align-self: flex-start;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.add-btn:hover {
|
|
background: var(--panel-hover);
|
|
}
|
|
</style>
|