0.8.3: custom tooltips, editor-hide, pack script, settings reshuffle

Tooltips
- New v-tooltip directive + TooltipHost floating panel replace the
  native title attribute across 95+ sites (topbar, timeline, sidebar,
  properties, stage overlays, fullscreen preview, export dialog,
  clipart drawer, modals). Styled dark card with a configurable
  label + optional help line for inline documentation; screen
  readers still get an aria-label when the panel is disabled.
- Settings → Interface toggle (`ruwio:tooltipsEnabled`) hides the
  visual panel for users who know the app. IconButton now routes
  its title prop through v-tooltip so every existing call site is
  tooltip-aware without churn.

Editor-hide + export filter
- New `Layer.editorHidden` flag, eye toggle per layer row, skipped
  by the Konva stage and the mask-preview canvas path but honoured
  by exports and fullscreen preview (runtime untouched).
- Export dialog gets a "Skip hidden tracks" switch that strips
  editor-hidden layers from every format (scene JS, runtime,
  example HTML, MP4, WebM) on opt-in. Disabled when the scene has
  no hidden layers.

Button editor
- `+` buttons appear on empty state slots in button-timeline rows
  and duplicate the nearest keyframe (prev by preference, next
  otherwise) onto that slot. New `duplicateKeyframeAt` store
  action carries props, easing, hold, and bezier handles across.

Stage / interaction
- Plain left-click on the empty content view pans the viewport,
  but only when the pointer isn't over a Konva shape — transformer
  handles, vertex dots, and selection boxes that extend past the
  canvas edge keep their drag.

Settings reshuffle
- Timeline tab merged into Document (Duration + FPS sit under a
  new "Timing" sub-heading). New Interface tab gathers three
  global prefs: Show tooltips, Show locked tracks, Asset
  thumbnails — driven by shared composables so the existing
  toolbar toggles stay in sync. Interface-tab CSS defensively
  resets the scoped label flex-column + uppercase rule that was
  making the toggle row look like an accident.

Project identity + UX polish
- Favicon wired up (moved to public/favicon.ico, linked from
  index.html); scripts/pack.sh packs the minimum Vue project
  (src, index.html, configs, public) into a tarball with the
  right version in the filename, and `npm run pack` drives it.
- About dialog now credits Torsten Hoeltge.

Test infra
- Progress bar turns red mid-run on the first failure: runAll
  gets an onResult callback that TestOverlay uses to push each
  result into the reactive array incrementally.
- mountComponent in the test harness registers v-tooltip on the
  isolated app instance so IconButton's aria-label contract
  actually fires under test.

Regression tests
- duplicateKeyframeAt: copy fidelity (props, easing, hold),
  collision refusal, unknown-id guards, separated-track targeting.
- Shared composables: useTooltipPref and useAssetThumbsPref both
  return the same ref across calls and persist to localStorage.
- Tooltip contract: IconButton ships `aria-label` (not title);
  live-DOM smoke tests match on aria-label selectors now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 23:57:35 +02:00
1 parent a2c8e5998a
commit 1d84c34c15
33 files changed
+1185 -128

No files matched your search

+9
View File
@@ -46,6 +46,8 @@ const shortcuts: Shortcut[] = [
</li>
</ul>
</section>
<p class="credit">{{ t('about.credit') }}</p>
</div>
</Modal>
</template>
@@ -128,4 +130,11 @@ kbd {
color: var(--text-dim-strong);
}
.credit {
margin: 6px 0 0;
font-size: 11px;
text-align: center;
color: var(--text-dim);
}
</style>
+3 -3
View File
@@ -106,13 +106,13 @@ function onTypeChange(index: number, newType: Action['type']) {
{{ actionTypeLabel(type) }}
</option>
</select>
<button class="icon-btn" :title="t('actions.moveUp')" :disabled="i === 0" @click="moveUp(i)">
<button class="icon-btn" v-tooltip="t('actions.moveUp')" :disabled="i === 0" @click="moveUp(i)">
<PhArrowUp :size="13" />
</button>
<button class="icon-btn" :title="t('actions.moveDown')" :disabled="i === actions.length - 1" @click="moveDown(i)">
<button class="icon-btn" v-tooltip="t('actions.moveDown')" :disabled="i === actions.length - 1" @click="moveDown(i)">
<PhArrowDown :size="13" />
</button>
<button class="icon-btn danger" :title="t('actions.remove')" @click="removeAt(i)">
<button class="icon-btn danger" v-tooltip="t('actions.remove')" @click="removeAt(i)">
<PhTrash :size="13" />
</button>
</div>
+21 -29
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import {
PhCircle,
PhCursorClick,
@@ -17,6 +17,7 @@ import {
PhUploadSimple,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { importImageFile } from '@/lib/fileLoader'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
@@ -28,16 +29,7 @@ const store = useSceneStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const STORAGE_KEY = 'animatr.assetThumbs'
const storedThumbs = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const showThumbs = ref(storedThumbs !== 'false')
watch(showThumbs, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
const { enabled: showThumbs } = useAssetThumbsPref()
function addSampleRect() {
const asset = store.addAsset({
@@ -311,7 +303,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div
class="panel"
:class="{ 'hit-disabled': store.activeButtonEditMode === 'hit' }"
:title="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
v-tooltip="store.activeButtonEditMode === 'hit' ? t('sidebar.hitModeDisabled') : undefined"
>
<h3>Assets</h3>
@@ -321,27 +313,27 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
</button>
<div class="actions">
<button class="shape-btn" :title="t('sidebar.shape.rect')" @click="addSampleRect">
<button class="shape-btn" v-tooltip="t('sidebar.shape.rect')" @click="addSampleRect">
<PhSquare :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.circle')" @click="addSampleCircle">
<button class="shape-btn" v-tooltip="t('sidebar.shape.circle')" @click="addSampleCircle">
<PhCircle :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.text')" @click="addSampleText">
<button class="shape-btn" v-tooltip="t('sidebar.shape.text')" @click="addSampleText">
<PhTextT :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<button class="shape-btn" v-tooltip="t('sidebar.shape.polygon')" @click="addSamplePolygon">
<PhPolygon :size="16" />
</button>
<button
class="shape-btn"
:class="{ active: store.drawingMode === 'freeform' }"
:title="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
v-tooltip="store.drawingMode === 'freeform' ? t('sidebar.shape.freeformActive') : t('sidebar.shape.freeform')"
@click="startFreeformDrawing"
>
<PhPencilSimpleLine :size="16" />
</button>
<button class="shape-btn" :title="t('sidebar.shape.button')" @click="addButton">
<button class="shape-btn" v-tooltip="t('sidebar.shape.button')" @click="addButton">
<PhCursorClick :size="16" />
</button>
</div>
@@ -360,7 +352,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<h4>{{ t('sidebar.library') }}</h4>
<button
class="thumb-toggle"
:title="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
@click="showThumbs = !showThumbs"
>
<PhImageSquare v-if="showThumbs" :size="13" />
@@ -383,12 +375,12 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<component :is="assetIconMap[asset.kind]" :size="14" class="asset-icon" />
</template>
<div class="meta">
<span class="name" :title="asset.name">{{ asset.name }}</span>
<span class="name" v-tooltip="asset.name">{{ asset.name }}</span>
<span v-if="showThumbs" class="sub">{{ t('sidebar.imageLabel') }}</span>
</div>
<button
class="add-layer"
:title="t('sidebar.addLayerTooltip')"
v-tooltip="t('sidebar.addLayerTooltip')"
@click="store.addLayer(asset.id)"
>
<PhPlus :size="12" weight="bold" />
@@ -399,7 +391,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.clips') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createClipTooltip')" @click="addClip">
<button class="thumb-toggle" v-tooltip="t('sidebar.createClipTooltip')" @click="addClip">
<PhPlus :size="12" weight="bold" />
</button>
</div>
@@ -417,7 +409,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<PhFilmStrip :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
</span>
@@ -425,12 +417,12 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" :title="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<button class="clip-btn danger" v-tooltip="t('sidebar.deleteTooltip')" @click.stop="deleteClip(clip.id)">
<PhTrash :size="11" />
</button>
</div>
@@ -440,7 +432,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="lib-header clips-header">
<h4>{{ t('sidebar.buttons') }}</h4>
<button class="thumb-toggle" :title="t('sidebar.createButtonTooltip')" @click="addButton">
<button class="thumb-toggle" v-tooltip="t('sidebar.createButtonTooltip')" @click="addButton">
<PhPlus :size="12" weight="bold" />
</button>
</div>
@@ -468,7 +460,7 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<PhCursorClick :size="14" class="asset-icon" />
</template>
<div class="clip-meta">
<span class="clip-name" :title="ba.name">{{ ba.name }}</span>
<span class="clip-name" v-tooltip="ba.name">{{ ba.name }}</span>
<span v-if="showThumbs" class="clip-sub">
{{ ba.canvas.width }} × {{ ba.canvas.height }}
</span>
@@ -476,14 +468,14 @@ function onAssetDragStart(e: DragEvent, asset: Extract<Asset, { kind: 'image' }>
<div class="clip-actions">
<button
class="clip-btn"
:title="t('sidebar.renameTooltip')"
v-tooltip="t('sidebar.renameTooltip')"
@click.stop="renameButtonAssetPrompt(ba.id, ba.name)"
>
<PhPencilSimple :size="11" />
</button>
<button
class="clip-btn danger"
:title="t('sidebar.deleteTooltip')"
v-tooltip="t('sidebar.deleteTooltip')"
@click.stop="deleteButtonAsset(ba.id)"
>
<PhTrash :size="11" />
+40 -14
View File
@@ -747,6 +747,7 @@ const stackItems = computed<StackItem[]>(() => {
const layers = ba.layers.slice().reverse()
for (const layer of layers) {
if (!layer.visible) continue
if (layer.editorHidden) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
const props = applyLivePose(sampleLayer(layer, store.playhead), layer.id)
@@ -760,6 +761,7 @@ const stackItems = computed<StackItem[]>(() => {
const out: StackItem[] = []
const layers = store.activeLayers.slice().reverse()
for (const layer of layers) {
if (layer.editorHidden) continue
if (!isLayerActiveAt(layer, store.playhead)) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
@@ -794,6 +796,10 @@ const stackItems = computed<StackItem[]>(() => {
const id = order[i]
const layer = layerById.get(id)
if (layer) {
// Editor-only hide: skip on the Konva stage while the scene
// file still carries the layer, so export/fullscreen-preview
// render it normally.
if (layer.editorHidden) continue
if (!isLayerActiveAt(layer, store.playhead)) continue
const asset = assetMap.value.get(layer.assetId)
if (!asset) continue
@@ -2612,13 +2618,33 @@ function isInsideCanvasBounds(e: PointerEvent | MouseEvent): boolean {
)
}
function pointerHitsKonvaShape(e: PointerEvent | MouseEvent): boolean {
// Transformer handles, vertex dots, and stage shapes can live
// outside the canvas rectangle (selection box for a shape near the
// edge, a polygon vertex dragged past the border, …). Asking Konva
// for an intersection at the pointer lets us tell "user grabbed a
// handle" from "user clicked the empty background" even when both
// happen outside the canvas bounds.
const stage = stageRef.value?.getStage()
if (!stage) return false
const rect = stage.container().getBoundingClientRect()
const hit = stage.getIntersection({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
})
return !!hit
}
function isPanTrigger(e: PointerEvent | MouseEvent) {
if (e.button === 1) return true
if (e.button === 0 && e.ctrlKey) return true
// Plain left-click on the empty content view (outside the canvas
// rectangle) pans the viewport too — saves a ctrl-modifier for the
// common case of re-centering after zoom.
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && !isInsideCanvasBounds(e)) {
// common case of re-centering after zoom. Skip panning when the
// click is actually on a Konva shape: a transformer handle, a
// polygon vertex, or a selection box that extends past the canvas
// edge still belongs to its owning shape and must stay draggable.
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && !isInsideCanvasBounds(e) && !pointerHitsKonvaShape(e)) {
return true
}
return false
@@ -3121,22 +3147,22 @@ onBeforeUnmount(() => {
class="hit-tools"
@pointerdown.stop
>
<button class="hit-btn" :title="t('canvas.hit.addRect')" @click="addHitRect">
<button class="hit-btn" v-tooltip="t('canvas.hit.addRect')" @click="addHitRect">
<PhSquare :size="13" />
<span>rect</span>
</button>
<button class="hit-btn" :title="t('canvas.hit.addCircle')" @click="addHitCircle">
<button class="hit-btn" v-tooltip="t('canvas.hit.addCircle')" @click="addHitCircle">
<PhCircle :size="13" />
<span>circle</span>
</button>
<button class="hit-btn" :title="t('canvas.hit.addPolygon')" @click="addHitPolygon">
<button class="hit-btn" v-tooltip="t('canvas.hit.addPolygon')" @click="addHitPolygon">
<PhPolygon :size="13" />
<span>polygon</span>
</button>
<button
class="hit-btn danger"
:disabled="selectedHitShapeIndex === null"
:title="t('canvas.hit.remove')"
v-tooltip="t('canvas.hit.remove')"
@click="removeSelectedHitShape"
>
<PhTrash :size="13" />
@@ -3144,21 +3170,21 @@ onBeforeUnmount(() => {
</div>
<div class="zoom-controls" @pointerdown.stop>
<button class="zc-btn" :title="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" :title="t('canvas.fitToView')" @click="fitToContainer">
<button class="zc-btn" v-tooltip="t('canvas.zoomOut')" @click="zoomBy(1 / 1.25)">−</button>
<button class="zc-label" v-tooltip="t('canvas.fitToView')" @click="fitToContainer">
{{ zoomPercent }}%
</button>
<button class="zc-btn" :title="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
<button class="zc-btn" v-tooltip="t('canvas.zoomIn')" @click="zoomBy(1.25)">+</button>
<div class="zc-sep"></div>
<button class="zc-btn wide" :title="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
<button class="zc-btn" :title="t('canvas.fitToView')" @click="fitToContainer">
<button class="zc-btn wide" v-tooltip="t('canvas.zoomOneToOne')" @click="zoomToOne">1:1</button>
<button class="zc-btn" v-tooltip="t('canvas.fitToView')" @click="fitToContainer">
<PhCornersIn :size="13" />
</button>
<div class="zc-sep"></div>
<button
class="zc-btn crop-btn"
:class="{ active: cropObjects }"
:title="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
v-tooltip="cropObjects ? t('canvas.cropOff') : t('canvas.cropOn')"
@click="cropObjects = !cropObjects"
>
<PhCrop :size="13" />
@@ -3166,7 +3192,7 @@ onBeforeUnmount(() => {
<button
class="zc-btn"
:class="{ active: maskPreview }"
:title="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
v-tooltip="maskPreview ? t('canvas.maskPreviewOff') : t('canvas.maskPreviewOn')"
@click="maskPreview = !maskPreview"
>
<PhMaskHappy v-if="!maskPreview" :size="13" />
@@ -3175,7 +3201,7 @@ onBeforeUnmount(() => {
<button
class="zc-btn stage-preview-btn"
:class="{ active: stagePreview }"
:title="stagePreview ? t('canvas.stagePreviewOff') : t('canvas.stagePreviewOn')"
v-tooltip="stagePreview ? t('canvas.stagePreviewOff') : t('canvas.stagePreviewOn')"
@click="stagePreview = !stagePreview"
>
<PhCursorClick :size="13" />
+3 -3
View File
@@ -44,7 +44,7 @@ function onItemDragStart(e: DragEvent, index: number) {
<aside class="clipart-drawer" :class="{ open: clipart.open }">
<button
class="drawer-tab"
:title="clipart.open ? t('clipart.close') : t('clipart.open')"
v-tooltip="clipart.open ? t('clipart.close') : t('clipart.open')"
@click="clipart.toggle()"
>
<component :is="clipart.open ? PhCaretLeft : PhCaretRight" :size="14" />
@@ -56,7 +56,7 @@ function onItemDragStart(e: DragEvent, index: number) {
<button
v-if="clipart.handle"
class="refresh-btn"
:title="t('clipart.refresh')"
v-tooltip="t('clipart.refresh')"
:disabled="clipart.loading"
@click="clipart.refresh()"
>
@@ -83,7 +83,7 @@ function onItemDragStart(e: DragEvent, index: number) {
v-for="(item, i) in clipart.items"
:key="item.name + i"
class="tile"
:title="item.name"
v-tooltip="item.name"
draggable="true"
@dragstart="onItemDragStart($event, i)"
>
+141 -10
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhClock,
PhFilmReel,
PhFolder,
PhFrameCorners,
PhMoon,
PhPalette,
PhSlidersHorizontal,
PhSun,
PhX,
} from '@phosphor-icons/vue'
@@ -14,6 +14,9 @@ import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { showLocked } from '@/composables/useShowLocked'
import { Modal, confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
@@ -23,6 +26,8 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const clipart = useClipartStore()
const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref()
// Re-check the stored clipart folder whenever settings opens — covers the
// case where initial App.vue init raced ahead of IDB or the user opens
@@ -34,12 +39,12 @@ watch(
},
)
type TabId = 'document' | 'timeline' | 'clipart' | 'theme'
type TabId = 'document' | 'clipart' | 'interface' | 'theme'
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners },
{ id: 'timeline', label: t('settings.tab.timeline'), icon: PhClock },
{ id: 'clipart', label: t('settings.tab.clipart'), icon: PhFolder },
{ id: 'interface', label: t('settings.tab.interface'), icon: PhSlidersHorizontal },
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
]
@@ -280,7 +285,7 @@ function applyTheme(t: Theme) {
<button
v-if="store.scene.backgroundVideo"
class="ghost-btn inline-clear"
:title="t('settings.bgVideoClear')"
v-tooltip="t('settings.bgVideoClear')"
@click="clearBgVideo"
>
<PhX :size="12" />
@@ -304,10 +309,8 @@ function applyTheme(t: Theme) {
/>
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
<p class="hint">{{ t('settings.bgVideoHint') }}</p>
</section>
<section v-else-if="activeTab === 'timeline'">
<h3>{{ t('settings.tab.timeline') }}</h3>
<h3 class="sub-heading">{{ t('settings.timing') }}</h3>
<div class="row">
<label>
{{ t('settings.duration') }}
@@ -318,9 +321,7 @@ function applyTheme(t: Theme) {
<input type="number" min="1" step="1" v-model.number="fps" />
</label>
</div>
<p class="hint">
{{ t('settings.timelineHint') }}
</p>
<p class="hint">{{ t('settings.timelineHint') }}</p>
</section>
@@ -359,6 +360,39 @@ function applyTheme(t: Theme) {
<p class="hint">{{ t('clipart.settingsHint') }}</p>
</section>
<section v-else-if="activeTab === 'interface'">
<h3>{{ t('settings.tab.interface') }}</h3>
<label class="row-toggle">
<span class="switch" :class="{ on: tooltipsEnabled }">
<input type="checkbox" v-model="tooltipsEnabled" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: showLocked }">
<input type="checkbox" v-model="showLocked" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: thumbsEnabled }">
<input type="checkbox" v-model="thumbsEnabled" />
</span>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
</div>
</label>
</section>
<section v-else-if="activeTab === 'theme'">
<h3>{{ t('settings.tab.theme') }}</h3>
<div class="theme-grid">
@@ -461,6 +495,13 @@ section h3 {
font-weight: 600;
}
/* A second h3 inside the same section (e.g. "Timing" under the
Document tab) needs breathing room so it reads as its own subgroup
rather than colliding with the preceding hint paragraph. */
section h3.sub-heading {
margin-top: 12px;
}
.row {
display: flex;
gap: 10px;
@@ -564,6 +605,96 @@ input[type='number'] {
margin: 0;
}
/* Switch + two-line label. Defensive resets defeat the scoped
blanket `label { flex-direction: column; text-transform: uppercase;
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
depend on — without the reset that rule would stack the switch on
top of the text and uppercase everything inside. */
.row-toggle {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
font-size: 13px;
text-transform: none;
letter-spacing: normal;
color: var(--text);
flex: 0 0 auto;
}
.row-toggle-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
text-transform: none;
}
.row-toggle-label {
font-size: 13px;
font-weight: 500;
color: var(--text);
letter-spacing: normal;
text-transform: none;
}
.row-toggle-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.4;
letter-spacing: normal;
text-transform: none;
}
.row-toggle .switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.row-toggle .switch.on {
background: var(--accent);
}
.row-toggle .switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.row-toggle .switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.row-toggle .switch.on::after {
transform: translateX(14px);
}
.clipart-row {
display: flex;
flex-direction: column;
+135 -1
View File
@@ -11,6 +11,7 @@ import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadFromServer } from '@/lib/serverExport'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
@@ -29,8 +30,40 @@ const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
// Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow
// aid and shouldn't silently drop content on export unless the user
// explicitly asks for it. Applies to every format below.
const skipHidden = ref(false)
const hasHiddenLayers = computed(() => {
if (store.scene.layers.some((l) => l.editorHidden)) return true
for (const clip of store.scene.clips ?? []) {
if (clip.layers.some((l) => l.editorHidden)) return true
}
for (const asset of store.scene.assets) {
if (asset.kind === 'button' && asset.layers.some((l) => l.editorHidden)) return true
}
return false
})
function sceneClone() {
return JSON.parse(JSON.stringify(store.scene))
const scene = JSON.parse(JSON.stringify(store.scene))
if (!skipHidden.value) return scene
scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden)
if (Array.isArray(scene.clips)) {
for (const clip of scene.clips) {
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
}
}
if (Array.isArray(scene.assets)) {
for (const asset of scene.assets as ButtonAsset[]) {
if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) {
asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden)
}
}
}
return scene
}
function exportScene() {
@@ -131,6 +164,24 @@ function onUpdate(v: boolean) {
@update:model-value="onUpdate"
>
<div v-if="!progress" class="options">
<label
class="export-toggle"
:class="{ disabled: !hasHiddenLayers }"
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
>
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }">
<input
type="checkbox"
v-model="skipHidden"
:disabled="!hasHiddenLayers"
/>
</span>
<div class="export-toggle-text">
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
</div>
</label>
<button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text">
@@ -223,6 +274,89 @@ function onUpdate(v: boolean) {
padding: 12px;
}
.export-toggle {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.export-toggle.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.export-toggle-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.export-toggle-label {
font-size: 12px;
font-weight: 500;
color: var(--text);
}
.export-toggle-desc {
font-size: 11px;
color: var(--text-dim);
line-height: 1.35;
}
/* iOS-style switch. The <input> is a transparent overlay that still
handles keyboard focus and click targeting; the visual is drawn by
the span + ::after pill. */
.switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.switch.on {
background: var(--accent);
}
.switch input {
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: inherit;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.on::after {
transform: translateX(14px);
}
.opt {
display: flex;
align-items: center;
+3 -3
View File
@@ -231,16 +231,16 @@ const timeLabel = computed(() => {
<div class="fs-progress-handle" :style="{ left: progressPct + '%' }"></div>
</div>
<div class="fs-toolbar">
<button class="fs-btn" :title="playing ? t('fullscreen.pause') : t('fullscreen.play')" @click="toggle">
<button class="fs-btn" v-tooltip="playing ? t('fullscreen.pause') : t('fullscreen.play')" @click="toggle">
<PhPause v-if="playing" :size="15" weight="fill" />
<PhPlay v-else :size="15" weight="fill" />
</button>
<button class="fs-btn" :title="t('fullscreen.jumpToStart')" @click="rewind">
<button class="fs-btn" v-tooltip="t('fullscreen.jumpToStart')" @click="rewind">
<PhSkipBack :size="15" weight="fill" />
</button>
<span class="fs-time">{{ timeLabel }}</span>
<div class="fs-spacer"></div>
<button class="fs-btn close" :title="t('fullscreen.close')" @click="close">
<button class="fs-btn close" v-tooltip="t('fullscreen.close')" @click="close">
<PhX :size="15" weight="bold" />
</button>
</div>
+7 -7
View File
@@ -384,7 +384,7 @@ function removeKeyframeHere() {
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" :title="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
</button>
</div>
@@ -430,7 +430,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle"
:class="{ active: polygonClosed }"
:title="t('properties.polygonClosed')"
v-tooltip="t('properties.polygonClosed')"
@click="polygonClosed = !polygonClosed"
>
<PhPolygon :size="14" :weight="polygonClosed ? 'fill' : 'regular'" />
@@ -465,7 +465,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle alpha-toggle-btn"
:class="{ active: fillTransparent }"
:title="t('properties.transparent')"
v-tooltip="t('properties.transparent')"
@click="fillTransparent = !fillTransparent"
>
<component :is="fillTransparent ? PhEyeSlash : PhEye" :size="13" />
@@ -487,7 +487,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle alpha-toggle-btn"
:class="{ active: strokeTransparent }"
:title="t('properties.transparent')"
v-tooltip="t('properties.transparent')"
@click="strokeTransparent = !strokeTransparent"
>
<component :is="strokeTransparent ? PhEyeSlash : PhEye" :size="13" />
@@ -588,7 +588,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle"
:class="{ active: !!selected.isMask }"
:title="t('properties.maskUse')"
v-tooltip="t('properties.maskUse')"
@click="store.setLayerMask(selected.id, !selected.isMask)"
>
<PhMaskHappy :size="14" />
@@ -601,7 +601,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle"
:class="{ active: (selected.maskMode ?? 'negative') === 'negative', danger: (selected.maskMode ?? 'negative') === 'negative' }"
:title="t('properties.maskNegative')"
v-tooltip="t('properties.maskNegative')"
@click="store.setLayerMaskMode(selected.id, 'negative')"
>
<PhMinus :size="13" weight="bold" />
@@ -610,7 +610,7 @@ function removeKeyframeHere() {
type="button"
class="icon-toggle"
:class="{ active: selected.maskMode === 'positive' }"
:title="t('properties.maskPositive')"
v-tooltip="t('properties.maskPositive')"
@click="store.setLayerMaskMode(selected.id, 'positive')"
>
<PhPlus :size="13" weight="bold" />
+146 -21
View File
@@ -46,6 +46,7 @@ import type {
Layer,
TrackGroup,
} from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
interface TimelineLayerTrack {
type: 'layer'
@@ -1127,6 +1128,41 @@ function segmentStyle(seg: KfSegment) {
}
}
// Button-editor only: list the 4 state-slot times that this track has
// NO keyframe on. The template renders a "+" placeholder over each
// empty slot; clicking it duplicates the nearest keyframe (prev by
// preference, next if there's nothing earlier) onto that slot.
const BUTTON_SLOT_TIMES = [
BUTTON_STATE_TIMES.normal,
BUTTON_STATE_TIMES.hover,
BUTTON_STATE_TIMES.pressed,
BUTTON_STATE_TIMES.disabled,
] as const
function emptyButtonSlots(kfs: Keyframe[]): number[] {
if (!store.activeButtonAssetId) return []
if (kfs.length === 0) return []
const EPS = 1e-3
const has = (t: number) => kfs.some((k) => Math.abs(k.time - t) < EPS)
return BUTTON_SLOT_TIMES.filter((t) => !has(t))
}
function addKfFromPrev(layerId: string, kfs: Keyframe[], time: number) {
if (!kfs.length) return
let prev: Keyframe | null = null
let next: Keyframe | null = null
for (const k of kfs) {
if (k.time < time) {
if (!prev || k.time > prev.time) prev = k
} else if (k.time > time) {
if (!next || k.time < next.time) next = k
}
}
const source = prev ?? next
if (!source) return
store.duplicateKeyframeAt(layerId, source.id, time)
}
const EASING_OPTIONS: { value: Easing; label: string }[] = [
{ value: 'linear', label: t('timeline.easingLinear') },
{ value: 'easeIn', label: t('timeline.easingEaseIn') },
@@ -2145,13 +2181,13 @@ onBeforeUnmount(() => {
<button
class="mode-btn"
:class="{ active: store.activeButtonEditMode === 'states' }"
:title="t('timeline.buttonModeStates')"
v-tooltip="t('timeline.buttonModeStates')"
@click="store.setButtonEditMode('states')"
>{{ t('timeline.buttonModeStatesShort') }}</button>
<button
class="mode-btn hit"
:class="{ active: store.activeButtonEditMode === 'hit' }"
:title="t('timeline.buttonModeHit')"
v-tooltip="t('timeline.buttonModeHit')"
@click="store.setButtonEditMode('hit')"
>{{ t('timeline.buttonModeHitShort') }}</button>
</div>
@@ -2192,7 +2228,7 @@ onBeforeUnmount(() => {
</div>
<div v-else class="scrub-bar">
<div class="name-spacer">
<div class="mode-toggle" :title="t('timeline.toggleTimeUnit')">
<div class="mode-toggle" v-tooltip="t('timeline.toggleTimeUnit')">
<button
class="mode-btn"
:class="{ active: displayMode === 'seconds' }"
@@ -2207,7 +2243,7 @@ onBeforeUnmount(() => {
<button
class="snap-btn"
:class="{ active: snapEnabled }"
:title="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
v-tooltip="snapEnabled ? t('timeline.snapOn') : t('timeline.snapOff')"
@click="snapEnabled = !snapEnabled"
>
<PhMagnet :size="12" :weight="snapEnabled ? 'fill' : 'regular'" />
@@ -2215,7 +2251,7 @@ onBeforeUnmount(() => {
<button
class="snap-btn"
:class="{ active: !showLocked }"
:title="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
v-tooltip="showLocked ? t('timeline.hideLocked') : t('timeline.showLocked')"
@click="showLocked = !showLocked"
>
<component
@@ -2226,7 +2262,7 @@ onBeforeUnmount(() => {
<button
v-if="!inClipEdit"
class="snap-btn"
:title="t('timeline.addGroupTooltip')"
v-tooltip="t('timeline.addGroupTooltip')"
@click="addGroup"
>
<PhFolderPlus :size="12" />
@@ -2234,14 +2270,14 @@ onBeforeUnmount(() => {
<button
v-if="!inClipEdit"
class="snap-btn"
:title="t('timeline.addScriptTrackTooltip')"
v-tooltip="t('timeline.addScriptTrackTooltip')"
@click="store.addScriptTrack()"
>
<PhCode :size="12" />
</button>
<button
class="snap-btn add-kf-btn"
:title="t('timeline.addKeyframeTooltip')"
v-tooltip="t('timeline.addKeyframeTooltip')"
:disabled="!canCreateKf"
@click="addKeyframeForSelected"
>
@@ -2281,7 +2317,7 @@ onBeforeUnmount(() => {
></span>
<span
class="range-bracket bracket-in"
:title="t('timeline.inPoint')"
v-tooltip="t('timeline.inPoint')"
:style="{ left: pxFromTime(store.playbackIn) + 'px' }"
@pointerdown="onInBracketPointerDown"
@pointermove="onInBracketPointerMove"
@@ -2290,7 +2326,7 @@ onBeforeUnmount(() => {
></span>
<span
class="range-bracket bracket-out"
:title="t('timeline.outPoint')"
v-tooltip="t('timeline.outPoint')"
:style="{ left: pxFromTime(store.playbackOut) + 'px' }"
@pointerdown="onOutBracketPointerDown"
@pointermove="onOutBracketPointerMove"
@@ -2306,7 +2342,7 @@ onBeforeUnmount(() => {
left: pxFromTime(marker.time) + 'px',
...(marker.color ? { '--marker-color': marker.color } : {}),
}"
:title="marker.name"
v-tooltip="marker.name"
@pointerdown="onMarkerPointerDown($event, marker.id)"
@pointermove="onMarkerPointerMove($event, marker.id)"
@pointerup="onMarkerPointerUp($event, marker.id)"
@@ -2413,11 +2449,22 @@ onBeforeUnmount(() => {
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
<span class="track-label">{{ track.label }}</span>
<button
v-if="track.isParent"
class="eye-btn"
:class="{ hidden: track.layer.editorHidden }"
v-tooltip="track.layer.editorHidden ? t('timeline.showInEditor') : t('timeline.hideInEditor')"
@click.stop="store.setLayerEditorHidden(track.layerId, !track.layer.editorHidden)"
@mousedown.stop
@dragstart.prevent.stop
>
<component :is="track.layer.editorHidden ? PhEyeSlash : PhEye" :size="12" />
</button>
<button
v-if="track.isParent && !lockedGroupChildIds.has(track.layerId)"
class="lock-btn"
:class="{ locked: track.layer.locked }"
:title="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
v-tooltip="track.layer.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.layerId, !track.layer.locked)"
@mousedown.stop
@dragstart.prevent.stop
@@ -2460,7 +2507,7 @@ onBeforeUnmount(() => {
:data-layer-id="track.layerId"
:data-kind="track.kind"
:data-kf-id="kf.id"
:title="
v-tooltip="
kf.hold
? t('timeline.stillFrameTooltip', { time: formatKfTime(kf.time) })
: t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })
@@ -2473,6 +2520,19 @@ onBeforeUnmount(() => {
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
/>
<button
v-for="emptyT in emptyButtonSlots(track.kfs)"
:key="`empty-${track.layerId}-${track.kind}-${emptyT}`"
type="button"
class="kf-add"
:style="{ left: pxFromTime(emptyT) + 'px' }"
v-tooltip="t('timeline.addKfFromPrev')"
@mousedown.stop
@pointerdown.stop
@click.stop="addKfFromPrev(track.layerId, track.kfs, emptyT)"
>
<PhPlus :size="10" weight="bold" />
</button>
</div>
</div>
@@ -2525,7 +2585,7 @@ onBeforeUnmount(() => {
v-if="!lockedGroupChildIds.has(track.clipTrackId)"
class="lock-btn"
:class="{ locked: !!clipTrackById.get(track.clipTrackId)?.locked }"
:title="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
v-tooltip="clipTrackById.get(track.clipTrackId)?.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="
store.setTrackLocked(
track.clipTrackId,
@@ -2568,7 +2628,7 @@ onBeforeUnmount(() => {
moving: clipMove?.instanceId === ci.id,
}"
:style="clipBlockStyle(ci)"
:title="trimTooltip(ci)"
v-tooltip="trimTooltip(ci)"
@pointerdown="onClipBlockPointerDown($event, ci)"
@pointermove="onClipBlockPointerMove"
@pointerup="onClipBlockPointerUp"
@@ -2634,7 +2694,7 @@ onBeforeUnmount(() => {
<button
class="lock-btn"
:class="{ locked: track.locked }"
:title="track.locked ? t('timeline.unlock') : t('timeline.lock')"
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.scriptTrackId, !track.locked)"
@mousedown.stop
@dragstart.prevent.stop
@@ -2662,7 +2722,7 @@ onBeforeUnmount(() => {
:key="frame.id"
class="script-frame"
:style="{ left: pxFromTime(frame.time) + 'px' }"
:title="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
v-tooltip="frame.label ?? t('timeline.scriptFrameTooltip', { n: frame.actions.length })"
@pointerdown="onScriptFramePointerDown($event, frame.id)"
@pointermove="onScriptFramePointerMove"
@pointerup="onScriptFramePointerUp"
@@ -2713,7 +2773,7 @@ onBeforeUnmount(() => {
<button
class="lock-btn"
:class="{ locked: track.locked }"
:title="track.locked ? t('timeline.unlock') : t('timeline.lock')"
v-tooltip="track.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setTrackLocked(track.groupId, !track.locked)"
@mousedown.stop
@dragstart.prevent.stop
@@ -2768,7 +2828,7 @@ onBeforeUnmount(() => {
<span class="hit-track-label">{{ hitShapeLabel(shape, idx) }}</span>
<button
class="hit-track-delete"
:title="t('timeline.hitTrackDelete')"
v-tooltip="t('timeline.hitTrackDelete')"
@pointerdown.stop
@click.stop="onDeleteHitShape(idx)"
>×</button>
@@ -2806,7 +2866,7 @@ onBeforeUnmount(() => {
class="name-col-resize"
:class="{ resizing: nameResizing }"
:style="{ left: trackNameWidth + 'px' }"
:title="t('timeline.resizeTrackColumn')"
v-tooltip="t('timeline.resizeTrackColumn')"
@pointerdown="onNameResizeStart"
@pointermove="onNameResizeMove"
@pointerup="onNameResizeEnd"
@@ -2880,7 +2940,7 @@ onBeforeUnmount(() => {
class="color-tile"
:class="{ selected: trackMenu.currentColor === c.value, none: c.value === null }"
:style="c.value ? { background: c.value } : undefined"
:title="c.label"
v-tooltip="c.label"
@click="applyTrackColor(c.value)"
>
<PhCheck
@@ -3631,6 +3691,40 @@ onBeforeUnmount(() => {
transition: opacity 140ms var(--ease), color 140ms var(--ease);
}
/* Editor-only show/hide toggle. Sits to the left of the lock button.
Stays visible when the layer is hidden so the state is obvious; on
hover it fades in on every track so you can reach it without the
layer first being hidden. */
.eye-btn {
margin-left: auto;
margin-right: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 140ms var(--ease), color 140ms var(--ease);
}
.track-name:hover .eye-btn {
opacity: 0.7;
}
.eye-btn:hover {
color: var(--text);
opacity: 1;
}
.eye-btn.hidden {
opacity: 1;
color: var(--accent);
}
.track-name:hover .lock-btn {
opacity: 0.7;
}
@@ -3905,6 +3999,37 @@ onBeforeUnmount(() => {
box-shadow: none;
}
/* Button-editor empty-slot placeholder. Outlined accent-coloured
circle that stays fully opaque (no fade) so it's obvious the slot
is interactive; on hover the fill solidifies into the accent so
the user sees "I'm about to drop a keyframe here". */
.kf-add {
position: absolute;
top: 50%;
width: 18px;
height: 18px;
margin-left: -9px;
margin-top: -9px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel);
border: 1.5px solid var(--track-color, var(--accent));
border-radius: 50%;
color: var(--track-color, var(--accent));
cursor: pointer;
transition: background 140ms var(--ease), color 140ms var(--ease),
transform 140ms var(--ease);
z-index: 2;
}
.kf-add:hover {
background: var(--track-color, var(--accent));
color: #fff;
transform: scale(1.1);
}
.kf.hold:hover {
background: var(--panel);
border-color: #fff;
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { activeTooltip } from '@/directives/tooltip'
const VIEWPORT_MARGIN = 8
const GAP = 8
const hostEl = ref<HTMLDivElement | null>(null)
const position = ref<{ top: number; left: number; placement: 'top' | 'bottom' } | null>(null)
const content = computed(() => activeTooltip.value?.content ?? null)
const target = computed(() => activeTooltip.value?.target ?? null)
async function reposition() {
const t = target.value
if (!t) {
position.value = null
return
}
const tooltip = hostEl.value
if (!tooltip) return
const rect = t.getBoundingClientRect()
const tw = tooltip.offsetWidth
const th = tooltip.offsetHeight
const vpH = window.innerHeight
const vpW = window.innerWidth
// Prefer below; flip above if not enough room.
const spaceBelow = vpH - rect.bottom - VIEWPORT_MARGIN
const spaceAbove = rect.top - VIEWPORT_MARGIN
const useAbove = spaceBelow < th + GAP && spaceAbove >= th + GAP
const placement: 'top' | 'bottom' = useAbove ? 'top' : 'bottom'
const top = useAbove ? rect.top - th - GAP : rect.bottom + GAP
// Horizontal centering, clamped to viewport.
const desiredLeft = rect.left + rect.width / 2 - tw / 2
const left = Math.max(
VIEWPORT_MARGIN,
Math.min(vpW - tw - VIEWPORT_MARGIN, desiredLeft),
)
position.value = { top, left, placement }
}
watch(activeTooltip, async (v) => {
if (!v) {
position.value = null
return
}
// When we jump from one tooltip to another we keep the previous
// position while the new div mounts — that's one frame of visual
// jitter, but it avoids a briefly-invisible flash between tooltips.
await nextTick()
await reposition()
})
</script>
<template>
<Teleport to="body">
<div
v-if="content"
ref="hostEl"
class="tt"
:class="[`tt-${position?.placement ?? 'bottom'}`, { 'tt-ready': !!position }]"
:style="{
top: (position?.top ?? -9999) + 'px',
left: (position?.left ?? -9999) + 'px',
}"
role="tooltip"
>
<div class="tt-label">{{ content.label }}</div>
<div v-if="content.help" class="tt-help">{{ content.help }}</div>
</div>
</Teleport>
</template>
<style scoped>
/* The tooltip mounts at an off-screen position (top/left = -9999) and
stays `opacity: 0` until `.tt-ready` is applied — that's the frame
after we measure the element and know where it should actually go.
CSS handles the fade-in instead of a <Transition> wrapper so we can
defer the opacity change until after positioning without fighting
the enter animation. */
.tt {
position: fixed;
z-index: 1000;
max-width: 280px;
padding: 7px 10px;
background: #1f1f24;
color: #f5f5f7;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.35),
0 8px 24px rgba(0, 0, 0, 0.4);
pointer-events: none;
font-size: 12px;
line-height: 1.35;
user-select: none;
-webkit-user-select: none;
opacity: 0;
transform: translateY(2px);
transition: opacity 120ms ease, transform 120ms ease;
}
.tt.tt-ready {
opacity: 1;
transform: translateY(0);
}
.tt.tt-top {
transform: translateY(-2px);
}
.tt.tt-top.tt-ready {
transform: translateY(0);
}
.tt-label {
font-weight: 500;
color: #f5f5f7;
}
/* Help line reads as an explanation under the label — dimmer, smaller,
so the two lines register as "name + description" instead of
"two equal things". */
.tt-help {
margin-top: 3px;
font-size: 11px;
color: #b8b8be;
}
</style>
+1 -1
View File
@@ -161,7 +161,7 @@ watch(
<button
class="del"
tabindex="-1"
:title="t('welcome.deleteTooltip')"
v-tooltip="t('welcome.deleteTooltip')"
@click="removeProject($event, p)"
>
<PhTrash :size="13" />
+12 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, type Component } from 'vue'
import type { TooltipContent } from '@/directives/tooltip'
type Variant = 'default' | 'ghost' | 'primary' | 'danger'
type Size = 'xs' | 'sm' | 'md'
@@ -14,7 +15,10 @@ const props = withDefaults(
active?: boolean
disabled?: boolean
rounded?: boolean
/** Plain tooltip label. Callers pass a simple string. */
title?: string
/** Rich tooltip content — takes precedence over `title` when given. */
tooltip?: string | TooltipContent
}>(),
{
iconWeight: 'regular',
@@ -27,6 +31,12 @@ const props = withDefaults(
)
const iconPx: Record<Size, number> = { xs: 12, sm: 14, md: 15 }
const tooltipBinding = computed<string | TooltipContent | undefined>(() => {
if (props.tooltip !== undefined) return props.tooltip
if (props.title) return props.title
return undefined
})
</script>
<template>
@@ -35,7 +45,7 @@ const iconPx: Record<Size, number> = { xs: 12, sm: 14, md: 15 }
class="icon-btn"
:class="[`size-${size}`, `variant-${variant}`, { active, rounded }]"
:disabled="disabled"
:title="title"
v-tooltip="tooltipBinding"
>
<slot>
<component
+1 -1
View File
@@ -108,7 +108,7 @@ onBeforeUnmount(() => document.removeEventListener('keydown', onKeyDown))
v-if="dismissable"
class="close"
:disabled="!dismissable"
:title="t('dialog.close')"
v-tooltip="t('dialog.close')"
@click="close"
>
<PhX :size="14" weight="bold" />