diff --git a/package.json b/package.json index ab2782d..d0b7ff3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "0.17.0", + "version": "0.18.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index c9b311d..81eb633 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -39,6 +39,7 @@ import { t } from '@/i18n' import { useKfSelection } from '@/composables/useKfSelection' import { showLocked } from '@/composables/useShowLocked' import { expandedTrackOrder } from '@/lib/sceneOrder' +import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBounds' const store = useSceneStore() const clipart = useClipartStore() @@ -737,6 +738,10 @@ interface RenderedClipLayer extends RenderedLayer { interface RenderedClipInstance { instance: ClipInstance + /** Union bbox of every clip layer across its full animation — the + * stable frame that the transformer wraps. Null when the clip has + * no measurable layers (template falls back to a placeholder). */ + bounds: Rect | null items: RenderedClipLayer[] } @@ -807,6 +812,8 @@ const stackItems = computed(() => { for (const l of store.scene.layers) layerById.set(l.id, l) const clipById = new Map[number]>() for (const c of store.scene.clips ?? []) clipById.set(c.id, c) + const clipTrackById = new Map[number]>() + for (const t of store.scene.clipTracks ?? []) clipTrackById.set(t.id, t) const instancesByTrack = new Map>() for (const ci of store.scene.clipInstances ?? []) { const arr = instancesByTrack.get(ci.clipTrackId) ?? [] @@ -838,6 +845,11 @@ const stackItems = computed(() => { }) continue } + // Editor-only hide for an entire clip track — skip every instance + // on it in the Konva stack so the stage matches the eye-icon + // state. Exports still see the instances unless the user opts + // into strip-hidden. + if (clipTrackById.get(id)?.editorHidden) continue const instances = instancesByTrack.get(id) ?? [] for (const inst of instances) { const clip = clipById.get(inst.clipId) @@ -865,9 +877,18 @@ const stackItems = computed(() => { props: sampleLayer(cl, localTime), }) } - if (items.length) { - out.push({ kind: 'clipInstance', bundle: { instance: inst, items } }) - } + // Always push the bundle when the instance is in time-range, + // even if no items are renderable at this playhead: the frame + // rect still needs to render so the clip can be selected (and + // the transformer framed) on empty frames. + out.push({ + kind: 'clipInstance', + bundle: { + instance: inst, + bounds: computeClipBounds(clip, assetMap.value), + items, + }, + }) } } return out @@ -1261,6 +1282,30 @@ function clipInstanceGroupConfig(inst: ClipInstance) { } } +/** Invisible frame rect sized to the clip's union bbox. Sits as the + * first child of the ci-group so: + * - the group's client rect is dominated by this stable rectangle, + * keeping transformer handles at the clip's outer constraints + * instead of jittering with animated inner content; + * - clicks anywhere in the clip's spatial footprint (including + * empty playhead frames) still hit this rect, bubble to the group + * and select the instance. `fill: 'black' + opacity: 0` renders + * nothing but still paints the hit graph, so listening works. */ +function clipFrameConfig(instanceId: string, bounds: Rect | null) { + const b = bounds ?? CLIP_FALLBACK_BOUNDS + return { + id: `ci-frame:${instanceId}`, + name: 'ci-frame', + x: b.x, + y: b.y, + width: Math.max(1, b.width), + height: Math.max(1, b.height), + fill: 'black', + opacity: 0, + listening: true, + } +} + function onClipInstanceDragEnd( e: Konva.KonvaEventObject, instanceId: string, @@ -3342,6 +3387,12 @@ function onResizeHandlePointerUp(e: PointerEvent) { @dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)" @transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)" > +