场景对象的复制与粘贴+配置追加功能

This commit is contained in:
tianmo committed 2026-11-09 11:34:09 +08:00
1 parent 3587129b90
commit 6dd32ec8fc
24 files changed
+761 -14

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
import type { CollisionBody, ParticleSystem, ScenePath, useParticleStore } from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes'
import { ensureEmitterConfig } from '../core/particleEmitter'
import { registerSpineAsset } from '../spine/spineAssetRegistry'
import { translate } from '../i18n'
import { cloneEditorValue } from './editorClone'
import {
hydrateParticleResources,
parseEditorConfig,
parseEmbeddedSpines,
withoutLegacyColliderTags,
} from './editorConfig'
type EditorStore = ReturnType<typeof useParticleStore>
function nextId(items: readonly { id: number }[]) {
return Math.max(0, ...items.map((item) => Number(item.id) || 0)) + 1
}
function remapId(map: ReadonlyMap<number, number>, id: number) {
return map.get(id) || 0
}
export async function appendEditorConfigText(store: EditorStore, text: string) {
const config = parseEditorConfig(text)
const systems = cloneEditorValue(config.scene.systems) as unknown as ParticleSystem[]
const colliders = config.scene.colliders.map(withoutLegacyColliderTags)
const paths = cloneEditorValue(config.scene.paths) as unknown as ScenePath[]
const spines = cloneEditorValue(config.scene.spines) as unknown as SpineSceneObject[]
let systemId = nextId(store.systems)
let colliderId = nextId(store.colliders)
let pathId = nextId(store.paths)
let spineId = nextId(store.spines)
const systemIds = new Map<number, number>()
const colliderIds = new Map<number, number>()
const pathIds = new Map<number, number>()
const spineIds = new Map<number, number>()
for (const system of systems) {
systemIds.set(system.id, systemId)
system.id = systemId++
}
for (const collider of colliders) {
colliderIds.set(collider.id, colliderId)
collider.id = colliderId++
}
for (const path of paths) {
pathIds.set(path.id, pathId)
path.id = pathId++
}
for (const spine of spines) {
if (spine.sourceBundle == null) spine.sourceBundle = null
if (spine.dataVersion == null) spine.dataVersion = ''
if (!Array.isArray(spine.slots)) spine.slots = []
if (typeof spine.selectedBone !== 'string') spine.selectedBone = ''
if (typeof spine.selectedSlot !== 'string') spine.selectedSlot = ''
spineIds.set(spine.id, spineId)
spine.id = spineId++
}
for (const system of systems) {
const config = ensureEmitterConfig(system.config as any)
system.frames = undefined
config.collisionColliderIds = config.collisionColliderIds
.map((id) => remapId(colliderIds, id))
.filter((id) => id > 0)
config.spawnPathId = remapId(pathIds, config.spawnPathId)
if (!config.spawnPathId && (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent')) {
config.emissionAngleMode = 'fixed'
}
config.emitterFollowPathId = remapId(pathIds, config.emitterFollowPathId)
config.emitterFollowSpineId = remapId(spineIds, config.emitterFollowSpineId)
config.renderLayerSpineId = remapId(spineIds, config.renderLayerSpineId)
for (const rule of config.events || []) {
rule.targetSystemId = remapId(systemIds, rule.targetSystemId)
}
}
for (const collider of colliders) {
collider.followPathId = remapId(pathIds, collider.followPathId)
if (!collider.followPathId) collider.followPath = false
}
await hydrateParticleResources(systems)
const parsedSpines = await parseEmbeddedSpines(spines)
const animations = [...store.timeline.animations]
for (const animation of config.timeline?.animations || []) {
const name = typeof animation === 'string' ? animation.trim() : ''
if (name && !animations.includes(name)) animations.push(name)
}
for (const system of systems) {
if (!animations.includes(system.animation)) {
system.animation = store.timeline.animation || store.timeline.animations[0] || 'animation'
}
}
for (const spine of spines) {
const parsed = parsedSpines.get(spine.id)
spine.assetVersion = Math.max(1, Number(spine.assetVersion) || 0) + 1
if (!parsed) {
spine.status = 'empty'
spine.error = spine.skeletonFileName ? translate('errors.missingEmbeddedSpine') : ''
spine.animations = []
spine.bones = []
spine.slots = []
spine.selectedAnimation = ''
continue
}
registerSpineAsset(spine.id, parsed)
spine.dataVersion = parsed.dataVersion
spine.animations = parsed.animations
spine.bones = parsed.bones
spine.slots = parsed.slots
if (!parsed.animations.some((animation) => animation.name === spine.selectedAnimation)) {
spine.selectedAnimation = parsed.animations[0]?.name || ''
}
spine.status = 'ready'
spine.error = ''
}
store.systems.push(...systems)
store.colliders.push(...colliders)
store.paths.push(...paths)
store.spines.push(...spines)
store.timeline.animations = animations
store.syncSceneSequences()
if (systems[0]) store.selectSceneObject('particle', systems[0].id)
else if (colliders[0]) store.selectSceneObject('collision', colliders[0].id)
else if (paths[0]) store.selectSceneObject('path', paths[0].id)
else if (spines[0]) store.selectSceneObject('spine', spines[0].id)
store.recalcTotalFrames()
}
+3 -3
View File
@@ -83,7 +83,7 @@ function jsonClone<T>(value: T): T {
})) as T
}
function withoutLegacyColliderTags(collider: CollisionBody): CollisionBody {
export function withoutLegacyColliderTags(collider: CollisionBody): CollisionBody {
const copy = cloneEditorValue(collider) as CollisionBody & { tags?: unknown; selectedTag?: unknown }
delete copy.tags
delete copy.selectedTag
@@ -192,7 +192,7 @@ export function parseEditorConfig(text: string): EditorConfigFile {
return candidate as EditorConfigFile
}
async function hydrateParticleResources(systems: ParticleSystem[]) {
export async function hydrateParticleResources(systems: ParticleSystem[]) {
const textureCache = new Map<string, Promise<Texture>>()
const loadTexture = (url: string) => {
const source = url === '/particles/star.png'
@@ -225,7 +225,7 @@ async function hydrateParticleResources(systems: ParticleSystem[]) {
}))
}
async function parseEmbeddedSpines(spines: SpineSceneObject[]) {
export async function parseEmbeddedSpines(spines: SpineSceneObject[]) {
const parsed = new Map<number, ParsedSpineBundle>()
try {
for (const spine of spines) {
+275
View File
@@ -0,0 +1,275 @@
import type {
CollisionBody,
ParticleSystem,
SceneObjectType,
ScenePath,
useParticleStore,
} from '../store/particleStore'
import type { SpineSceneObject } from '../spine/spineTypes'
import { registerSpineAsset, releaseSpineAsset } from '../spine/spineAssetRegistry'
import { ensureEmitterConfig } from '../core/particleEmitter'
import { collectEmitterAncestors } from '../core/subEmitterIds'
import { translate } from '../i18n'
import { cloneEditorValue } from './editorClone'
import { hydrateParticleResources, parseEmbeddedSpines, withoutLegacyColliderTags } from './editorConfig'
type EditorStore = ReturnType<typeof useParticleStore>
const CLIPBOARD_FORMAT = 'SpineParticle.SceneObject'
const CLIPBOARD_VERSION = 1
type ClipboardObject = ParticleSystem | CollisionBody | ScenePath | SpineSceneObject
interface SceneObjectClipboardPayload {
format: typeof CLIPBOARD_FORMAT
version: typeof CLIPBOARD_VERSION
type: SceneObjectType
object: ClipboardObject
}
let pasteBusy = false
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function jsonClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value, (key, item) => {
if (key === 'frames' || key === 'texture' || key === 'trailTexture') return undefined
return item
})) as T
}
function objectForCopy(store: EditorStore, type: SceneObjectType, id: number): ClipboardObject | null {
if (type === 'particle') {
const system = store.systems.find((item) => item.id === id)
return system ? jsonClone(system) as unknown as ParticleSystem : null
}
if (type === 'collision') {
const collider = store.colliders.find((item) => item.id === id)
return collider ? withoutLegacyColliderTags(collider) : null
}
if (type === 'path') {
const path = store.paths.find((item) => item.id === id)
return path ? cloneEditorValue(path) : null
}
const spine = store.spines.find((item) => item.id === id)
if (!spine) return null
const copy = jsonClone(spine)
copy.selectedBone = ''
copy.selectedSlot = ''
copy.status = copy.sourceBundle ? 'ready' : 'empty'
copy.error = ''
return copy
}
function parsePayload(text: string): SceneObjectClipboardPayload {
let value: unknown
try {
value = JSON.parse(text, (key, item) => (
key === '__proto__' || key === 'prototype' || key === 'constructor' ? undefined : item
))
} catch {
throw new Error(translate('scene.clipboardInvalid'))
}
if (!isRecord(value)
|| value.format !== CLIPBOARD_FORMAT
|| value.version !== CLIPBOARD_VERSION
|| !['particle', 'collision', 'path', 'spine'].includes(String(value.type))
|| !isRecord(value.object)) {
throw new Error(translate('scene.clipboardInvalid'))
}
return value as unknown as SceneObjectClipboardPayload
}
function validObjectForType(payload: SceneObjectClipboardPayload) {
const object = payload.object as unknown as Record<string, unknown>
if (payload.type === 'particle') return isRecord(object.config)
if (payload.type === 'collision') return typeof object.name === 'string'
if (payload.type === 'path') return typeof object.name === 'string' && Array.isArray(object.points)
return typeof object.name === 'string'
&& (object.sourceBundle == null || isRecord(object.sourceBundle))
}
function typeLabel(type: SceneObjectType) {
return type === 'spine' ? 'Spine' : translate(`scene.${type}`)
}
function resetParticleReferences(store: EditorStore, system: ParticleSystem) {
const config = ensureEmitterConfig(system.config as any)
const systems = store.systems.map((item) => (item.id === system.id ? system : item))
const systemIds = new Set(systems.map((item) => item.id))
const colliderIds = new Set(store.colliders.map((item) => item.id))
const pathIds = new Set(store.paths.map((item) => item.id))
const spineIds = new Set(store.spines.map((item) => item.id))
const ancestors = collectEmitterAncestors(systems, system.id)
config.collisionColliderIds = config.collisionColliderIds.filter((id) => colliderIds.has(id))
if (!pathIds.has(config.spawnPathId)) {
config.spawnPathId = 0
if (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent') {
config.emissionAngleMode = 'fixed'
}
}
for (const rule of config.events || []) {
if (!systemIds.has(rule.targetSystemId) || rule.targetSystemId === system.id || ancestors.has(rule.targetSystemId)) {
rule.targetSystemId = 0
}
}
if (config.emitterFollowMode === 'path' && !pathIds.has(config.emitterFollowPathId)) {
config.emitterFollow = false
config.emitterFollowMode = 'none'
config.emitterFollowPathId = 0
}
if (!spineIds.has(config.emitterFollowSpineId)) {
config.emitterFollowSpineId = 0
config.emitterFollowBoneName = ''
if (config.emitterFollowMode === 'bone') {
config.emitterFollow = false
config.emitterFollowMode = 'none'
}
}
if (config.emitterFollowMode === 'bone') {
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
if (!spine?.bones.some((bone) => bone.name === config.emitterFollowBoneName)) {
config.emitterFollow = false
config.emitterFollowMode = 'none'
config.emitterFollowSpineId = 0
config.emitterFollowBoneName = ''
}
}
if (!spineIds.has(config.renderLayerSpineId)) {
config.renderLayerSpineId = 0
config.renderLayerSlotName = ''
if (config.renderLayerMode === 'slot') config.renderLayerMode = 'top'
}
}
export async function copySceneObjectToClipboard(store: EditorStore, type: SceneObjectType, id: number) {
const object = objectForCopy(store, type, id)
if (!object) throw new Error(translate('scene.clipboardInvalid'))
if (!navigator.clipboard?.writeText) throw new Error(translate('scene.clipboardUnavailable'))
const payload: SceneObjectClipboardPayload = {
format: CLIPBOARD_FORMAT,
version: CLIPBOARD_VERSION,
type,
object,
}
try {
await navigator.clipboard.writeText(JSON.stringify(payload))
} catch {
throw new Error(translate('scene.clipboardCopyFailed'))
}
}
export async function pasteSceneObjectFromClipboard(store: EditorStore, type: SceneObjectType, id: number) {
if (pasteBusy) return
pasteBusy = true
try {
if (!navigator.clipboard?.readText) throw new Error(translate('scene.clipboardUnavailable'))
let text: string
try {
text = await navigator.clipboard.readText()
} catch {
throw new Error(translate('scene.clipboardReadFailed'))
}
const payload = parsePayload(text)
if (!validObjectForType(payload)) throw new Error(translate('scene.clipboardInvalid'))
if (payload.type !== type) {
throw new Error(translate('scene.clipboardTypeMismatch', {
source: typeLabel(payload.type),
target: typeLabel(type),
}))
}
if (type === 'particle') {
const index = store.systems.findIndex((item) => item.id === id)
if (index < 0) throw new Error(translate('scene.clipboardInvalid'))
const target = store.systems[index]
const source = payload.object as ParticleSystem
if (!isRecord(source.config)) throw new Error(translate('scene.clipboardInvalid'))
const system = cloneEditorValue(source)
system.id = target.id
system.config = ensureEmitterConfig(system.config as any)
system.config.name = target.config.name
system.frames = undefined
system.animation = store.timeline.animations.includes(system.animation)
? system.animation
: store.timeline.animation
resetParticleReferences(store, system)
await hydrateParticleResources([system])
store.systems.splice(index, 1, system)
store.selectSceneObject('particle', system.id)
store.recalcTotalFrames()
return
}
if (type === 'collision') {
const index = store.colliders.findIndex((item) => item.id === id)
if (index < 0) throw new Error(translate('scene.clipboardInvalid'))
const target = store.colliders[index]
const collider = withoutLegacyColliderTags(payload.object as CollisionBody)
collider.id = target.id
collider.name = target.name
if (!store.paths.some((item) => item.id === collider.followPathId)) {
collider.followPath = false
collider.followPathId = 0
}
store.colliders.splice(index, 1, collider)
store.selectSceneObject('collision', collider.id)
return
}
if (type === 'path') {
const index = store.paths.findIndex((item) => item.id === id)
if (index < 0) throw new Error(translate('scene.clipboardInvalid'))
const target = store.paths[index]
const path = cloneEditorValue(payload.object as ScenePath)
path.id = target.id
path.name = target.name
store.paths.splice(index, 1, path)
store.selectSceneObject('path', path.id)
return
}
const index = store.spines.findIndex((item) => item.id === id)
if (index < 0) throw new Error(translate('scene.clipboardInvalid'))
const target = store.spines[index]
const spine = cloneEditorValue(payload.object as SpineSceneObject)
spine.id = target.id
spine.name = target.name
spine.selectedBone = ''
spine.selectedSlot = ''
spine.assetVersion = target.assetVersion + 1
spine.status = 'loading'
spine.error = ''
const parsed = (await parseEmbeddedSpines([spine])).get(spine.id)
if (parsed) {
releaseSpineAsset(target.id)
registerSpineAsset(spine.id, parsed)
spine.dataVersion = parsed.dataVersion
spine.animations = parsed.animations
spine.bones = parsed.bones
spine.slots = parsed.slots
if (!parsed.animations.some((animation) => animation.name === spine.selectedAnimation)) {
spine.selectedAnimation = parsed.animations[0]?.name || ''
}
spine.status = 'ready'
} else {
releaseSpineAsset(target.id)
spine.dataVersion = ''
spine.animations = []
spine.bones = []
spine.slots = []
spine.selectedAnimation = ''
spine.status = 'empty'
}
store.spines.splice(index, 1, spine)
store.selectSceneObject('spine', spine.id)
store.recalcTotalFrames()
} catch (error) {
if (error instanceof Error && error.message) throw error
throw new Error(translate('scene.clipboardPasteFailed'))
} finally {
pasteBusy = false
}
}
+13
View File
@@ -164,6 +164,9 @@
"storageUnavailable": "Der lokale Browserspeicher ist nicht verfügbar",
"saved": "Im Browser gespeichert: {name}",
"loaded": "Aus dem Browser geladen: {name}",
"appendToScene": "Diese Konfiguration an die Szene anhängen",
"appending": "Konfiguration wird angehängt: {name}",
"appended": "An die Szene angehängt: {name}",
"deleted": "Browserkonfiguration gelöscht: {name}",
"downloadEmpty": "Keine Konfigurationen zum Herunterladen",
"downloaded": "Heruntergeladen: {name}"
@@ -501,6 +504,14 @@
"addPathShort": "+ Pfad",
"addSpine": "Fügen Sie Spine hinzu",
"copyTitle": "Kopieren Sie das aktuell ausgewählte Szenenobjekt",
"contextCopy": "In die Zwischenablage kopieren",
"contextPaste": "Einfügen und überschreiben",
"clipboardUnavailable": "Dieser Browser erlaubt keinen Zugriff auf die Zwischenablage",
"clipboardInvalid": "Die Zwischenablage enthält kein gültiges Szenenobjekt",
"clipboardCopyFailed": "Das Szenenobjekt konnte nicht in die Zwischenablage kopiert werden",
"clipboardReadFailed": "Die Zwischenablage konnte nicht gelesen werden. Prüfen Sie die Browserberechtigungen.",
"clipboardTypeMismatch": "Nur Objekte desselben Typs können überschrieben werden (Zwischenablage: {source}, aktuell: {target})",
"clipboardPasteFailed": "Das Szenenobjekt konnte nicht wiederhergestellt werden",
"showParticle": "Partikelsystem anzeigen",
"hideParticle": "Partikelsystem ausblenden",
"showCollider": "Collider anzeigen",
@@ -802,6 +813,8 @@
"eye": "kleine Augen",
"eyeDesc": "Objekte einzeln ein-/ausblenden; Verborgene Partikelsysteme werden nicht exportiert.",
"copyDesc": "Kopiert das aktuell ausgewählte Objekt vollständig, um den Vergleich verschiedener Parameter zu erleichtern.",
"sceneClipboard": "Szenen-Zwischenablage",
"sceneClipboardDesc": "Rechtsklick auf ein Szenenobjekt, um es zu kopieren oder durch Einfügen zu ersetzen.",
"backgroundDesc": "Laden Sie das Referenzbild hoch und passen Sie Position und Transparenz nur für die Leinwandvorschau an.",
"settingsDesc": "Das Layout der Verwaltungsoberfläche, die Zusatzanzeige, der Maßstab usw. werden automatisch im Browser gespeichert.",
"undoDesc": "Ctrl+Z fährt einen Schritt zurück; Die maximale Schrittanzahl kann in den Systemeinstellungen angepasst werden.",
+13
View File
@@ -176,6 +176,9 @@
"storageUnavailable": "Browser local storage is unavailable",
"saved": "Saved to browser: {name}",
"loaded": "Loaded from browser: {name}",
"appendToScene": "Append this configuration to the scene",
"appending": "Appending configuration: {name}",
"appended": "Appended to scene: {name}",
"deleted": "Deleted browser configuration: {name}",
"downloadEmpty": "No configurations to download",
"downloaded": "Downloaded: {name}"
@@ -513,6 +516,14 @@
"addPathShort": "+ Path",
"addSpine": "Add Spine",
"copyTitle": "Duplicate selected scene object",
"contextCopy": "Copy to clipboard",
"contextPaste": "Paste and overwrite",
"clipboardUnavailable": "This browser does not allow clipboard access",
"clipboardInvalid": "The clipboard does not contain a valid scene object",
"clipboardCopyFailed": "Could not copy the scene object to the clipboard",
"clipboardReadFailed": "Could not read the clipboard. Check browser permissions.",
"clipboardTypeMismatch": "Only objects of the same type can be overwritten (clipboard: {source}, current: {target})",
"clipboardPasteFailed": "Could not restore the scene object",
"showParticle": "Show particle system",
"hideParticle": "Hide particle system",
"showCollider": "Show collider",
@@ -825,6 +836,8 @@
"eye": "Visibility",
"eyeDesc": "Show or hide an object independently. Hidden particle systems are not exported.",
"copyDesc": "Duplicate the selected object for parameter comparisons.",
"sceneClipboard": "Scene clipboard",
"sceneClipboardDesc": "Right-click a scene object to copy it to the clipboard or overwrite it by pasting.",
"backgroundDesc": "Upload a reference image and adjust its position and opacity for canvas preview only.",
"settingsDesc": "Manage layout, canvas guides, tick display, and other editor settings.",
"undoDesc": "Ctrl+Z undoes one step. The maximum history size is configurable.",
+13
View File
@@ -164,6 +164,9 @@
"storageUnavailable": "El almacenamiento local del navegador no está disponible",
"saved": "Guardado en el navegador: {name}",
"loaded": "Cargado desde el navegador: {name}",
"appendToScene": "Añadir esta configuración a la escena",
"appending": "Añadiendo configuración: {name}",
"appended": "Añadido a la escena: {name}",
"deleted": "Configuración eliminada del navegador: {name}",
"downloadEmpty": "No hay configuraciones para descargar",
"downloaded": "Descargado: {name}"
@@ -501,6 +504,14 @@
"addPathShort": "+ camino",
"addSpine": "Añadir Spine",
"copyTitle": "Copiar el objeto de escena seleccionado actualmente",
"contextCopy": "Copiar al portapapeles",
"contextPaste": "Pegar y sobrescribir",
"clipboardUnavailable": "Este navegador no permite acceder al portapapeles",
"clipboardInvalid": "El portapapeles no contiene un objeto de escena válido",
"clipboardCopyFailed": "No se pudo copiar el objeto de escena al portapapeles",
"clipboardReadFailed": "No se pudo leer el portapapeles. Comprueba los permisos del navegador.",
"clipboardTypeMismatch": "Solo se pueden sobrescribir objetos del mismo tipo (portapapeles: {source}, actual: {target})",
"clipboardPasteFailed": "No se pudo restaurar el objeto de escena",
"showParticle": "Mostrar sistema de partículas",
"hideParticle": "Ocultar sistema de partículas",
"showCollider": "mostrar colisionadores",
@@ -802,6 +813,8 @@
"eye": "ojos pequeños",
"eyeDesc": "Mostrar/ocultar objetos individualmente; Los sistemas de partículas ocultas no se exportan.",
"copyDesc": "Copia completamente el objeto seleccionado actualmente para facilitar la comparación de diferentes parámetros.",
"sceneClipboard": "Portapapeles de escena",
"sceneClipboardDesc": "Haz clic derecho en un objeto de escena para copiarlo o sustituirlo pegando.",
"backgroundDesc": "Cargue la imagen de referencia y ajuste la posición y la transparencia solo para la vista previa del lienzo.",
"settingsDesc": "El diseño de la interfaz de administración, la pantalla auxiliar, la escala, etc. se guardan automáticamente en el navegador.",
"undoDesc": "Ctrl+Z retrocede un paso; el número máximo de pasos se puede ajustar en la configuración del sistema.",
+13
View File
@@ -164,6 +164,9 @@
"storageUnavailable": "Le stockage local du navigateur est indisponible",
"saved": "Enregistré dans le navigateur : {name}",
"loaded": "Chargé depuis le navigateur : {name}",
"appendToScene": "Ajouter cette configuration à la scène",
"appending": "Ajout de la configuration : {name}",
"appended": "Ajouté à la scène : {name}",
"deleted": "Configuration supprimée du navigateur : {name}",
"downloadEmpty": "Aucune configuration à télécharger",
"downloaded": "Téléchargé : {name}"
@@ -501,6 +504,14 @@
"addPathShort": "+ chemin",
"addSpine": "Ajouter Spine",
"copyTitle": "Copier l'objet de scène actuellement sélectionné",
"contextCopy": "Copier dans le presse-papiers",
"contextPaste": "Coller et remplacer",
"clipboardUnavailable": "Ce navigateur n’autorise pas l’accès au presse-papiers",
"clipboardInvalid": "Le presse-papiers ne contient aucun objet de scène valide",
"clipboardCopyFailed": "Impossible de copier l’objet de scène dans le presse-papiers",
"clipboardReadFailed": "Impossible de lire le presse-papiers. Vérifiez les autorisations du navigateur.",
"clipboardTypeMismatch": "Seul un objet de même type peut être remplacé (presse-papiers : {source}, actuel : {target})",
"clipboardPasteFailed": "Impossible de restaurer l’objet de scène",
"showParticle": "Afficher le système de particules",
"hideParticle": "Masquer le système de particules",
"showCollider": "montrer les collisionneurs",
@@ -802,6 +813,8 @@
"eye": "petits yeux",
"eyeDesc": "Afficher/masquer les objets individuellement ; les systèmes de particules cachées ne sont pas exportés.",
"copyDesc": "Copie entièrement l'objet actuellement sélectionné pour faciliter la comparaison des différents paramètres.",
"sceneClipboard": "Presse-papiers de scène",
"sceneClipboardDesc": "Faites un clic droit sur un objet de scène pour le copier ou le remplacer par collage.",
"backgroundDesc": "Téléchargez l'image de référence et ajustez la position et la transparence pour l'aperçu du canevas uniquement.",
"settingsDesc": "La disposition de l'interface de gestion, l'affichage auxiliaire, la balance, etc. sont automatiquement enregistrés dans le navigateur.",
"undoDesc": "Ctrl+Z se rétracte d'un pas ; le nombre maximum d'étapes peut être ajusté dans les paramètres du système.",
+13
View File
@@ -164,6 +164,9 @@
"storageUnavailable": "ブラウザのローカルストレージを利用できません",
"saved": "ブラウザに保存しました:{name}",
"loaded": "ブラウザから読み込みました:{name}",
"appendToScene": "この設定をシーンに追加",
"appending": "設定を追加しています:{name}",
"appended": "シーンに追加しました:{name}",
"deleted": "ブラウザの設定を削除しました:{name}",
"downloadEmpty": "ダウンロードできる設定がありません",
"downloaded": "ダウンロードしました:{name}"
@@ -501,6 +504,14 @@
"addPathShort": "+ パス",
"addSpine": "Spineを追加",
"copyTitle": "現在選択されているシーン オブジェクトをコピーします",
"contextCopy": "クリップボードへコピー",
"contextPaste": "貼り付けて上書き",
"clipboardUnavailable": "このブラウザではクリップボードにアクセスできません",
"clipboardInvalid": "クリップボードに有効なシーンオブジェクトがありません",
"clipboardCopyFailed": "シーンオブジェクトをクリップボードへコピーできませんでした",
"clipboardReadFailed": "クリップボードを読み取れません。ブラウザの権限を確認してください。",
"clipboardTypeMismatch": "同じ種類のシーンオブジェクトだけを上書きできます(クリップボード: {source}、現在: {target})",
"clipboardPasteFailed": "シーンオブジェクトを復元できませんでした",
"showParticle": "パーティクル システムを表示する",
"hideParticle": "パーティクル システムを非表示にする",
"showCollider": "コライダーを表示",
@@ -802,6 +813,8 @@
"eye": "小さな目",
"eyeDesc": "オブジェクトを個別に表示/非表示にします。非表示のパーティクル システムはエクスポートされません。",
"copyDesc": "現在選択されているオブジェクトを完全にコピーして、さまざまなパラメータの比較を容易にします。",
"sceneClipboard": "シーンクリップボード",
"sceneClipboardDesc": "シーンオブジェクトを右クリックすると、クリップボードへのコピーや貼り付けによる上書きができます。",
"backgroundDesc": "参照画像をアップロードし、キャンバスプレビューのみ位置と透明度を調整します。",
"settingsDesc": "管理インターフェイスのレイアウト、補助表示、スケールなどはブラウザに自動的に保存されます。",
"undoDesc": "Ctrl+Z は 1 ステップ後退します。最大ステップ数はシステム設定で調整できます。",
+13
View File
@@ -164,6 +164,9 @@
"storageUnavailable": "브라우저 로컬 저장소를 사용할 수 없습니다",
"saved": "브라우저에 저장했습니다: {name}",
"loaded": "브라우저에서 불러왔습니다: {name}",
"appendToScene": "이 구성을 장면에 추가",
"appending": "구성을 추가하는 중: {name}",
"appended": "장면에 추가했습니다: {name}",
"deleted": "브라우저 설정을 삭제했습니다: {name}",
"downloadEmpty": "다운로드할 설정이 없습니다",
"downloaded": "다운로드했습니다: {name}"
@@ -501,6 +504,14 @@
"addPathShort": "+ 경로",
"addSpine": "Spine 추가",
"copyTitle": "현재 선택된 장면 객체를 복사합니다.",
"contextCopy": "클립보드로 복사",
"contextPaste": "붙여넣어 덮어쓰기",
"clipboardUnavailable": "이 브라우저에서는 클립보드에 접근할 수 없습니다",
"clipboardInvalid": "클립보드에 사용할 수 있는 장면 객체가 없습니다",
"clipboardCopyFailed": "장면 객체를 클립보드로 복사하지 못했습니다",
"clipboardReadFailed": "클립보드를 읽을 수 없습니다. 브라우저 권한을 확인하세요.",
"clipboardTypeMismatch": "같은 유형의 장면 객체만 덮어쓸 수 있습니다(클립보드: {source}, 현재: {target})",
"clipboardPasteFailed": "장면 객체를 복원하지 못했습니다",
"showParticle": "입자 시스템 표시",
"hideParticle": "입자 시스템 숨기기",
"showCollider": "충돌체 표시",
@@ -802,6 +813,8 @@
"eye": "작은 눈",
"eyeDesc": "개체를 개별적으로 표시하거나 숨깁니다. 숨겨진 입자 시스템은 내보내지지 않습니다.",
"copyDesc": "다양한 매개변수의 비교를 용이하게 하기 위해 현재 선택된 개체를 완전히 복사합니다.",
"sceneClipboard": "장면 클립보드",
"sceneClipboardDesc": "장면 객체를 마우스 오른쪽 버튼으로 클릭하여 클립보드로 복사하거나 붙여넣어 덮어쓸 수 있습니다.",
"backgroundDesc": "참조 이미지를 업로드하고 캔버스 미리보기 전용 위치와 투명도를 조정하세요.",
"settingsDesc": "관리 인터페이스 레이아웃, 보조 디스플레이, 규모 등은 브라우저에 자동으로 저장됩니다.",
"undoDesc": "Ctrl+Z는 한 단계 후퇴합니다. 최대 단계 수는 시스템 설정에서 조정할 수 있습니다.",
+13
View File
@@ -164,6 +164,9 @@
"storageUnavailable": "O armazenamento local do navegador não está disponível",
"saved": "Salvo no navegador: {name}",
"loaded": "Carregado do navegador: {name}",
"appendToScene": "Adicionar esta configuração à cena",
"appending": "Adicionando configuração: {name}",
"appended": "Adicionado à cena: {name}",
"deleted": "Configuração excluída do navegador: {name}",
"downloadEmpty": "Nenhuma configuração para baixar",
"downloaded": "Baixado: {name}"
@@ -501,6 +504,14 @@
"addPathShort": "+ caminho",
"addSpine": "Adicionar Spine",
"copyTitle": "Copie o objeto de cena atualmente selecionado",
"contextCopy": "Copiar para a área de transferência",
"contextPaste": "Colar e substituir",
"clipboardUnavailable": "Este navegador não permite acessar a área de transferência",
"clipboardInvalid": "A área de transferência não contém um objeto de cena válido",
"clipboardCopyFailed": "Não foi possível copiar o objeto de cena para a área de transferência",
"clipboardReadFailed": "Não foi possível ler a área de transferência. Verifique as permissões do navegador.",
"clipboardTypeMismatch": "Somente objetos do mesmo tipo podem ser substituídos (área de transferência: {source}, atual: {target})",
"clipboardPasteFailed": "Não foi possível restaurar o objeto de cena",
"showParticle": "Mostrar sistema de partículas",
"hideParticle": "Ocultar sistema de partículas",
"showCollider": "mostrar colisores",
@@ -802,6 +813,8 @@
"eye": "olhos pequenos",
"eyeDesc": "Mostrar/ocultar objetos individualmente; sistemas de partículas ocultas não são exportados.",
"copyDesc": "Copia completamente o objeto atualmente selecionado para facilitar a comparação de diferentes parâmetros.",
"sceneClipboard": "Área de transferência da cena",
"sceneClipboardDesc": "Clique com o botão direito em um objeto de cena para copiá-lo ou substituí-lo colando.",
"backgroundDesc": "Carregue a imagem de referência e ajuste a posição e a transparência apenas para visualização da tela.",
"settingsDesc": "Layout da interface de gerenciamento, display auxiliar, balança, etc. são salvos automaticamente no navegador.",
"undoDesc": "Ctrl+Z retrai um passo; o número máximo de etapas pode ser ajustado nas configurações do sistema.",
+13
View File
@@ -176,6 +176,9 @@
"storageUnavailable": "Локальное хранилище браузера недоступно",
"saved": "Сохранено в браузере: {name}",
"loaded": "Загружено из браузера: {name}",
"appendToScene": "Добавить эту конфигурацию в сцену",
"appending": "Добавление конфигурации: {name}",
"appended": "Добавлено в сцену: {name}",
"deleted": "Удалено из кэша браузера: {name}",
"downloadEmpty": "Нет конфигураций для скачивания",
"downloaded": "Скачано: {name}"
@@ -513,6 +516,14 @@
"addPathShort": "+Путь",
"addSpine": "Добавить Spine",
"copyTitle": "Дублировать выбранный объект сцены",
"contextCopy": "Копировать в буфер обмена",
"contextPaste": "Вставить с заменой",
"clipboardUnavailable": "Браузер не разрешает доступ к буферу обмена",
"clipboardInvalid": "В буфере обмена нет допустимого объекта сцены",
"clipboardCopyFailed": "Не удалось скопировать объект сцены в буфер обмена",
"clipboardReadFailed": "Не удалось прочитать буфер обмена. Проверьте разрешения браузера.",
"clipboardTypeMismatch": "Можно заменять только объект того же типа (в буфере: {source}, текущий: {target})",
"clipboardPasteFailed": "Не удалось восстановить объект сцены",
"showParticle": "Показать систему частиц",
"hideParticle": "Скрыть систему частиц",
"showCollider": "Показать коллайдер",
@@ -825,6 +836,8 @@
"eye": "Видимость",
"eyeDesc": "Показывает или скрывает отдельный объект. Скрытые системы частиц не экспортируются.",
"copyDesc": "Полностью дублирует выбранный объект для сравнения параметров.",
"sceneClipboard": "Буфер объектов сцены",
"sceneClipboardDesc": "Щёлкните объект сцены правой кнопкой, чтобы скопировать его в буфер или заменить вставкой.",
"backgroundDesc": "Загружает опорное изображение и позволяет настроить его положение и непрозрачность только для предпросмотра на холсте.",
"settingsDesc": "Управление расположением интерфейса, вспомогательными элементами, делениями и другими настройками; значения автоматически сохраняются в браузере.",
"undoDesc": "Ctrl+Z отменяет одно действие. Максимальное число шагов задаётся в системных настройках.",
+13
View File
@@ -176,6 +176,9 @@
"storageUnavailable": "浏览器本地存储不可用",
"saved": "已保存到浏览器:{name}",
"loaded": "已从浏览器加载:{name}",
"appendToScene": "将当前配置追加到场景中",
"appending": "正在追加配置:{name}",
"appended": "已追加到场景:{name}",
"deleted": "已删除缓存配置:{name}",
"downloadEmpty": "没有可下载的配置",
"downloaded": "已下载:{name}"
@@ -513,6 +516,14 @@
"addPathShort": "+路径",
"addSpine": "添加 Spine",
"copyTitle": "复制当前选中的场景对象",
"contextCopy": "复制到剪贴板",
"contextPaste": "粘贴覆盖",
"clipboardUnavailable": "当前浏览器不允许访问剪贴板",
"clipboardInvalid": "剪贴板中没有可用的场景对象",
"clipboardCopyFailed": "复制场景对象到剪贴板失败",
"clipboardReadFailed": "无法读取剪贴板,请检查浏览器权限",
"clipboardTypeMismatch": "只能覆盖同类型场景对象(剪贴板为 {source},当前为 {target})",
"clipboardPasteFailed": "场景对象恢复失败",
"showParticle": "显示粒子系统",
"hideParticle": "隐藏粒子系统",
"showCollider": "显示碰撞体",
@@ -825,6 +836,8 @@
"eye": "小眼睛",
"eyeDesc": "单独显示/隐藏对象;隐藏的粒子系统不会被导出。",
"copyDesc": "完整复制当前选中对象,方便对比不同参数。",
"sceneClipboard": "场景剪贴板",
"sceneClipboardDesc": "右键场景对象可复制到剪贴板或覆盖粘贴。",
"backgroundDesc": "上传参考图并调整位置和透明度,只用于画布预览。",
"settingsDesc": "管理界面布局、辅助显示、刻度等,自动存入浏览器。",
"undoDesc": "Ctrl+Z 撤回一步;最大步数可在系统设置中调整。",
+13
View File
@@ -164,6 +164,9 @@
"storageUnavailable": "瀏覽器本機儲存空間無法使用",
"saved": "已儲存到瀏覽器:{name}",
"loaded": "已從瀏覽器載入:{name}",
"appendToScene": "將目前設定追加到場景中",
"appending": "正在追加設定:{name}",
"appended": "已追加到場景:{name}",
"deleted": "已刪除瀏覽器設定:{name}",
"downloadEmpty": "沒有可下載的設定",
"downloaded": "已下載:{name}"
@@ -501,6 +504,14 @@
"addPathShort": "+路徑",
"addSpine": "加入 Spine",
"copyTitle": "複製目前選取的場景對象",
"contextCopy": "複製到剪貼簿",
"contextPaste": "貼上覆蓋",
"clipboardUnavailable": "目前瀏覽器不允許存取剪貼簿",
"clipboardInvalid": "剪貼簿中沒有可用的場景物件",
"clipboardCopyFailed": "無法複製場景物件到剪貼簿",
"clipboardReadFailed": "無法讀取剪貼簿,請檢查瀏覽器權限",
"clipboardTypeMismatch": "只能覆蓋同類型場景物件(剪貼簿為 {source},目前為 {target})",
"clipboardPasteFailed": "場景物件還原失敗",
"showParticle": "顯示粒子系統",
"hideParticle": "隱藏粒子系統",
"showCollider": "顯示碰撞體",
@@ -802,6 +813,8 @@
"eye": "小眼睛",
"eyeDesc": "單獨顯示/隱藏物件;隱藏的粒子系統不會被匯出。",
"copyDesc": "完整複製目前選取對象,方便比較不同參數。",
"sceneClipboard": "場景剪貼簿",
"sceneClipboardDesc": "在場景物件上按右鍵,即可複製到剪貼簿或貼上覆蓋。",
"backgroundDesc": "上傳參考圖並調整位置和透明度,只用於畫布預覽。",
"settingsDesc": "管理介面佈局、輔助顯示、刻度等,自動存入瀏覽器。",
"undoDesc": "Ctrl+Z 撤回一步;最大步數可在系統設定中調整。",
+1
View File
@@ -49,6 +49,7 @@
<dl class="help-list">
<div><dt>{{ $t('help.eye') }}</dt><dd>{{ $t('help.eyeDesc') }}</dd></div>
<div><dt>+Copy</dt><dd>{{ $t('help.copyDesc') }}</dd></div>
<div><dt>{{ $t('help.sceneClipboard') }}</dt><dd>{{ $t('help.sceneClipboardDesc') }}</dd></div>
<div><dt>{{ $t('toolbar.background') }}</dt><dd>{{ $t('help.backgroundDesc') }}</dd></div>
<div><dt>{{ $t('toolbar.settings') }}</dt><dd>{{ $t('help.settingsDesc') }}</dd></div>
<div><dt>{{ $t('help.undoShortcut') }}</dt><dd>{{ $t('help.undoDesc') }}</dd></div>
+16 -2
View File
@@ -93,7 +93,7 @@
<div class="tl-playhead" :style="{ left: playPx + 'px' }"></div>
<!-- 每系统一条轨道(粒子条) -->
<div class="tl-lanes">
<div class="tl-lanes" @wheel.stop="onLaneWheel">
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
<span class="lane-ico" :class="{ 'sub-lane-ico': isSubEmitter(sys) }" :style="{ left: startMarkerPx(sys) + 'px' }">●</span>
<!-- 粒子条:left=delay;红色底条=系统存在粒子的总时长;绿色覆盖层=持续发射时长 -->
@@ -405,6 +405,13 @@ function onWheel(e: WheelEvent) {
viewFrames.value = Math.round(nv)
}
function onLaneWheel(e: WheelEvent) {
const lanes = e.currentTarget as HTMLElement
if (lanes.scrollHeight > lanes.clientHeight) return
e.preventDefault()
onWheel(e)
}
// 拖动时间轴滑动(scrub):只允许进入实际播放范围。
function onScrubDown(e: PointerEvent) {
const vp = vpRef.value!
@@ -580,7 +587,14 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
.tl-tickline { position: absolute; top: 0; bottom: 0; width: 1px; background: #263040; }
.tl-ticklabel { position: absolute; top: 2px; transform: translateX(-50%); font-size: 9px; color: #667; }
.tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #ffcc33; z-index: 5; pointer-events: none; }
.tl-lanes { position: absolute; top: 22px; left: 0; right: 0; bottom: 0; }
.tl-lanes {
position: absolute; top: 22px; left: 0; right: 0; bottom: 0;
overflow-x: hidden; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #46516b #0d0d14;
}
.tl-lanes::-webkit-scrollbar { width: 8px; }
.tl-lanes::-webkit-scrollbar-track { background: #0d0d14; }
.tl-lanes::-webkit-scrollbar-thumb { border-radius: 4px; background: #46516b; }
.tl-lanes::-webkit-scrollbar-thumb:hover { background: #5d6a88; }
.tl-lane { position: relative; height: 26px; margin-bottom: 4px; }
.lane-ico { position: absolute; left: 4px; top: 6px; font-size: 11px; color: #dde; z-index: 2; pointer-events: none; }
.lane-ico.sub-lane-ico { color: #7f8999; }
@@ -31,6 +31,7 @@
@dblclick="loadLocalProject(project)"
@keydown.enter.prevent="loadLocalProject(project)"
@keydown.space.prevent="selectLocalProject(project)"
@contextmenu.prevent.stop="openProjectContextMenu($event, project)"
>
<span class="project-name">{{ project.name }}</span>
<span v-if="store.settings.showProjectSize" class="project-size">{{ formatProjectSize(project.size) }}</span>
@@ -95,10 +96,25 @@
</form>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="projectContextMenu.open"
ref="projectContextMenuElement"
class="project-context-menu"
role="menu"
:style="{ left: projectContextMenu.x + 'px', top: projectContextMenu.y + 'px' }"
@pointerdown.stop
@contextmenu.prevent
>
<button type="button" role="menuitem" @click="appendProjectFromContextMenu">{{ $t('config.local.appendToScene') }}</button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LocalProject } from '../../editor/projectLibrary'
import NumSlider from '../NumSlider.vue'
import CurveEditor from '../CurveEditor.vue'
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
@@ -111,12 +127,65 @@ const {
store,
configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError,
localProjects, projectSaveOpen, projectNameDraft, projectSaveError, projectStorageBusy,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, downloadLocalProject,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, appendLocalProject, downloadLocalProject,
downloadCurrentProject, removeLocalProject, downloadAllLocalProjects, projectFilesInput, loadAllProjectFiles,
selectedLocalProjectName, selectLocalProject, onConfigFileChange, refreshPresets,
loadSelectedPreset, collapsed, toggle, sectionOpen, select,
} = useParticlePanelContext()
const { locale } = useI18n()
const projectContextMenuElement = ref<HTMLElement | null>(null)
const projectContextMenu = reactive({
open: false,
x: 0,
y: 0,
project: null as LocalProject | null,
})
function closeProjectContextMenu() {
projectContextMenu.open = false
projectContextMenu.project = null
}
async function openProjectContextMenu(event: MouseEvent, project: LocalProject) {
selectLocalProject(project)
Object.assign(projectContextMenu, { open: true, x: event.clientX, y: event.clientY, project })
await nextTick()
const menu = projectContextMenuElement.value
if (!menu) return
const bounds = menu.getBoundingClientRect()
projectContextMenu.x = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8))
projectContextMenu.y = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8))
}
function appendProjectFromContextMenu() {
const project = projectContextMenu.project
closeProjectContextMenu()
if (project) void appendLocalProject(project)
}
function onProjectMenuPointerDown(event: PointerEvent) {
if (projectContextMenu.open && !projectContextMenuElement.value?.contains(event.target as Node)) {
closeProjectContextMenu()
}
}
function onProjectMenuKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') closeProjectContextMenu()
}
onMounted(() => {
window.addEventListener('pointerdown', onProjectMenuPointerDown, true)
window.addEventListener('keydown', onProjectMenuKeyDown, true)
window.addEventListener('scroll', closeProjectContextMenu, true)
window.addEventListener('blur', closeProjectContextMenu)
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onProjectMenuPointerDown, true)
window.removeEventListener('keydown', onProjectMenuKeyDown, true)
window.removeEventListener('scroll', closeProjectContextMenu, true)
window.removeEventListener('blur', closeProjectContextMenu)
})
function formatProjectSize(bytes: number) {
const kb = Math.max(1, bytes / 1024)
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`
@@ -129,6 +198,18 @@ function presetDisplayName(preset: PresetOption) {
</script>
<style scoped>
.project-context-menu {
position: fixed; z-index: 10000; min-width: 180px; padding: 4px;
border: 1px solid #3d4962; border-radius: 7px; background: #171d2a;
box-shadow: 0 10px 28px rgba(0, 0, 0, .48);
}
.project-context-menu button {
display: block; width: 100%; height: 30px; padding: 0 10px; border: 0; border-radius: 5px;
background: transparent; color: #d5dced; font: inherit; font-size: 12px; text-align: left; white-space: nowrap; cursor: pointer;
}
.project-context-menu button:hover, .project-context-menu button:focus-visible {
outline: none; background: #2d3953; color: #fff;
}
.config-management-body { padding-bottom: 2px; }
.group-download {
max-width: 50%; min-height: 24px; margin-left: auto; padding: 3px 8px; border: 1px solid #485673; border-radius: 5px;
@@ -20,6 +20,7 @@
:class="{ on: activeObjectType === 'particle' && entry.system.id === store.activeObjectId, 'scene-object-child': entry.depth > 0 }"
:style="entry.depth > 0 ? { paddingLeft: `${entry.depth * 18}px` } : undefined"
@click="store.selectSystem(entry.system.id)"
@contextmenu.prevent.stop="openSceneObjectMenu($event, 'particle', entry.system.id)"
>
<span v-if="entry.depth > 0" class="sub-branch">└</span>
<span v-else class="tdot">●</span>
@@ -36,6 +37,7 @@
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'collision' && collider.id === store.activeObjectId }"
@click="store.selectSceneObject('collision', collider.id)"
@contextmenu.prevent.stop="openSceneObjectMenu($event, 'collision', collider.id)"
>
<span class="tdot collision-dot">■</span>
<span class="tlabel">{{ collider.name }}</span>
@@ -51,6 +53,7 @@
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'path' && path.id === store.activeObjectId }"
@click="store.selectSceneObject('path', path.id)"
@contextmenu.prevent.stop="openSceneObjectMenu($event, 'path', path.id)"
>
<span class="tdot path-dot">◇</span>
<span class="tlabel">{{ path.name }}</span>
@@ -66,6 +69,7 @@
class="tree-item scene-object-item"
:class="{ on: activeObjectType === 'spine' && spine.id === store.activeObjectId }"
@click="store.selectSceneObject('spine', spine.id)"
@contextmenu.prevent.stop="openSceneObjectMenu($event, 'spine', spine.id)"
>
<span class="tdot spine-dot">◆</span>
<span class="tlabel">{{ spine.name }}</span>
@@ -199,10 +203,24 @@
<div v-else class="panel-hint">{{ $t('scene.panelHint') }}</div>
</div>
</div>
<Teleport to="body">
<div
v-if="sceneContextMenu.open"
ref="sceneContextMenuElement"
class="scene-context-menu"
role="menu"
:style="{ left: sceneContextMenu.x + 'px', top: sceneContextMenu.y + 'px' }"
@pointerdown.stop
@contextmenu.prevent
>
<button type="button" role="menuitem" @click="copyFromSceneContextMenu">{{ $t('scene.contextCopy') }}</button>
<button type="button" role="menuitem" @click="pasteFromSceneContextMenu">{{ $t('scene.contextPaste') }}</button>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import NumSlider from '../NumSlider.vue'
import CurveEditor from '../CurveEditor.vue'
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
@@ -210,11 +228,80 @@ import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { collectSubEmitterIds } from '../../core/subEmitterIds'
import { copySceneObjectToClipboard, pasteSceneObjectFromClipboard } from '../../editor/sceneObjectClipboard'
import type { SceneObjectType } from '../../store/particleStore'
import { useParticlePanelContext } from './particlePanelContext'
const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, collapsed, toggle, sectionOpen, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, LINEAR_CURVE } = useParticlePanelContext()
type DisplaySystem = { system: (typeof store.systems)[number]; depth: number }
const sceneContextMenuElement = ref<HTMLElement | null>(null)
const sceneContextMenu = reactive({
open: false,
x: 0,
y: 0,
type: 'particle' as SceneObjectType,
id: 0,
})
function closeSceneContextMenu() {
sceneContextMenu.open = false
}
async function openSceneObjectMenu(event: MouseEvent, type: SceneObjectType, id: number) {
store.selectSceneObject(type, id)
Object.assign(sceneContextMenu, { open: true, x: event.clientX, y: event.clientY, type, id })
await nextTick()
const menu = sceneContextMenuElement.value
if (!menu) return
const bounds = menu.getBoundingClientRect()
sceneContextMenu.x = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8))
sceneContextMenu.y = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8))
}
async function runSceneContextAction(action: 'copy' | 'paste') {
const { type, id } = sceneContextMenu
closeSceneContextMenu()
try {
if (action === 'copy') await copySceneObjectToClipboard(store, type, id)
else await pasteSceneObjectFromClipboard(store, type, id)
} catch (error) {
window.alert(error instanceof Error ? error.message : String(error))
}
}
function copyFromSceneContextMenu() {
void runSceneContextAction('copy')
}
function pasteFromSceneContextMenu() {
void runSceneContextAction('paste')
}
function onSceneMenuPointerDown(event: PointerEvent) {
if (sceneContextMenu.open && !sceneContextMenuElement.value?.contains(event.target as Node)) {
closeSceneContextMenu()
}
}
function onSceneMenuKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') closeSceneContextMenu()
}
onMounted(() => {
window.addEventListener('pointerdown', onSceneMenuPointerDown, true)
window.addEventListener('keydown', onSceneMenuKeyDown, true)
window.addEventListener('scroll', closeSceneContextMenu, true)
window.addEventListener('blur', closeSceneContextMenu)
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onSceneMenuPointerDown, true)
window.removeEventListener('keydown', onSceneMenuKeyDown, true)
window.removeEventListener('scroll', closeSceneContextMenu, true)
window.removeEventListener('blur', closeSceneContextMenu)
})
const warmupDisabled = computed(() => !!sys.value && (
collectSubEmitterIds(store.systems).has(sys.value.id)
|| (sys.value.config.mode === 'stream' && sys.value.config.streamBehavior === 'loop')
@@ -270,6 +357,18 @@ const displaySystems = computed<DisplaySystem[]>(() => {
</script>
<style scoped>
.scene-context-menu {
position: fixed; z-index: 10000; min-width: 142px; padding: 4px;
border: 1px solid #3d4962; border-radius: 7px; background: #171d2a;
box-shadow: 0 10px 28px rgba(0, 0, 0, .48);
}
.scene-context-menu button {
display: block; width: 100%; height: 30px; padding: 0 10px; border: 0; border-radius: 5px;
background: transparent; color: #d5dced; font: inherit; font-size: 12px; text-align: left; cursor: pointer;
}
.scene-context-menu button:hover, .scene-context-menu button:focus-visible {
outline: none; background: #2d3953; color: #fff;
}
.panel { display: flex; width: 100%; height: 100%; flex-direction: column; overflow: hidden; background: #14141f; border-left: 1px solid #2a2a3c; padding: 12px; box-sizing: border-box; }
.empty, .panel-hint { color: #667; font-size: 12px; padding: 8px 0; }
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
+2 -2
View File
@@ -41,7 +41,7 @@ const { exportSettings, exportBusy, exportTarget, exportMessage, exportError, co
const {
configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError,
localProjects, projectSaveOpen, projectNameDraft, projectSaveError, projectStorageBusy,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, downloadLocalProject, downloadCurrentProject,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, appendLocalProject, downloadLocalProject, downloadCurrentProject,
removeLocalProject, downloadAllLocalProjects, projectFilesInput, loadAllProjectFiles,
selectedLocalProjectName, selectLocalProject, onConfigFileChange, refreshPresets, loadSelectedPreset,
} = useProjectPresets(store)
@@ -657,5 +657,5 @@ function onSpawnPathChange() {
// 重置/删除图片 → 回到默认 star.png
return { store, exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorPairUrl, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, refreshConnectorHealth, exportSpineJson, exportSpineProject, exportSpineImages, configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, localProjects, selectedLocalProjectName, selectLocalProject, projectSaveOpen, projectNameDraft, projectSaveError, projectStorageBusy, projectFilesInput, saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, downloadLocalProject, downloadCurrentProject, removeLocalProject, downloadAllLocalProjects, loadAllProjectFiles, onConfigFileChange, refreshPresets, loadSelectedPreset, resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, setImageMode, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, activeSection, canUseSection, activateSection, sectionVisible, sectionOpen, activeCollider, activePath, activeSpine, canCopyObject, selectedSpineBone, boundSpineBone, renderLayerSlotPickerOpen, renderLayerSlotSearch, renderLayerReadySpines, renderLayerSpineReady, renderLayerSlots, renderLayerFilteredSlots, renderLayerSlotLabel, renderLayerResolvedLabel, renderLayerWarning, setRenderLayerSlotMode, selectRenderLayerSpine, pickRenderLayerSlot, clearRenderLayerSlot, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, setEmitMode, setLoopDurationFrames, setStreamBehavior, setLoopSection, setLoopSectionDuration, setLoopCustomDuration, onEmitterFollowModeChange, bindSelectedBone, resetModule, normalizeExportFps, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, setSpeedOverLifeMode, prepareScaleOverLifeCurve, setScaleOverLifeMode, onShapeChange, onSpawnPathChange, LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE }
return { store, exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorPairUrl, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, refreshConnectorHealth, exportSpineJson, exportSpineProject, exportSpineImages, configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, localProjects, selectedLocalProjectName, selectLocalProject, projectSaveOpen, projectNameDraft, projectSaveError, projectStorageBusy, projectFilesInput, saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, appendLocalProject, downloadLocalProject, downloadCurrentProject, removeLocalProject, downloadAllLocalProjects, loadAllProjectFiles, onConfigFileChange, refreshPresets, loadSelectedPreset, resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, setImageMode, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, activeSection, canUseSection, activateSection, sectionVisible, sectionOpen, activeCollider, activePath, activeSpine, canCopyObject, selectedSpineBone, boundSpineBone, renderLayerSlotPickerOpen, renderLayerSlotSearch, renderLayerReadySpines, renderLayerSpineReady, renderLayerSlots, renderLayerFilteredSlots, renderLayerSlotLabel, renderLayerResolvedLabel, renderLayerWarning, setRenderLayerSlotMode, selectRenderLayerSpine, pickRenderLayerSlot, clearRenderLayerSlot, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, setEmitMode, setLoopDurationFrames, setStreamBehavior, setLoopSection, setLoopSectionDuration, setLoopCustomDuration, onEmitterFollowModeChange, bindSelectedBone, resetModule, normalizeExportFps, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, setSpeedOverLifeMode, prepareScaleOverLifeCurve, setScaleOverLifeMode, onShapeChange, onSpawnPathChange, LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE }
}
+14 -1
View File
@@ -11,6 +11,7 @@ import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { createEditorConfig, loadEditorConfigFile, loadEditorConfigText, parseEditorConfig } from '../../editor/editorConfig'
import { appendEditorConfigText } from '../../editor/appendSceneConfig'
import { beginTransfer, readResponseTextWithProgress } from '../../editor/transferProgress'
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../../export/spineExportSettings'
import { buildSpineJson, downloadSpineJson } from '../../export/spineJsonExporter'
@@ -128,6 +129,18 @@ async function loadLocalProject(project: LocalProject) {
}
}
async function appendLocalProject(project: LocalProject) {
configError.value = false
configMessage.value = translate('config.local.appending', { name: project.name })
try {
await appendEditorConfigText(store, project.text)
configMessage.value = translate('config.local.appended', { name: project.name })
} catch (error) {
configError.value = true
configMessage.value = error instanceof Error ? error.message : String(error)
}
}
function downloadLocalProject(project: LocalProject) {
const fileName = projectJsonFileName(project.name)
downloadTextFile(project.text, fileName)
@@ -280,7 +293,7 @@ onBeforeUnmount(() => window.removeEventListener(PROJECT_SAVE_EVENT, saveConfig)
return {
configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError,
localProjects, selectedLocalProjectName, projectSaveOpen, projectNameDraft, projectSaveError, projectStorageBusy,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, downloadLocalProject,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, appendLocalProject, downloadLocalProject,
downloadCurrentProject, removeLocalProject, downloadAllLocalProjects, projectFilesInput, loadAllProjectFiles,
selectLocalProject, onConfigFileChange, refreshPresets, loadSelectedPreset,
}