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

+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" />