完善浏览器配置库与未保存提示

- 新增 IndexedDB 配置保存、加载、删除及批量导入导出

- 配置列表支持选中、双击加载和悬浮操作提示

- 新增未保存提示设置,默认关闭并持久化到浏览器

- 优化配置管理界面并同步九种语言文案

- 更新开发文档与生产构建产物
This commit is contained in:
tianmo committed 2026-10-23 11:48:21 +08:00
1 parent 8bd9154414
commit 46083c8d2a
30 files changed
+2244 -1293

No files matched your search

+13 -2
View File
@@ -29,7 +29,7 @@ interface EditorSnapshot {
spineAssets: Map<number, LoadedSpineAsset>
}
export const editorHistoryState = reactive({ undoCount: 0 })
export const editorHistoryState = reactive({ undoCount: 0, dirty: false })
let managedStore: EditorStore | null = null
let currentSnapshot: EditorSnapshot | null = null
@@ -37,6 +37,7 @@ let undoStack: EditorSnapshot[] = []
let stopHistoryWatch: WatchStopHandle | null = null
let commitTimer: ReturnType<typeof setTimeout> | null = null
let applyingSnapshot = false
let savedSignature = ''
function captureData(store: EditorStore): EditorSnapshotData {
const systems = store.systems.map((system) => {
@@ -95,8 +96,9 @@ function commitCurrentChange() {
trimUndoStack(store.settings.undoLimit)
}
function scheduleCommit() {
function scheduleCommit(signature: string) {
if (applyingSnapshot) return
editorHistoryState.dirty = signature !== savedSignature
if (commitTimer) clearTimeout(commitTimer)
commitTimer = setTimeout(() => {
commitTimer = null
@@ -157,6 +159,7 @@ function restoreSnapshot(store: EditorStore, target: EditorSnapshot) {
repairSelection(store)
store.recalcTotalFrames()
currentSnapshot = target
editorHistoryState.dirty = target.signature !== savedSignature
} finally {
applyingSnapshot = false
releaseSnapshot(previousSnapshot)
@@ -172,7 +175,9 @@ export function installEditorHistory(store: EditorStore) {
undoStack = []
managedStore = store
currentSnapshot = createSnapshot(store)
savedSignature = currentSnapshot.signature
editorHistoryState.undoCount = 0
editorHistoryState.dirty = false
stopHistoryWatch = watch(
() => dataSignature(captureData(store)),
scheduleCommit,
@@ -180,6 +185,12 @@ export function installEditorHistory(store: EditorStore) {
)
}
export function markEditorSaved(store: EditorStore) {
if (managedStore !== store) return
savedSignature = dataSignature(captureData(store))
editorHistoryState.dirty = false
}
export function setUndoLimit(store: EditorStore, value: number) {
const limit = Math.min(200, Math.max(1, Math.round(Number(value) || 20)))
store.settings.undoLimit = limit
+125
View File
@@ -0,0 +1,125 @@
import { createZipArchive } from '../export/zipArchive'
import { translate } from '../i18n'
const DATABASE_NAME = 'SpineParticle.ProjectLibrary'
const STORE_NAME = 'projects'
const DATABASE_VERSION = 1
export const PROJECT_SAVE_EVENT = 'spine-particle:request-save'
export interface LocalProject {
name: string
text: string
savedAt: string
size: number
}
let databasePromise: Promise<IDBDatabase> | null = null
function openDatabase() {
if (!('indexedDB' in globalThis)) return Promise.reject(new Error(translate('config.storageUnavailable')))
if (databasePromise) return databasePromise
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
request.onupgradeneeded = () => {
const database = request.result
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'name' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error || new Error(translate('config.storageUnavailable')))
})
return databasePromise
}
function requestResult<T>(request: IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error || new Error(translate('config.storageUnavailable')))
})
}
function transactionDone(transaction: IDBTransaction) {
return new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error || new Error(translate('config.storageUnavailable')))
transaction.onabort = () => reject(transaction.error || new Error(translate('config.storageUnavailable')))
})
}
export function normalizeProjectName(value: string) {
const name = value.trim().replace(/\.json$/i, '').trim()
if (!name) throw new Error(translate('config.local.nameRequired'))
if (/[\\/:*?"<>|\u0000-\u001f]/.test(name)) throw new Error(translate('config.local.nameInvalid'))
return name
}
export function projectJsonFileName(name: string) {
return `${name}.json`
}
export async function listLocalProjects() {
const database = await openDatabase()
const transaction = database.transaction(STORE_NAME, 'readonly')
const projects = await requestResult(transaction.objectStore(STORE_NAME).getAll() as IDBRequest<LocalProject[]>)
return projects.sort((a, b) => b.savedAt.localeCompare(a.savedAt))
}
export async function getLocalProject(name: string) {
const database = await openDatabase()
const transaction = database.transaction(STORE_NAME, 'readonly')
return requestResult(transaction.objectStore(STORE_NAME).get(name) as IDBRequest<LocalProject | undefined>)
}
export async function saveLocalProject(name: string, text: string) {
const normalizedName = normalizeProjectName(name)
const project: LocalProject = {
name: normalizedName,
text,
savedAt: new Date().toISOString(),
size: new Blob([text]).size,
}
const database = await openDatabase()
const transaction = database.transaction(STORE_NAME, 'readwrite')
transaction.objectStore(STORE_NAME).put(project)
await transactionDone(transaction)
return project
}
export async function deleteLocalProject(name: string) {
const database = await openDatabase()
const transaction = database.transaction(STORE_NAME, 'readwrite')
transaction.objectStore(STORE_NAME).delete(name)
await transactionDone(transaction)
}
export function downloadTextFile(text: string, fileName: string) {
const blob = new Blob([text], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export function downloadProjectsArchive(projects: LocalProject[], fileName: string) {
const encoder = new TextEncoder()
const archive = createZipArchive(projects.map((project) => ({
name: projectJsonFileName(project.name),
data: encoder.encode(project.text),
})))
const url = URL.createObjectURL(archive)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
+3
View File
@@ -20,6 +20,7 @@ export type SystemSettingsState = Pick<SettingsState,
| 'axisFontSize'
| 'tickWidth'
| 'showAxisLabels'
| 'unsavedChangesPrompt'
| 'axisColor'
>
@@ -48,6 +49,7 @@ export function captureSystemSettings(settings: SettingsState): SystemSettingsSt
axisFontSize: settings.axisFontSize,
tickWidth: settings.tickWidth,
showAxisLabels: settings.showAxisLabels,
unsavedChangesPrompt: settings.unsavedChangesPrompt,
axisColor: settings.axisColor,
}
}
@@ -71,6 +73,7 @@ function normalizedSystemSettings(value: Partial<SystemSettingsState>, current:
axisFontSize: Math.round(clamp(value.axisFontSize, 8, 40, current.axisFontSize)),
tickWidth: clamp(value.tickWidth, 1, 5, current.tickWidth),
showAxisLabels: booleanValue('showAxisLabels'),
unsavedChangesPrompt: booleanValue('unsavedChangesPrompt'),
axisColor,
}
}
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "Dicke der Teilstriche",
"axisColor": "Achsenfarbe",
"showAxisLabels": "Skalenwert anzeigen",
"unsavedPrompt": "Warnung bei ungespeicherten Änderungen",
"cacheNote": "Die gesamten Systemeinstellungen werden automatisch im aktuellen Browser gespeichert"
},
"config": {
"title": "Konfiguration",
"save": "Konfiguration speichern",
"saveTitle": "Alle Editorkonfigurationen speichern (Ctrl+S)",
"saveTitle": "Konfiguration im Browser-Cache speichern",
"load": "Konfiguration laden",
"loadTitle": "Lokale Editorkonfiguration laden",
"presets": "Voreinstellungsliste",
@@ -98,7 +99,35 @@
"presetRestoring": "Standardressourcen werden analysiert und wiederhergestellt...",
"presetComplete": "Das Laden der Voreinstellungen ist abgeschlossen",
"presetListError": "Voreinstellungsliste kann nicht gelesen werden",
"presetReadError": "Voreinstellung konnte nicht gelesen werden: {file}"
"presetReadError": "Voreinstellung konnte nicht gelesen werden: {file}",
"local": {
"listTitle": "Konfigurationsliste",
"empty": "Keine Konfigurationen in diesem Browser gespeichert",
"saveTitle": "Konfiguration im Browser-Cache speichern",
"name": "Konfigurationsname",
"namePlaceholder": "Zum Beispiel: Feuerwerkseffekt",
"load": "Laden",
"loadHint": "Doppelklicken zum Laden",
"download": "Herunterladen",
"delete": "Löschen",
"downloadCurrent": "Aktuelle Konfiguration herunterladen",
"downloadAll": "Alle Konfigurationen herunterladen",
"loadAll": "Alle Konfigurationen laden",
"loadAllConfirm": "{count} Konfigurationsdateien importieren? Gleichnamige Konfigurationen werden überschrieben.",
"loadAllDone": "{count} Konfigurationen aus Dateien geladen",
"loadAllPartial": "{imported} Konfigurationen importiert; {failed} Dateien fehlgeschlagen",
"overwriteSave": "„{name}“ existiert bereits. Überschreiben?",
"loadOverwrite": "Beim Laden von „{name}“ wird die aktuelle Szene ersetzt und ungespeicherte Änderungen gehen verloren. Fortfahren?",
"deleteConfirm": "„{name}“ löschen?",
"nameRequired": "Geben Sie einen Konfigurationsnamen ein",
"nameInvalid": "Der Name darf \\ / : * ? \" < > | nicht enthalten",
"storageUnavailable": "Der lokale Browserspeicher ist nicht verfügbar",
"saved": "Im Browser gespeichert: {name}",
"loaded": "Aus dem Browser geladen: {name}",
"deleted": "Browserkonfiguration gelöscht: {name}",
"downloadEmpty": "Keine Konfigurationen zum Herunterladen",
"downloaded": "Heruntergeladen: {name}"
}
},
"curve": {
"bezierHint": "Ziehen Sie den quadratischen Griff, um den Bogen anzupassen. · Doppelklicken Sie auf eine leere Stelle, um einen neuen zu erstellen. · Doppelklicken Sie auf einen Knoten, um ihn zu löschen",
+31 -2
View File
@@ -87,12 +87,13 @@
"tickWidth": "Tick line width",
"axisColor": "Axis color",
"showAxisLabels": "Show tick values",
"unsavedPrompt": "Prompt for unsaved changes",
"cacheNote": "Settings are saved automatically in this browser"
},
"config": {
"title": "Configuration",
"save": "Save Config",
"saveTitle": "Save the complete editor configuration (Ctrl+S)",
"saveTitle": "Save configuration to browser cache",
"load": "Load Config",
"loadTitle": "Load an editor configuration file",
"presets": "Preset list",
@@ -110,7 +111,35 @@
"presetRestoring": "Parsing and restoring preset resources…",
"presetComplete": "Preset loaded",
"presetListError": "Unable to read preset list",
"presetReadError": "Unable to read preset: {file}"
"presetReadError": "Unable to read preset: {file}",
"local": {
"listTitle": "Configuration List",
"empty": "No configurations saved in this browser",
"saveTitle": "Save configuration to browser cache",
"name": "Configuration name",
"namePlaceholder": "For example: Firework effect",
"load": "Load",
"loadHint": "Double-click to load",
"download": "Download",
"delete": "Delete",
"downloadCurrent": "Download Current Configuration",
"downloadAll": "Download All Configurations",
"loadAll": "Load All Configurations",
"loadAllConfirm": "Import {count} configuration files? Existing configurations with the same names will be overwritten.",
"loadAllDone": "Loaded {count} configurations from files",
"loadAllPartial": "Imported {imported} configurations; {failed} files failed",
"overwriteSave": "“{name}” already exists. Overwrite it?",
"loadOverwrite": "Loading “{name}” will replace the current scene and discard unsaved changes. Continue?",
"deleteConfirm": "Delete “{name}”?",
"nameRequired": "Enter a configuration name",
"nameInvalid": "The name cannot contain \\ / : * ? \" < > |",
"storageUnavailable": "Browser local storage is unavailable",
"saved": "Saved to browser: {name}",
"loaded": "Loaded from browser: {name}",
"deleted": "Deleted browser configuration: {name}",
"downloadEmpty": "No configurations to download",
"downloaded": "Downloaded: {name}"
}
},
"curve": {
"bezierHint": "Drag square handles · Double-click empty space to add · Double-click a node to delete",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "espesor de la marca de verificación",
"axisColor": "color del eje",
"showAxisLabels": "Mostrar valor de escala",
"unsavedPrompt": "Avisar sobre cambios sin guardar",
"cacheNote": "Toda la configuración del sistema se guarda automáticamente en el navegador actual."
},
"config": {
"title": "Gestión de configuración",
"save": "Guardar configuración",
"saveTitle": "Guarde todas las configuraciones del editor (Ctrl+S)",
"saveTitle": "Guardar configuración en la caché del navegador",
"load": "Cargar configuración",
"loadTitle": "Cargar la configuración del editor local",
"presets": "Lista preestablecida",
@@ -98,7 +99,35 @@
"presetRestoring": "Analizando y restaurando recursos predeterminados...",
"presetComplete": "Carga preestablecida completada",
"presetListError": "No se puede leer la lista preestablecida",
"presetReadError": "No se puede leer el ajuste preestablecido: {file}"
"presetReadError": "No se puede leer el ajuste preestablecido: {file}",
"local": {
"listTitle": "Lista de configuraciones",
"empty": "No hay configuraciones guardadas en este navegador",
"saveTitle": "Guardar configuración en la caché del navegador",
"name": "Nombre de la configuración",
"namePlaceholder": "Por ejemplo: Efecto de fuegos artificiales",
"load": "Cargar",
"loadHint": "Doble clic para cargar",
"download": "Descargar",
"delete": "Eliminar",
"downloadCurrent": "Descargar configuración actual",
"downloadAll": "Descargar todas las configuraciones",
"loadAll": "Cargar todas las configuraciones",
"loadAllConfirm": "¿Importar {count} archivos de configuración? Las configuraciones con el mismo nombre se sobrescribirán.",
"loadAllDone": "{count} configuraciones cargadas desde archivos",
"loadAllPartial": "{imported} configuraciones importadas; {failed} archivos fallaron",
"overwriteSave": "«{name}» ya existe. ¿Sobrescribir?",
"loadOverwrite": "Cargar «{name}» sustituirá la escena actual y descartará los cambios no guardados. ¿Continuar?",
"deleteConfirm": "¿Eliminar «{name}»?",
"nameRequired": "Introduce un nombre de configuración",
"nameInvalid": "El nombre no puede contener \\ / : * ? \" < > |",
"storageUnavailable": "El almacenamiento local del navegador no está disponible",
"saved": "Guardado en el navegador: {name}",
"loaded": "Cargado desde el navegador: {name}",
"deleted": "Configuración eliminada del navegador: {name}",
"downloadEmpty": "No hay configuraciones para descargar",
"downloaded": "Descargado: {name}"
}
},
"curve": {
"bezierHint": "Arrastre el controlador cuadrado para ajustar el arco · Haga doble clic en un espacio en blanco para crear uno nuevo · Haga doble clic en un nodo para eliminarlo",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "épaisseur de la graduation",
"axisColor": "couleur de l'axe",
"showAxisLabels": "Afficher la valeur de l'échelle",
"unsavedPrompt": "Avertir des modifications non enregistrées",
"cacheNote": "L'ensemble des paramètres du système sont automatiquement enregistrés dans le navigateur actuel"
},
"config": {
"title": "Configuration",
"save": "Enregistrer",
"saveTitle": "Enregistrez toutes les configurations de l'éditeur (Ctrl+S)",
"saveTitle": "Enregistrer la configuration dans le cache du navigateur",
"load": "Charger",
"loadTitle": "Charger la configuration de l'éditeur local",
"presets": "Liste des préréglages",
@@ -98,7 +99,35 @@
"presetRestoring": "Analyse et restauration des ressources par défaut...",
"presetComplete": "Chargement du préréglage terminé",
"presetListError": "Impossible de lire la liste des préréglages",
"presetReadError": "Impossible de lire le préréglage : {file}"
"presetReadError": "Impossible de lire le préréglage : {file}",
"local": {
"listTitle": "Liste des configurations",
"empty": "Aucune configuration enregistrée dans ce navigateur",
"saveTitle": "Enregistrer la configuration dans le cache du navigateur",
"name": "Nom de la configuration",
"namePlaceholder": "Par exemple : Effet de feux d’artifice",
"load": "Charger",
"loadHint": "Double-cliquez pour charger",
"download": "Télécharger",
"delete": "Supprimer",
"downloadCurrent": "Télécharger la configuration actuelle",
"downloadAll": "Télécharger toutes les configurations",
"loadAll": "Charger toutes les configurations",
"loadAllConfirm": "Importer {count} fichiers de configuration ? Les configurations portant le même nom seront écrasées.",
"loadAllDone": "{count} configurations chargées depuis les fichiers",
"loadAllPartial": "{imported} configurations importées ; {failed} fichiers en échec",
"overwriteSave": "« {name} » existe déjà. L’écraser ?",
"loadOverwrite": "Le chargement de « {name} » remplacera la scène actuelle et perdra les modifications non enregistrées. Continuer ?",
"deleteConfirm": "Supprimer « {name} » ?",
"nameRequired": "Saisissez un nom de configuration",
"nameInvalid": "Le nom ne peut pas contenir \\ / : * ? \" < > |",
"storageUnavailable": "Le stockage local du navigateur est indisponible",
"saved": "Enregistré dans le navigateur : {name}",
"loaded": "Chargé depuis le navigateur : {name}",
"deleted": "Configuration supprimée du navigateur : {name}",
"downloadEmpty": "Aucune configuration à télécharger",
"downloaded": "Téléchargé : {name}"
}
},
"curve": {
"bezierHint": "Faites glisser la poignée carrée pour ajuster l'arc · Double-cliquez sur un espace vide pour en créer un nouveau · Double-cliquez sur un nœud pour le supprimer",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "目盛りの太さ",
"axisColor": "軸の色",
"showAxisLabels": "表示スケール値",
"unsavedPrompt": "未保存の変更を確認",
"cacheNote": "システム設定全体が現在のブラウザに自動的に保存されます"
},
"config": {
"title": "設定管理",
"save": "設定を保存",
"saveTitle": "すべてのエディタ設定を保存する (Ctrl+S)",
"saveTitle": "設定をブラウザキャッシュに保存",
"load": "設定を読み込む",
"loadTitle": "ローカルエディタ設定をロードする",
"presets": "プリセットリスト",
@@ -98,7 +99,35 @@
"presetRestoring": "デフォルトのリソースを解析して復元しています...",
"presetComplete": "プリセットのロードが完了しました",
"presetListError": "プリセットリストを読み取れません",
"presetReadError": "プリセットを読み取れません: {file}"
"presetReadError": "プリセットを読み取れません: {file}",
"local": {
"listTitle": "設定一覧",
"empty": "このブラウザに保存された設定はありません",
"saveTitle": "設定をブラウザキャッシュに保存",
"name": "設定名",
"namePlaceholder": "例:花火エフェクト",
"load": "読み込む",
"loadHint": "ダブルクリックで読み込む",
"download": "ダウンロード",
"delete": "削除",
"downloadCurrent": "現在の設定をダウンロード",
"downloadAll": "すべての設定をダウンロード",
"loadAll": "すべての設定を読み込む",
"loadAllConfirm": "{count} 個の設定ファイルを読み込みます。同名の設定は上書きされます。続行しますか?",
"loadAllDone": "ファイルから {count} 個の設定を読み込みました",
"loadAllPartial": "{imported} 個の設定を読み込み、{failed} 個のファイルが失敗しました",
"overwriteSave": "「{name}」は既に存在します。上書きしますか?",
"loadOverwrite": "「{name}」を読み込むと現在のシーンが置き換えられ、未保存の変更は失われます。続行しますか?",
"deleteConfirm": "「{name}」を削除しますか?",
"nameRequired": "設定名を入力してください",
"nameInvalid": "設定名に \\ / : * ? \" < > | は使用できません",
"storageUnavailable": "ブラウザのローカルストレージを利用できません",
"saved": "ブラウザに保存しました:{name}",
"loaded": "ブラウザから読み込みました:{name}",
"deleted": "ブラウザの設定を削除しました:{name}",
"downloadEmpty": "ダウンロードできる設定がありません",
"downloaded": "ダウンロードしました:{name}"
}
},
"curve": {
"bezierHint": "四角いハンドルをドラッグして円弧を調整します。 · 空白スペースをダブルクリックして新しいスペースを作成します。 · ノードをダブルクリックして削除します。",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "눈금 표시 두께",
"axisColor": "축 색상",
"showAxisLabels": "표시 스케일 값",
"unsavedPrompt": "저장되지 않은 변경 확인",
"cacheNote": "전체 시스템 설정이 현재 브라우저에 자동으로 저장됩니다."
},
"config": {
"title": "설정 관리",
"save": "설정 저장",
"saveTitle": "모든 편집기 구성 저장(Ctrl+S)",
"saveTitle": "브라우저 캐시에 설정 저장",
"load": "설정 불러오기",
"loadTitle": "로컬 편집기 구성 로드",
"presets": "프리셋 목록",
@@ -98,7 +99,35 @@
"presetRestoring": "기본 리소스를 구문 분석하고 복원하는 중...",
"presetComplete": "프리셋 로딩 완료",
"presetListError": "사전 설정 목록을 읽을 수 없습니다.",
"presetReadError": "사전 설정을 읽을 수 없습니다: {file}"
"presetReadError": "사전 설정을 읽을 수 없습니다: {file}",
"local": {
"listTitle": "설정 목록",
"empty": "이 브라우저에 저장된 설정이 없습니다",
"saveTitle": "브라우저 캐시에 설정 저장",
"name": "설정 이름",
"namePlaceholder": "예: 폭죽 효과",
"load": "불러오기",
"loadHint": "더블클릭하여 불러오기",
"download": "다운로드",
"delete": "삭제",
"downloadCurrent": "현재 설정 다운로드",
"downloadAll": "모든 설정 다운로드",
"loadAll": "모든 설정 불러오기",
"loadAllConfirm": "설정 파일 {count}개를 가져옵니다. 같은 이름의 설정은 덮어씁니다. 계속할까요?",
"loadAllDone": "파일에서 설정 {count}개를 불러왔습니다",
"loadAllPartial": "설정 {imported}개를 가져왔고 {failed}개 파일이 실패했습니다",
"overwriteSave": "'{name}'이(가) 이미 있습니다. 덮어쓸까요?",
"loadOverwrite": "'{name}'을(를) 불러오면 현재 장면이 교체되고 저장하지 않은 변경 사항이 사라집니다. 계속할까요?",
"deleteConfirm": "'{name}'을(를) 삭제할까요?",
"nameRequired": "설정 이름을 입력하세요",
"nameInvalid": "설정 이름에는 \\ / : * ? \" < > | 문자를 사용할 수 없습니다",
"storageUnavailable": "브라우저 로컬 저장소를 사용할 수 없습니다",
"saved": "브라우저에 저장했습니다: {name}",
"loaded": "브라우저에서 불러왔습니다: {name}",
"deleted": "브라우저 설정을 삭제했습니다: {name}",
"downloadEmpty": "다운로드할 설정이 없습니다",
"downloaded": "다운로드했습니다: {name}"
}
},
"curve": {
"bezierHint": "원호를 조정하려면 사각형 핸들을 드래그하세요. · 새 공간을 만들려면 빈 공간을 두 번 클릭하세요. · 삭제하려면 노드를 두 번 클릭하세요.",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "espessura da marca",
"axisColor": "cor do eixo",
"showAxisLabels": "Exibir valor da escala",
"unsavedPrompt": "Avisar sobre alterações não salvas",
"cacheNote": "Todas as configurações do sistema são salvas automaticamente no navegador atual"
},
"config": {
"title": "Gerenciamento de configuração",
"save": "Salvar configuração",
"saveTitle": "Salve todas as configurações do editor (Ctrl+S)",
"saveTitle": "Salvar configuração no cache do navegador",
"load": "Carregar configuração",
"loadTitle": "Carregar configuração do editor local",
"presets": "Lista predefinida",
@@ -98,7 +99,35 @@
"presetRestoring": "Analisando e restaurando recursos padrão...",
"presetComplete": "Carregamento predefinido concluído",
"presetListError": "Não é possível ler a lista predefinida",
"presetReadError": "Não é possível ler a predefinição: {file}"
"presetReadError": "Não é possível ler a predefinição: {file}",
"local": {
"listTitle": "Lista de configurações",
"empty": "Nenhuma configuração salva neste navegador",
"saveTitle": "Salvar configuração no cache do navegador",
"name": "Nome da configuração",
"namePlaceholder": "Por exemplo: Efeito de fogos",
"load": "Carregar",
"loadHint": "Clique duplo para carregar",
"download": "Baixar",
"delete": "Excluir",
"downloadCurrent": "Baixar configuração atual",
"downloadAll": "Baixar todas as configurações",
"loadAll": "Carregar todas as configurações",
"loadAllConfirm": "Importar {count} arquivos de configuração? Configurações com o mesmo nome serão substituídas.",
"loadAllDone": "{count} configurações carregadas dos arquivos",
"loadAllPartial": "{imported} configurações importadas; {failed} arquivos falharam",
"overwriteSave": "“{name}” já existe. Substituir?",
"loadOverwrite": "Carregar “{name}” substituirá a cena atual e descartará alterações não salvas. Continuar?",
"deleteConfirm": "Excluir “{name}”?",
"nameRequired": "Digite um nome de configuração",
"nameInvalid": "O nome não pode conter \\ / : * ? \" < > |",
"storageUnavailable": "O armazenamento local do navegador não está disponível",
"saved": "Salvo no navegador: {name}",
"loaded": "Carregado do navegador: {name}",
"deleted": "Configuração excluída do navegador: {name}",
"downloadEmpty": "Nenhuma configuração para baixar",
"downloaded": "Baixado: {name}"
}
},
"curve": {
"bezierHint": "Arraste a alça quadrada para ajustar o arco · Clique duas vezes em um espaço em branco para criar um novo · Clique duas vezes em um nó para excluí-lo",
+31 -2
View File
@@ -87,12 +87,13 @@
"tickWidth": "刻度线粗细",
"axisColor": "坐标轴颜色",
"showAxisLabels": "显示刻度数值",
"unsavedPrompt": "未保存提示",
"cacheNote": "整个系统设置会自动保存到当前浏览器"
},
"config": {
"title": "配置管理",
"save": "保存配置",
"saveTitle": "保存全部编辑器配置 (Ctrl+S)",
"saveTitle": "保存配置到浏览器缓存",
"load": "加载配置",
"loadTitle": "加载本地编辑器配置",
"presets": "预设列表",
@@ -110,7 +111,35 @@
"presetRestoring": "正在解析并恢复预设资源…",
"presetComplete": "预设加载完成",
"presetListError": "无法读取预设列表",
"presetReadError": "无法读取预设:{file}"
"presetReadError": "无法读取预设:{file}",
"local": {
"listTitle": "配置列表",
"empty": "浏览器中还没有保存的配置",
"saveTitle": "保存配置到浏览器缓存",
"name": "配置名称",
"namePlaceholder": "例如:烟花效果",
"load": "加载",
"loadHint": "双击以加载",
"download": "下载",
"delete": "删除",
"downloadCurrent": "下载当前配置",
"downloadAll": "下载所有配置",
"loadAll": "加载所有配置",
"loadAllConfirm": "将导入 {count} 个配置文件,同名配置会被覆盖。是否继续?",
"loadAllDone": "已从文件加载 {count} 个配置",
"loadAllPartial": "已导入 {imported} 个配置,{failed} 个文件失败",
"overwriteSave": "“{name}”已存在,是否覆盖?",
"loadOverwrite": "加载“{name}”会覆盖当前场景,未保存的修改将丢失。是否继续?",
"deleteConfirm": "确定删除“{name}”吗?",
"nameRequired": "请输入配置名称",
"nameInvalid": "配置名称不能包含 \\ / : * ? \" < > |",
"storageUnavailable": "浏览器本地存储不可用",
"saved": "已保存到浏览器:{name}",
"loaded": "已从浏览器加载:{name}",
"deleted": "已删除缓存配置:{name}",
"downloadEmpty": "没有可下载的配置",
"downloaded": "已下载:{name}"
}
},
"curve": {
"bezierHint": "拖动方形手柄调整弧度 · 双击空白新建 · 双击节点删除",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "刻度線粗細",
"axisColor": "座標軸顏色",
"showAxisLabels": "顯示刻度數值",
"unsavedPrompt": "未儲存提示",
"cacheNote": "整個系統設定會自動儲存到目前瀏覽器"
},
"config": {
"title": "設定管理",
"save": "儲存設定",
"saveTitle": "儲存全部編輯器配置 (Ctrl+S)",
"saveTitle": "儲存設定到瀏覽器快取",
"load": "載入設定",
"loadTitle": "載入本機編輯器配置",
"presets": "預設列表",
@@ -98,7 +99,35 @@
"presetRestoring": "正在解析並恢復預設資源…",
"presetComplete": "預設載入完成",
"presetListError": "無法讀取預設列表",
"presetReadError": "無法讀取預設:{file}"
"presetReadError": "無法讀取預設:{file}",
"local": {
"listTitle": "設定列表",
"empty": "瀏覽器中尚無已儲存的設定",
"saveTitle": "儲存設定到瀏覽器快取",
"name": "設定名稱",
"namePlaceholder": "例如:煙花效果",
"load": "載入",
"loadHint": "按兩下以載入",
"download": "下載",
"delete": "刪除",
"downloadCurrent": "下載目前設定",
"downloadAll": "下載所有設定",
"loadAll": "載入所有設定",
"loadAllConfirm": "將匯入 {count} 個設定檔,同名設定會被覆寫。是否繼續?",
"loadAllDone": "已從檔案載入 {count} 個設定",
"loadAllPartial": "已匯入 {imported} 個設定,{failed} 個檔案失敗",
"overwriteSave": "「{name}」已存在,是否覆寫?",
"loadOverwrite": "載入「{name}」會覆寫目前場景,未儲存的修改將遺失。是否繼續?",
"deleteConfirm": "確定刪除「{name}」嗎?",
"nameRequired": "請輸入設定名稱",
"nameInvalid": "設定名稱不能包含 \\ / : * ? \" < > |",
"storageUnavailable": "瀏覽器本機儲存空間無法使用",
"saved": "已儲存到瀏覽器:{name}",
"loaded": "已從瀏覽器載入:{name}",
"deleted": "已刪除瀏覽器設定:{name}",
"downloadEmpty": "沒有可下載的設定",
"downloaded": "已下載:{name}"
}
},
"curve": {
"bezierHint": "拖曳方形手把調整弧度 · 雙擊空白新建 · 雙擊節點刪除",
+3
View File
@@ -65,6 +65,8 @@ export interface SettingsState {
tickWidth: number
/** 是否显示坐标轴刻度数值 */
showAxisLabels: boolean
/** 有未保存修改时,刷新或关闭页面前显示浏览器原生提示。 */
unsavedChangesPrompt: boolean
/** 坐标轴数值颜色(#rrggbb) */
axisColor: string
/** 最大撤回操作数。 */
@@ -143,6 +145,7 @@ export const useParticleStore = defineStore('particle', {
axisFontSize: 12,
tickWidth: 1,
showAxisLabels: true,
unsavedChangesPrompt: false,
axisColor: '#7a86ad',
undoLimit: 20,
backgroundX: 0,
+11 -3
View File
@@ -40,9 +40,9 @@ import { loopPlaybackRange } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { useParticleStore, type CollisionBody, type ParticleSystem } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
import { installEditorHistory, undoLastOperation } from '../editor/editorHistory'
import { editorHistoryState, installEditorHistory, undoLastOperation } from '../editor/editorHistory'
import { useEditorShortcuts } from '../editor/useEditorShortcuts'
import { downloadEditorConfig } from '../editor/editorConfig'
import { PROJECT_SAVE_EVENT } from '../editor/projectLibrary'
import HelpDialog from './HelpDialog.vue'
import StageToolbar from './StageToolbar.vue'
import StageSettingsPanel from './StageSettingsPanel.vue'
@@ -166,6 +166,12 @@ function onStageDoubleClick(e: MouseEvent) {
pathEditing.onStageDoubleClick(e)
}
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!store.settings.unsavedChangesPrompt || !editorHistoryState.dirty) return
event.preventDefault()
event.returnValue = ''
}
function onWheelEvent(e: WheelEvent) {
onWheel(stageContext, e)
}
@@ -182,7 +188,7 @@ useEditorShortcuts({
canvasElement: holder,
canvasFocused,
undo: undoLastOperation,
save: () => downloadEditorConfig(store),
save: () => window.dispatchEvent(new Event(PROJECT_SAVE_EVENT)),
togglePlay: () => store.togglePlay(),
toStart: () => store.toStart(),
setTool: (tool) => { activeTool.value = tool },
@@ -553,9 +559,11 @@ onMounted(async () => {
},
)
installEditorHistory(store)
window.addEventListener('beforeunload', onBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
cancelAnimationFrame(raf)
spineRuntime?.destroy()
spineRuntime = null
+10
View File
@@ -74,6 +74,15 @@ function normalizeBoneDebugSizes() {
</label>
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showAxisLabels" />{{ t('settings.showAxisLabels') }}</label>
</template>
<div class="sp-divider"></div>
<label class="sp-switch-row">
<span>{{ t('settings.unsavedPrompt') }}</span>
<span class="sp-switch-control">
<input type="checkbox" v-model="store.settings.unsavedChangesPrompt" :aria-label="t('settings.unsavedPrompt')" />
<span class="sp-switch-ui" aria-hidden="true"></span>
<b>{{ store.settings.unsavedChangesPrompt ? t('common.enabled') : t('common.disabled') }}</b>
</span>
</label>
<div class="sp-cache-note">{{ t('settings.cacheNote') }}</div>
</div>
</template>
@@ -87,6 +96,7 @@ function normalizeBoneDebugSizes() {
.sp-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #cdf; margin-bottom: 10px; }
.sp-section-title { margin: 2px 0 8px; color: #8a93bb; font-size: 11px; }
.sp-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #aab; }
.sp-switch-row > span:first-child { min-width: 0; flex: 1; }
.sp-switch-control { display: inline-flex; align-items: center; gap: 7px; }
.sp-switch-control input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.sp-switch-ui { position: relative; width: 34px; height: 18px; border-radius: 999px; background: #39445a; cursor: pointer; transition: background .16s ease; }
@@ -2,6 +2,7 @@
<div class="group config-management">
<div class="group-head" @click="toggle('config')">
<span class="caret">{{ collapsed.config ? '▸' : '▾' }}</span><span>{{ $t('config.title') }}</span>
<button type="button" class="group-download" @click.stop="downloadCurrentProject">{{ $t('config.local.downloadCurrent') }}</button>
</div>
<div class="group-body config-management-body" v-show="!collapsed.config">
<div class="config-toolbar">
@@ -14,8 +15,67 @@
<input ref="configFileInput" hidden type="file" accept=".json,application/json" @change="onConfigFileChange" />
</div>
<div v-if="configMessage" class="config-message" :class="{ error: configError }">{{ configMessage }}</div>
<div class="project-library">
<div class="project-library-title">{{ $t('config.local.listTitle') }}</div>
<div v-if="localProjects.length" class="project-list" role="listbox">
<div
v-for="project in localProjects"
:key="project.name"
class="project-row"
:class="{ selected: selectedLocalProjectName === project.name }"
:title="$t('config.local.loadHint')"
role="option"
tabindex="0"
:aria-selected="selectedLocalProjectName === project.name"
@click="selectLocalProject(project)"
@dblclick="loadLocalProject(project)"
@keydown.enter.prevent="loadLocalProject(project)"
@keydown.space.prevent="selectLocalProject(project)"
>
<span class="project-name">{{ project.name }}</span>
<div class="project-actions" @dblclick.stop>
<button type="button" @click.stop="removeLocalProject(project)">{{ $t('config.local.delete') }}</button>
<button type="button" @click.stop="downloadLocalProject(project)">{{ $t('config.local.download') }}</button>
</div>
</div>
</div>
<div v-else class="project-empty">{{ $t('config.local.empty') }}</div>
</div>
<div class="project-bulk-actions">
<button type="button" :disabled="projectStorageBusy" @click="downloadAllLocalProjects">
{{ $t('config.local.downloadAll') }}
</button>
<button type="button" :disabled="projectStorageBusy" @click="projectFilesInput?.click()">
{{ $t('config.local.loadAll') }}
</button>
<input ref="projectFilesInput" hidden type="file" multiple accept=".json,application/json" @change="loadAllProjectFiles" />
</div>
</div>
</div>
<Teleport to="body">
<div v-if="projectSaveOpen" class="project-save-backdrop" @pointerdown.self="closeProjectSaveDialog">
<form class="project-save-dialog" @submit.prevent="confirmSaveConfig" @keydown.esc.prevent="closeProjectSaveDialog">
<div class="project-save-head">
<span>{{ $t('config.local.saveTitle') }}</span>
<button type="button" :disabled="projectStorageBusy" @click="closeProjectSaveDialog">✕</button>
</div>
<label>
<span>{{ $t('config.local.name') }}</span>
<input
v-model="projectNameDraft"
class="inp"
:placeholder="$t('config.local.namePlaceholder')"
autofocus
/>
</label>
<div v-if="projectSaveError" class="project-save-error">{{ projectSaveError }}</div>
<div class="project-save-actions">
<button type="button" :disabled="projectStorageBusy" @click="closeProjectSaveDialog">{{ $t('common.cancel') }}</button>
<button type="submit" class="primary" :disabled="projectStorageBusy">{{ $t('config.save') }}</button>
</div>
</form>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -28,7 +88,14 @@ import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { useParticlePanelContext } from './particlePanelContext'
import { resolvePresetDisplayName, type PresetOption } from './presetNames'
const { configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset, collapsed, toggle, select } = useParticlePanelContext()
const {
configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError,
localProjects, projectSaveOpen, projectNameDraft, projectSaveError, projectStorageBusy,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, downloadLocalProject,
downloadCurrentProject, removeLocalProject, downloadAllLocalProjects, projectFilesInput, loadAllProjectFiles,
selectedLocalProjectName, selectLocalProject, onConfigFileChange, refreshPresets,
loadSelectedPreset, collapsed, toggle, select,
} = useParticlePanelContext()
const { locale } = useI18n()
function presetDisplayName(preset: PresetOption) {
return resolvePresetDisplayName(preset, locale.value)
@@ -37,6 +104,11 @@ function presetDisplayName(preset: PresetOption) {
<style scoped>
.config-management-body { padding-bottom: 2px; }
.group-download {
margin-left: auto; height: 24px; padding: 0 8px; border: 1px solid #485673; border-radius: 5px;
background: #202a3b; color: #d7e0f0; font-size: 10px; font-weight: 700; cursor: pointer;
}
.group-download:hover { border-color: #7774ff; background: #303858; color: #fff; }
.config-toolbar { display: grid; grid-template-columns: auto auto minmax(0,1fr); gap: 6px; margin-bottom: 10px; }
.config-toolbar button,.config-toolbar select {
min-width: 0; height: 30px; box-sizing: border-box; border: 1px solid #3b4864; border-radius: 6px;
@@ -48,6 +120,50 @@ function presetDisplayName(preset: PresetOption) {
.config-toolbar select:disabled { color: #68738a; background: #1b2130; }
.config-message { margin: -4px 0 9px; color: #83b9a2; font-size: 10px; }
.config-message.error { color: #e8959e; }
.project-library { margin-top: 4px; padding: 9px; border: 1px solid #2f3b52; border-radius: 7px; background: #111725; }
.project-library-title { margin-bottom: 6px; color: #cbd5e8; font-size: 11px; font-weight: 700; }
.project-list { display: flex; flex-direction: column; max-height: 190px; overflow-y: auto; }
.project-row {
display: flex; min-height: 32px; align-items: center; gap: 8px; border-top: 1px solid #222d40;
padding: 0 9px; cursor: pointer; outline: none; user-select: none;
}
.project-row:first-child { border-top: 0; }
.project-row:hover { background: #182033; }
.project-row.selected { background: #25264b; box-shadow: inset 2px 0 #8179ff; }
.project-row:focus-visible { box-shadow: inset 0 0 0 1px #8179ff; }
.project-name { min-width: 0; flex: 1; overflow: hidden; color: #c7d2e5; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.project-actions { display: flex; flex: 0 0 auto; gap: 6px; }
.project-actions button {
display: inline-flex; height: 24px; align-items: center; padding: 0 7px; box-sizing: border-box;
border: 1px solid #3b4864; border-radius: 5px; background: #1b2332; color: #9eabd3; font-size: 10px; cursor: pointer;
}
.project-actions button:hover { border-color: #7169dc; background: #29324b; color: #fff; }
.project-actions button:first-child:hover { border-color: #8c5462; background: #35222b; color: #ef9da9; }
.project-empty { padding: 9px 2px; color: #68758d; font-size: 10px; text-align: center; }
.project-bulk-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 8px; }
.project-bulk-actions button {
width: 100%; min-height: 34px; padding: 5px 6px; border: 1px solid #4d4264; border-radius: 7px;
background: #241c31; color: #e4d9f2; font-size: 11px; font-weight: 700; cursor: pointer;
}
.project-bulk-actions button:hover:not(:disabled) { border-color: #8a6fbd; background: #35264a; }
.project-bulk-actions button:disabled { opacity: .5; cursor: not-allowed; }
.project-save-backdrop {
position: fixed; z-index: 3000; inset: 0; display: grid; place-items: center;
background: rgba(5, 8, 15, .72); backdrop-filter: blur(3px);
}
.project-save-dialog {
width: min(380px, calc(100vw - 32px)); padding: 15px; border: 1px solid #465273; border-radius: 10px;
background: #171d2b; color: #d8e0ef; box-shadow: 0 20px 60px rgba(0,0,0,.55);
}
.project-save-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 13px; font-size: 14px; font-weight: 700; }
.project-save-head button { width: 24px; height: 24px; border: 1px solid #3b4864; border-radius: 5px; background: #252f43; color: #bfc9dc; cursor: pointer; }
.project-save-dialog label { display: flex; flex-direction: column; gap: 6px; color: #8f9cb8; font-size: 11px; }
.project-save-dialog .inp { width: 100%; height: 34px; box-sizing: border-box; }
.project-save-error { margin-top: 8px; color: #ef9aa7; font-size: 10px; line-height: 1.4; }
.project-save-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.project-save-actions button { height: 31px; padding: 0 14px; border: 1px solid #3d4b66; border-radius: 6px; background: #27334a; color: #cbd5e7; cursor: pointer; }
.project-save-actions button.primary { border-color: #625ff1; background: #514dcc; color: #fff; }
.project-save-actions button:disabled { opacity: .5; cursor: not-allowed; }
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
+8 -2
View File
@@ -37,7 +37,13 @@ export function useParticlePanel() {
const store = useParticleStore()
const { t } = useI18n()
const { exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorHealth, connectorPairUrl, connectorExportErrorCode, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, connectorHealthTimer, connectorHealthRequestId, refreshConnectorHealth, nextPaint, prepareExportFrames, exportSpineJson, exportSpineProject, exportSpineImages } = useParticleExport(store)
const { configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset } = useProjectPresets(store)
const {
configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError,
localProjects, projectSaveOpen, projectNameDraft, projectSaveError, projectStorageBusy,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, downloadLocalProject, downloadCurrentProject,
removeLocalProject, downloadAllLocalProjects, projectFilesInput, loadAllProjectFiles,
selectedLocalProjectName, selectLocalProject, onConfigFileChange, refreshPresets, loadSelectedPreset,
} = useProjectPresets(store)
function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
return mode === '贝塞尔' ? 'bezier' : 'linear'
@@ -589,5 +595,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, saveConfig, 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, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, 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, 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, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, 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 }
}
+187 -5
View File
@@ -10,7 +10,7 @@ import ParticleAttributeControl from '../ParticleAttributeControl.vue'
import ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { downloadEditorConfig, loadEditorConfigFile, loadEditorConfigText } from '../../editor/editorConfig'
import { createEditorConfig, loadEditorConfigFile, loadEditorConfigText, parseEditorConfig } from '../../editor/editorConfig'
import { beginTransfer, readResponseTextWithProgress } from '../../editor/transferProgress'
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../../export/spineExportSettings'
import { buildSpineJson, downloadSpineJson } from '../../export/spineJsonExporter'
@@ -25,7 +25,19 @@ import {
} from '../../export/spineConnectorClient'
import { automaticLoopTransitionFrames } from '../../core/loopSegmentTiming'
import { translate } from '../../i18n'
import { markEditorSaved } from '../../editor/editorHistory'
import { parsePresetIndex, type PresetOption } from './presetNames'
import {
PROJECT_SAVE_EVENT,
deleteLocalProject as deleteStoredProject,
downloadProjectsArchive,
downloadTextFile,
listLocalProjects,
normalizeProjectName,
projectJsonFileName,
saveLocalProject,
type LocalProject,
} from '../../editor/projectLibrary'
export function useProjectPresets(store: ReturnType<typeof useParticleStore>) {
const configFileInput = ref<HTMLInputElement | null>(null)
@@ -34,10 +46,167 @@ const presetOptions = ref<PresetOption[]>([])
const presetLoading = ref(false)
const configMessage = ref('')
const configError = ref(false)
const localProjects = ref<LocalProject[]>([])
const projectSaveOpen = ref(false)
const projectNameDraft = ref('')
const projectSaveError = ref('')
const projectStorageBusy = ref(false)
const projectFilesInput = ref<HTMLInputElement | null>(null)
const selectedLocalProjectName = ref('')
function defaultProjectName() {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '').replace('T', '-')
return `SpineParticle-${stamp}`
}
function saveConfig() {
downloadEditorConfig(store)
configError.value = false
configMessage.value = translate('config.saved')
projectSaveError.value = ''
projectNameDraft.value = defaultProjectName()
projectSaveOpen.value = true
}
function closeProjectSaveDialog() {
if (projectStorageBusy.value) return
projectSaveOpen.value = false
projectSaveError.value = ''
}
async function refreshLocalProjects() {
try {
localProjects.value = await listLocalProjects()
if (!localProjects.value.some((project) => project.name === selectedLocalProjectName.value)) {
selectedLocalProjectName.value = ''
}
} catch (error) {
localProjects.value = []
configError.value = true
configMessage.value = error instanceof Error ? error.message : translate('config.local.storageUnavailable')
}
}
async function confirmSaveConfig() {
if (projectStorageBusy.value) return
projectSaveError.value = ''
projectStorageBusy.value = true
try {
const name = normalizeProjectName(projectNameDraft.value)
if (localProjects.value.some((project) => project.name === name)
&& !window.confirm(translate('config.local.overwriteSave', { name }))) {
return
}
const text = JSON.stringify(createEditorConfig(store), null, 2)
await saveLocalProject(name, text)
markEditorSaved(store)
selectedLocalProjectName.value = name
await refreshLocalProjects()
configError.value = false
configMessage.value = translate('config.local.saved', { name })
projectSaveOpen.value = false
} catch (error) {
projectSaveError.value = error instanceof Error ? error.message : translate('config.local.storageUnavailable')
} finally {
projectStorageBusy.value = false
}
}
function selectLocalProject(project: LocalProject) {
selectedLocalProjectName.value = project.name
}
async function loadLocalProject(project: LocalProject) {
if (!window.confirm(translate('config.local.loadOverwrite', { name: project.name }))) return
configError.value = false
configMessage.value = translate('config.loading')
try {
await loadEditorConfigText(store, project.text)
markEditorSaved(store)
configMessage.value = translate('config.local.loaded', { 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)
configError.value = false
configMessage.value = translate('config.local.downloaded', { name: fileName })
}
function downloadCurrentProject() {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '').replace('T', '-')
const fileName = `SpineParticle-${stamp}.json`
downloadTextFile(JSON.stringify(createEditorConfig(store), null, 2), fileName)
markEditorSaved(store)
configError.value = false
configMessage.value = translate('config.local.downloaded', { name: fileName })
}
async function removeLocalProject(project: LocalProject) {
if (!window.confirm(translate('config.local.deleteConfirm', { name: project.name }))) return
try {
await deleteStoredProject(project.name)
if (selectedLocalProjectName.value === project.name) selectedLocalProjectName.value = ''
await refreshLocalProjects()
configError.value = false
configMessage.value = translate('config.local.deleted', { name: project.name })
} catch (error) {
configError.value = true
configMessage.value = error instanceof Error ? error.message : translate('config.local.storageUnavailable')
}
}
async function downloadAllLocalProjects() {
try {
const projects = await listLocalProjects()
if (!projects.length) {
configError.value = true
configMessage.value = translate('config.local.downloadEmpty')
return
}
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, '').replace('T', '-')
const fileName = `SpineParticle-configs-${stamp}.zip`
downloadProjectsArchive(projects, fileName)
configError.value = false
configMessage.value = translate('config.local.downloaded', { name: fileName })
} catch (error) {
configError.value = true
configMessage.value = error instanceof Error ? error.message : translate('config.local.storageUnavailable')
}
}
async function loadAllProjectFiles(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (!files.length || projectStorageBusy.value) return
if (!window.confirm(translate('config.local.loadAllConfirm', { count: files.length }))) return
projectStorageBusy.value = true
let imported = 0
let failed = 0
try {
for (const file of files) {
try {
if (!/\.json$/i.test(file.name)) throw new Error(translate('errors.chooseJsonConfig'))
const text = await file.text()
parseEditorConfig(text)
const name = normalizeProjectName(file.name)
await saveLocalProject(name, text)
imported++
} catch {
failed++
}
}
await refreshLocalProjects()
configError.value = failed > 0
configMessage.value = failed
? translate('config.local.loadAllPartial', { imported, failed })
: translate('config.local.loadAllDone', { count: imported })
} finally {
projectStorageBusy.value = false
}
}
async function onConfigFileChange(event: Event) {
@@ -48,6 +217,7 @@ async function onConfigFileChange(event: Event) {
configError.value = false
try {
await loadEditorConfigFile(store, file)
markEditorSaved(store)
configMessage.value = translate('config.loaded', { name: file.name })
} catch (error) {
configError.value = true
@@ -85,6 +255,7 @@ async function loadSelectedPreset() {
transfer.update(58, translate('config.presetParsing'))
transfer.update(70, translate('config.presetRestoring'))
await loadEditorConfigText(store, text)
markEditorSaved(store)
configMessage.value = translate('config.presetLoaded', { name: file.replace(/\.json$/i, '') })
await transfer.finish(translate('config.presetComplete'))
} catch (error) {
@@ -99,7 +270,18 @@ async function loadSelectedPreset() {
}
onMounted(refreshPresets)
onMounted(() => {
refreshPresets()
refreshLocalProjects()
window.addEventListener(PROJECT_SAVE_EVENT, saveConfig)
})
onBeforeUnmount(() => window.removeEventListener(PROJECT_SAVE_EVENT, saveConfig))
return { configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset }
return {
configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError,
localProjects, selectedLocalProjectName, projectSaveOpen, projectNameDraft, projectSaveError, projectStorageBusy,
saveConfig, closeProjectSaveDialog, confirmSaveConfig, loadLocalProject, downloadLocalProject,
downloadCurrentProject, removeLocalProject, downloadAllLocalProjects, projectFilesInput, loadAllProjectFiles,
selectLocalProject, onConfigFileChange, refreshPresets, loadSelectedPreset,
}
}