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:
thandClaude Opus 4.7 committed 2026-04-21 23:57:35 +02:00
1 parent a2c8e5998a
commit 1d84c34c15
33 files changed
+1184 -127

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
node_modules
dist
dist-ssr
dist-archive
*.local
.vite
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RUWIO</title>
</head>
+3 -2
View File
@@ -1,13 +1,14 @@
{
"name": "animatr",
"private": true,
"version": "0.8.2",
"version": "0.8.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
"type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Pack everything needed to install + run the Vue frontend into a
# single tarball: sources, entry HTML, Vite/TS config, and the lock
# file. Recipient runs:
#
# tar -xzf ruwio-<version>.tar.gz
# cd ruwio-<version>
# npm install
# npm run dev # or: npm run build
#
# Deliberately excluded: node_modules, dist, the render server,
# docker-compose, logo working files, personal notes, screenshots,
# .claude/, .git/.
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION=$(node -p "require('./package.json').version")
NAME="ruwio-${VERSION}"
OUT_DIR="dist-archive"
ARCHIVE="${OUT_DIR}/${NAME}.tar.gz"
mkdir -p "$OUT_DIR"
rm -f "$ARCHIVE"
# Explicit allowlist — everything else stays out. If you add a new
# top-level file the Vue app depends on (e.g. a separate env file or
# a PostCSS config), extend this list.
tar --transform "s,^,${NAME}/," -czf "$ARCHIVE" \
index.html \
package.json \
package-lock.json \
tsconfig.json \
vite.config.ts \
.gitignore \
public \
src
echo "Created $ARCHIVE"
ls -lh "$ARCHIVE"
+21 -9
View File
@@ -26,6 +26,7 @@ import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue'
import Timeline from './components/Timeline.vue'
import TooltipHost from './components/TooltipHost.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import ruwioLogo from './assets/logo.png'
@@ -418,13 +419,16 @@ onBeforeUnmount(() => {
<div class="app" :style="appStyle">
<header class="topbar">
<div class="brand-area">
<button class="brand" :title="t('about.openTooltip')" @click="aboutOpen = true">
<button class="brand" v-tooltip="t('about.openTooltip')" @click="aboutOpen = true">
<img :src="ruwioLogo" alt="RUWIO" class="brand-logo" />
</button>
<button
class="project-name-btn"
:class="{ 'is-untitled': !currentProjectName }"
:title="t('app.renameProject')"
v-tooltip="{
label: t('app.renameProject'),
help: t('app.renameProjectHelp'),
}"
@click="renameProject"
>
<span class="project-name-label">
@@ -476,22 +480,29 @@ onBeforeUnmount(() => {
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
</div>
<div class="topbar-right">
<button class="icon-btn" :title="t('app.recentProjects')" @click="welcomeOpen = true">
<button class="icon-btn" v-tooltip="t('app.recentProjects')" @click="welcomeOpen = true">
<PhFolderOpen :size="16" />
</button>
<button
class="icon-btn save-btn"
:class="{ dirty: isDirty }"
:title="isDirty ? t('app.saveProjectDirty') : t('app.saveProject')"
v-tooltip="{
label: isDirty ? t('app.saveProjectDirty') : t('app.saveProject'),
help: isDirty ? t('app.saveProjectDirtyHelp') : undefined,
}"
@click="saveProject"
>
<PhFloppyDisk :size="16" />
<span v-if="isDirty" class="dirty-dot" aria-hidden="true"></span>
</button>
<button class="icon-btn" :title="t('app.documentSettings')" @click="settingsOpen = true">
<button class="icon-btn" v-tooltip="t('app.documentSettings')" @click="settingsOpen = true">
<PhGearSix :size="16" />
</button>
<button class="export-btn" :title="t('app.export')" @click="exportOpen = true">
<button
class="export-btn"
v-tooltip="{ label: t('app.export'), help: t('app.exportHelp') }"
@click="exportOpen = true"
>
<PhExport :size="14" weight="bold" />
{{ t('app.export') }}
</button>
@@ -504,7 +515,7 @@ onBeforeUnmount(() => {
<PhArrowLeft :size="12" weight="bold" />
{{ t('app.mainTimeline') }}
</button>
<span class="scope-current" :title="store.activeClip?.name ?? ''">
<span class="scope-current" v-tooltip="store.activeClip?.name ?? ''">
<PhFilmStrip :size="12" />
{{ store.activeClip?.name ?? '' }}
</span>
@@ -514,7 +525,7 @@ onBeforeUnmount(() => {
<PhArrowLeft :size="12" weight="bold" />
{{ t('app.mainTimeline') }}
</button>
<span class="scope-current" :title="store.activeButtonAsset?.name ?? ''">
<span class="scope-current" v-tooltip="store.activeButtonAsset?.name ?? ''">
<PhCursorClick :size="12" />
{{ store.activeButtonAsset?.name ?? '' }}
</span>
@@ -526,7 +537,7 @@ onBeforeUnmount(() => {
<div
class="tl-resize"
:class="{ resizing }"
:title="t('app.dragResize')"
v-tooltip="t('app.dragResize')"
@pointerdown="onTimelineResizeStart"
@pointermove="onTimelineResizeMove"
@pointerup="onTimelineResizeEnd"
@@ -552,6 +563,7 @@ onBeforeUnmount(() => {
</Toast>
<Toast :visible="!!savedToast" variant="info" :message="savedToast ?? ''" />
</div>
<TooltipHost />
</div>
</template>
+9
View File
@@ -46,6 +46,8 @@ const shortcuts: Shortcut[] = [
</li>
</ul>
</section>
<p class="credit">{{ t('about.credit') }}</p>
</div>
</Modal>
</template>
@@ -128,4 +130,11 @@ kbd {
color: var(--text-dim-strong);
}
.credit {
margin: 6px 0 0;
font-size: 11px;
text-align: center;
color: var(--text-dim);
}
</style>
+3 -3
View File
@@ -106,13 +106,13 @@ function onTypeChange(index: number, newType: Action['type']) {
{{ actionTypeLabel(type) }}
</option>
</select>
<button class="icon-btn" :title="t('actions.moveUp')" :disabled="i === 0" @click="moveUp(i)">
<button class="icon-btn" v-tooltip="t('actions.moveUp')" :disabled="i === 0" @click="moveUp(i)">
<PhArrowUp :size="13" />
</button>
<button class="icon-btn" :title="t('actions.moveDown')" :disabled="i === actions.length - 1" @click="moveDown(i)">
<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" :title="t('actions.remove')" @click="removeAt(i)">
<button class="icon-btn danger" v-tooltip="t('actions.remove')" @click="removeAt(i)">
<PhTrash :size="13" />
</button>
</div>
+21 -29
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import {
PhCircle,
PhCursorClick,
@@ -17,6 +17,7 @@ import {
PhUploadSimple,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { importImageFile } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
@@ -28,16 +29,7 @@ const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const STORAGE_KEY = 'animatr.assetThumbs'
const storedThumbs = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const showThumbs = ref(storedThumbs !== 'false')
watch(showThumbs, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
const { enabled: showThumbs } = useAssetThumbsPref()
function addSampleRect() {
const asset = store.addAsset({
@@ -311,7 +303,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div
class="panel"
:class="{ 'hit-disabled': store.activeButtonEditMode === 'hit' }"
:title="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
v-tooltip="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
>
<h3>Assets</h3>
@@ -321,27 +313,27 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
</button>
<div class="actions">
<button class="shape-btn" :title="t('sidebar.shape.rect')" @click="addSampleRect">
<button class="shape-btn" v-tooltip="t('sidebar.shape.rect')" @click="addSampleRect">
<PhSquare :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.circle')" @click="addSampleCircle">
<button class="shape-btn" v-tooltip="t('sidebar.shape.circle')" @click="addSampleCircle">
<PhCircle :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.text')" @click="addSampleText">
<button class="shape-btn" v-tooltip="t('sidebar.shape.text')" @click="addSampleText">
<PhTextT :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<button class="shape-btn" v-tooltip="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<PhPolygon :size="16" />
</button>
<button
class="shape-btn"
:class="{ active: store.drawingMode === 'freeform' }"
:title="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
v-tooltip="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
@click="startFreeformDrawing"
>
<PhPencilSimpleLine :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.button')" @click="addButton">
<button class="shape-btn" v-tooltip="t('sidebar.shape.button')" @click="addButton">
<PhCursorClick :size="16" />
</button>
</div>
@@ -360,7 +352,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<h4>{{ t('sidebar.library') }}</h4>
<button
class="thumb-toggle"
:title="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
@@ -383,12 +375,12 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<component :is="assetIconMap[asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" :title="asset.name">{{ asset.name }}</span>
<span class="name" v-tooltip="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
</div>
<button
class="add-layer"
:title="t('sidebar.addLayerTooltip')"
v-tooltip="t('sidebar.addLayerTooltip')"
@click="store.addLayer(asset.id)"
>
<PhPlus :size="12" weight="bold" />
@@ -399,7 +391,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.clips') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createClipTooltip')" @click="addClip">
<button class="thumb-toggle" v-tooltip="t('sidebar.createClipTooltip')" @click="addClip">
<PhPlus :size="12" weight="bold" />
</button>
</div>
@@ -417,7 +409,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<PhFilmStrip :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
</span>
@@ -425,12 +417,12 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" :title="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<button class="clip-btn danger" v-tooltip="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<PhTrash :size="11" />
</button>
</div>
@@ -440,7 +432,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.buttons') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createButtonTooltip')" @click="addButton">
<button class="thumb-toggle" v-tooltip="t('sidebar.createButtonTooltip')" @click="addButton">
<PhPlus :size="12" weight="bold" />
</button>
</div>
@@ -468,7 +460,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<PhCursorClick :size="14" class="asset-icon" />
</template>
<div class="clip-meta">
<span class="clip-name" :title="ba.name">{{ ba.name }}</span>
<span class="clip-name" v-tooltip="ba.name">{{ ba.name }}</span>
<span v-if="showThumbs" class="clip-sub">
{{ ba.canvas.width }} × {{ ba.canvas.height }}
</span>
@@ -476,14 +468,14 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameButtonAssetPrompt(ba.id, ba.name)"
>
<PhPencilSimple :size="11" />
</button>
<button
class="clip-btn danger"
:title="t('sidebar.deleteTooltip')"
v-tooltip="t('sidebar.deleteTooltip')"
@click.stop="deleteButtonAsset(ba.id)"
>
<PhTrash :size="11" />
+40 -14
View File
@@ -747,6 +747,7 @@ const stackItems = computed<StackItem[]>(() => {
const layers = ba.layers.slice().reverse()
for (const layer of layers) {
if (!layer.visible) continue
if (layer.editorHidden) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
const props = applyLivePose(sampleLayer(layer, store.playhead), layer.id)
@@ -760,6 +761,7 @@ const stackItems = computed<StackItem[]>(() => {
const out: StackItem[] = []
const layers = store.activeLayers.slice().reverse()
for (const layer of layers) {
if (layer.editorHidden) continue
if (!isLayerActiveAt(layer, store.playhead)) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
@@ -794,6 +796,10 @@ const stackItems = computed<StackItem[]>(() => {
const id = order[i]
const layer = layerById.get(id)
if (layer) {
// Editor-only hide: skip on the Konva stage while the scene
// file still carries the layer, so export/fullscreen-preview
// render it normally.
if (layer.editorHidden) continue
if (!isLayerActiveAt(layer, store.playhead)) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
@@ -2612,13 +2618,33 @@ function isInsideCanvasBounds(e: PointerEvent | MouseEvent): boolean {
)
}
function pointerHitsKonvaShape(e: PointerEvent | MouseEvent): boolean {
// Transformer handles, vertex dots, and stage shapes can live
// outside the canvas rectangle (selection box for a shape near the
// edge, a polygon vertex dragged past the border, …). Asking Konva
// for an intersection at the pointer lets us tell "user grabbed a
// handle" from "user clicked the empty background" even when both
// happen outside the canvas bounds.
const stage = stageRef.value?.getStage()
if (!stage) return false
const rect = stage.container().getBoundingClientRect()
const hit = stage.getIntersection({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
})
return !!hit
}
function isPanTrigger(e: PointerEvent | MouseEvent) {
if (e.button === 1) return true
if (e.button === 0 && e.ctrlKey) return true
// Plain left-click on the empty content view (outside the canvas
// rectangle) pans the viewport too — saves a ctrl-modifier for the
// common case of re-centering after zoom.
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && !isInsideCanvasBounds(e)) {
// common case of re-centering after zoom. Skip panning when the
// click is actually on a Konva shape: a transformer handle, a
// polygon vertex, or a selection box that extends past the canvas
// edge still belongs to its owning shape and must stay draggable.
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && !isInsideCanvasBounds(e) && !pointerHitsKonvaShape(e)) {
return true
}
return false
@@ -3121,22 +3147,22 @@ onBeforeUnmount(() => {
class="hit-tools"
@pointerdown.stop
>
<button class="hit-btn" :title="t('canvas.hit.addRect')" @click="addHitRect">
<button class="hit-btn" v-tooltip="t('canvas.hit.addRect')" @click="addHitRect">
<PhSquare :size="13" />
<span>rect</span>
</button>
<button class="hit-btn" :title="t('canvas.hit.addCircle')" @click="addHitCircle">
<button class="hit-btn" v-tooltip="t('canvas.hit.addCircle')" @click="addHitCircle">
<PhCircle :size="13" />
<span>circle</span>
</button>
<button class="hit-btn" :title="t('canvas.hit.addPolygon')" @click="addHitPolygon">
<button class="hit-btn" v-tooltip="t('canvas.hit.addPolygon')" @click="addHitPolygon">
<PhPolygon :size="13" />
<span>polygon</span>
</button>
<button
class="hit-btn danger"
:disabled="selectedHitShapeIndex === null"
:title="t('canvas.hit.remove')"
v-tooltip="t('canvas.hit.remove')"
@click="removeSelectedHitShape"
>
<PhTrash :size="13" />
@@ -3144,21 +3170,21 @@ onBeforeUnmount(() => {
</div>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" :title="t('canvas.fitToView')" @click="fitToContainer">
<button class="zc-btn" v-tooltip="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" v-tooltip="t('canvas.fitToView')" @click="fitToContainer">
{{ zoomPercent }}%
</button>
<button class="zc-btn" :title="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
<button class="zc-btn" v-tooltip="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
<div class="zc-sep"></div>
<button class="zc-btn wide" :title="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
<button class="zc-btn" :title="t('canvas.fitToView')" @click="fitToContainer">
<button class="zc-btn wide" v-tooltip="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
<button class="zc-btn" v-tooltip="t('canvas.fitToView')" @click="fitToContainer">
<PhCornersIn :size="13" />
</button>
<div class="zc-sep"></div>
<button
class="zc-btn crop-btn"
:class="{ active: cropObjects }"
:title="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
v-tooltip="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
@click="cropObjects = !cropObjects"
>
<PhCrop :size="13" />
@@ -3166,7 +3192,7 @@ onBeforeUnmount(() => {
<button
class="zc-btn"
:class="{ active: maskPreview }"
:title="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
v-tooltip="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
@click="maskPreview = !maskPreview"
>
<PhMaskHappy v-if="!maskPreview" :size="13" />
@@ -3175,7 +3201,7 @@ onBeforeUnmount(() => {
<button
class="zc-btn stage-preview-btn"
:class="{ active: stagePreview }"
:title="stagePreview ? t('canvas.stagePreviewOff') : t('canvas.stagePreviewOn')"
v-tooltip="stagePreview ? t('canvas.stagePreviewOff') : t('canvas.stagePreviewOn')"
@click="stagePreview = !stagePreview"
>
<PhCursorClick :size="13" />
+3 -3
View File
@@ -44,7 +44,7 @@ function onItemDragStart(e: DragEvent, index: number) {
<aside class="clipart-drawer" :class="{ open: clipart.open }">
<button
class="drawer-tab"
:title="clipart.open ? t('clipart.close') : t('clipart.open')"
v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')"
@click="clipart.toggle()"
>
<component :is="clipart.open ? PhCaretLeft : PhCaretRight" :size="14" />
@@ -56,7 +56,7 @@ function onItemDragStart(e: DragEvent, index: number) {
<button
v-if="clipart.handle"
class="refresh-btn"
:title="t('clipart.refresh')"
v-tooltip="t('clipart.refresh')"
:disabled="clipart.loading"
@click="clipart.refresh()"
>
@@ -83,7 +83,7 @@ function onItemDragStart(e: DragEvent, index: number) {
v-for="(item, i) in clipart.items"
:key="item.name + i"
class="tile"
:title="item.name"
v-tooltip="item.name"
draggable="true"
@dragstart="onItemDragStart($event, i)"
>
+141 -10
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhClock,
PhFilmReel,
PhFolder,
PhFrameCorners,
PhMoon,
PhPalette,
PhSlidersHorizontal,
PhSun,
PhX,
} from '@phosphor-icons/vue'
@@ -14,6 +14,9 @@ import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { showLocked } from '@/composables/useShowLocked'
import { Modal, confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
@@ -23,6 +26,8 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const clipart = useClipartStore()
const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref()
// Re-check the stored clipart folder whenever settings opens — covers the
// case where initial App.vue init raced ahead of IDB or the user opens
@@ -34,12 +39,12 @@ watch(
},
)
type TabId = 'document' | 'timeline' | 'clipart' | 'theme'
type TabId = 'document' | 'clipart' | 'interface' | 'theme'
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners },
{ id: 'timeline', label: t('settings.tab.timeline'), icon: PhClock },
{ id: 'clipart', label: t('settings.tab.clipart'), icon: PhFolder },
{ id: 'interface', label: t('settings.tab.interface'), icon: PhSlidersHorizontal },
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
]
@@ -280,7 +285,7 @@ function applyTheme(t: Theme) {
<button
v-if="store.scene.backgroundVideo"
class="ghost-btn inline-clear"
:title="t('settings.bgVideoClear')"
v-tooltip="t('settings.bgVideoClear')"
@click="clearBgVideo"
>
<PhX :size="12" />
@@ -304,10 +309,8 @@ function applyTheme(t: Theme) {
/>
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
<p class="hint">{{ t('settings.bgVideoHint') }}</p>
</section>
<section v-else-if="activeTab === 'timeline'">
<h3>{{ t('settings.tab.timeline') }}</h3>
<h3 class="sub-heading">{{ t('settings.timing') }}</h3>
<div class="row">
<label>
{{ t('settings.duration') }}
@@ -318,9 +321,7 @@ function applyTheme(t: Theme) {
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<p class="hint">
{{ t('settings.timelineHint') }}
</p>
<p class="hint">{{ t('settings.timelineHint') }}</p>
</section>
@@ -359,6 +360,39 @@ function applyTheme(t: Theme) {
<p class="hint">{{ t('clipart.settingsHint') }}</p>
</section>
<section v-else-if="activeTab === 'interface'">
<h3>{{ t('settings.tab.interface') }}</h3>
<label class="row-toggle">
<span class="switch" :class="{ on: tooltipsEnabled }">
<input type="checkbox" v-model="tooltipsEnabled" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: showLocked }">
<input type="checkbox" v-model="showLocked" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: thumbsEnabled }">
<input type="checkbox" v-model="thumbsEnabled" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
</div>
</label>
</section>
<section v-else-if="activeTab === 'theme'">
<h3>{{ t('settings.tab.theme') }}</h3>
<div class="theme-grid">
@@ -461,6 +495,13 @@ section h3 {
font-weight: 600;
}
/* A second h3 inside the same section (e.g. "Timing" under the
Document tab) needs breathing room so it reads as its own subgroup
rather than colliding with the preceding hint paragraph. */
section h3.sub-heading {
margin-top: 12px;
}
.row {
display: flex;
gap: 10px;
@@ -564,6 +605,96 @@ input[type='number'] {
margin: 0;
}
/* Switch + two-line label. Defensive resets defeat the scoped
blanket `label { flex-direction: column; text-transform: uppercase;
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
depend on — without the reset that rule would stack the switch on
top of the text and uppercase everything inside. */
.row-toggle {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
font-size: 13px;
text-transform: none;
letter-spacing: normal;
color: var(--text);
flex: 0 0 auto;
}
.row-toggle-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
text-transform: none;
}
.row-toggle-label {
font-size: 13px;
font-weight: 500;
color: var(--text);
letter-spacing: normal;
text-transform: none;
}
.row-toggle-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.4;
letter-spacing: normal;
text-transform: none;
}
.row-toggle .switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.row-toggle .switch.on {
background: var(--accent);
}
.row-toggle .switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.row-toggle .switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.row-toggle .switch.on::after {
transform: translateX(14px);
}
.clipart-row {
display: flex;
flex-direction: column;
+135 -1
View File
@@ -11,6 +11,7 @@ import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
@@ -29,8 +30,40 @@ const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
// Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow
// aid and shouldn't silently drop content on export unless the user
// explicitly asks for it. Applies to every format below.
const skipHidden = ref(false)
const hasHiddenLayers = computed(() => {
if (store.scene.layers.some((l) => l.editorHidden)) return true
for (const clip of store.scene.clips ?? []) {
if (clip.layers.some((l) => l.editorHidden)) return true
}
for (const asset of store.scene.assets) {
if (asset.kind === 'button' && asset.layers.some((l) => l.editorHidden)) return true
}
return false
})
function sceneClone() {
return JSON.parse(JSON.stringify(store.scene))
const scene = JSON.parse(JSON.stringify(store.scene))
if (!skipHidden.value) return scene
scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden)
if (Array.isArray(scene.clips)) {
for (const clip of scene.clips) {
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
}
}
if (Array.isArray(scene.assets)) {
for (const asset of scene.assets as ButtonAsset[]) {
if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) {
asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden)
}
}
}
return scene
}
function exportScene() {
@@ -131,6 +164,24 @@ function onUpdate(v: boolean) {
@update:model-value="onUpdate"
>
<div v-if="!progress" class="options">
<label
class="export-toggle"
:class="{ disabled: !hasHiddenLayers }"
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
>
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }">
<input
type="checkbox"
v-model="skipHidden"
:disabled="!hasHiddenLayers"
/>
</span>
<div class="export-toggle-text">
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
</div>
</label>
<button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text">
@@ -223,6 +274,89 @@ function onUpdate(v: boolean) {
padding: 12px;
}
.export-toggle {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.export-toggle.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.export-toggle-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.export-toggle-label {
font-size: 12px;
font-weight: 500;
color: var(--text);
}
.export-toggle-desc {
font-size: 11px;
color: var(--text-dim);
line-height: 1.35;
}
/* iOS-style switch. The <input> is a transparent overlay that still
handles keyboard focus and click targeting; the visual is drawn by
the span + ::after pill. */
.switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.switch.on {
background: var(--accent);
}
.switch input {
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: inherit;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.on::after {
transform: translateX(14px);
}
.opt {
display: flex;
align-items: center;
+3 -3
View File
@@ -231,16 +231,16 @@ const timeLabel = computed(() => {
<div class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
</div>
<div class="fs-toolbar">
<button class="fs-btn" :title="playing ? t('fullscreen.pause') : t('fullscreen.play')" @click="toggle">
<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" :title="t('fullscreen.jumpToStart')" @click="rewind">
<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" :title="t('fullscreen.close')" @click="close">
<button class="fs-btn close" v-tooltip="t('fullscreen.close')" @click="close">
<PhX :size="15" weight="bold" />
</button>
</div>
+7 -7
View File
@@ -384,7 +384,7 @@ function removeKeyframeHere() {
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" :title="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
</button>
</div>
@@ -430,7 +430,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle"
:class="{ active: polygonClosed }"
:title="t('properties.polygonClosed')"
v-tooltip="t('properties.polygonClosed')"
@click="polygonClosed = !polygonClosed"
>
<PhPolygon :size="14" :weight="polygonClosed ? 'fill' : 'regular'" />
@@ -465,7 +465,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle alpha-toggle-btn"
:class="{ active: fillTransparent }"
:title="t('properties.transparent')"
v-tooltip="t('properties.transparent')"
@click="fillTransparent = !fillTransparent"
>
<component :is="fillTransparent ? PhEyeSlash : PhEye" :size="13" />
@@ -487,7 +487,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle alpha-toggle-btn"
:class="{ active: strokeTransparent }"
:title="t('properties.transparent')"
v-tooltip="t('properties.transparent')"
@click="strokeTransparent = !strokeTransparent"
>
<component :is="strokeTransparent ? PhEyeSlash : PhEye" :size="13" />
@@ -588,7 +588,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle"
:class="{ active: !!selected.isMask }"
:title="t('properties.maskUse')"
v-tooltip="t('properties.maskUse')"
@click="store.setLayerMask(selected.id, !selected.isMask)"
>
<PhMaskHappy :size="14" />
@@ -601,7 +601,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle"
:class="{ active: (selected.maskMode ?? 'negative') === 'negative', danger: (selected.maskMode ?? 'negative') === 'negative' }"
:title="t('properties.maskNegative')"
v-tooltip="t('properties.maskNegative')"
@click="store.setLayerMaskMode(selected.id, 'negative')"
>
<PhMinus :size="13" weight="bold" />
@@ -610,7 +610,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle"
:class="{ active: selected.maskMode === 'positive' }"
:title="t('properties.maskPositive')"
v-tooltip="t('properties.maskPositive')"
@click="store.setLayerMaskMode(selected.id, 'positive')"
>
<PhPlus :size="13" weight="bold" />
+146 -21
View File
@@ -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;
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { activeTooltip } from '@/directives/tooltip'
const VIEWPORT_MARGIN = 8
const GAP = 8
const hostEl = ref<HTMLDivElement | null>(null)
const position = ref<{ top: number; left: number; placement: 'top' | 'bottom' } | null>(null)
const content = computed(() => activeTooltip.value?.content ?? null)
const target = computed(() => activeTooltip.value?.target ?? null)
async function reposition() {
const t = target.value
if (!t) {
position.value = null
return
}
const tooltip = hostEl.value
if (!tooltip) return
const rect = t.getBoundingClientRect()
const tw = tooltip.offsetWidth
const th = tooltip.offsetHeight
const vpH = window.innerHeight
const vpW = window.innerWidth
// Prefer below; flip above if not enough room.
const spaceBelow = vpH - rect.bottom - VIEWPORT_MARGIN
const spaceAbove = rect.top - VIEWPORT_MARGIN
const useAbove = spaceBelow < th + GAP && spaceAbove >= th + GAP
const placement: 'top' | 'bottom' = useAbove ? 'top' : 'bottom'
const top = useAbove ? rect.top - th - GAP : rect.bottom + GAP
// Horizontal centering, clamped to viewport.
const desiredLeft = rect.left + rect.width / 2 - tw / 2
const left = Math.max(
VIEWPORT_MARGIN,
Math.min(vpW - tw - VIEWPORT_MARGIN, desiredLeft),
)
position.value = { top, left, placement }
}
watch(activeTooltip, async (v) => {
if (!v) {
position.value = null
return
}
// When we jump from one tooltip to another we keep the previous
// position while the new div mounts — that's one frame of visual
// jitter, but it avoids a briefly-invisible flash between tooltips.
await nextTick()
await reposition()
})
</script>
<template>
<Teleport to="body">
<div
v-if="content"
ref="hostEl"
class="tt"
:class="[`tt-${position?.placement ?? 'bottom'}`, { 'tt-ready': !!position }]"
:style="{
top: (position?.top ?? -9999) + 'px',
left: (position?.left ?? -9999) + 'px',
}"
role="tooltip"
>
<div class="tt-label">{{ content.label }}</div>
<div v-if="content.help" class="tt-help">{{ content.help }}</div>
</div>
</Teleport>
</template>
<style scoped>
/* The tooltip mounts at an off-screen position (top/left = -9999) and
stays `opacity: 0` until `.tt-ready` is applied — that's the frame
after we measure the element and know where it should actually go.
CSS handles the fade-in instead of a <Transition> wrapper so we can
defer the opacity change until after positioning without fighting
the enter animation. */
.tt {
position: fixed;
z-index: 1000;
max-width: 280px;
padding: 7px 10px;
background: #1f1f24;
color: #f5f5f7;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.35),
0 8px 24px rgba(0, 0, 0, 0.4);
pointer-events: none;
font-size: 12px;
line-height: 1.35;
user-select: none;
-webkit-user-select: none;
opacity: 0;
transform: translateY(2px);
transition: opacity 120ms ease, transform 120ms ease;
}
.tt.tt-ready {
opacity: 1;
transform: translateY(0);
}
.tt.tt-top {
transform: translateY(-2px);
}
.tt.tt-top.tt-ready {
transform: translateY(0);
}
.tt-label {
font-weight: 500;
color: #f5f5f7;
}
/* Help line reads as an explanation under the label — dimmer, smaller,
so the two lines register as "name + description" instead of
"two equal things". */
.tt-help {
margin-top: 3px;
font-size: 11px;
color: #b8b8be;
}
</style>
+1 -1
View File
@@ -161,7 +161,7 @@ watch(
<button
class="del"
tabindex="-1"
:title="t('welcome.deleteTooltip')"
v-tooltip="t('welcome.deleteTooltip')"
@click="removeProject($event, p)"
>
<PhTrash :size="13" />
+12 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, type Component } from 'vue'
import type { TooltipContent } from '@/directives/tooltip'
type Variant = 'default' | 'ghost' | 'primary' | 'danger'
type Size = 'xs' | 'sm' | 'md'
@@ -14,7 +15,10 @@ const props = withDefaults(
active?: boolean
disabled?: boolean
rounded?: boolean
/** Plain tooltip label. Callers pass a simple string. */
title?: string
/** Rich tooltip content — takes precedence over `title` when given. */
tooltip?: string | TooltipContent
}>(),
{
iconWeight: 'regular',
@@ -27,6 +31,12 @@ const props = withDefaults(
)
const iconPx: Record<Size, number> = { xs: 12, sm: 14, md: 15 }
const tooltipBinding = computed<string | TooltipContent | undefined>(() => {
if (props.tooltip !== undefined) return props.tooltip
if (props.title) return props.title
return undefined
})
</script>
<template>
@@ -35,7 +45,7 @@ const iconPx: Record<Size, number> = { xs: 12, sm: 14, md: 15 }
class="icon-btn"
:class="[`size-${size}`, `variant-${variant}`, { active, rounded }]"
:disabled="disabled"
:title="title"
v-tooltip="tooltipBinding"
>
<slot>
<component
+1 -1
View File
@@ -108,7 +108,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
v-if="dismissable"
class="close"
:disabled="!dismissable"
:title="t('dialog.close')"
v-tooltip="t('dialog.close')"
@click="close"
>
<PhX :size="14" weight="bold" />
+28
View File
@@ -0,0 +1,28 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'animatr.assetThumbs'
function read(): boolean {
try {
const v = localStorage.getItem(STORAGE_KEY)
return v !== 'false'
} catch {
return true
}
}
// Module-level shared ref so AssetSidebar and the settings dialog
// both write to the same preference.
export const assetThumbsEnabled = ref<boolean>(read())
watch(assetThumbsEnabled, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
export function useAssetThumbsPref() {
return { enabled: assetThumbsEnabled }
}
+25
View File
@@ -0,0 +1,25 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'ruwio:tooltipsEnabled'
function readInitial(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== '0'
} catch {
return true
}
}
const enabled = ref<boolean>(readInitial())
watch(enabled, (v) => {
try {
localStorage.setItem(STORAGE_KEY, v ? '1' : '0')
} catch {
/* storage disabled — no persistence, behavior stays in-memory */
}
})
export function useTooltipPref() {
return { enabled }
}
+10 -4
View File
@@ -141,7 +141,7 @@ async function run() {
startedAt.value = performance.now()
const snapshot = JSON.parse(JSON.stringify(store.$state))
try {
results.value = await runAll(registerAllTests, {
await runAll(registerAllTests, {
onProgress: (d, t) => {
done.value = d
total.value = t
@@ -150,6 +150,12 @@ async function run() {
currentSuite.value = suiteName
currentTest.value = testName
},
// Push each result as it arrives so `failedSoFar` updates live
// — that's what drives the progress bar turning red the moment
// any test fails, instead of only at the end of the run.
onResult: (r) => {
results.value.push(r)
},
interTestDelayMs: 80,
})
} finally {
@@ -230,16 +236,16 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
<button
class="tbtn"
:disabled="!results.length"
:title="clipboardStatus === 'copied' ? t('tests.copiedTooltip') : t('tests.copyTooltip')"
v-tooltip="clipboardStatus === 'copied' ? t('tests.copiedTooltip') : t('tests.copyTooltip')"
:class="{ copied: clipboardStatus === 'copied', failed: clipboardStatus === 'failed' }"
@click="copyReport"
>
<PhClipboardText :size="14" />
</button>
<button class="tbtn" :title="t('tests.runAgain')" @click="run">
<button class="tbtn" v-tooltip="t('tests.runAgain')" @click="run">
<PhArrowClockwise :size="14" />
</button>
<button class="tbtn" :title="t('tests.close')" @click="close">
<button class="tbtn" v-tooltip="t('tests.close')" @click="close">
<PhX :size="14" weight="bold" />
</button>
</div>
+12 -4
View File
@@ -131,6 +131,7 @@ function cleanStack(stack: string | undefined): string | undefined {
export interface RunOptions {
onProgress?: (done: number, total: number) => void
onTestStart?: (suite: string, name: string) => void
onResult?: (result: TestResult) => void
interTestDelayMs?: number
}
@@ -149,16 +150,17 @@ export async function runAll(
opts.onTestStart?.(s.name, t.name)
await new Promise((r) => requestAnimationFrame(() => r(null)))
const start = performance.now()
let pushed: TestResult
try {
if (s.beforeEach) await s.beforeEach()
await t.fn()
if (s.afterEach) await s.afterEach()
results.push({
pushed = {
suite: s.name,
name: t.name,
ok: true,
durationMs: performance.now() - start,
})
}
} catch (err) {
try {
if (s.afterEach) await s.afterEach()
@@ -166,15 +168,21 @@ export async function runAll(
/* ignore cleanup errors if test already failed */
}
const e = err as Error
results.push({
pushed = {
suite: s.name,
name: t.name,
ok: false,
error: e.message || String(err),
stack: cleanStack(e.stack),
durationMs: performance.now() - start,
})
}
}
results.push(pushed)
// Per-result callback fires before the progress tick so the UI
// can paint a failing-run indicator (e.g. flip the progress bar
// to red) the moment a test fails instead of only once runAll
// resolves.
opts.onResult?.(pushed)
done++
opts.onProgress?.(done, total)
if (delay > 0) await new Promise((r) => setTimeout(r, delay))
+118 -4
View File
@@ -36,6 +36,7 @@ import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue'
import { vTooltip } from '@/directives/tooltip'
import { expect, suite, test } from './testRunner'
interface MountedHandle {
@@ -88,6 +89,11 @@ function mountComponent(
return h(component, props, slots)
},
})
// Register the same global directives the real app registers in
// main.ts — without this, anything using `v-tooltip` (IconButton,
// the toolbar buttons, …) no-ops inside the test harness because
// each `mountComponent` spins up an isolated app instance.
app.directive('tooltip', vTooltip)
app.mount(host)
return {
host,
@@ -319,6 +325,68 @@ export function registerAllTests(): void {
expect(dup.time).toBeGreaterThan(1)
})
test('duplicateKeyframeAt copies props + easing + hold to an explicit time', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Src', width: 50, height: 50, fill: '#abcdef' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 123, y: 45 })
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
src.easing = 'easeInOut'
s.setKeyframeHold(l.id, src.id, true)
const newId = s.duplicateKeyframeAt(l.id, src.id, 3)
expect(newId).toBeTruthy()
const dup = l.keyframes.find((k) => k.id === newId)!
expect(dup.time).toBe(3)
expect(dup.props.x).toBe(123)
expect(dup.props.y).toBe(45)
expect(dup.easing).toBe('easeInOut')
expect(dup.hold).toBe(true)
// props must be a clone — mutating the duplicate must not touch the source.
dup.props.x = 999
expect(src.props.x).toBe(123)
})
test('duplicateKeyframeAt refuses to overwrite an existing keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 10 })
s.upsertKeyframe(l.id, 2, { x: 20 })
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
const countBefore = l.keyframes.length
const newId = s.duplicateKeyframeAt(l.id, src.id, 2)
expect(newId).toBe(null)
expect(l.keyframes.length).toBe(countBefore)
})
test('duplicateKeyframeAt returns null for unknown layer or keyframe ids', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 10 })
const src = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!
expect(s.duplicateKeyframeAt('nope-layer', src.id, 2)).toBe(null)
expect(s.duplicateKeyframeAt(l.id, 'nope-kf', 2)).toBe(null)
})
test('duplicateKeyframeAt targets the source keyframe\'s own track (position vs main)', () => {
// Separated layers have independent main / position / rotation
// arrays. The duplicate must land in the same array the source
// came from, not accidentally jump to another.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Sep', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setLayerSeparated(l.id, true)
s.upsertKeyframe(l.id, 1, { x: 50, y: 60 })
const posKf = l.positionKeyframes!.find((k) => Math.abs(k.time - 1) < 1e-6)!
s.duplicateKeyframeAt(l.id, posKf.id, 2)
const dup = l.positionKeyframes!.find((k) => Math.abs(k.time - 2) < 1e-6)
expect(dup).toBeTruthy()
// Nothing should have been added to the main array.
expect(l.keyframes.some((k) => Math.abs(k.time - 2) < 1e-6)).toBe(false)
})
test('setKeyframeHold toggles hold flag', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Hold', width: 50, height: 50, fill: '#ddaa44' })
@@ -1085,11 +1153,15 @@ export function registerAllTests(): void {
suite('ui — IconButton', () => {
test('renders a button element', async () => {
// IconButton now uses the custom v-tooltip directive instead of
// the native `title` attribute. The directive still sets an
// `aria-label` so screen readers get the tooltip text — that's
// the stable contract this test pins.
const handle = mountComponent(IconButton, { title: 'hi' })
await nextTick()
const btn = handle.host.querySelector('button')
expect(btn).toBeTruthy()
expect(btn!.getAttribute('title')).toBe('hi')
expect(btn!.getAttribute('aria-label')).toBe('hi')
handle.unmount()
})
@@ -1772,8 +1844,11 @@ export function registerAllTests(): void {
})
test('topbar has save & recents buttons', () => {
const saveBtn = document.querySelector('button[title^="Save project"]')
const recentsBtn = document.querySelector('button[title="Recent projects"]')
// Tooltips live in `aria-label` now (custom v-tooltip directive
// replaced the native `title` attribute on every interactive
// element).
const saveBtn = document.querySelector('button[aria-label^="Save project"]')
const recentsBtn = document.querySelector('button[aria-label="Recent projects"]')
expect(saveBtn).toBeTruthy()
expect(recentsBtn).toBeTruthy()
})
@@ -1798,7 +1873,7 @@ export function registerAllTests(): void {
})
test('timeline has add-group button', () => {
const btn = document.querySelector('button[title="Create a new track group"]')
const btn = document.querySelector('button[aria-label="Create a new track group"]')
expect(btn).toBeTruthy()
})
})
@@ -2710,6 +2785,45 @@ export function registerAllTests(): void {
})
})
suite('interface prefs — shared composables', () => {
// Regression: the tooltip + asset-thumbs prefs are shared module-
// level refs so the Settings dialog and the sidebar/timeline
// toolbars stay in lock-step. The same refs also have to persist
// to localStorage. The thumbs pref was originally an inline ref
// inside AssetSidebar.vue — the settings tab couldn't change it
// without this extraction.
test('useTooltipPref persists to localStorage and shares the ref', async () => {
const { useTooltipPref } = await import('@/composables/useTooltipPref')
const a = useTooltipPref()
const b = useTooltipPref()
expect(a.enabled).toBe(b.enabled) // same ref instance
const initial = a.enabled.value
a.enabled.value = !initial
await nextTick()
expect(localStorage.getItem('ruwio:tooltipsEnabled')).toBe(initial ? '0' : '1')
// Both consumers see the flip because the ref is shared.
expect(b.enabled.value).toBe(!initial)
// Restore so other tests don't inherit a toggled state.
a.enabled.value = initial
await nextTick()
})
test('useAssetThumbsPref persists to localStorage and shares the ref', async () => {
const { useAssetThumbsPref } = await import('@/composables/useAssetThumbsPref')
const a = useAssetThumbsPref()
const b = useAssetThumbsPref()
expect(a.enabled).toBe(b.enabled)
const initial = a.enabled.value
a.enabled.value = !initial
await nextTick()
expect(localStorage.getItem('animatr.assetThumbs')).toBe(String(!initial))
expect(b.enabled.value).toBe(!initial)
a.enabled.value = initial
await nextTick()
})
})
suite('clipart drawer — grid pre-layout', () => {
// Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on
// clipart.open and given `flex: 1; min-width: 0` — which meant the
+173
View File
@@ -0,0 +1,173 @@
import { ref, type Directive, type DirectiveBinding } from 'vue'
import { useTooltipPref } from '@/composables/useTooltipPref'
/**
* Custom tooltip system. `v-tooltip` replaces the native `title` attribute
* with a styled floating panel that's positioned over its target. Accepts
* either a plain string (label only) or an object with both a `label` and
* a longer `help` line for inline documentation.
*
* <button v-tooltip="'Save'">…</button>
* <button v-tooltip="{ label: 'Crop', help: 'Clips layers to canvas' }">…</button>
*
* TooltipHost.vue renders the currently-active tooltip from `activeTooltip`.
* When the user disables tooltips in Settings → Interface, the directive
* silently no-ops (show is never called) — but `aria-label` stays set so
* screen readers still announce the label.
*/
export interface TooltipContent {
label: string
help?: string
}
export interface ActiveTooltip {
target: HTMLElement
content: TooltipContent
}
export const activeTooltip = ref<ActiveTooltip | null>(null)
const SHOW_DELAY_MS = 350
let showTimer: ReturnType<typeof setTimeout> | null = null
let pendingTarget: HTMLElement | null = null
function cancelShow() {
if (showTimer) {
clearTimeout(showTimer)
showTimer = null
}
pendingTarget = null
}
function scheduleShow(target: HTMLElement, content: TooltipContent) {
cancelShow()
pendingTarget = target
showTimer = setTimeout(() => {
showTimer = null
pendingTarget = null
activeTooltip.value = { target, content }
}, SHOW_DELAY_MS)
}
function hideIfFor(target: HTMLElement) {
if (pendingTarget === target) cancelShow()
if (activeTooltip.value?.target === target) activeTooltip.value = null
}
function hideAll() {
cancelShow()
activeTooltip.value = null
}
function normalize(value: unknown): TooltipContent | null {
if (value == null) return null
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed ? { label: trimmed } : null
}
if (typeof value === 'object') {
const label = (value as TooltipContent).label
if (typeof label !== 'string' || !label.trim()) return null
const help = (value as TooltipContent).help
return {
label: label.trim(),
help: typeof help === 'string' && help.trim() ? help.trim() : undefined,
}
}
return null
}
interface AttachedState {
content: TooltipContent | null
onEnter: (e: Event) => void
onLeave: (e: Event) => void
onFocus: (e: Event) => void
onBlur: (e: Event) => void
onPointerDown: (e: Event) => void
}
const attached = new WeakMap<HTMLElement, AttachedState>()
const { enabled } = useTooltipPref()
function attach(el: HTMLElement, content: TooltipContent | null) {
const state: AttachedState = {
content,
onEnter: () => {
if (!enabled.value) return
if (!state.content) return
scheduleShow(el, state.content)
},
onLeave: () => hideIfFor(el),
onFocus: () => {
if (!enabled.value) return
if (!state.content) return
// Keyboard focus should show the tooltip immediately — the user
// already indicated intent by tabbing to this control.
activeTooltip.value = { target: el, content: state.content }
},
onBlur: () => hideIfFor(el),
// A pointerdown on the element (click, drag-start, etc.) is a
// clear "I'm using this, stop hovering" signal — dismiss.
onPointerDown: () => hideIfFor(el),
}
el.addEventListener('mouseenter', state.onEnter)
el.addEventListener('mouseleave', state.onLeave)
el.addEventListener('focusin', state.onFocus)
el.addEventListener('focusout', state.onBlur)
el.addEventListener('pointerdown', state.onPointerDown)
attached.set(el, state)
syncAria(el, content)
}
function syncAria(el: HTMLElement, content: TooltipContent | null) {
if (content) el.setAttribute('aria-label', content.label)
else el.removeAttribute('aria-label')
}
function update(el: HTMLElement, content: TooltipContent | null) {
const state = attached.get(el)
if (!state) return attach(el, content)
state.content = content
syncAria(el, content)
// If this element is currently showing, update live.
if (activeTooltip.value?.target === el) {
if (content) activeTooltip.value = { target: el, content }
else activeTooltip.value = null
}
}
function detach(el: HTMLElement) {
const state = attached.get(el)
if (!state) return
el.removeEventListener('mouseenter', state.onEnter)
el.removeEventListener('mouseleave', state.onLeave)
el.removeEventListener('focusin', state.onFocus)
el.removeEventListener('focusout', state.onBlur)
el.removeEventListener('pointerdown', state.onPointerDown)
attached.delete(el)
hideIfFor(el)
}
export const vTooltip: Directive<HTMLElement, unknown> = {
mounted(el, binding: DirectiveBinding<unknown>) {
attach(el, normalize(binding.value))
},
updated(el, binding: DirectiveBinding<unknown>) {
update(el, normalize(binding.value))
},
beforeUnmount(el) {
detach(el)
},
}
export function hideActiveTooltip() {
hideAll()
}
// Dismiss the active tooltip whenever the window loses focus or the page
// scrolls — both can leave a stale floating panel behind.
if (typeof window !== 'undefined') {
window.addEventListener('blur', hideAll)
window.addEventListener('scroll', hideAll, true)
}
+25 -2
View File
@@ -22,12 +22,18 @@ export const en = {
defaultName: 'Project {date}',
},
renameProject: 'Rename project',
renameProjectHelp:
'Click to rename. Saved projects update in place; new names apply at the next save.',
renameProjectDialog: {
title: 'Rename project',
message: 'Choose a new name for this project.',
confirm: 'Rename',
},
untitledProject: 'Untitled project',
saveProjectDirtyHelp:
'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.',
exportHelp:
'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.',
replaceDialog: {
title: 'Replace project?',
message:
@@ -235,6 +241,7 @@ export const en = {
renameMarkerRetry: 'Try again',
stillFrameTooltip: 'Still frame {time} — holds until the next keyframe',
keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options',
addKfFromPrev: 'Fill with nearest keyframe — copies the previous state\'s pose into this slot',
trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',
trimStart: 'Start -{value}s',
trimEnd: 'End -{value}s',
@@ -250,6 +257,8 @@ export const en = {
color: 'Color',
lock: 'Lock',
unlock: 'Unlock',
hideInEditor: 'Hide in editor (still exported)',
showInEditor: 'Show in editor',
deleteLayer: 'Delete layer',
deleteClipTrack: 'Delete clip track',
deleteScriptTrack: 'Delete script track',
@@ -290,10 +299,20 @@ export const en = {
title: 'Settings',
tab: {
document: 'Document',
timeline: 'Timeline',
clipart: 'Clipart',
interface: 'Interface',
theme: 'UI Theme',
},
timing: 'Timing',
tooltipsLabel: 'Show tooltips',
tooltipsDesc:
'Floating help text when you hover over a button or control. Turn off if you know the app well and find them distracting — screen readers still get the label.',
showLockedLabel: 'Show locked tracks',
showLockedDesc:
'When off, locked tracks disappear from the timeline and the stage. Use to focus on the layers you\'re still editing. Also togglable from the timeline toolbar.',
assetThumbsLabel: 'Asset thumbnails',
assetThumbsDesc:
'Render small previews in the Assets / Clips / Buttons panels. Turn off for a compact list when working with lots of items.',
bgVideoLabel: 'Video',
bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…',
@@ -346,6 +365,9 @@ export const en = {
back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…',
labelMp4: 'Rendering MP4 (server)…',
skipHidden: 'Skip hidden tracks',
skipHiddenDesc: 'Drop layers toggled off in the editor from every format below.',
skipHiddenNone: 'No editor-hidden tracks in the scene.',
},
tests: {
@@ -389,12 +411,13 @@ export const en = {
},
about: {
openTooltip: 'About Animatr',
openTooltip: 'About RUWIO',
title: 'About {name}',
tagline: 'A timeline-based animation editor that runs in your browser.',
versionLabel: 'Version',
buildLabel: 'Build',
close: 'Close',
credit: 'Made with love by Torsten Hoeltge',
shortcutsHeading: 'Shortcuts',
shortcut: {
undo: 'Ctrl+Z',
+6
View File
@@ -136,6 +136,12 @@ function drawLayers(
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i]
if (!layer.visible) continue
// Editor-only hide: respected by the mask-preview overlay and
// sidebar thumbnails so toggling a layer off really removes it
// from the in-editor render. The runtime in exportPlayer.ts does
// NOT read this flag, so fullscreen preview and exports keep
// rendering the layer.
if (layer.editorHidden) continue
if (!alwaysActive && !isLayerActiveAt(layer, time)) continue
const asset = assetMap.get(layer.assetId)
if (!asset) continue
+2
View File
@@ -4,8 +4,10 @@ import VueKonva from 'vue-konva'
import App from './App.vue'
import './composables/useTheme'
import './styles/global.css'
import { vTooltip } from './directives/tooltip'
const app = createApp(App)
app.use(createPinia())
app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount('#app')
+37
View File
@@ -641,6 +641,37 @@ export const useSceneStore = defineStore('scene', {
shift(layer.keyframes)
shift(layer.positionKeyframes)
},
/** Copy the referenced keyframe to a specific time. Carries easing,
* hold, and control-point handles along; refuses if the target
* time is already occupied in the same array. Used by the
* button-editor's + buttons to fill empty state slots with the
* previous (or next) keyframe's values. */
duplicateKeyframeAt(
layerId: string,
keyframeId: string,
targetTime: number,
): string | null {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const found = findKf(layer, keyframeId)
if (!found) return null
const { kf: source, arr } = found
if (arr.some((k) => Math.abs(k.time - targetTime) < KEYFRAME_EPSILON)) return null
const newKf: Keyframe = {
id: uid(),
time: targetTime,
props: { ...source.props },
easing: source.easing,
}
if (source.hold) newKf.hold = true
if (source.ctrlX !== undefined && source.ctrlY !== undefined) {
newKf.ctrlX = source.ctrlX
newKf.ctrlY = source.ctrlY
}
arr.push(newKf)
sortKfs(arr)
return newKf.id
},
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
@@ -1225,6 +1256,12 @@ export const useSceneStore = defineStore('scene', {
else delete group.locked
}
},
setLayerEditorHidden(layerId: string, hidden: boolean) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (hidden) layer.editorHidden = true
else delete layer.editorHidden
},
// -------- Markers --------------------------------------------------
+9
View File
@@ -166,6 +166,15 @@ export interface Layer {
*/
colorOverridden?: boolean
locked?: boolean
/**
* Editor-only hide. Skipped by the Konva stage and the in-editor
* mask-preview / thumbnail canvas paths, but NOT by the export
* runtime — a hidden layer still shows up in fullscreen preview,
* exports, and the rendered server output. Lets the user mute a
* track temporarily without touching `visible`, which IS honoured
* by every render path.
*/
editorHidden?: boolean
isMask?: boolean
maskMode?: MaskMode
/** Only meaningful when `assetId` points to a ButtonAsset: fires on click. */
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.8.2'
export const APP_VERSION = '0.8.3'
export const APP_NAME = 'RUWIO'
+18 -6
View File
@@ -136,14 +136,26 @@ die inline hilfe texte in den settings sind zu klein. das kann man nicht lesen
warum ist eigentlich still frame easing disabled? man muss doch zu einem still-frame hin easen können. verstehe den grund nicht
ich würde an dieser stelle mal ein paar regression-tests hinzufügen und dann committen
wenn man den leeren teil des content-views zieht (außerhalb der canvas constraints) muss canvas move auch ohne control key gehen
export dateien heißen noch animatr. wir haben in RUWIO umbenannt
die tooltips sind hässlich. bau ein schöneres custom tooltip system ein. nimm dir zeit und such alle tooltips raus die wir haben. wenn es sinnvoll ist eine funktion besser zu erklären kann ein tooltip auch eine inline hilfe sein. das muss man unter settings -> interface abstellen können
----
buttons müssen einen pointer cursor anzeigen
die track-lanes der nicht aktiven traks zeigen durchsichtige keyframes. das muss genauso aussehen wie bei den aktiven - nur etwas ausgefaded
bei den button timelines zeigen wir + icons an auf leeren frames. da wird dann der vorrige hin dupliziert
element auf gruppe ziehen muss auch in der track label spalte gehen
der bereich wo man den timeline-bereich größer ziehen kann ist nur noch 1px. da hatten wir 15px vorher. man trifft diesen anfasser jetzt nicht mehr