0.8.3: custom tooltips, editor-hide, pack script, settings reshuffle
Tooltips - New v-tooltip directive + TooltipHost floating panel replace the native title attribute across 95+ sites (topbar, timeline, sidebar, properties, stage overlays, fullscreen preview, export dialog, clipart drawer, modals). Styled dark card with a configurable label + optional help line for inline documentation; screen readers still get an aria-label when the panel is disabled. - Settings → Interface toggle (`ruwio:tooltipsEnabled`) hides the visual panel for users who know the app. IconButton now routes its title prop through v-tooltip so every existing call site is tooltip-aware without churn. Editor-hide + export filter - New `Layer.editorHidden` flag, eye toggle per layer row, skipped by the Konva stage and the mask-preview canvas path but honoured by exports and fullscreen preview (runtime untouched). - Export dialog gets a "Skip hidden tracks" switch that strips editor-hidden layers from every format (scene JS, runtime, example HTML, MP4, WebM) on opt-in. Disabled when the scene has no hidden layers. Button editor - `+` buttons appear on empty state slots in button-timeline rows and duplicate the nearest keyframe (prev by preference, next otherwise) onto that slot. New `duplicateKeyframeAt` store action carries props, easing, hold, and bezier handles across. Stage / interaction - Plain left-click on the empty content view pans the viewport, but only when the pointer isn't over a Konva shape — transformer handles, vertex dots, and selection boxes that extend past the canvas edge keep their drag. Settings reshuffle - Timeline tab merged into Document (Duration + FPS sit under a new "Timing" sub-heading). New Interface tab gathers three global prefs: Show tooltips, Show locked tracks, Asset thumbnails — driven by shared composables so the existing toolbar toggles stay in sync. Interface-tab CSS defensively resets the scoped label flex-column + uppercase rule that was making the toggle row look like an accident. Project identity + UX polish - Favicon wired up (moved to public/favicon.ico, linked from index.html); scripts/pack.sh packs the minimum Vue project (src, index.html, configs, public) into a tarball with the right version in the filename, and `npm run pack` drives it. - About dialog now credits Torsten Hoeltge. Test infra - Progress bar turns red mid-run on the first failure: runAll gets an onResult callback that TestOverlay uses to push each result into the reactive array incrementally. - mountComponent in the test harness registers v-tooltip on the isolated app instance so IconButton's aria-label contract actually fires under test. Regression tests - duplicateKeyframeAt: copy fidelity (props, easing, hold), collision refusal, unknown-id guards, separated-track targeting. - Shared composables: useTooltipPref and useAssetThumbsPref both return the same ref across calls and persist to localStorage. - Tooltip contract: IconButton ships `aria-label` (not title); live-DOM smoke tests match on aria-label selectors now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a2c8e5998a
commit
1d84c34c15
33 files changed
+1185
-128
No files matched your search
+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'
|
||||
Reference in new issue
Block a user