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
+1184
-127
No files matched your search
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dist-archive
|
||||
*.local
|
||||
.vite
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
Executable
+40
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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 --------------------------------------------------
|
||||
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.8.2'
|
||||
export const APP_VERSION = '0.8.3'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
@@ -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
|
||||
Reference in new issue
Block a user