diff --git a/package.json b/package.json index d11c5d9..3457d27 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "1.2.2", + "version": "1.2.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index c10158e..bb589b8 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -1757,10 +1757,10 @@ interface VertexHandle { } const polygonVertexHandles = computed(() => { - // Vertex handles only apply to a SOLO polygon selection — in a - // mixed multi-selection the shared transformer bounding box owns - // scale / rotate, and per-vertex handles would visually fight it. - if (store.selectedLayerIds.length > 1) return [] + // Vertex handles only apply to a SOLO polygon selection in + // *vertex-edit mode* (entered via double-click). Level-1 + // selection of a polygon shows the regular transformer instead. + if (!polygonInVertexEdit.value) return [] const id = store.selectedLayerId if (!id) return [] const layer = store.activeLayers.find((l) => l.id === id) @@ -2085,10 +2085,26 @@ const selectedPolygonLayer = computed(() => { return { layer, asset } }) +/** Layer id of the polygon currently in vertex-edit mode. A + * polygon's first selection lands at "level 1" — the regular + * transformer (size + rotation) attaches like any other shape. + * Double-click promotes the polygon to "level 2" / vertex edit: + * per-vertex handles + tangent handles + edge-insert dbl-clicks + * light up. Cleared on selection change, scope change, or Escape. */ +const polygonVertexEditId = ref(null) + +const polygonInVertexEdit = computed(() => { + if (store.selectedLayerIds.length > 1) return false + const id = store.selectedLayerId + if (!id) return false + return polygonVertexEditId.value === id +}) + watch( () => store.selectedLayerId, () => { selectedVertexIndices.value = new Set() + polygonVertexEditId.value = null closeVertexMenu() }, ) @@ -2313,8 +2329,10 @@ function attachTransformer(retriesLeft = 3) { // Multi-select: wrap every selected layer node in one transformer // so the bounding box + scale / rotate handles span the group. - // A SOLE polygon selection still bypasses the transformer because - // a single polygon is edited via vertex handles; mixed selections + // A SOLE polygon selection bypasses the transformer ONLY when + // the user has promoted it to vertex-edit mode (double-click); + // in level-1 / single-click selection the polygon gets the + // regular transformer like any other shape. Mixed selections // that happen to include a polygon fold it into the shared bbox. const ids = store.selectedLayerIds.length ? store.selectedLayerIds @@ -2333,7 +2351,13 @@ function attachTransformer(retriesLeft = 3) { const layer = store.activeLayers.find((l) => l.id === id) if (!layer) continue const asset = assetMap.value.get(layer.assetId) - if (asset?.kind === 'polygon' && single) continue + if ( + asset?.kind === 'polygon' && + single && + polygonInVertexEdit.value + ) { + continue + } const node = stage.findOne(`#${id}`) if (!node) { anyMissing = true @@ -2359,6 +2383,7 @@ watch( store.selectedClipInstanceId, stackItems.value, store.playhead, + polygonVertexEditId.value, ] as const, async () => { await nextTick() @@ -2700,7 +2725,9 @@ function handleStageMouseDown(e: Konva.KonvaEventObject) { // selection — inside a multi-select the box always targets // layers so new picks add to the existing group. const mode: 'vertex' | 'layer' = - selectedPolygonLayer.value && store.selectedLayerIds.length <= 1 + selectedPolygonLayer.value && + store.selectedLayerIds.length <= 1 && + polygonInVertexEdit.value ? 'vertex' : 'layer' boxSelect.value = { @@ -2894,6 +2921,16 @@ function onContainerDblClick(e: MouseEvent) { return } if (asset.kind !== 'polygon') return + // First dbl-click on a level-1-selected polygon → enter + // vertex-edit mode (level 2). The transformer detaches in the + // attachTransformer watcher, vertex handles light up. Subsequent + // dbl-clicks (already in vertex-edit mode) fall through to the + // edge-insert / open-polygon resume gestures below. SVG-edit + // scope skips this beat since polygons are always level-2 there. + if (!polygonInVertexEdit.value) { + polygonVertexEditId.value = layer.id + return + } const sampled = sampleLayer(layer, store.playhead) const pts = sampled.points ?? asset.points if (pts.length < 1) return diff --git a/src/components/SvgLayerPanel.vue b/src/components/SvgLayerPanel.vue index d975996..c8894cf 100644 --- a/src/components/SvgLayerPanel.vue +++ b/src/components/SvgLayerPanel.vue @@ -301,6 +301,7 @@ function onDragEnd() { flex: 1 1 0; min-width: 0; height: 28px; + padding: 0; display: inline-flex; align-items: center; justify-content: center; @@ -409,6 +410,10 @@ function onDragEnd() { .row-btn { width: 22px; height: 22px; + /* Override the global `button { padding: 5px 10px }` reset — + with box-sizing:border-box that padding eats the entire 22×22 + box and clips the icon out of view ("icons missing"). */ + padding: 0; display: inline-flex; align-items: center; justify-content: center; diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 16538fc..d1821a8 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -2787,10 +2787,56 @@ const trackMenuRef = ref(null) function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) { if (isTrackEffectivelyLocked(track)) return - if (e.shiftKey && track.isParent) { + if (!track.isParent) { + // Sub-tracks (separated position / rotation lanes) can't carry + // their own selection — just route to the parent layer. + store.selectLayer(track.layerId) + return + } + // Ctrl / Cmd → toggle this layer in/out of the selection. + if (e.ctrlKey || e.metaKey) { store.toggleLayerSelection(track.layerId) return } + // Shift → range select: every parent-layer track between the + // current anchor (selectedLayerId) and the clicked one in the + // visible timeline order joins the selection. + if (e.shiftKey) { + const anchorId = store.selectedLayerId + if (!anchorId || anchorId === track.layerId) { + store.selectLayer(track.layerId) + return + } + const layerOrder: string[] = [] + for (const tr of tracks.value) { + if (tr.type === 'layer' && tr.isParent) layerOrder.push(tr.layerId) + } + const a = layerOrder.indexOf(anchorId) + const b = layerOrder.indexOf(track.layerId) + if (a < 0 || b < 0) { + store.selectLayer(track.layerId) + return + } + const [from, to] = a <= b ? [a, b] : [b, a] + const range = layerOrder.slice(from, to + 1) + // Skip layers the user can't actually act on (locked or inside + // a locked parent group). selectLayer / toggle paths skip these + // too, so the range stays consistent with the rest of the UX. + const usable = range.filter((id) => { + const layer = store.activeLayers.find((l) => l.id === id) + if (!layer || layer.locked) return false + if (lockedGroupChildIds.value.has(id)) return false + return true + }) + if (!usable.length) return + store.selectedLayerIds = usable + // Anchor moves to the most-recently clicked id so the next + // shift-click extends from there. Matches typical file-manager + // shift-select behaviour. + store.selectedLayerId = track.layerId + store.selectedClipInstanceId = null + return + } store.selectLayer(track.layerId) } diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 3a9ac4d..59a54d7 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -6030,6 +6030,136 @@ export function registerAllTests(): void { s.setPolygonClosed(asset.id, true) expect(asset.closed).toBe(true) }) + + test('insertPolygonPointOnEdge updates kf.points for layers in SVG-asset scope', () => { + // Regression: the action filtered scene.layers only, missing + // layers that live inside an SvgAsset.layers (or a button + // asset). Result: the asset.points grew but kf.props.points + // didn't, so the polygon rendered without the new node. + const s = makeFreshStore() + const svg = s.addSvgAsset('Logo', 200, 200) + s.enterSvgAssetEdit(svg.id) + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 40 }], + fill: '#abc', + }) as PolygonAsset + const layer = s.addLayer(poly.id)! + // Pre-condition: kf.props.points already mirrors asset.points. + expect(layer.keyframes[0].props.points!.length).toBe(3) + s.insertPolygonPointOnEdge(poly.id, 1, 0.5) + expect(poly.points.length).toBe(4) + // The kf in the SVG-asset's layer must be updated too. + expect(layer.keyframes[0].props.points!.length).toBe(4) + expect(layer.keyframes[0].props.points![1].x).toBe(20) + s.exitSvgAssetEdit() + }) + + test('addPolygonPoint syncs kf.points for an SVG-asset layer', () => { + const s = makeFreshStore() + const svg = s.addSvgAsset('Logo', 200, 200) + s.enterSvgAssetEdit(svg.id) + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }], + fill: '#abc', + }) as PolygonAsset + const layer = s.addLayer(poly.id)! + s.addPolygonPoint(poly.id, 2, { x: 99, y: 88 }) + expect(poly.points.length).toBe(4) + expect(layer.keyframes[0].props.points!.length).toBe(4) + expect(layer.keyframes[0].props.points![2].x).toBe(99) + s.exitSvgAssetEdit() + }) + + test('removePolygonPoint syncs kf.points for an SVG-asset layer', () => { + const s = makeFreshStore() + const svg = s.addSvgAsset('Logo', 200, 200) + s.enterSvgAssetEdit(svg.id) + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }], + fill: '#abc', + }) as PolygonAsset + const layer = s.addLayer(poly.id)! + s.removePolygonPoint(poly.id, 1) + expect(poly.points.length).toBe(3) + expect(layer.keyframes[0].props.points!.length).toBe(3) + s.exitSvgAssetEdit() + }) + + test('setPolygonPointCount grows + shrinks across SVG-asset layers', () => { + const s = makeFreshStore() + const svg = s.addSvgAsset('Logo', 200, 200) + s.enterSvgAssetEdit(svg.id) + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }], + fill: '#abc', + }) as PolygonAsset + const layer = s.addLayer(poly.id)! + s.setPolygonPointCount(poly.id, 6) + expect(poly.points.length).toBe(6) + expect(layer.keyframes[0].props.points!.length).toBe(6) + s.setPolygonPointCount(poly.id, 4) + expect(poly.points.length).toBe(4) + expect(layer.keyframes[0].props.points!.length).toBe(4) + s.exitSvgAssetEdit() + }) + + test('reversePolygonPoints reverses kf.points for SVG-asset layers', () => { + const s = makeFreshStore() + const svg = s.addSvgAsset('Logo', 200, 200) + s.enterSvgAssetEdit(svg.id) + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }], + fill: '#abc', + }) as PolygonAsset + const layer = s.addLayer(poly.id)! + const firstX = poly.points[0].x + s.reversePolygonPoints(poly.id) + const kfPts = layer.keyframes[0].props.points! + expect(kfPts[kfPts.length - 1].x).toBe(firstX) + s.exitSvgAssetEdit() + }) + + test('movePolygonVertex writes to the SVG-asset layer kf at playhead 0', () => { + const s = makeFreshStore() + const svg = s.addSvgAsset('Logo', 200, 200) + s.enterSvgAssetEdit(svg.id) + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }], + fill: '#abc', + }) as PolygonAsset + const layer = s.addLayer(poly.id)! + // SVG scope pins the playhead to 0; movePolygonVertex writes + // a kf at playhead. + s.movePolygonVertex(layer.id, 0, 77, -55) + const kf = layer.keyframes.find((k) => Math.abs(k.time) < 1e-3)! + expect(kf.props.points![0].x).toBe(77) + expect(kf.props.points![0].y).toBe(-55) + s.exitSvgAssetEdit() + }) + + test('insertPolygonPointOnEdge syncs kf.points for layers in a button asset', () => { + // Same cross-scope guarantee for ButtonAsset.layers — they + // also use layersUsingAsset under the hood. + const s = makeFreshStore() + const ba = s.addButtonAsset('Btn', 80, 30) + s.enterButtonAssetEdit(ba.id) + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }], + fill: '#abc', + }) as PolygonAsset + const layer = s.addLayer(poly.id)! + s.insertPolygonPointOnEdge(poly.id, 1, 0.5) + expect(poly.points.length).toBe(4) + expect(layer.keyframes[0].props.points!.length).toBe(4) + s.exitButtonAssetEdit() + }) }) suite('freeform drawing — lifecycle', () => { @@ -8295,6 +8425,123 @@ export function registerAllTests(): void { }) }) + suite('ui — Timeline track-name selection (shift / ctrl)', () => { + // Click semantics on a parent layer's track-name row: + // - plain → single-select that layer + // - shift → range select from anchor to clicked, inclusive + // - ctrl/meta → toggle clicked layer in/out of the selection + test('plain click selects only that layer', async () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const lA = s.addLayer(a.id)! + const lB = s.addLayer(a.id)! + // Seed a multi-selection so "plain click collapses to one" is observable. + s.selectedLayerIds = [lA.id, lB.id] + s.selectedLayerId = lB.id + const handle = mountComponent(Timeline, {}) + try { + await nextTick() + const rows = handle.host.querySelectorAll('.track .track-name') as NodeListOf + // Layers render top-down newest-first, so rows[0] is lB, rows[1] is lA. + rows[1].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + await nextTick() + expect(s.selectedLayerIds).toEqual([lA.id]) + expect(s.selectedLayerId).toBe(lA.id) + } finally { + handle.unmount() + } + }) + + test('ctrl-click toggles the clicked layer into / out of the selection', async () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const lA = s.addLayer(a.id)! + const lB = s.addLayer(a.id)! + s.selectLayer(lA.id) + const handle = mountComponent(Timeline, {}) + try { + await nextTick() + const rows = handle.host.querySelectorAll('.track .track-name') as NodeListOf + // ctrl+click on lB (rows[0]) → adds lB to selection. + rows[0].dispatchEvent(new MouseEvent('click', { + bubbles: true, cancelable: true, ctrlKey: true, + })) + await nextTick() + expect(s.selectedLayerIds.length).toBe(2) + expect(s.selectedLayerIds.includes(lA.id)).toBe(true) + expect(s.selectedLayerIds.includes(lB.id)).toBe(true) + // ctrl+click on lB again → removes it. + rows[0].dispatchEvent(new MouseEvent('click', { + bubbles: true, cancelable: true, ctrlKey: true, + })) + await nextTick() + expect(s.selectedLayerIds).toEqual([lA.id]) + } finally { + handle.unmount() + } + }) + + test('shift-click range-selects every parent layer between anchor and clicked', async () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const lA = s.addLayer(a.id)! + const lB = s.addLayer(a.id)! + const lC = s.addLayer(a.id)! + const lD = s.addLayer(a.id)! + // addLayer prepends, so visible order top-down is lD, lC, lB, lA. + // Anchor on lD (top), shift-click on lB → expect [lD, lC, lB]. + s.selectLayer(lD.id) + const handle = mountComponent(Timeline, {}) + try { + await nextTick() + const rows = handle.host.querySelectorAll('.track .track-name') as NodeListOf + // rows index: 0=lD, 1=lC, 2=lB, 3=lA. + rows[2].dispatchEvent(new MouseEvent('click', { + bubbles: true, cancelable: true, shiftKey: true, + })) + await nextTick() + expect(s.selectedLayerIds.length).toBe(3) + // Anchor moves to the just-clicked layer. + expect(s.selectedLayerId).toBe(lB.id) + // The range should include lD, lC, lB (in some order). + const ids = new Set(s.selectedLayerIds) + expect(ids.has(lD.id)).toBe(true) + expect(ids.has(lC.id)).toBe(true) + expect(ids.has(lB.id)).toBe(true) + expect(ids.has(lA.id)).toBe(false) + } finally { + handle.unmount() + } + }) + + test('shift-click skips locked layers in the range', async () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' }) + const lA = s.addLayer(a.id)! + const lB = s.addLayer(a.id)! + const lC = s.addLayer(a.id)! + // Lock lB so it's in the visual range but unselectable. + s.setTrackLocked(lB.id, true) + // Anchor on lC (top), shift-click on lA (bottom) → range + // would be [lC, lB, lA]; the locked lB drops out. + s.selectLayer(lC.id) + const handle = mountComponent(Timeline, {}) + try { + await nextTick() + const rows = handle.host.querySelectorAll('.track .track-name') as NodeListOf + rows[2].dispatchEvent(new MouseEvent('click', { + bubbles: true, cancelable: true, shiftKey: true, + })) + await nextTick() + expect(s.selectedLayerIds.includes(lC.id)).toBe(true) + expect(s.selectedLayerIds.includes(lA.id)).toBe(true) + expect(s.selectedLayerIds.includes(lB.id)).toBe(false) + } finally { + handle.unmount() + } + }) + }) + suite('ui — Timeline rename menu (layer + clip track)', () => { // Finds the menu item whose label contains the given i18n string. // Menu items share `.kf-menu-item`, so labels are the only stable @@ -10632,6 +10879,205 @@ export function registerAllTests(): void { }) }) + suite('ui — CanvasStage polygon vertex-edit promotion', () => { + // Selecting a polygon at level 1 attaches the regular + // transformer (size + rotation). Double-clicking the polygon + // promotes it to vertex-edit mode (level 2): the transformer + // detaches and per-vertex handles render. Selecting a different + // layer drops the polygon back to level-1 next time. + + async function mountCanvasWithKonva() { + const VueKonva = (await import('vue-konva')).default + const host = document.createElement('div') + host.setAttribute('data-test-host', '') + host.style.width = '600px' + host.style.height = '400px' + 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('level-1: polygon selection attaches the transformer (no vertex handles)', async () => { + const s = makeFreshStore() + s.scene.canvas = { width: 400, height: 300, background: '#fff' } + const a = s.addAsset({ + kind: 'polygon', + name: 'P', + points: [ + { x: -30, y: -30 }, { x: 30, y: -30 }, { x: 30, y: 30 }, { x: -30, y: 30 }, + ], + fill: '#22cc88', + }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + const handle = await mountCanvasWithKonva() + try { + await nextTick() + await tick(40) + const stage = await getKonvaStage() as unknown as { + find: (sel: string) => Array<{ nodes: () => unknown[] }> + } + const tr = stage.find('Transformer').find((t) => t.nodes().length > 0) + expect(tr).toBeTruthy() + // Vertex handles render with name 'vertex-handle' on the + // Konva nodes; the test host element has no '.vertex-handle' + // DOM class but Konva exposes them via its own naming. We + // assert the absence by counting vertex-named nodes. + const stageT = await getKonvaStage() as unknown as { + find: (sel: string) => unknown[] + } + const verts = stageT.find('.vertex-handle') + expect(verts.length).toBe(0) + } finally { + handle.unmount() + } + }) + + test('level-2 via dblclick: vertex handles render and the transformer detaches', async () => { + const s = makeFreshStore() + s.scene.canvas = { width: 400, height: 300, background: '#fff' } + const a = s.addAsset({ + kind: 'polygon', + name: 'P', + points: [ + { x: -30, y: -30 }, { x: 30, y: -30 }, { x: 30, y: 30 }, { x: -30, y: 30 }, + ], + fill: '#22cc88', + }) + const l = s.addLayer(a.id)! + s.selectLayer(l.id) + const handle = await mountCanvasWithKonva() + try { + await nextTick() + await tick(40) + // Trigger the container's dblclick handler on the polygon's + // approximate stage centre. + const container = handle.host.querySelector('.stage-container') as HTMLElement + const r = container.getBoundingClientRect() + container.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, cancelable: true, + clientX: r.left + r.width / 2, + clientY: r.top + r.height / 2, + }), + ) + await nextTick() + await tick(40) + const stage = await getKonvaStage() as unknown as { + find: (sel: string) => unknown[] + } + // Vertex handles now exist: 4 polygon points → 4 vertex + // circles named 'vertex-handle'. + const verts = stage.find('.vertex-handle') as Array + expect(verts.length).toBe(4) + // Transformer no longer has the polygon attached. + const trs = stage.find('Transformer') as Array<{ nodes: () => unknown[] }> + const attached = trs.find((t) => t.nodes().length > 0) + expect(attached).toBeFalsy() + } finally { + handle.unmount() + } + }) + + test('SVG-edit scope follows the same level-1 / level-2 split', async () => { + // Polygon authoring inside an SVG asset uses the same UX as + // the main scene: selection lands at level 1 (transformer + // attaches, no vertex handles); a dbl-click promotes to + // level 2 and surfaces the vertex handles + edge-insert. + const s = makeFreshStore() + const svg = s.addSvgAsset('Logo', 200, 200) + s.enterSvgAssetEdit(svg.id) + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: -40, y: 0 }, { x: 0, y: -30 }, { x: 40, y: 0 }], + fill: '#abc', + }) as Extract + const l = s.addLayer(poly.id)! + s.selectLayer(l.id) + const handle = await mountCanvasWithKonva() + try { + await nextTick() + await tick(40) + const stage = await getKonvaStage() as unknown as { + find: (sel: string) => Array<{ nodes?: () => unknown[] }> + } + // Level 1 on selection: no vertex handles yet. + expect(stage.find('.vertex-handle').length).toBe(0) + // First dbl-click → promote, vertex handles light up, + // transformer detaches. + const container = handle.host.querySelector('.stage-container') as HTMLElement + const r = container.getBoundingClientRect() + container.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, cancelable: true, + clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, + }), + ) + await nextTick() + await tick(40) + expect(stage.find('.vertex-handle').length).toBe(3) + } finally { + handle.unmount() + s.exitSvgAssetEdit() + } + }) + + test('selecting a different layer drops the polygon back to level 1', async () => { + const s = makeFreshStore() + s.scene.canvas = { width: 400, height: 300, background: '#fff' } + const poly = s.addAsset({ + kind: 'polygon', name: 'P', + points: [{ x: -30, y: -30 }, { x: 30, y: -30 }, { x: 30, y: 30 }], + fill: '#22cc88', + }) + const lP = s.addLayer(poly.id)! + const rect = s.addAsset({ + kind: 'rect', name: 'R', width: 40, height: 40, fill: '#666', + }) + const lR = s.addLayer(rect.id)! + s.selectLayer(lP.id) + const handle = await mountCanvasWithKonva() + try { + await nextTick() + await tick(40) + const container = handle.host.querySelector('.stage-container') as HTMLElement + const r = container.getBoundingClientRect() + // dblclick → vertex-edit mode for polygon. + container.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, cancelable: true, + clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, + }), + ) + await nextTick() + await tick(40) + // Switch to the rect: vertex-edit mode should clear. + s.selectLayer(lR.id) + await nextTick() + await tick(40) + const stage = await getKonvaStage() as unknown as { + find: (sel: string) => unknown[] + } + // Switch back to the polygon — should be level-1 again, no + // vertex handles until the user dbl-clicks. + s.selectLayer(lP.id) + await nextTick() + await tick(40) + const verts = stage.find('.vertex-handle') + expect(verts.length).toBe(0) + } finally { + handle.unmount() + } + }) + }) + suite('ui — AssetSidebar audio section', () => { test('lists audio assets with a duration label under the Audio heading', async () => { const s = makeFreshStore() diff --git a/src/stores/scene.ts b/src/stores/scene.ts index 62bb823..0601e7d 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -298,6 +298,36 @@ function sortKfs(arr: Keyframe[]) { arr.sort((a, b) => a.time - b.time) } +/** Every layer in the project that references the given asset id — + * across the main scene pages, every clip's layer stack, and every + * button / svg asset's nested layers. Used by polygon-mutation + * actions so a point insert / remove updates kf.props.points + * correctly regardless of which scope the layer lives in. + * Without this, scope-specific layers (e.g. a freeform inside an + * SVG asset) would have their kf.points drift out of sync with + * the asset's reference array. */ +function layersUsingAsset(project: Project, assetId: string): Layer[] { + const out: Layer[] = [] + for (const page of project.scenes ?? []) { + for (const layer of page.layers ?? []) { + if (layer.assetId === assetId) out.push(layer) + } + } + for (const clip of project.clips ?? []) { + for (const layer of clip.layers ?? []) { + if (layer.assetId === assetId) out.push(layer) + } + } + for (const a of project.assets ?? []) { + if (a.kind === 'button' || a.kind === 'svg') { + for (const layer of a.layers ?? []) { + if (layer.assetId === assetId) out.push(layer) + } + } + } + return out +} + function upsertInto( arr: Keyframe[], time: number, @@ -1533,7 +1563,7 @@ export const useSceneStore = defineStore('scene', { if (!asset || asset.kind !== 'polygon') return const insertAt = Math.max(0, Math.min(atIndex, asset.points.length)) asset.points.splice(insertAt, 0, { x: point.x, y: point.y }) - const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) + const layersUsing = layersUsingAsset(this.project, assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue @@ -1562,7 +1592,7 @@ export const useSceneStore = defineStore('scene', { return arr } asset.points = resize(asset.points) - const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) + const layersUsing = layersUsingAsset(this.project, assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue @@ -1579,7 +1609,7 @@ export const useSceneStore = defineStore('scene', { if (asset.points.length <= 3) return if (pointIndex < 0 || pointIndex >= asset.points.length) return asset.points.splice(pointIndex, 1) - const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) + const layersUsing = layersUsingAsset(this.project, assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue @@ -1800,7 +1830,7 @@ export const useSceneStore = defineStore('scene', { const type = asset.points[index].type if (!type || type === 'corner') return const t = this.playhead - const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) + const layersUsing = layersUsingAsset(this.project, assetId) for (const layer of layersUsing) { // Backfill any existing keyframes that have points but lack tangents for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { @@ -1870,7 +1900,7 @@ export const useSceneStore = defineStore('scene', { const asset = this.scene.assets.find((a) => a.id === assetId) if (!asset || asset.kind !== 'polygon') return asset.points.reverse() - const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) + const layersUsing = layersUsingAsset(this.project, assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue @@ -1897,7 +1927,7 @@ export const useSceneStore = defineStore('scene', { x: prev.x + (next.x - prev.x) * tOnEdge, y: prev.y + (next.y - prev.y) * tOnEdge, }) - const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId) + const layersUsing = layersUsingAsset(this.project, assetId) for (const layer of layersUsing) { for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) { if (!arr) continue diff --git a/src/version.ts b/src/version.ts index 7548b61..68dd785 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '1.2.2' +export const APP_VERSION = '1.2.3' export const APP_NAME = 'RUWIO'