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
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user