1.2.3: track-name shift/ctrl select, polygon level-1/level-2, cross-scope polygon ops
- Track-name click semantics in the Timeline:
- plain → single-select that layer
- shift → range-select every parent layer between anchor and click
(locked + locked-group children are filtered out)
- ctrl/cmd → toggle the clicked layer in/out of the selection
Anchor moves to the just-clicked id like a file manager.
- Polygon / freeform selection now lands at "level 1" — the regular
size + rotation transformer attaches like any other shape. A
double-click promotes to level 2 (vertex handles + edge-insert
dbl-click + tangent handles); selecting another layer drops back
to level 1. Same UX inside the SVG sub-editor.
- New layersUsingAsset(project, assetId) helper walks every layer
collection in the project (scenes, clips, button assets, svg
assets). All six polygon-mutation actions (addPolygonPoint,
removePolygonPoint, setPolygonPointCount, reversePolygonPoints,
movePolygonVertex, insertPolygonPointOnEdge) now use it, so
changes to the asset's points propagate to kf.props.points on
layers in any scope. Fixes "no node added on dblclick of a
freeform line inside an SVG asset".
- SvgLayerPanel buttons reset padding:0 — the global button reset
was eating the 22×22 row-button area with box-sizing:border-box
and clipping the Phosphor icons out of view.
This commit is contained in:
1 parent
c17e2e8a9f
commit
127812d3bf
7 files changed
+581
-17
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.2",
|
"version": "1.2.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1757,10 +1757,10 @@ interface VertexHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const polygonVertexHandles = computed<VertexHandle[]>(() => {
|
const polygonVertexHandles = computed<VertexHandle[]>(() => {
|
||||||
// Vertex handles only apply to a SOLO polygon selection — in a
|
// Vertex handles only apply to a SOLO polygon selection in
|
||||||
// mixed multi-selection the shared transformer bounding box owns
|
// *vertex-edit mode* (entered via double-click). Level-1
|
||||||
// scale / rotate, and per-vertex handles would visually fight it.
|
// selection of a polygon shows the regular transformer instead.
|
||||||
if (store.selectedLayerIds.length > 1) return []
|
if (!polygonInVertexEdit.value) return []
|
||||||
const id = store.selectedLayerId
|
const id = store.selectedLayerId
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
const layer = store.activeLayers.find((l) => l.id === id)
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
@@ -2085,10 +2085,26 @@ const selectedPolygonLayer = computed(() => {
|
|||||||
return { layer, asset }
|
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<string | null>(null)
|
||||||
|
|
||||||
|
const polygonInVertexEdit = computed(() => {
|
||||||
|
if (store.selectedLayerIds.length > 1) return false
|
||||||
|
const id = store.selectedLayerId
|
||||||
|
if (!id) return false
|
||||||
|
return polygonVertexEditId.value === id
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.selectedLayerId,
|
() => store.selectedLayerId,
|
||||||
() => {
|
() => {
|
||||||
selectedVertexIndices.value = new Set()
|
selectedVertexIndices.value = new Set()
|
||||||
|
polygonVertexEditId.value = null
|
||||||
closeVertexMenu()
|
closeVertexMenu()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2313,8 +2329,10 @@ function attachTransformer(retriesLeft = 3) {
|
|||||||
|
|
||||||
// Multi-select: wrap every selected layer node in one transformer
|
// Multi-select: wrap every selected layer node in one transformer
|
||||||
// so the bounding box + scale / rotate handles span the group.
|
// so the bounding box + scale / rotate handles span the group.
|
||||||
// A SOLE polygon selection still bypasses the transformer because
|
// A SOLE polygon selection bypasses the transformer ONLY when
|
||||||
// a single polygon is edited via vertex handles; mixed selections
|
// 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.
|
// that happen to include a polygon fold it into the shared bbox.
|
||||||
const ids = store.selectedLayerIds.length
|
const ids = store.selectedLayerIds.length
|
||||||
? store.selectedLayerIds
|
? store.selectedLayerIds
|
||||||
@@ -2333,7 +2351,13 @@ function attachTransformer(retriesLeft = 3) {
|
|||||||
const layer = store.activeLayers.find((l) => l.id === id)
|
const layer = store.activeLayers.find((l) => l.id === id)
|
||||||
if (!layer) continue
|
if (!layer) continue
|
||||||
const asset = assetMap.value.get(layer.assetId)
|
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<Konva.Node>(`#${id}`)
|
const node = stage.findOne<Konva.Node>(`#${id}`)
|
||||||
if (!node) {
|
if (!node) {
|
||||||
anyMissing = true
|
anyMissing = true
|
||||||
@@ -2359,6 +2383,7 @@ watch(
|
|||||||
store.selectedClipInstanceId,
|
store.selectedClipInstanceId,
|
||||||
stackItems.value,
|
stackItems.value,
|
||||||
store.playhead,
|
store.playhead,
|
||||||
|
polygonVertexEditId.value,
|
||||||
] as const,
|
] as const,
|
||||||
async () => {
|
async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -2700,7 +2725,9 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
|||||||
// selection — inside a multi-select the box always targets
|
// selection — inside a multi-select the box always targets
|
||||||
// layers so new picks add to the existing group.
|
// layers so new picks add to the existing group.
|
||||||
const mode: 'vertex' | 'layer' =
|
const mode: 'vertex' | 'layer' =
|
||||||
selectedPolygonLayer.value && store.selectedLayerIds.length <= 1
|
selectedPolygonLayer.value &&
|
||||||
|
store.selectedLayerIds.length <= 1 &&
|
||||||
|
polygonInVertexEdit.value
|
||||||
? 'vertex'
|
? 'vertex'
|
||||||
: 'layer'
|
: 'layer'
|
||||||
boxSelect.value = {
|
boxSelect.value = {
|
||||||
@@ -2894,6 +2921,16 @@ function onContainerDblClick(e: MouseEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (asset.kind !== 'polygon') 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 sampled = sampleLayer(layer, store.playhead)
|
||||||
const pts = sampled.points ?? asset.points
|
const pts = sampled.points ?? asset.points
|
||||||
if (pts.length < 1) return
|
if (pts.length < 1) return
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ function onDragEnd() {
|
|||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -409,6 +410,10 @@ function onDragEnd() {
|
|||||||
.row-btn {
|
.row-btn {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -2787,10 +2787,56 @@ const trackMenuRef = ref<HTMLDivElement | null>(null)
|
|||||||
|
|
||||||
function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) {
|
function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) {
|
||||||
if (isTrackEffectivelyLocked(track)) return
|
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)
|
store.toggleLayerSelection(track.layerId)
|
||||||
return
|
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)
|
store.selectLayer(track.layerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6030,6 +6030,136 @@ export function registerAllTests(): void {
|
|||||||
s.setPolygonClosed(asset.id, true)
|
s.setPolygonClosed(asset.id, true)
|
||||||
expect(asset.closed).toBe(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', () => {
|
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<HTMLElement>
|
||||||
|
// 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<HTMLElement>
|
||||||
|
// 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<HTMLElement>
|
||||||
|
// 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<HTMLElement>
|
||||||
|
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)', () => {
|
suite('ui — Timeline rename menu (layer + clip track)', () => {
|
||||||
// Finds the menu item whose label contains the given i18n string.
|
// Finds the menu item whose label contains the given i18n string.
|
||||||
// Menu items share `.kf-menu-item`, so labels are the only stable
|
// 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<unknown>
|
||||||
|
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<typeof s.scene.assets[number], { kind: 'polygon' }>
|
||||||
|
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', () => {
|
suite('ui — AssetSidebar audio section', () => {
|
||||||
test('lists audio assets with a duration label under the Audio heading', async () => {
|
test('lists audio assets with a duration label under the Audio heading', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
|
|||||||
+36
-6
@@ -298,6 +298,36 @@ function sortKfs(arr: Keyframe[]) {
|
|||||||
arr.sort((a, b) => a.time - b.time)
|
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(
|
function upsertInto(
|
||||||
arr: Keyframe[],
|
arr: Keyframe[],
|
||||||
time: number,
|
time: number,
|
||||||
@@ -1533,7 +1563,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (!asset || asset.kind !== 'polygon') return
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
const insertAt = Math.max(0, Math.min(atIndex, asset.points.length))
|
const insertAt = Math.max(0, Math.min(atIndex, asset.points.length))
|
||||||
asset.points.splice(insertAt, 0, { x: point.x, y: point.y })
|
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 layer of layersUsing) {
|
||||||
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
if (!arr) continue
|
if (!arr) continue
|
||||||
@@ -1562,7 +1592,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
asset.points = resize(asset.points)
|
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 layer of layersUsing) {
|
||||||
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
if (!arr) continue
|
if (!arr) continue
|
||||||
@@ -1579,7 +1609,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
if (asset.points.length <= 3) return
|
if (asset.points.length <= 3) return
|
||||||
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
if (pointIndex < 0 || pointIndex >= asset.points.length) return
|
||||||
asset.points.splice(pointIndex, 1)
|
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 layer of layersUsing) {
|
||||||
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
if (!arr) continue
|
if (!arr) continue
|
||||||
@@ -1800,7 +1830,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
const type = asset.points[index].type
|
const type = asset.points[index].type
|
||||||
if (!type || type === 'corner') return
|
if (!type || type === 'corner') return
|
||||||
const t = this.playhead
|
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) {
|
for (const layer of layersUsing) {
|
||||||
// Backfill any existing keyframes that have points but lack tangents
|
// Backfill any existing keyframes that have points but lack tangents
|
||||||
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
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)
|
const asset = this.scene.assets.find((a) => a.id === assetId)
|
||||||
if (!asset || asset.kind !== 'polygon') return
|
if (!asset || asset.kind !== 'polygon') return
|
||||||
asset.points.reverse()
|
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 layer of layersUsing) {
|
||||||
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
if (!arr) continue
|
if (!arr) continue
|
||||||
@@ -1897,7 +1927,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
x: prev.x + (next.x - prev.x) * tOnEdge,
|
x: prev.x + (next.x - prev.x) * tOnEdge,
|
||||||
y: prev.y + (next.y - prev.y) * 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 layer of layersUsing) {
|
||||||
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
|
||||||
if (!arr) continue
|
if (!arr) continue
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.2.2'
|
export const APP_VERSION = '1.2.3'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user