Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24d3c48
feat(spx-gui): add a configurable button to the custom transformer
Overu Dec 5, 2025
9236530
feat(spx-gui): add SpriteItem title visible icon
Overu Dec 7, 2025
8633a54
feat(spx-gui): add sprite quick configuration UI
Overu Dec 8, 2025
ef7293b
feat(spx-gui): complete sprite quick config UI
Overu Dec 9, 2025
ba32faa
refactor(spx-gui): implement a new quick configuration system for wid…
Overu Dec 10, 2025
ad197a5
refactor(spx-gui): move update handling logic into SpriteNode and Mon…
Overu Dec 10, 2025
44d92bd
add keyboard movement sprite
Overu Dec 10, 2025
d65acaa
feat(spx-gui): the movement of a graphic element (sprite) controlled …
Overu Dec 11, 2025
7ab2ffa
refactor(spx-gui): remove sprite basic config from editor panel
Overu Dec 11, 2025
72cf139
style(spx-gui): replace transformer icon
Overu Dec 11, 2025
9656202
refactor(spx-gui): extract common config items and rename panels for …
Overu Dec 11, 2025
1260f9f
feat(spx-gui): add quick config to MapViwer
Overu Dec 12, 2025
6433f52
refactor(spx-gui): modify QuickConfig style
Overu Dec 12, 2025
05d8e8c
refactor(spx-gui): replace custom-transformer icons
Overu Dec 15, 2025
11addf4
feat(spx-gui): make QuickConfig follow sprites
Overu Dec 15, 2025
62424d6
refactor(spx-gui): fix QuickConfig centering
Overu Dec 15, 2025
d987446
refactor(spx-gui): refactor config type handling to pass specific data
Overu Dec 16, 2025
adb98d8
refactor(spx-gui): fix config UI positioning for transformed nodes
Overu Dec 16, 2025
4175389
refactor(spx-gui): refine quick config initialization and interaction
Overu Dec 16, 2025
6b3ad9e
refactor(spx-gui): remove transformer config
Overu Dec 17, 2025
670bd74
refactor(spx-gui): enhance quick config stability with cleanup logic
Overu Dec 17, 2025
e4b784a
refactor(spx-gui): clean up code and add comments
Overu Dec 18, 2025
df5f317
refactor(spx-gui): improve data flow in QuickConfig
Overu Dec 24, 2025
1926a97
feat(spx-gui): unify sprite and widget quick config handlers
Overu Feb 3, 2026
219e55c
refactor(spx-gui): rm ZOrderConfigItem from cached
Overu Feb 3, 2026
5408329
refactor(spx-gui): move quick-config panels and improve monitor node …
Overu Feb 5, 2026
424e912
refactor(spx-gui): fix type-check
Overu Feb 5, 2026
ae4ffa3
refactor(spx-gui): refine sprite/widget transformation logic and fix …
Overu Feb 5, 2026
39770d5
refactor(spx-gui): simplify quick config popup management
Overu Feb 6, 2026
697e24a
feat(spx-gui): use LocalConfig for stage viewer nodes and quick config
Overu Feb 6, 2026
e4dfaff
refactor(spx-gui): use standard transform events for nodes
Overu Feb 9, 2026
4b9af4e
refactor(spx-gui): rename rotate to heading and refine quick-config l…
Overu Feb 9, 2026
4833d00
refactor(spx-gui): improve transformer events and quick config intera…
Overu Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions spx-gui/src/components/editor/common/AnglePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { computed, ref, watch } from 'vue'
import { useDraggableAngleForElement } from '@/utils/dom'
import { makeArcPathString } from '@/utils/svg'
import { nomalizeDegree, useDebouncedModel } from '@/utils/utils'
import { normalizeDegree, useDebouncedModel } from '@/utils/utils'
import { specialDirections } from '@/utils/spx'
import { UITag } from '@/components/ui'

Expand All @@ -16,7 +16,7 @@ const emit = defineEmits<{

const [modelValue] = useDebouncedModel<number>(
() => props.modelValue,
(v) => emit('update:modelValue', nomalizeDegree(Math.floor(v)))
(v) => emit('update:modelValue', normalizeDegree(Math.floor(v)))
)

const svgEl = ref<HTMLElement | null>(null)
Expand All @@ -26,7 +26,7 @@ const arcPath = computed(() => {
return makeArcPathString({ x: 70, y: 70, r: 63, start, end })
})
const angle = useDraggableAngleForElement(svgEl, { initialValue: props.modelValue, snap: 15 })
watch(angle, (v) => (modelValue.value = nomalizeDegree(v)))
watch(angle, (v) => (modelValue.value = normalizeDegree(v)))
</script>

<template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import { headingToLeftRight, LeftRight, leftRightToHeading, RotationStyle, type
import { wrapUpdateHandler } from '../utils'

import AnglePicker from '@/components/editor/common/AnglePicker.vue'
import { UIButtonGroup, UIButtonGroupItem, UIDropdown, UINumberInput, UITooltip } from '@/components/ui'
import rotateIcon from './rotate.svg?raw'
import leftRightIcon from './left-right.svg?raw'
import noRotateIcon from './no-rotate.svg?raw'
import { UIButtonGroup, UIButtonGroupItem, UIDropdown, UIIcon, UINumberInput, UITooltip } from '@/components/ui'

const props = defineProps<{
sprite: Sprite
Expand Down Expand Up @@ -71,23 +68,23 @@ const handleHeadingUpdate = wrapUpdateHandler(
{{ $t(rotationStyleTips.normal) }}
<template #trigger>
<UIButtonGroupItem :value="RotationStyle.Normal">
<i class="rotation-icon" v-html="rotateIcon"></i>
<UIIcon type="rotateAround" />
</UIButtonGroupItem>
</template>
</UITooltip>
<UITooltip>
{{ $t(rotationStyleTips.leftRight) }}
<template #trigger>
<UIButtonGroupItem :value="RotationStyle.LeftRight">
<i class="rotation-icon" v-html="leftRightIcon"></i>
<UIIcon type="leftRight" />
</UIButtonGroupItem>
</template>
</UITooltip>
<UITooltip>
{{ $t(rotationStyleTips.none) }}
<template #trigger>
<UIButtonGroupItem :value="RotationStyle.None">
<i class="rotation-icon" v-html="noRotateIcon"></i>
<UIIcon type="notRotate" />
</UIButtonGroupItem>
</template>
</UITooltip>
Expand Down Expand Up @@ -148,14 +145,4 @@ const handleHeadingUpdate = wrapUpdateHandler(
align-items: center;
gap: 12px;
}

.rotation-icon {
display: flex;
width: 16px;
height: 16px;
:deep(svg) {
width: 100%;
height: 100%;
}
}
</style>

This file was deleted.

This file was deleted.

3 changes: 0 additions & 3 deletions spx-gui/src/components/editor/common/config/sprite/rotate.svg

This file was deleted.

4 changes: 2 additions & 2 deletions spx-gui/src/components/editor/common/viewer/DecoratorNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { computed } from 'vue'
import type { ImageConfig } from 'konva/lib/shapes/Image'
import type { Size } from '@/models/common'
import type { Decorator } from '@/models/tilemap'
import { nomalizeDegree } from '@/utils/utils'
import { normalizeDegree } from '@/utils/utils'
import { useFileImg } from '@/utils/file'

const props = defineProps<{
Expand All @@ -24,7 +24,7 @@ const config = computed<ImageConfig>(() => {
offsetY: pivot.y,
x: props.mapSize.width / 2 + position.x,
y: props.mapSize.height / 2 - position.y,
rotation: nomalizeDegree(rotation - 90),
rotation: normalizeDegree(rotation - 90),
scaleX: scale.x,
scaleY: scale.y
} satisfies ImageConfig
Expand Down
37 changes: 35 additions & 2 deletions spx-gui/src/components/editor/common/viewer/NodeTransformer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
</template>

<script setup lang="ts">
import { computed, effect, nextTick, ref } from 'vue'
import { computed, nextTick, ref, watchEffect } from 'vue'
import type { Node } from 'konva/lib/Node'
import { Sprite } from '@/models/sprite'
import type { Widget } from '@/models/widget'
import type { CustomTransformer, CustomTransformerConfig } from './custom-transformer'
import { getNodeId } from './common'
import { debounce } from 'lodash'
import type Konva from 'konva'

const props = defineProps<{
target: Sprite | Widget | null
Expand All @@ -30,7 +32,36 @@ const config = computed<CustomTransformerConfig>(() => {
}
})

effect(async () => {
const keyboardMovementCodes = ['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft']
const keyboardMovementOffset = [
[0, -1],
[1, 0],
[0, 1],
[-1, 0]
]

function setupKeyboardMovement(stage: Konva.Stage, selectedNode: Node) {
stage.container().tabIndex = 1
stage.container().focus()
stage.container().style.outline = 'none'
const keyboardMovementEnd = debounce(() => selectedNode.fire('dragend'), 500)
const handler = (e: KeyboardEvent) => {
const idx = keyboardMovementCodes.indexOf(e.code)
if (idx === -1) return
selectedNode.x(selectedNode.x() + keyboardMovementOffset[idx][0])
selectedNode.y(selectedNode.y() + keyboardMovementOffset[idx][1])
selectedNode.fire('dragmove')
e.preventDefault()
keyboardMovementEnd()
}
stage.container().addEventListener('keydown', handler)
return () => {
keyboardMovementEnd.cancel()
stage.container().removeEventListener('keydown', handler)
}
}

watchEffect(async (onCleanup) => {
if (transformer.value == null) return
const transformerNode = transformer.value.getNode()
transformerNode.nodes([])
Expand All @@ -44,6 +75,8 @@ effect(async () => {
if (selectedNode == null || selectedNode === (transformerNode as any).node()) return
await nextTick() // Wait to ensure the selected node updated by Konva
transformerNode.nodes([selectedNode])

onCleanup(setupKeyboardMovement(stage, selectedNode))
})

defineExpose({
Expand Down
140 changes: 105 additions & 35 deletions spx-gui/src/components/editor/common/viewer/SpriteNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@
import { computed, onMounted, ref, watchEffect } from 'vue'
import type { KonvaEventObject } from 'konva/lib/Node'
import type { Image, ImageConfig } from 'konva/lib/shapes/Image'
import type { Action, Project } from '@/models/project'
import { LeftRight, RotationStyle, headingToLeftRight, leftRightToHeading, type Sprite } from '@/models/sprite'
import type { Project } from '@/models/project'
import { LeftRight, RotationStyle, headingToLeftRight, leftRightToHeading } from '@/models/sprite'
import type { Size } from '@/models/common'
import { nomalizeDegree, round, useAsyncComputedLegacy } from '@/utils/utils'
import { normalizeDegree, round, useAsyncComputedLegacy } from '@/utils/utils'
import { useFileImg } from '@/utils/file'
import { cancelBubble, getNodeId } from './common'
import type { SpriteLocalConfig } from './quick-config/utils'
import type { TransformOp } from './custom-transformer'
import type Konva from 'konva'

const props = defineProps<{
sprite: Sprite
localConfig: SpriteLocalConfig
selected: boolean
project: Project
mapSize: Size
Expand All @@ -22,19 +25,35 @@ export type CameraScrollNotifyFn = (
delta: { x: number; y: number }
) => void

type ConfigGetter = {
get x(): number
get y(): number
get rotationStyle(): RotationStyle
get heading(): number
get size(): number
get visible(): boolean
}

const emit = defineEmits<{
selected: []
dragMove: [notifyCameraScroll: CameraScrollNotifyFn]
dragEnd: []
updateTransformOp: [op: TransformOp | null]
}>()

const nodeRef = ref<KonvaNodeInstance<Image>>()
const costume = computed(() => props.sprite.defaultCostume)
const costume = computed(() => props.localConfig.defaultCostume)
const bitmapResolution = computed(() => costume.value?.bitmapResolution ?? 1)
const [image] = useFileImg(() => costume.value?.img)
const rawSize = useAsyncComputedLegacy(async () => costume.value?.getRawSize() ?? null)

const nodeId = computed(() => getNodeId(props.sprite))
const nodeId = computed(() => getNodeId(props.localConfig))

const snapshotRef = ref<ConfigGetter | null>(null)
const configGetter = computed(() => {
if (snapshotRef.value != null) return snapshotRef.value
return props.localConfig
})

watchEffect((onCleanup) => {
props.nodeReadyMap.set(nodeId.value, image.value != null)
Expand All @@ -48,39 +67,87 @@ onMounted(() => {
// Konva warning: Node has no parent. zIndex parameter is ignored.
// Konva warning: Unexpected value 2 for zIndex property. zIndex is just index of a node in children of its parent. Expected value is from 0 to 1.
// ```
const zIndex = props.project.zorder.indexOf(props.sprite.id)
const zIndex = props.project.zorder.indexOf(props.localConfig.id)
if (zIndex >= 0) {
nodeRef.value!.getNode().zIndex(zIndex)
}
})

function updateLocalConfigByShape(node: Konva.Node) {
if (!props.selected) return
const localConfig = props.localConfig
const { x: oldX, y: oldY, heading: oldHeading, size: oldSize } = configGetter.value
const size = toSize(node)
if (oldSize !== size) {
localConfig.setSize(size)
emit('updateTransformOp', 'scale')
}
const heading = toHeading(node)
if (oldHeading !== heading && localConfig.rotationStyle === RotationStyle.Normal) {
localConfig.setHeading(heading)
emit('updateTransformOp', 'rotate')
}
// Sprite's pivot causes x or y to change when size or heading changes, so they need to be updated together
const { x, y } = toPosition(node)
if (oldX !== x || oldY !== y) {
localConfig.setX(x)
localConfig.setY(y)
}
}

function syncLocalConfigByShape(node: Konva.Node) {
const localConfig = props.localConfig
localConfig.setSize(toSize(node))
localConfig.setHeading(toHeading(node))
const { x, y } = toPosition(node)
localConfig.setX(x)
localConfig.setY(y)

localConfig.sync()
}

function handleDragMove(e: KonvaEventObject<unknown>) {
cancelBubble(e)
const localConfig = props.localConfig
const { x, y } = toPosition(e.target)
localConfig.setX(x)
localConfig.setY(y)
emit('updateTransformOp', 'move')

emit('dragMove', (delta) => {
// Adjust position if camera scrolled during dragging to keep the sprite visually unmoved
e.target.x(e.target.x() - delta.x)
e.target.y(e.target.y() - delta.y)
})
}

function handleDragEnd(e: KonvaEventObject<unknown>) {
function handleDragEnd(e: KonvaEventObject<TransformOp>) {
cancelBubble(e)
const sname = props.sprite.name
handleChange(e, {
name: { en: `Move sprite ${sname}`, zh: `移动精灵 ${sname}` }
})
syncLocalConfigByShape(e.target)
emit('dragEnd')
}

function handleTransformed(e: KonvaEventObject<unknown>) {
const sname = props.sprite.name
handleChange(e, {
name: { en: `Transform sprite ${sname}`, zh: `调整精灵 ${sname}` }
})
// Temporarily cache localConfig data at the start of transformation to prevent abnormal Konva.Node behavior caused by continuous data updates during the process.
function handleTransformStart() {
snapshotRef.value = {
x: props.localConfig.x,
y: props.localConfig.y,
heading: props.localConfig.heading,
size: props.localConfig.size,
visible: props.localConfig.visible,
rotationStyle: props.localConfig.rotationStyle
}
}
function handleTransform(e: KonvaEventObject<unknown>) {
updateLocalConfigByShape(e.target)
}
function handleTransformEnd(e: KonvaEventObject<unknown>) {
syncLocalConfigByShape(e.target)
snapshotRef.value = null
}

const config = computed<ImageConfig>(() => {
const { visible, x, y, rotationStyle, heading, size } = props.sprite
const { visible, x, y, rotationStyle, heading, size } = configGetter.value
const scale = size / bitmapResolution.value
const costumePivot = costume.value?.pivot ?? { x: 0, y: 0 }
const config = {
Expand All @@ -94,7 +161,7 @@ const config = computed<ImageConfig>(() => {
visible: visible,
x: props.mapSize.width / 2 + x,
y: props.mapSize.height / 2 - y,
rotation: nomalizeDegree(heading - 90),
rotation: normalizeDegree(heading - 90),
scaleX: scale,
scaleY: scale
} satisfies ImageConfig
Expand All @@ -108,22 +175,23 @@ const config = computed<ImageConfig>(() => {
return config
})

/** Handler for position-change (drag) or transform */
function handleChange(e: KonvaEventObject<unknown>, action: Action) {
const { sprite, mapSize } = props
const x = round(e.target.x() - mapSize.width / 2)
const y = round(mapSize.height / 2 - e.target.y())
let heading = sprite.heading
if (sprite.rotationStyle === RotationStyle.Normal || sprite.rotationStyle === RotationStyle.LeftRight) {
heading = nomalizeDegree(round(e.target.rotation() + 90))
function toPosition(node: Konva.Node) {
const { mapSize } = props
const x = round(node.x() - mapSize.width / 2)
const y = round(mapSize.height / 2 - node.y())
return { x, y }
}
function toHeading(node: Konva.Node) {
const { localConfig } = props
let heading = localConfig.heading
if (localConfig.rotationStyle === RotationStyle.Normal || localConfig.rotationStyle === RotationStyle.LeftRight) {
heading = normalizeDegree(round(node.rotation() + 90))
}
const size = round(Math.abs(e.target.scaleX()) * bitmapResolution.value, 2)
props.project.history.doAction(action, () => {
sprite.setX(x)
sprite.setY(y)
sprite.setHeading(heading)
sprite.setSize(size)
})
return heading
}
function toSize(node: Konva.Node) {
const size = round(Math.abs(node.scaleX()) * bitmapResolution.value, 2)
return size
}

function handleClick() {
Expand All @@ -137,7 +205,9 @@ function handleClick() {
:config="config"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transformend="handleTransformed"
@transformstart="handleTransformStart"
@transform="handleTransform"
@transformend="handleTransformEnd"
@click="handleClick"
/>
</template>
Loading