diff --git a/package.json b/package.json
index 4f84adc..163a5cc 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "0.18.1",
+ "version": "0.18.2",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index d9bce85..8c69ab8 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -5,6 +5,7 @@ import {
PhCircle,
PhCornersIn,
PhCrop,
+ PhCrosshair,
PhCursorClick,
PhFrameCorners,
PhMaskHappy,
@@ -56,6 +57,10 @@ const viewX = ref(0)
const viewY = ref(0)
const cropObjects = 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
// assets — draggable, no hover/click interaction. When true the canvas
// 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(() => {
// In button-asset edit: checkerboard-ish transparent background so the
// user sees where the button's canvas ends.
@@ -935,6 +975,20 @@ function isLayerEffectivelyLocked(layer: Layer): boolean {
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 {
return isLayerEffectivelyLocked(layer) && !showLocked.value
}
@@ -1280,6 +1334,12 @@ function imageConfig(
* events funnel to the outer group and drag/transform apply as a
* unit. */
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 {
id: `ci-group:${inst.id}`,
x: inst.x ?? 0,
@@ -1287,7 +1347,8 @@ function clipInstanceGroupConfig(inst: ClipInstance) {
scaleX: inst.scaleX ?? 1,
scaleY: inst.scaleY ?? 1,
rotation: inst.rotation ?? 0,
- draggable: true,
+ draggable: !locked,
+ listening: !locked,
}
}
@@ -3479,6 +3540,10 @@ function onResizeHandlePointerUp(e: PointerEvent) {
+
+
+
+
+
+
+
{
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;
}
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 6fd9726..05e2580 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -6142,6 +6142,38 @@ export function registerAllTests(): void {
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', () => {
const s = makeFreshStore()
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', () => {
test('clicking the eye toggles editorHidden on the clip track', async () => {
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', () => {
// mountComponent skips VueKonva, which is fine for DOM-overlay
// 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 () => {
// Matches the layer editor-hide semantics: the track stays in
// the scene file (so exports still see it) but no ci-group or
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 47d6323..7ea36c3 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -324,6 +324,8 @@ export const en = {
maskPreviewOff: 'Back to edit view',
stagePreviewOn: 'Turn stage preview on — buttons become interactive',
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
+ centerCrosshairOn: 'Show centre crosshair',
+ centerCrosshairOff: 'Hide centre crosshair',
vertexCorner: 'Corner',
vertexCurve: 'Curve (smooth)',
vertexSplitCurve: 'Split curve',
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index 4ad111b..543458b 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -1764,6 +1764,10 @@ export const useSceneStore = defineStore('scene', {
if (clipTrack) {
if (locked) clipTrack.locked = true
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
}
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
* the selection so the properties panel, keyboard shortcuts,
* 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() {
const id = this.selectedLayerId
- if (!id) return
- const sel = this.activeLayers.find((l) => l.id === id)
- 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)) {
+ if (id) {
+ const sel = this.activeLayers.find((l) => l.id === id)
+ if (!sel) {
this.selectedLayerId = null
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
}
+ 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) {
diff --git a/src/version.ts b/src/version.ts
index 292d72b..0d56b9c 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '0.18.1'
+export const APP_VERSION = '0.18.2'
export const APP_NAME = 'RUWIO'