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>
This commit is contained in:
1 parent
a2c8e5998a
commit
1d84c34c15
33 files changed
+1185
-128
No files matched your search
+146
-21
@@ -46,6 +46,7 @@ import type {
|
||||
Layer,
|
||||
TrackGroup,
|
||||
} from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||
|
||||
interface TimelineLayerTrack {
|
||||
type: 'layer'
|
||||
@@ -1127,6 +1128,41 @@ function segmentStyle(seg: KfSegment) {
|
||||
}
|
||||
}
|
||||
|
||||
// Button-editor only: list the 4 state-slot times that this track has
|
||||
// NO keyframe on. The template renders a "+" placeholder over each
|
||||
// empty slot; clicking it duplicates the nearest keyframe (prev by
|
||||
// preference, next if there's nothing earlier) onto that slot.
|
||||
const BUTTON_SLOT_TIMES = [
|
||||
BUTTON_STATE_TIMES.normal,
|
||||
BUTTON_STATE_TIMES.hover,
|
||||
BUTTON_STATE_TIMES.pressed,
|
||||
BUTTON_STATE_TIMES.disabled,
|
||||
] as const
|
||||
|
||||
function emptyButtonSlots(kfs: Keyframe[]): number[] {
|
||||
if (!store.activeButtonAssetId) return []
|
||||
if (kfs.length === 0) return []
|
||||
const EPS = 1e-3
|
||||
const has = (t: number) => kfs.some((k) => Math.abs(k.time - t) < EPS)
|
||||
return BUTTON_SLOT_TIMES.filter((t) => !has(t))
|
||||
}
|
||||
|
||||
function addKfFromPrev(layerId: string, kfs: Keyframe[], time: number) {
|
||||
if (!kfs.length) return
|
||||
let prev: Keyframe | null = null
|
||||
let next: Keyframe | null = null
|
||||
for (const k of kfs) {
|
||||
if (k.time < time) {
|
||||
if (!prev || k.time > prev.time) prev = k
|
||||
} else if (k.time > time) {
|
||||
if (!next || k.time < next.time) next = k
|
||||
}
|
||||
}
|
||||
const source = prev ?? next
|
||||
if (!source) return
|
||||
store.duplicateKeyframeAt(layerId, source.id, time)
|
||||
}
|
||||
|
||||
const EASING_OPTIONS: { value: Easing; label: string }[] = [
|
||||
{ value: 'linear', label: t('timeline.easingLinear') },
|
||||
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
|
||||
@@ -2145,13 +2181,13 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: store.activeButtonEditMode === 'states' }"
|
||||
:title="t('timeline.buttonModeStates')"
|
||||
v-tooltip="t('timeline.buttonModeStates')"
|
||||
@click="store.setButtonEditMode('states')"
|
||||
>{{ t('timeline.buttonModeStatesShort') }}</button>
|
||||
<button
|
||||
class="mode-btn hit"
|
||||
:class="{ active: store.activeButtonEditMode === 'hit' }"
|
||||
:title="t('timeline.buttonModeHit')"
|
||||
v-tooltip="t('timeline.buttonModeHit')"
|
||||
@click="store.setButtonEditMode('hit')"
|
||||
>{{ t('timeline.buttonModeHitShort') }}</button>
|
||||
</div>
|
||||
@@ -2192,7 +2228,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div v-else class="scrub-bar">
|
||||
<div class="name-spacer">
|
||||
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
|
||||
<div class="mode-toggle" v-tooltip="t('timeline.toggleTimeUnit')">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: displayMode === 'seconds' }"
|
||||
@@ -2207,7 +2243,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="snap-btn"
|
||||
:class="{ active: snapEnabled }"
|
||||
:title="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
|
||||
v-tooltip="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
|
||||
@click="snapEnabled = !snapEnabled"
|
||||
>
|
||||
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
|
||||
@@ -2215,7 +2251,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="snap-btn"
|
||||
:class="{ active: !showLocked }"
|
||||
:title="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
|
||||
v-tooltip="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
|
||||
@click="showLocked = !showLocked"
|
||||
>
|
||||
<component
|
||||
@@ -2226,7 +2262,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="!inClipEdit"
|
||||
class="snap-btn"
|
||||
:title="t('timeline.addGroupTooltip')"
|
||||
v-tooltip="t('timeline.addGroupTooltip')"
|
||||
@click="addGroup"
|
||||
>
|
||||
<PhFolderPlus :size="12" />
|
||||
@@ -2234,14 +2270,14 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="!inClipEdit"
|
||||
class="snap-btn"
|
||||
:title="t('timeline.addScriptTrackTooltip')"
|
||||
v-tooltip="t('timeline.addScriptTrackTooltip')"
|
||||
@click="store.addScriptTrack()"
|
||||
>
|
||||
<PhCode :size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="snap-btn add-kf-btn"
|
||||
:title="t('timeline.addKeyframeTooltip')"
|
||||
v-tooltip="t('timeline.addKeyframeTooltip')"
|
||||
:disabled="!canCreateKf"
|
||||
@click="addKeyframeForSelected"
|
||||
>
|
||||
@@ -2281,7 +2317,7 @@ onBeforeUnmount(() => {
|
||||
></span>
|
||||
<span
|
||||
class="range-bracket bracket-in"
|
||||
:title="t('timeline.inPoint')"
|
||||
v-tooltip="t('timeline.inPoint')"
|
||||
:style="{ left: pxFromTime(store.playbackIn) + 'px' }"
|
||||
@pointerdown="onInBracketPointerDown"
|
||||
@pointermove="onInBracketPointerMove"
|
||||
@@ -2290,7 +2326,7 @@ onBeforeUnmount(() => {
|
||||
></span>
|
||||
<span
|
||||
class="range-bracket bracket-out"
|
||||
:title="t('timeline.outPoint')"
|
||||
v-tooltip="t('timeline.outPoint')"
|
||||
:style="{ left: pxFromTime(store.playbackOut) + 'px' }"
|
||||
@pointerdown="onOutBracketPointerDown"
|
||||
@pointermove="onOutBracketPointerMove"
|
||||
@@ -2306,7 +2342,7 @@ onBeforeUnmount(() => {
|
||||
left: pxFromTime(marker.time) + 'px',
|
||||
...(marker.color ? { '--marker-color': marker.color } : {}),
|
||||
}"
|
||||
:title="marker.name"
|
||||
v-tooltip="marker.name"
|
||||
@pointerdown="onMarkerPointerDown($event, marker.id)"
|
||||
@pointermove="onMarkerPointerMove($event, marker.id)"
|
||||
@pointerup="onMarkerPointerUp($event, marker.id)"
|
||||
@@ -2413,11 +2449,22 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<span v-if="!track.isParent" class="sub-indent">↳</span>
|
||||
<span class="track-label">{{ track.label }}</span>
|
||||
<button
|
||||
v-if="track.isParent"
|
||||
class="eye-btn"
|
||||
:class="{ hidden: track.layer.editorHidden }"
|
||||
v-tooltip="track.layer.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
|
||||
@click.stop="store.setLayerEditorHidden(track.layerId, !track.layer.editorHidden)"
|
||||
@mousedown.stop
|
||||
@dragstart.prevent.stop
|
||||
>
|
||||
<component :is="track.layer.editorHidden ? PhEyeSlash : PhEye" :size="12" />
|
||||
</button>
|
||||
<button
|
||||
v-if="track.isParent && !lockedGroupChildIds.has(track.layerId)"
|
||||
class="lock-btn"
|
||||
:class="{ locked: track.layer.locked }"
|
||||
:title="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||||
v-tooltip="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||||
@click.stop="store.setTrackLocked(track.layerId, !track.layer.locked)"
|
||||
@mousedown.stop
|
||||
@dragstart.prevent.stop
|
||||
@@ -2460,7 +2507,7 @@ onBeforeUnmount(() => {
|
||||
:data-layer-id="track.layerId"
|
||||
:data-kind="track.kind"
|
||||
:data-kf-id="kf.id"
|
||||
:title="
|
||||
v-tooltip="
|
||||
kf.hold
|
||||
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
|
||||
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
|
||||
@@ -2473,6 +2520,19 @@ onBeforeUnmount(() => {
|
||||
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
|
||||
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
|
||||
/>
|
||||
<button
|
||||
v-for="emptyT in emptyButtonSlots(track.kfs)"
|
||||
:key="`empty-${track.layerId}-${track.kind}-${emptyT}`"
|
||||
type="button"
|
||||
class="kf-add"
|
||||
:style="{ left: pxFromTime(emptyT) + 'px' }"
|
||||
v-tooltip="t('timeline.addKfFromPrev')"
|
||||
@mousedown.stop
|
||||
@pointerdown.stop
|
||||
@click.stop="addKfFromPrev(track.layerId, track.kfs, emptyT)"
|
||||
>
|
||||
<PhPlus :size="10" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2525,7 +2585,7 @@ onBeforeUnmount(() => {
|
||||
v-if="!lockedGroupChildIds.has(track.clipTrackId)"
|
||||
class="lock-btn"
|
||||
:class="{ locked: !!clipTrackById.get(track.clipTrackId)?.locked }"
|
||||
:title="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||||
v-tooltip="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||||
@click.stop="
|
||||
store.setTrackLocked(
|
||||
track.clipTrackId,
|
||||
@@ -2568,7 +2628,7 @@ onBeforeUnmount(() => {
|
||||
moving: clipMove?.instanceId === ci.id,
|
||||
}"
|
||||
:style="clipBlockStyle(ci)"
|
||||
:title="trimTooltip(ci)"
|
||||
v-tooltip="trimTooltip(ci)"
|
||||
@pointerdown="onClipBlockPointerDown($event, ci)"
|
||||
@pointermove="onClipBlockPointerMove"
|
||||
@pointerup="onClipBlockPointerUp"
|
||||
@@ -2634,7 +2694,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="lock-btn"
|
||||
:class="{ locked: track.locked }"
|
||||
:title="track.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||||
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||||
@click.stop="store.setTrackLocked(track.scriptTrackId, !track.locked)"
|
||||
@mousedown.stop
|
||||
@dragstart.prevent.stop
|
||||
@@ -2662,7 +2722,7 @@ onBeforeUnmount(() => {
|
||||
:key="frame.id"
|
||||
class="script-frame"
|
||||
:style="{ left: pxFromTime(frame.time) + 'px' }"
|
||||
:title="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
|
||||
v-tooltip="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
|
||||
@pointerdown="onScriptFramePointerDown($event, frame.id)"
|
||||
@pointermove="onScriptFramePointerMove"
|
||||
@pointerup="onScriptFramePointerUp"
|
||||
@@ -2713,7 +2773,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
class="lock-btn"
|
||||
:class="{ locked: track.locked }"
|
||||
:title="track.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||||
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
|
||||
@click.stop="store.setTrackLocked(track.groupId, !track.locked)"
|
||||
@mousedown.stop
|
||||
@dragstart.prevent.stop
|
||||
@@ -2768,7 +2828,7 @@ onBeforeUnmount(() => {
|
||||
<span class="hit-track-label">{{ hitShapeLabel(shape, idx) }}</span>
|
||||
<button
|
||||
class="hit-track-delete"
|
||||
:title="t('timeline.hitTrackDelete')"
|
||||
v-tooltip="t('timeline.hitTrackDelete')"
|
||||
@pointerdown.stop
|
||||
@click.stop="onDeleteHitShape(idx)"
|
||||
>×</button>
|
||||
@@ -2806,7 +2866,7 @@ onBeforeUnmount(() => {
|
||||
class="name-col-resize"
|
||||
:class="{ resizing: nameResizing }"
|
||||
:style="{ left: trackNameWidth + 'px' }"
|
||||
:title="t('timeline.resizeTrackColumn')"
|
||||
v-tooltip="t('timeline.resizeTrackColumn')"
|
||||
@pointerdown="onNameResizeStart"
|
||||
@pointermove="onNameResizeMove"
|
||||
@pointerup="onNameResizeEnd"
|
||||
@@ -2880,7 +2940,7 @@ onBeforeUnmount(() => {
|
||||
class="color-tile"
|
||||
:class="{ selected: trackMenu.currentColor === c.value, none: c.value === null }"
|
||||
:style="c.value ? { background: c.value } : undefined"
|
||||
:title="c.label"
|
||||
v-tooltip="c.label"
|
||||
@click="applyTrackColor(c.value)"
|
||||
>
|
||||
<PhCheck
|
||||
@@ -3631,6 +3691,40 @@ onBeforeUnmount(() => {
|
||||
transition: opacity 140ms var(--ease), color 140ms var(--ease);
|
||||
}
|
||||
|
||||
/* Editor-only show/hide toggle. Sits to the left of the lock button.
|
||||
Stays visible when the layer is hidden so the state is obvious; on
|
||||
hover it fades in on every track so you can reach it without the
|
||||
layer first being hidden. */
|
||||
.eye-btn {
|
||||
margin-left: auto;
|
||||
margin-right: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 140ms var(--ease), color 140ms var(--ease);
|
||||
}
|
||||
|
||||
.track-name:hover .eye-btn {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.eye-btn:hover {
|
||||
color: var(--text);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.eye-btn.hidden {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.track-name:hover .lock-btn {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -3905,6 +3999,37 @@ onBeforeUnmount(() => {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Button-editor empty-slot placeholder. Outlined accent-coloured
|
||||
circle that stays fully opaque (no fade) so it's obvious the slot
|
||||
is interactive; on hover the fill solidifies into the accent so
|
||||
the user sees "I'm about to drop a keyframe here". */
|
||||
.kf-add {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: -9px;
|
||||
margin-top: -9px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--panel);
|
||||
border: 1.5px solid var(--track-color, var(--accent));
|
||||
border-radius: 50%;
|
||||
color: var(--track-color, var(--accent));
|
||||
cursor: pointer;
|
||||
transition: background 140ms var(--ease), color 140ms var(--ease),
|
||||
transform 140ms var(--ease);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.kf-add:hover {
|
||||
background: var(--track-color, var(--accent));
|
||||
color: #fff;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.kf.hold:hover {
|
||||
background: var(--panel);
|
||||
border-color: #fff;
|
||||
|
||||
Reference in new issue
Block a user