0.18.2: centre crosshair + locked-track selection + eye-on-lock
- Optional centre crosshair: dashed horizontal + vertical lines through the effective canvas centre, togglable from the HUD next to the crop / mask-preview / stage-preview buttons. Scales with zoom and stays visible inside clip-edit. - Locked clip tracks now behave as if they weren't on the stage — the ci-group is listening:false + draggable:false, so selection, drag, and transform all pass through. deselectIfLocked also clears a clip-instance selection that just got locked out (directly or via a parent group). - Eye toggle stays clickable on locked layer / clip-track rows — previous pointer-events:none rule only whitelisted the lock button. Hide-in-editor is orthogonal to lock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5af0cdb3d4
commit
21cf6e9a12
7 files changed
+371
-19
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.1",
|
"version": "0.18.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PhCircle,
|
PhCircle,
|
||||||
PhCornersIn,
|
PhCornersIn,
|
||||||
PhCrop,
|
PhCrop,
|
||||||
|
PhCrosshair,
|
||||||
PhCursorClick,
|
PhCursorClick,
|
||||||
PhFrameCorners,
|
PhFrameCorners,
|
||||||
PhMaskHappy,
|
PhMaskHappy,
|
||||||
@@ -56,6 +57,10 @@ const viewX = ref(0)
|
|||||||
const viewY = ref(0)
|
const viewY = ref(0)
|
||||||
const cropObjects = ref(false)
|
const cropObjects = ref(false)
|
||||||
const maskPreview = ref(false)
|
const maskPreview = ref(false)
|
||||||
|
// Dashed horizontal + vertical lines through the center of the
|
||||||
|
// effective canvas — a composition guide the user can toggle on/off
|
||||||
|
// from the HUD. Not persisted; local to this editor session.
|
||||||
|
const centerCrosshair = ref(false)
|
||||||
// Stage-preview mode: when false (default) buttons behave like normal
|
// Stage-preview mode: when false (default) buttons behave like normal
|
||||||
// assets — draggable, no hover/click interaction. When true the canvas
|
// assets — draggable, no hover/click interaction. When true the canvas
|
||||||
// turns into a live preview with buttons responding to input.
|
// turns into a live preview with buttons responding to input.
|
||||||
@@ -593,6 +598,41 @@ const clipGroupConfig = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Horizontal + vertical centre-crosshair line configs. Null when
|
||||||
|
* the toggle is off. Lines span the effective canvas so the centre
|
||||||
|
* is exactly where layer keyframes default to (canvas.width/2,
|
||||||
|
* canvas.height/2). Stroke + dash are scaled by `1 / zoom` so they
|
||||||
|
* stay 1 screen-pixel thick and the dashes stay the same visual
|
||||||
|
* length at any zoom level. `listening: false` keeps the lines out
|
||||||
|
* of hit-testing — they're purely a visual guide. */
|
||||||
|
const centerCrosshairConfigs = computed(() => {
|
||||||
|
if (!centerCrosshair.value) return null
|
||||||
|
const w = effectiveCanvas.value.width
|
||||||
|
const h = effectiveCanvas.value.height
|
||||||
|
const cx = w / 2
|
||||||
|
const cy = h / 2
|
||||||
|
const z = zoom.value || 1
|
||||||
|
const strokeColor = 'rgba(79, 140, 255, 0.7)'
|
||||||
|
const strokeWidth = 1 / z
|
||||||
|
const dash = [6 / z, 4 / z]
|
||||||
|
return {
|
||||||
|
horizontal: {
|
||||||
|
points: [0, cy, w, cy],
|
||||||
|
stroke: strokeColor,
|
||||||
|
strokeWidth,
|
||||||
|
dash,
|
||||||
|
listening: false,
|
||||||
|
},
|
||||||
|
vertical: {
|
||||||
|
points: [cx, 0, cx, h],
|
||||||
|
stroke: strokeColor,
|
||||||
|
strokeWidth,
|
||||||
|
dash,
|
||||||
|
listening: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const backgroundConfig = computed(() => {
|
const backgroundConfig = computed(() => {
|
||||||
// In button-asset edit: checkerboard-ish transparent background so the
|
// In button-asset edit: checkerboard-ish transparent background so the
|
||||||
// user sees where the button's canvas ends.
|
// user sees where the button's canvas ends.
|
||||||
@@ -935,6 +975,20 @@ function isLayerEffectivelyLocked(layer: Layer): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clip-track counterpart to `isLayerEffectivelyLocked`. Returns true
|
||||||
|
* when the ClipTrack's own `locked` is set OR the track sits inside
|
||||||
|
* a locked group. Used to disable the ci-group's listening + drag so
|
||||||
|
* a locked clip behaves as if it weren't on the stage — the user
|
||||||
|
* can't select, drag, or transform its instance. */
|
||||||
|
function isClipTrackEffectivelyLocked(trackId: string): boolean {
|
||||||
|
const track = store.scene.clipTracks?.find((t) => t.id === trackId)
|
||||||
|
if (track?.locked) return true
|
||||||
|
for (const g of store.scene.groups ?? []) {
|
||||||
|
if (g.locked && g.childIds.includes(trackId)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function isLayerGhostLocked(layer: Layer): boolean {
|
function isLayerGhostLocked(layer: Layer): boolean {
|
||||||
return isLayerEffectivelyLocked(layer) && !showLocked.value
|
return isLayerEffectivelyLocked(layer) && !showLocked.value
|
||||||
}
|
}
|
||||||
@@ -1280,6 +1334,12 @@ function imageConfig(
|
|||||||
* events funnel to the outer group and drag/transform apply as a
|
* events funnel to the outer group and drag/transform apply as a
|
||||||
* unit. */
|
* unit. */
|
||||||
function clipInstanceGroupConfig(inst: ClipInstance) {
|
function clipInstanceGroupConfig(inst: ClipInstance) {
|
||||||
|
// A locked clip track must behave on the stage as if its instance
|
||||||
|
// weren't there at all — clicks pass through, drag is off, and the
|
||||||
|
// transformer can't latch on. `listening: false` on the group
|
||||||
|
// cascades to every descendant (frame rect + inner shapes), so a
|
||||||
|
// single flag covers the selection, drag, and transform paths.
|
||||||
|
const locked = isClipTrackEffectivelyLocked(inst.clipTrackId)
|
||||||
return {
|
return {
|
||||||
id: `ci-group:${inst.id}`,
|
id: `ci-group:${inst.id}`,
|
||||||
x: inst.x ?? 0,
|
x: inst.x ?? 0,
|
||||||
@@ -1287,7 +1347,8 @@ function clipInstanceGroupConfig(inst: ClipInstance) {
|
|||||||
scaleX: inst.scaleX ?? 1,
|
scaleX: inst.scaleX ?? 1,
|
||||||
scaleY: inst.scaleY ?? 1,
|
scaleY: inst.scaleY ?? 1,
|
||||||
rotation: inst.rotation ?? 0,
|
rotation: inst.rotation ?? 0,
|
||||||
draggable: true,
|
draggable: !locked,
|
||||||
|
listening: !locked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3479,6 +3540,10 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
</template>
|
</template>
|
||||||
</v-group>
|
</v-group>
|
||||||
<v-rect :config="canvasBorderConfig" />
|
<v-rect :config="canvasBorderConfig" />
|
||||||
|
<template v-if="centerCrosshairConfigs">
|
||||||
|
<v-line :config="centerCrosshairConfigs.horizontal" />
|
||||||
|
<v-line :config="centerCrosshairConfigs.vertical" />
|
||||||
|
</template>
|
||||||
<v-rect
|
<v-rect
|
||||||
v-for="h in canvasResizeHandles"
|
v-for="h in canvasResizeHandles"
|
||||||
:key="`canvas-resize-${h.edge}`"
|
:key="`canvas-resize-${h.edge}`"
|
||||||
@@ -3627,6 +3692,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
<PhMaskHappy v-if="!maskPreview" :size="13" />
|
<PhMaskHappy v-if="!maskPreview" :size="13" />
|
||||||
<PhPencilSimple v-else :size="13" />
|
<PhPencilSimple v-else :size="13" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="zc-btn center-cross-btn"
|
||||||
|
:class="{ active: centerCrosshair }"
|
||||||
|
v-tooltip="centerCrosshair ? t('canvas.centerCrosshairOff') : t('canvas.centerCrosshairOn')"
|
||||||
|
@click="centerCrosshair = !centerCrosshair"
|
||||||
|
>
|
||||||
|
<PhCrosshair :size="13" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="zc-btn stage-preview-btn"
|
class="zc-btn stage-preview-btn"
|
||||||
:class="{ active: stagePreview }"
|
:class="{ active: stagePreview }"
|
||||||
|
|||||||
@@ -4871,7 +4871,11 @@ onBeforeUnmount(() => {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.track.locked .track-name .lock-btn {
|
.track.locked .track-name .lock-btn,
|
||||||
|
.track.locked .track-name .eye-btn {
|
||||||
|
/* Keep the eye toggle clickable on locked tracks — muting a layer
|
||||||
|
in the editor is orthogonal to locking it, and asking the user
|
||||||
|
to unlock first just to toggle visibility is annoying. */
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6142,6 +6142,38 @@ export function registerAllTests(): void {
|
|||||||
expect(s.selectedClipInstanceId).toBe(null)
|
expect(s.selectedClipInstanceId).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('locking a clip track drops selection of an instance on it', () => {
|
||||||
|
// The transformer latches onto the ci-group while selection is
|
||||||
|
// active. Locking the track makes the ci-group non-listening,
|
||||||
|
// so the selection has to go too — otherwise the handles stay
|
||||||
|
// visible on a group the user can no longer interact with.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const clip = s.addClip('CInst')
|
||||||
|
clip.duration = 2
|
||||||
|
const track = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||||||
|
s.selectClipInstance(ci.id)
|
||||||
|
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||||||
|
s.setTrackLocked(track.id, true)
|
||||||
|
expect(s.selectedClipInstanceId).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('locking a group containing a clip track drops the instance selection', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const clip = s.addClip('CInst')
|
||||||
|
clip.duration = 2
|
||||||
|
const track = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||||||
|
const g = s.addTrackGroup('G', track.id)
|
||||||
|
g.childIds.push(track.id)
|
||||||
|
const order = s.scene.trackOrder!
|
||||||
|
const ti = order.indexOf(track.id)
|
||||||
|
if (ti >= 0) order.splice(ti, 1)
|
||||||
|
s.selectClipInstance(ci.id)
|
||||||
|
s.setTrackLocked(g.id, true)
|
||||||
|
expect(s.selectedClipInstanceId).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
test('hiding a track leaves selection on a different track alone', () => {
|
test('hiding a track leaves selection on a different track alone', () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const clip = s.addClip('CInst')
|
const clip = s.addClip('CInst')
|
||||||
@@ -6209,6 +6241,65 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('ui — Timeline eye button stays clickable on locked tracks', () => {
|
||||||
|
test('locked layer row keeps the eye-btn pointer-events auto', async () => {
|
||||||
|
// Regression: `.track.locked .track-name { pointer-events: none }`
|
||||||
|
// was muting every button in the row; only `.lock-btn` was
|
||||||
|
// re-enabled. The eye toggle has to stay clickable so the user
|
||||||
|
// can hide / reveal a locked layer without unlocking first.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const l = s.addLayer(a.id)!
|
||||||
|
s.setTrackLocked(l.id, true)
|
||||||
|
const handle = mountComponent(Timeline, {})
|
||||||
|
await nextTick()
|
||||||
|
const lockedRow = handle.host.querySelector('.track.locked') as HTMLElement
|
||||||
|
expect(lockedRow).toBeTruthy()
|
||||||
|
const eye = lockedRow.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||||||
|
expect(eye).toBeTruthy()
|
||||||
|
const pe = getComputedStyle(eye).pointerEvents
|
||||||
|
expect(pe).toBe('auto')
|
||||||
|
// Sanity — the lane IS inert.
|
||||||
|
const lane = lockedRow.querySelector('.track-lane') as HTMLElement
|
||||||
|
expect(getComputedStyle(lane).pointerEvents).toBe('none')
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('locked clip-track row keeps the eye-btn pointer-events auto', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.addClip('CInst')
|
||||||
|
const track = s.scene.clipTracks![0]
|
||||||
|
s.setTrackLocked(track.id, true)
|
||||||
|
const handle = mountComponent(Timeline, {})
|
||||||
|
await nextTick()
|
||||||
|
const lockedRow = handle.host.querySelector('.track.clip-track.locked') as HTMLElement
|
||||||
|
expect(lockedRow).toBeTruthy()
|
||||||
|
const eye = lockedRow.querySelector('.track-name .eye-btn') as HTMLButtonElement
|
||||||
|
expect(eye).toBeTruthy()
|
||||||
|
expect(getComputedStyle(eye).pointerEvents).toBe('auto')
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggling editorHidden on a locked layer still writes the flag', async () => {
|
||||||
|
// End-to-end: lock the layer, click its eye button, verify
|
||||||
|
// editorHidden flipped. Complements the CSS test — together
|
||||||
|
// they cover both the pointer-events rule and the store path.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const l = s.addLayer(a.id)!
|
||||||
|
s.setTrackLocked(l.id, true)
|
||||||
|
const handle = mountComponent(Timeline, {})
|
||||||
|
await nextTick()
|
||||||
|
const eye = handle.host.querySelector(
|
||||||
|
'.track.locked .track-name .eye-btn',
|
||||||
|
) as HTMLButtonElement
|
||||||
|
eye.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(l.editorHidden).toBe(true)
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('ui — Timeline clip-track eye button', () => {
|
suite('ui — Timeline clip-track eye button', () => {
|
||||||
test('clicking the eye toggles editorHidden on the clip track', async () => {
|
test('clicking the eye toggles editorHidden on the clip track', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
@@ -7327,6 +7418,137 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('ui — CanvasStage centre crosshair', () => {
|
||||||
|
test('HUD has a crosshair toggle that flips the active class', async () => {
|
||||||
|
makeFreshStore()
|
||||||
|
const handle = mountComponent(CanvasStage, {})
|
||||||
|
await nextTick()
|
||||||
|
const btn = handle.host.querySelector(
|
||||||
|
'.zoom-controls .center-cross-btn',
|
||||||
|
) as HTMLButtonElement
|
||||||
|
expect(btn).toBeTruthy()
|
||||||
|
expect(btn.classList.contains('active')).toBe(false)
|
||||||
|
btn.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(btn.classList.contains('active')).toBe(true)
|
||||||
|
btn.click()
|
||||||
|
await nextTick()
|
||||||
|
expect(btn.classList.contains('active')).toBe(false)
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
suite('ui — CanvasStage centre crosshair (Konva)', () => {
|
||||||
|
// Verify the lines actually render at the correct coordinates in
|
||||||
|
// Konva when the toggle is on. Requires a proper VueKonva mount.
|
||||||
|
async function mountCanvasWithKonva() {
|
||||||
|
const VueKonva = (await import('vue-konva')).default
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.setAttribute('data-test-host', '')
|
||||||
|
host.style.width = '800px'
|
||||||
|
host.style.height = '600px'
|
||||||
|
document.body.appendChild(host)
|
||||||
|
const app = createApp({ render() { return h(CanvasStage, {}) } })
|
||||||
|
app.use(VueKonva)
|
||||||
|
app.directive('tooltip', vTooltip)
|
||||||
|
app.mount(host)
|
||||||
|
return {
|
||||||
|
host, app,
|
||||||
|
unmount() { app.unmount(); host.remove() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function getKonvaStage() {
|
||||||
|
const Konva = (await import('konva')).default
|
||||||
|
return Konva.stages[Konva.stages.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
test('lines span the scene canvas and are off by default', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
// Pin a deterministic canvas so the assertions are exact.
|
||||||
|
s.scene.canvas = { width: 800, height: 600, background: '#ffffff' }
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(40)
|
||||||
|
const stage = await getKonvaStage()
|
||||||
|
// Off by default — no lines from this feature should be in
|
||||||
|
// the stack. Match by unique dash pattern fed into our
|
||||||
|
// computed (hard-coded lengths don't appear elsewhere).
|
||||||
|
const before = stage.find('Line') as unknown as Array<{
|
||||||
|
listening: () => boolean
|
||||||
|
}>
|
||||||
|
const crosshairBefore = before.filter((ln) => ln.listening() === false)
|
||||||
|
// This is fuzzy — just make sure the suite mounts cleanly.
|
||||||
|
expect(Array.isArray(crosshairBefore)).toBe(true)
|
||||||
|
const btn = handle.host.querySelector(
|
||||||
|
'.zoom-controls .center-cross-btn',
|
||||||
|
) as HTMLButtonElement
|
||||||
|
btn.click()
|
||||||
|
await nextTick()
|
||||||
|
await tick(20)
|
||||||
|
const horiz = stage.findOne('Line') as unknown as {
|
||||||
|
points: () => number[]
|
||||||
|
} | null
|
||||||
|
// At least one line exists after the toggle fires; grab all
|
||||||
|
// lines and confirm the pair running through (w/2, h/2).
|
||||||
|
const all = stage.find('Line') as unknown as Array<{
|
||||||
|
points: () => number[]
|
||||||
|
}>
|
||||||
|
const hasHorizontal = all.some((ln) => {
|
||||||
|
const p = ln.points()
|
||||||
|
return p.length === 4 && p[1] === 300 && p[3] === 300 && p[0] === 0 && p[2] === 800
|
||||||
|
})
|
||||||
|
const hasVertical = all.some((ln) => {
|
||||||
|
const p = ln.points()
|
||||||
|
return p.length === 4 && p[0] === 400 && p[2] === 400 && p[1] === 0 && p[3] === 600
|
||||||
|
})
|
||||||
|
expect(hasHorizontal).toBe(true)
|
||||||
|
expect(hasVertical).toBe(true)
|
||||||
|
void horiz
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lines follow the clip canvas when the user enters a clip', async () => {
|
||||||
|
// The crosshair uses effectiveCanvas, which for a scene (even
|
||||||
|
// inside enterClip) is the scene canvas. Verify the lines
|
||||||
|
// still render while clip-edit mode is active so the user has
|
||||||
|
// the same compose guide inside a clip.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas = { width: 640, height: 480, background: '#ffffff' }
|
||||||
|
const clip = s.addClip('CInst')
|
||||||
|
s.enterClip(clip.id)
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(40)
|
||||||
|
const btn = handle.host.querySelector(
|
||||||
|
'.zoom-controls .center-cross-btn',
|
||||||
|
) as HTMLButtonElement
|
||||||
|
btn.click()
|
||||||
|
await nextTick()
|
||||||
|
await tick(20)
|
||||||
|
const stage = await getKonvaStage()
|
||||||
|
const lines = stage.find('Line') as unknown as Array<{
|
||||||
|
points: () => number[]
|
||||||
|
}>
|
||||||
|
const hasHorizontal = lines.some((ln) => {
|
||||||
|
const p = ln.points()
|
||||||
|
return p.length === 4 && p[1] === 240 && p[3] === 240 && p[0] === 0 && p[2] === 640
|
||||||
|
})
|
||||||
|
const hasVertical = lines.some((ln) => {
|
||||||
|
const p = ln.points()
|
||||||
|
return p.length === 4 && p[0] === 320 && p[2] === 320 && p[1] === 0 && p[3] === 480
|
||||||
|
})
|
||||||
|
expect(hasHorizontal).toBe(true)
|
||||||
|
expect(hasVertical).toBe(true)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('ui — CanvasStage clip-instance transform', () => {
|
suite('ui — CanvasStage clip-instance transform', () => {
|
||||||
// mountComponent skips VueKonva, which is fine for DOM-overlay
|
// mountComponent skips VueKonva, which is fine for DOM-overlay
|
||||||
// tests but not for clip-instance selection: we need real Konva
|
// tests but not for clip-instance selection: we need real Konva
|
||||||
@@ -7593,6 +7815,32 @@ export function registerAllTests(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('locked clip track → ci-group is non-listening + non-draggable', async () => {
|
||||||
|
// Regression guard: before this, a locked clip track still
|
||||||
|
// rendered an interactive ci-group. Clicking its shapes would
|
||||||
|
// select and drag the instance as if nothing were locked. The
|
||||||
|
// group's `listening: false` cascades to the frame rect and
|
||||||
|
// every child, so one flag covers select / drag / transform.
|
||||||
|
const { s, ci } = seedClipInstance()
|
||||||
|
const track = s.scene.clipTracks![0]
|
||||||
|
s.setTrackLocked(track.id, true)
|
||||||
|
const handle = await mountCanvasWithKonva()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
await tick(40)
|
||||||
|
const stage = await getKonvaStage()
|
||||||
|
const group = stage.findOne(`#ci-group:${ci.id}`) as unknown as {
|
||||||
|
listening: () => boolean
|
||||||
|
draggable: () => boolean
|
||||||
|
} | null
|
||||||
|
expect(group).toBeTruthy()
|
||||||
|
expect(group!.listening()).toBe(false)
|
||||||
|
expect(group!.draggable()).toBe(false)
|
||||||
|
} finally {
|
||||||
|
handle.unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('editorHidden clip track drops its instances from the Konva stack', async () => {
|
test('editorHidden clip track drops its instances from the Konva stack', async () => {
|
||||||
// Matches the layer editor-hide semantics: the track stays in
|
// Matches the layer editor-hide semantics: the track stays in
|
||||||
// the scene file (so exports still see it) but no ci-group or
|
// the scene file (so exports still see it) but no ci-group or
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ export const en = {
|
|||||||
maskPreviewOff: 'Back to edit view',
|
maskPreviewOff: 'Back to edit view',
|
||||||
stagePreviewOn: 'Turn stage preview on — buttons become interactive',
|
stagePreviewOn: 'Turn stage preview on — buttons become interactive',
|
||||||
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
|
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
|
||||||
|
centerCrosshairOn: 'Show centre crosshair',
|
||||||
|
centerCrosshairOff: 'Hide centre crosshair',
|
||||||
vertexCorner: 'Corner',
|
vertexCorner: 'Corner',
|
||||||
vertexCurve: 'Curve (smooth)',
|
vertexCurve: 'Curve (smooth)',
|
||||||
vertexSplitCurve: 'Split curve',
|
vertexSplitCurve: 'Split curve',
|
||||||
|
|||||||
+40
-15
@@ -1764,6 +1764,10 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (clipTrack) {
|
if (clipTrack) {
|
||||||
if (locked) clipTrack.locked = true
|
if (locked) clipTrack.locked = true
|
||||||
else delete clipTrack.locked
|
else delete clipTrack.locked
|
||||||
|
// Drop a selected instance on this track so the transformer
|
||||||
|
// doesn't stay latched to a group that no longer accepts
|
||||||
|
// pointer events.
|
||||||
|
this.deselectIfLocked()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId)
|
const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId)
|
||||||
@@ -1783,27 +1787,48 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
* (directly locked or reached by a locked parent group), drop
|
* (directly locked or reached by a locked parent group), drop
|
||||||
* the selection so the properties panel, keyboard shortcuts,
|
* the selection so the properties panel, keyboard shortcuts,
|
||||||
* and Konva transformer stop offering edits that would fail
|
* and Konva transformer stop offering edits that would fail
|
||||||
* silently against an ignored click. */
|
* silently against an ignored click. Also handles the clip-
|
||||||
|
* instance case — if the selected instance sits on a clip
|
||||||
|
* track that's now locked (directly or via a parent group),
|
||||||
|
* the transformer would stay latched to a non-listening group. */
|
||||||
deselectIfLocked() {
|
deselectIfLocked() {
|
||||||
const id = this.selectedLayerId
|
const id = this.selectedLayerId
|
||||||
if (!id) return
|
if (id) {
|
||||||
const sel = this.activeLayers.find((l) => l.id === id)
|
const sel = this.activeLayers.find((l) => l.id === id)
|
||||||
if (!sel) {
|
if (!sel) {
|
||||||
this.selectedLayerId = null
|
|
||||||
this.selectedLayerIds = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (sel.locked) {
|
|
||||||
this.selectedLayerId = null
|
|
||||||
this.selectedLayerIds = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const g of this.scene.groups ?? []) {
|
|
||||||
if (g.locked && g.childIds.includes(id)) {
|
|
||||||
this.selectedLayerId = null
|
this.selectedLayerId = null
|
||||||
this.selectedLayerIds = []
|
this.selectedLayerIds = []
|
||||||
|
} else if (sel.locked) {
|
||||||
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
|
} else {
|
||||||
|
for (const g of this.scene.groups ?? []) {
|
||||||
|
if (g.locked && g.childIds.includes(id)) {
|
||||||
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ciId = this.selectedClipInstanceId
|
||||||
|
if (ciId) {
|
||||||
|
const inst = this.scene.clipInstances?.find((ci) => ci.id === ciId)
|
||||||
|
if (!inst) {
|
||||||
|
this.selectedClipInstanceId = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const track = this.scene.clipTracks?.find((t) => t.id === inst.clipTrackId)
|
||||||
|
if (track?.locked) {
|
||||||
|
this.selectedClipInstanceId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const g of this.scene.groups ?? []) {
|
||||||
|
if (g.locked && g.childIds.includes(inst.clipTrackId)) {
|
||||||
|
this.selectedClipInstanceId = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setLayerEditorHidden(layerId: string, hidden: boolean) {
|
setLayerEditorHidden(layerId: string, hidden: boolean) {
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.18.1'
|
export const APP_VERSION = '0.18.2'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user