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

- 新增 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

@@ -84,6 +84,10 @@ AB 循环是一个独立功能开关。开启后会显示浮动输入窗,可
网站支持简体中文、繁体中文、英语、日语、韩语、法语、德语、葡萄牙语和西班牙语。首次访问会根据浏览器语言自动选择界面语言,也可以随时在画布顶部切换;选择结果会自动保存。 网站支持简体中文、繁体中文、英语、日语、韩语、法语、德语、葡萄牙语和西班牙语。首次访问会根据浏览器语言自动选择界面语言,也可以随时在画布顶部切换;选择结果会自动保存。
13. 浏览器本地配置库
点击“保存配置”可以为当前工程命名并保存到浏览器。配置列表支持快速加载、删除、单文件下载和全部打包下载。加载前会提示当前场景将被覆盖,避免误操作。
推荐使用流程 推荐使用流程
------------ ------------
+60
View File
@@ -0,0 +1,60 @@
# 浏览器配置库说明
## 存储方式
浏览器配置库使用 IndexedDB,不占用 `localStorage`。
数据库:
```text
SpineParticle.ProjectLibrary
```
对象仓库:
```text
projects
```
每条配置保存:
- 用户输入的名称;
- 完整编辑器配置 JSON;
- 保存时间;
- 文件大小。
实现位置:
```text
src/editor/projectLibrary.ts
```
## 保存
点击“保存配置”只打开命名弹窗并写入浏览器,不会下载文件。
同名配置已存在时会询问是否覆盖。
## 加载
点击列表中的“加载”后,会先提示:
```text
加载后会覆盖当前场景,未保存的修改将丢失。
```
用户确认后才恢复配置。
## 下载
- “下载当前配置”直接导出当前场景,不需要先保存到列表。
- 单条“下载”导出对应配置的 `.json` 文件。
- “下载所有配置”将列表中全部配置打包为 ZIP。
- 下载不会加载或覆盖当前场景。
## 限制
- IndexedDB 数据属于当前网站域名和当前浏览器。
- 清理浏览器数据、隐私模式或系统空间不足可能移除配置。
- 不同浏览器和设备之间不会同步。
- 下载到磁盘的文件仍应作为正式备份。
+5
View File
@@ -263,6 +263,11 @@ Spine 3.8 不支持 4.x 的逐通道数组贝塞尔,导出时会转换为经
- 快捷键保存; - 快捷键保存;
- 系统设置与工程配置分离; - 系统设置与工程配置分离;
- 配置版本校验和旧字段迁移。 - 配置版本校验和旧字段迁移。
- 配置保存到浏览器 IndexedDB,并显示本地配置列表;
- 列表支持加载、删除和单文件下载;
- 支持把全部浏览器配置打包为 ZIP 下载;
- 配置管理标题栏提供下载当前场景的快捷按钮;
- 加载本地配置前提示会覆盖当前场景。
### 7.2 场景对象 ### 7.2 场景对象
@@ -1,2 +1,2 @@
var ft=Object.defineProperty;var gt=(W,t,e)=>t in W?ft(W,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):W[t]=e;var T=(W,t,e)=>gt(W,typeof t!="symbol"?t+"":t,e);import{f as ht,R as pt,N as dt,ac as ut,Z as mt,b4 as yt,O as rt,b5 as at,U as D,B as Tt}from"./index-DnfvUUAC.js";import{$ as Rt,a0 as Yt,a1 as Ft,a2 as Et,y as _t,a3 as Ot,a4 as kt,a5 as Xt,a6 as Bt,a7 as Nt,a8 as Dt,a9 as $t,aa as zt,ab as Wt,ad as qt,H as Gt,ae as Jt,af as Ht,ag as Zt,ah as Kt,ai as Qt,aj as jt,ak as te,al as ee,am as se,an as ne,ao as ie,ap as oe,aq as re,ar as ae,as as le,at as ce,au as he,av as pe,aw as de,ax as ue,ay as fe,az as ge,aA as me,aB as ye,aC as Te,aD as be,aE as we,aF as xe,aG as Ae,aH as Pe,aI as Ie,aJ as Ce,aK as Ve,aL as Le,aM as Se,aN as Me,aO as ve,aP as Ue,aQ as Re,aR as Ye,aS as Fe,aT as Ee,x as _e,aU as Oe,aV as ke,aW as Xe,aX as Be,aY as Ne,aZ as De,a_ as $e,a$ as ze,b0 as We,b1 as qe,b2 as Ge,b3 as Je,b6 as He,b7 as Ze,b8 as Ke,b9 as Qe,ba as je,bb as ts,bc as es,bd as ss,be as ns,bf as is,bg as os,bh as rs,bi as as,bj as ls,bk as cs,bl as hs,bm as ps,bn as ds,bo as us,bp as fs,bq as gs,br as ms,bs as ys,bt as Ts,bu as bs,w as ws,X as xs,J as As,Y as Ps,z as Is,bv as Cs,bw as Vs,bx as Ls,by as Ss,bz as Ms,bA as vs,bB as Us,bC as Rs,bD as Ys,bE as Fs,bF as Es,bG as _s,T as Os,bH as ks,bI as Xs,b as Bs,bJ as Ns,c as Ds,bK as $s,bL as zs,bM as Ws,bN as qs,bO as Gs,bP as Js,bQ as Hs,bR as Zs,bS as Ks,bT as Qs,bU as js,bV as tn,bW as en,bX as sn,bY as nn,bZ as on,b_ as rn,V as an,b$ as ln,c0 as cn,c1 as hn,c2 as pn,c3 as dn}from"./index-DnfvUUAC.js";class It{constructor(t){T(this,"skeletonData");T(this,"animationToMixTime",{});T(this,"defaultMix",0);if(!t)throw new Error("skeletonData cannot be null.");this.skeletonData=t}setMix(t,e,n){return typeof t=="string"?this.setMix1(t,e,n):this.setMix2(t,e,n)}setMix1(t,e,n){const s=this.skeletonData.findAnimation(t);if(!s)throw new Error(`Animation not found: ${t}`);const i=this.skeletonData.findAnimation(e);if(!i)throw new Error(`Animation not found: ${e}`);this.setMix2(s,i,n)}setMix2(t,e,n){if(!t)throw new Error("from cannot be null.");if(!e)throw new Error("to cannot be null.");const s=`${t.name}.${e.name}`;this.animationToMixTime[s]=n}getMix(t,e){const n=`${t.name}.${e.name}`,s=this.animationToMixTime[n];return s===void 0?this.defaultMix:s}}class Ct{constructor(t,e="",n=new bt,s=new lt){T(this,"textureLoader");T(this,"pathPrefix");T(this,"downloader");T(this,"cache");T(this,"errors",{});T(this,"toLoad",0);T(this,"loaded",0);T(this,"texturePmaInfo",{});this.textureLoader=t,this.pathPrefix=e,this.downloader=n,this.cache=s}start(t){return this.toLoad++,this.pathPrefix+t}success(t,e,n){this.toLoad--,this.loaded++,this.cache.assets[e]=n,this.cache.assetsRefCount[e]=(this.cache.assetsRefCount[e]||0)+1,t&&t(e,n)}error(t,e,n){this.toLoad--,this.loaded++,this.errors[e]=n,t&&t(e,n)}loadAll(){return new Promise((e,n)=>{const s=()=>{if(this.isLoadingComplete()){this.hasErrors()?n(this.errors):e(this);return}requestAnimationFrame(s)};requestAnimationFrame(s)})}setRawDataURI(t,e){this.downloader.rawDataUris[this.pathPrefix+t]=e}loadBinary(t,e=()=>{},n=()=>{}){t=this.start(t),!this.reuseAssets(t,e,n)&&(this.cache.assetsLoaded[t]=new Promise((s,i)=>{this.downloader.downloadBinary(t,o=>{this.success(e,t,o),s(o)},(o,r)=>{const l=`Couldn't load binary ${t}: status ${o}, ${r}`;this.error(n,t,l),i(l)})}))}loadText(t,e=()=>{},n=()=>{}){t=this.start(t),this.downloader.downloadText(t,s=>{this.success(e,t,s)},(s,i)=>{this.error(n,t,`Couldn't load text ${t}: status ${s}, ${i}`)})}loadJson(t,e=()=>{},n=()=>{}){t=this.start(t),!this.reuseAssets(t,e,n)&&(this.cache.assetsLoaded[t]=new Promise((s,i)=>{this.downloader.downloadJson(t,o=>{this.success(e,t,o),s(o)},(o,r)=>{const l=`Couldn't load JSON ${t}: status ${o}, ${r}`;this.error(n,t,l),i(l)})}))}reuseAssets(t,e=()=>{},n=()=>{}){const s=this.cache.getAsset(t),i=s!==void 0;return i&&(this.cache.assetsLoaded[t]=s.then(o=>(o=o instanceof Image||o instanceof ImageBitmap?this.textureLoader(o):o,this.success(e,t,o),o)).catch(o=>{this.error(n,t,o)})),i}loadTexture(t,e=()=>{},n=()=>{}){if(t=this.start(t),this.reuseAssets(t,e,n))return;const s=this.texturePmaInfo[t];this.cache.assetsLoaded[t]=new Promise((i,o)=>{if(!!!(typeof window<"u"&&typeof navigator<"u"&&window.document))fetch(t,{mode:"cors"}).then(c=>{if(c.ok)return c.blob();const a=`Couldn't load image: ${t}`;this.error(n,t,`Couldn't load image: ${t}`),o(a)}).then(c=>c?createImageBitmap(c,{premultiplyAlpha:"none",colorSpaceConversion:"none"}):null).then(c=>{if(c){const a=this.createTexture(t,s,c);this.success(e,t,a),i(a)}});else{const c=new Image;c.crossOrigin="anonymous",c.onload=()=>{const a=this.createTexture(t,s,c);this.success(e,t,a),i(a)},c.onerror=()=>{const a=`Couldn't load image: ${t}`;this.error(n,t,a),o(a)},this.downloader.rawDataUris[t]&&(t=this.downloader.rawDataUris[t]),c.src=t}})}loadTextureAtlas(t,e=()=>{},n=()=>{},s){const i=t.lastIndexOf("/"),o=i>=0?t.substring(0,i+1)Line truncated var ft=Object.defineProperty;var gt=(W,t,e)=>t in W?ft(W,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):W[t]=e;var T=(W,t,e)=>gt(W,typeof t!="symbol"?t+"":t,e);import{f as ht,R as pt,N as dt,ac as ut,Z as mt,b4 as yt,O as rt,b5 as at,U as D,B as Tt}from"./index-DHREDOlg.js";import{$ as Rt,a0 as Yt,a1 as Ft,a2 as Et,y as _t,a3 as Ot,a4 as kt,a5 as Xt,a6 as Bt,a7 as Nt,a8 as Dt,a9 as $t,aa as zt,ab as Wt,ad as qt,H as Gt,ae as Jt,af as Ht,ag as Zt,ah as Kt,ai as Qt,aj as jt,ak as te,al as ee,am as se,an as ne,ao as ie,ap as oe,aq as re,ar as ae,as as le,at as ce,au as he,av as pe,aw as de,ax as ue,ay as fe,az as ge,aA as me,aB as ye,aC as Te,aD as be,aE as we,aF as xe,aG as Ae,aH as Pe,aI as Ie,aJ as Ce,aK as Ve,aL as Le,aM as Se,aN as Me,aO as ve,aP as Ue,aQ as Re,aR as Ye,aS as Fe,aT as Ee,x as _e,aU as Oe,aV as ke,aW as Xe,aX as Be,aY as Ne,aZ as De,a_ as $e,a$ as ze,b0 as We,b1 as qe,b2 as Ge,b3 as Je,b6 as He,b7 as Ze,b8 as Ke,b9 as Qe,ba as je,bb as ts,bc as es,bd as ss,be as ns,bf as is,bg as os,bh as rs,bi as as,bj as ls,bk as cs,bl as hs,bm as ps,bn as ds,bo as us,bp as fs,bq as gs,br as ms,bs as ys,bt as Ts,bu as bs,w as ws,X as xs,J as As,Y as Ps,z as Is,bv as Cs,bw as Vs,bx as Ls,by as Ss,bz as Ms,bA as vs,bB as Us,bC as Rs,bD as Ys,bE as Fs,bF as Es,bG as _s,T as Os,bH as ks,bI as Xs,b as Bs,bJ as Ns,c as Ds,bK as $s,bL as zs,bM as Ws,bN as qs,bO as Gs,bP as Js,bQ as Hs,bR as Zs,bS as Ks,bT as Qs,bU as js,bV as tn,bW as en,bX as sn,bY as nn,bZ as on,b_ as rn,V as an,b$ as ln,c0 as cn,c1 as hn,c2 as pn,c3 as dn}from"./index-DHREDOlg.js";class It{constructor(t){T(this,"skeletonData");T(this,"animationToMixTime",{});T(this,"defaultMix",0);if(!t)throw new Error("skeletonData cannot be null.");this.skeletonData=t}setMix(t,e,n){return typeof t=="string"?this.setMix1(t,e,n):this.setMix2(t,e,n)}setMix1(t,e,n){const s=this.skeletonData.findAnimation(t);if(!s)throw new Error(`Animation not found: ${t}`);const i=this.skeletonData.findAnimation(e);if(!i)throw new Error(`Animation not found: ${e}`);this.setMix2(s,i,n)}setMix2(t,e,n){if(!t)throw new Error("from cannot be null.");if(!e)throw new Error("to cannot be null.");const s=`${t.name}.${e.name}`;this.animationToMixTime[s]=n}getMix(t,e){const n=`${t.name}.${e.name}`,s=this.animationToMixTime[n];return s===void 0?this.defaultMix:s}}class Ct{constructor(t,e="",n=new bt,s=new lt){T(this,"textureLoader");T(this,"pathPrefix");T(this,"downloader");T(this,"cache");T(this,"errors",{});T(this,"toLoad",0);T(this,"loaded",0);T(this,"texturePmaInfo",{});this.textureLoader=t,this.pathPrefix=e,this.downloader=n,this.cache=s}start(t){return this.toLoad++,this.pathPrefix+t}success(t,e,n){this.toLoad--,this.loaded++,this.cache.assets[e]=n,this.cache.assetsRefCount[e]=(this.cache.assetsRefCount[e]||0)+1,t&&t(e,n)}error(t,e,n){this.toLoad--,this.loaded++,this.errors[e]=n,t&&t(e,n)}loadAll(){return new Promise((e,n)=>{const s=()=>{if(this.isLoadingComplete()){this.hasErrors()?n(this.errors):e(this);return}requestAnimationFrame(s)};requestAnimationFrame(s)})}setRawDataURI(t,e){this.downloader.rawDataUris[this.pathPrefix+t]=e}loadBinary(t,e=()=>{},n=()=>{}){t=this.start(t),!this.reuseAssets(t,e,n)&&(this.cache.assetsLoaded[t]=new Promise((s,i)=>{this.downloader.downloadBinary(t,o=>{this.success(e,t,o),s(o)},(o,r)=>{const l=`Couldn't load binary ${t}: status ${o}, ${r}`;this.error(n,t,l),i(l)})}))}loadText(t,e=()=>{},n=()=>{}){t=this.start(t),this.downloader.downloadText(t,s=>{this.success(e,t,s)},(s,i)=>{this.error(n,t,`Couldn't load text ${t}: status ${s}, ${i}`)})}loadJson(t,e=()=>{},n=()=>{}){t=this.start(t),!this.reuseAssets(t,e,n)&&(this.cache.assetsLoaded[t]=new Promise((s,i)=>{this.downloader.downloadJson(t,o=>{this.success(e,t,o),s(o)},(o,r)=>{const l=`Couldn't load JSON ${t}: status ${o}, ${r}`;this.error(n,t,l),i(l)})}))}reuseAssets(t,e=()=>{},n=()=>{}){const s=this.cache.getAsset(t),i=s!==void 0;return i&&(this.cache.assetsLoaded[t]=s.then(o=>(o=o instanceof Image||o instanceof ImageBitmap?this.textureLoader(o):o,this.success(e,t,o),o)).catch(o=>{this.error(n,t,o)})),i}loadTexture(t,e=()=>{},n=()=>{}){if(t=this.start(t),this.reuseAssets(t,e,n))return;const s=this.texturePmaInfo[t];this.cache.assetsLoaded[t]=new Promise((i,o)=>{if(!!!(typeof window<"u"&&typeof navigator<"u"&&window.document))fetch(t,{mode:"cors"}).then(c=>{if(c.ok)return c.blob();const a=`Couldn't load image: ${t}`;this.error(n,t,`Couldn't load image: ${t}`),o(a)}).then(c=>c?createImageBitmap(c,{premultiplyAlpha:"none",colorSpaceConversion:"none"}):null).then(c=>{if(c){const a=this.createTexture(t,s,c);this.success(e,t,a),i(a)}});else{const c=new Image;c.crossOrigin="anonymous",c.onload=()=>{const a=this.createTexture(t,s,c);this.success(e,t,a),i(a)},c.onerror=()=>{const a=`Couldn't load image: ${t}`;this.error(n,t,a),o(a)},this.downloader.rawDataUris[t]&&(t=this.downloader.rawDataUris[t]),c.src=t}})}loadTextureAtlas(t,e=()=>{},n=()=>{},s){const i=t.lastIndexOf("/"),o=i>=0?t.substring(0,i+1)Line truncated
${n}`:""}`)}remove(t){t=this.pathPrefix+t;const e=this.cache.assets[t];return e.dispose&&e.dispose(),delete this.cache.assets[t],delete this.cache.assetsRefCount[t],delete this.cache.assetsLoaded[t],e}removeAll(){for(const t in this.cache.assets){const e=this.cache.assets[t];e.dispose&&e.dispose()}this.cache.assets={},this.cache.assetsLoaded={},this.cache.assetsRefCount={}}isLoadingComplete(){return this.toLoad===0}getToLoad(){return this.toLoad}getLoaded(){return this.loaded}dispose(){this.removeAll()}disposeAsset(t){const e=this.cache.assets[t];if(e instanceof ht){e.dispose();return}this.disposeAssetInternal(t)}hasErrors(){return Object.keys(this.errors).length>0}getErrors(){return this.errors}disposeAssetInternal(t){if(this.cache.assetsRefCount[t]>0&&--this.cache.assetsRefCount[t]===0)return this.remove(t)}createTextureAtlas(t,e,n,s){const i=new ht(t);i.dispose=()=>{var o;if(!(this.cache.assetsRefCount[n]<=0)){this.disposeAssetInternal(n);for(const r of i.pages)(o=r.texture)==null||o.dispose()}};for(const o of i.pages){const r=this.texturePath(e,o.name,s);this.texturePmaInfo[this.pathPrefix+r]=o.pma}return i}createTexture(t,e,n){const s=this.textureLoader(n,e),i=s.dispose.bind(s);return s.dispose=()=>{this.disposeAssetInternal(t)&&i()},s}texturePath(t,e,n){return n?n[e]:t+e}}const nt=class nt{constructor(){T(this,"assets",{});T(this,"assetsRefCount",{});T(this,"assetsLoaded",{})}static getCache(t){const e=nt.AVAILABLE_CACHES.get(t);if(e)return e;const n=new nt;return nt.AVAILABLE_CACHES.set(t,n),n}async addAsset(t,e){return this.assetsLoaded[t]=Promise.resolve(e),this.assets[t]=e,e}getAsset(t){return this.assetsLoaded[t]}};T(nt,"AVAILABLE_CACHES",new Map);let lt=nt;class bt{constructor(){T(this,"callbacks",{});T(this,"rawDataUris",{})}dataUriToString(t){if(!t.startsWith("data:"))throw new Error("Not a data URI.");let e=t.indexOf("base64,");return e!==-1?(e+=7,atob(t.substr(e))):t.substr(t.indexOf(",")+1)}base64ToUint8Array(t){var e=window.atob(t),n=e.length,s=new Uint8Array(n);for(let i=0;i<n;i++)s[i]=e.charCodeAt(i);return s}dataUriToUint8Array(t){if(!t.startsWith("data:"))throw new Error("Not a data URI.");let e=t.indexOf("base64,");if(e===-1)throw new Error("Not a binary data URI.");return e+=7,this.base64ToUint8Array(t.substr(e))}downloadText(t,e,n){if(this.start(t,e,n))return;const s=this.rawDataUris[t];if(s&&!s.includes(".")){try{this.finish(t,200,this.dataUriToString(s))}catch(r){this.finish(t,400,JSON.stringify(r))}return}const i=new XMLHttpRequest;i.overrideMimeType("text/html"),i.open("GET",s||t,!0);const o=()=>{this.finish(t,i.status,i.responseText)};i.onload=o,i.onerror=o,i.send()}downloadJson(t,e,n){this.downloadText(t,s=>{e(JSON.parse(s))},n)}downloadBinary(t,e,n){if(this.start(t,e,n))return;const s=this.rawDataUris[t];if(s&&!s.includes(".")){try{this.finish(t,200,this.dataUriToUint8Array(s))}catch(r){this.finish(t,400,JSON.stringify(r))}return}const i=new XMLHttpRequest;i.open("GET",s||t,!0),i.responseType="arraybuffer";const o=()=>{this.finish(t,i.status,i.response)};i.onload=()=>{i.status===200||i.status===0?this.finish(t,200,new Uint8Array(i.response)):o()},i.onerror=o,i.send()}start(t,e,n){let s=this.callbacks[t];try{if(s)return!0;this.callbacks[t]=s=[]}finally{s.push(e,n)}}finish(t,e,n){const s=this.callbacks[t];if(delete this.callbacks[t],e===200||e===0)for(let i=0,o=s.length;i<o;i+=2)s[i](n);else for(let i=1,o=s.length;i<o;i+=2)s[i](e,n)}}class Vt{constructor(t,e=!1){T(this,"atlas");T(this,"allowMissingRegions");this.atlas=t,this.allowMissingRegions=e}findRegions(t,e,n){const s=n.regions;for(let i=0,o=s.length;i<o;i++)s[i]=this.findRegion(t,n.getPath(e,i))}findRegion(t,e){const n=this.atlas.findRegion(e);if(!n&&!this.allowMissingRegions)throw new Error(`Region not found in atlas: ${e} (attachment: ${t})`);return n}newRegionAttachment(t,e,n,s,i){return this.findRegions(n,s,i),new pt(n,i)}newMeshAttachment(t,e,n,s,i){return this.findRegions(n,s,i),new dt(n,i)}newBoundingBoxAttachment(t,e,n){return new ut(n)}newPathAttachment(t,e,n){return new mt(n)}newPointAttachment(t,e,n){return new yt(n)}newClippingAttachment(t,e,n){return new rt(n)}}class Lt{constructor(){T(this,"minX",0);T(this,"minY",0);T(this,"maxX",0);T(this,"maxY",0);T(this,"boundingBoxes",[]);T(this,"polygons",[]);T(this,"polygonPool",new at(()=>D.newFloatArray(16)))}update(t,e){if(!t)throw new Error("skeleton cannot be null.");const n=this.boundingBoxes,s=this.polygons,i=this.polygonPool,o=t.slots,r=o.length;n.length=0,i.freeAll(s),s.length=0;for(let l=0;l<r;l++){const c=o[l];if(!c.bone.active)continue;const a=c.appliedPose.attachment;if(a instanceof ut){n.push(a);let h=i.obtain();h.length!==a.worldVerticesLength&&(h=D.newFloatArray(a.worldVerticesLength)),s.push(h),a.computeWorldVertices(t,c,0,a.worldVerticesLength,h,0,2)}}e?this.aabbCompute():(this.minX=Number.POSITIVE_INFINITY,this.minY=Number.POSITIVE_INFINITY,this.maxX=Number.NEGATIVE_INFINITY,this.maxY=Number.NEGATIVE_INFINITY)}aabbCompute(){let t=Number.POSITIVE_INFLine truncated ${n}`:""}`)}remove(t){t=this.pathPrefix+t;const e=this.cache.assets[t];return e.dispose&&e.dispose(),delete this.cache.assets[t],delete this.cache.assetsRefCount[t],delete this.cache.assetsLoaded[t],e}removeAll(){for(const t in this.cache.assets){const e=this.cache.assets[t];e.dispose&&e.dispose()}this.cache.assets={},this.cache.assetsLoaded={},this.cache.assetsRefCount={}}isLoadingComplete(){return this.toLoad===0}getToLoad(){return this.toLoad}getLoaded(){return this.loaded}dispose(){this.removeAll()}disposeAsset(t){const e=this.cache.assets[t];if(e instanceof ht){e.dispose();return}this.disposeAssetInternal(t)}hasErrors(){return Object.keys(this.errors).length>0}getErrors(){return this.errors}disposeAssetInternal(t){if(this.cache.assetsRefCount[t]>0&&--this.cache.assetsRefCount[t]===0)return this.remove(t)}createTextureAtlas(t,e,n,s){const i=new ht(t);i.dispose=()=>{var o;if(!(this.cache.assetsRefCount[n]<=0)){this.disposeAssetInternal(n);for(const r of i.pages)(o=r.texture)==null||o.dispose()}};for(const o of i.pages){const r=this.texturePath(e,o.name,s);this.texturePmaInfo[this.pathPrefix+r]=o.pma}return i}createTexture(t,e,n){const s=this.textureLoader(n,e),i=s.dispose.bind(s);return s.dispose=()=>{this.disposeAssetInternal(t)&&i()},s}texturePath(t,e,n){return n?n[e]:t+e}}const nt=class nt{constructor(){T(this,"assets",{});T(this,"assetsRefCount",{});T(this,"assetsLoaded",{})}static getCache(t){const e=nt.AVAILABLE_CACHES.get(t);if(e)return e;const n=new nt;return nt.AVAILABLE_CACHES.set(t,n),n}async addAsset(t,e){return this.assetsLoaded[t]=Promise.resolve(e),this.assets[t]=e,e}getAsset(t){return this.assetsLoaded[t]}};T(nt,"AVAILABLE_CACHES",new Map);let lt=nt;class bt{constructor(){T(this,"callbacks",{});T(this,"rawDataUris",{})}dataUriToString(t){if(!t.startsWith("data:"))throw new Error("Not a data URI.");let e=t.indexOf("base64,");return e!==-1?(e+=7,atob(t.substr(e))):t.substr(t.indexOf(",")+1)}base64ToUint8Array(t){var e=window.atob(t),n=e.length,s=new Uint8Array(n);for(let i=0;i<n;i++)s[i]=e.charCodeAt(i);return s}dataUriToUint8Array(t){if(!t.startsWith("data:"))throw new Error("Not a data URI.");let e=t.indexOf("base64,");if(e===-1)throw new Error("Not a binary data URI.");return e+=7,this.base64ToUint8Array(t.substr(e))}downloadText(t,e,n){if(this.start(t,e,n))return;const s=this.rawDataUris[t];if(s&&!s.includes(".")){try{this.finish(t,200,this.dataUriToString(s))}catch(r){this.finish(t,400,JSON.stringify(r))}return}const i=new XMLHttpRequest;i.overrideMimeType("text/html"),i.open("GET",s||t,!0);const o=()=>{this.finish(t,i.status,i.responseText)};i.onload=o,i.onerror=o,i.send()}downloadJson(t,e,n){this.downloadText(t,s=>{e(JSON.parse(s))},n)}downloadBinary(t,e,n){if(this.start(t,e,n))return;const s=this.rawDataUris[t];if(s&&!s.includes(".")){try{this.finish(t,200,this.dataUriToUint8Array(s))}catch(r){this.finish(t,400,JSON.stringify(r))}return}const i=new XMLHttpRequest;i.open("GET",s||t,!0),i.responseType="arraybuffer";const o=()=>{this.finish(t,i.status,i.response)};i.onload=()=>{i.status===200||i.status===0?this.finish(t,200,new Uint8Array(i.response)):o()},i.onerror=o,i.send()}start(t,e,n){let s=this.callbacks[t];try{if(s)return!0;this.callbacks[t]=s=[]}finally{s.push(e,n)}}finish(t,e,n){const s=this.callbacks[t];if(delete this.callbacks[t],e===200||e===0)for(let i=0,o=s.length;i<o;i+=2)s[i](n);else for(let i=1,o=s.length;i<o;i+=2)s[i](e,n)}}class Vt{constructor(t,e=!1){T(this,"atlas");T(this,"allowMissingRegions");this.atlas=t,this.allowMissingRegions=e}findRegions(t,e,n){const s=n.regions;for(let i=0,o=s.length;i<o;i++)s[i]=this.findRegion(t,n.getPath(e,i))}findRegion(t,e){const n=this.atlas.findRegion(e);if(!n&&!this.allowMissingRegions)throw new Error(`Region not found in atlas: ${e} (attachment: ${t})`);return n}newRegionAttachment(t,e,n,s,i){return this.findRegions(n,s,i),new pt(n,i)}newMeshAttachment(t,e,n,s,i){return this.findRegions(n,s,i),new dt(n,i)}newBoundingBoxAttachment(t,e,n){return new ut(n)}newPathAttachment(t,e,n){return new mt(n)}newPointAttachment(t,e,n){return new yt(n)}newClippingAttachment(t,e,n){return new rt(n)}}class Lt{constructor(){T(this,"minX",0);T(this,"minY",0);T(this,"maxX",0);T(this,"maxY",0);T(this,"boundingBoxes",[]);T(this,"polygons",[]);T(this,"polygonPool",new at(()=>D.newFloatArray(16)))}update(t,e){if(!t)throw new Error("skeleton cannot be null.");const n=this.boundingBoxes,s=this.polygons,i=this.polygonPool,o=t.slots,r=o.length;n.length=0,i.freeAll(s),s.length=0;for(let l=0;l<r;l++){const c=o[l];if(!c.bone.active)continue;const a=c.appliedPose.attachment;if(a instanceof ut){n.push(a);let h=i.obtain();h.length!==a.worldVerticesLength&&(h=D.newFloatArray(a.worldVerticesLength)),s.push(h),a.computeWorldVertices(t,c,0,a.worldVerticesLength,h,0,2)}}e?this.aabbCompute():(this.minX=Number.POSITIVE_INFINITY,this.minY=Number.POSITIVE_INFINITY,this.maxX=Number.NEGATIVE_INFINITY,this.maxY=Number.NEGATIVE_INFINITY)}aabbCompute(){let t=Number.POSITIVE_INFLine truncated
+1
View File
@@ -0,0 +1 @@
.help-overlay[data-v-4bb9bc76]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:72px 24px 28px;box-sizing:border-box;background:#05080fb8;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.help-dialog[data-v-4bb9bc76]{width:min(820px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid #3a4864;border-radius:14px;background:#141a27;color:#b8c3d8;box-shadow:0 22px 70px #0000009e}.help-head[data-v-4bb9bc76]{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:18px 20px 15px;border-bottom:1px solid #2b354a;background:linear-gradient(135deg,#202942,#181e2d)}.help-head h2[data-v-4bb9bc76]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-4bb9bc76]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-4bb9bc76]{width:28px;height:28px;flex:0 0 28px;border:1px solid #43516b;border-radius:6px;background:#273249;color:#ccd7ed;cursor:pointer}.help-close[data-v-4bb9bc76]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-4bb9bc76]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-4bb9bc76]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-4bb9bc76]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-4bb9bc76]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-4bb9bc76]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-4bb9bc76]{color:#dce4f7}.help-grid[data-v-4bb9bc76]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-4bb9bc76]{display:grid;gap:0;margin:0}.help-list>div[data-v-4bb9bc76]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-4bb9bc76]:last-child{border-bottom:0}.help-list dt[data-v-4bb9bc76]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-4bb9bc76]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-4bb9bc76]{display:grid;gap:7px}.shortcut-list>div[data-v-4bb9bc76]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-4bb9bc76]{min-width:24px;box-sizing:border-box;padding:3px 6px;border:1px solid #465470;border-bottom-width:2px;border-radius:5px;background:#101623;color:#e0e7f6;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:center}.shortcut-list span[data-v-4bb9bc76]{color:#65728b;font-size:10px}.shortcut-list em[data-v-4bb9bc76]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-4bb9bc76]{margin:11px 0 0;padding-top:9px;border-top:1px solid #2a3549;color:#697791;font-size:10px;line-height:1.55}.help-export[data-v-4bb9bc76]{margin-top:12px}.help-export p[data-v-4bb9bc76]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-4bb9bc76]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-4bb9bc76]{margin-top:12px}.help-module-head[data-v-4bb9bc76]{display:flex;align-items:center;gap:8px;width:100%;padding:0;border:0;background:transparent;color:#9fb2ff;font:inherit;font-size:13px;font-weight:700;text-align:left;cursor:pointer}.help-module-head[data-v-4bb9bc76]:hover{color:#fff}.help-module-head .mtitle[data-v-4bb9bc76]{flex:1;min-width:0}.help-module-head .caret[data-v-4bb9bc76]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-4bb9bc76]{color:#fff}.help-module.open .help-module-head .caret[data-v-4bb9bc76]{color:#9fb2ff}.help-module .mnum[data-v-4bb9bc76]{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:linear-gradient(135deg,#4a53b8,#6a5fd0);color:#fff;font-size:12px;font-weight:700}.help-module.help-connector[data-v-4bb9bc76]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-4bb9bc76]{color:#b8c2ff}.help-module .connector-intro[data-v-4bb9bc76]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-4bb9bc76]{color:#dce4f7}.help-module .dl-link[data-v-4bb9bc76]{display:inline-flex;flex-direction:column;align-items:flex-start;gap:2px;margin:0 10px 6px 0;padding:6px 12px;border:1px solid #5652b5;border-radius:6px;background:#4540a0;color:#fff;font-size:12px;font-weight:700;text-decoration:none}.help-module .dl-link[data-v-4bb9bc76]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-4bb9bc76]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-4bb9bc76]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-4bb9bc76]{padding:66px 12px 14px}.help-grid[data-v-4bb9bc76]{grid-template-columns:1fr}}.topbar[data-v-6411b316]{position:absolute;top:10px;left:10px;right:10px;dispLine truncated
+1257
View File
File diff suppressed because it is too large. Load diff
-1257
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
.help-overlay[data-v-4bb9bc76]{position:absolute;top:0;right:0;bottom:0;left:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:72px 24px 28px;box-sizing:border-box;background:#05080fb8;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}.help-dialog[data-v-4bb9bc76]{width:min(820px,100%);max-height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid #3a4864;border-radius:14px;background:#141a27;color:#b8c3d8;box-shadow:0 22px 70px #0000009e}.help-head[data-v-4bb9bc76]{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:18px 20px 15px;border-bottom:1px solid #2b354a;background:linear-gradient(135deg,#202942,#181e2d)}.help-head h2[data-v-4bb9bc76]{margin:0;color:#eef2ff;font-size:19px}.help-head p[data-v-4bb9bc76]{margin:5px 0 0;color:#7786a3;font-size:11px}.help-close[data-v-4bb9bc76]{width:28px;height:28px;flex:0 0 28px;border:1px solid #43516b;border-radius:6px;background:#273249;color:#ccd7ed;cursor:pointer}.help-close[data-v-4bb9bc76]:hover{border-color:#7774ff;background:#3b4670;color:#fff}.help-body[data-v-4bb9bc76]{min-height:0;overflow:auto;padding:18px 20px 21px}.help-section[data-v-4bb9bc76]{padding:14px 15px;border:1px solid #2c3850;border-radius:10px;background:#181f2e}.help-section h3[data-v-4bb9bc76]{margin:0 0 11px;color:#9fb2ff;font-size:13px}.help-start ol[data-v-4bb9bc76]{margin:0;padding-left:21px;color:#aab6cc;font-size:12px;line-height:1.75}.help-start li[data-v-4bb9bc76]::marker{color:#7774ff;font-weight:700}.help-section b[data-v-4bb9bc76]{color:#dce4f7}.help-grid[data-v-4bb9bc76]{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(250px,.85fr);gap:12px;margin-top:12px}.help-list[data-v-4bb9bc76]{display:grid;gap:0;margin:0}.help-list>div[data-v-4bb9bc76]{display:grid;grid-template-columns:78px 1fr;gap:9px;padding:7px 0;border-bottom:1px solid #263146}.help-list>div[data-v-4bb9bc76]:last-child{border-bottom:0}.help-list dt[data-v-4bb9bc76]{color:#d3dcf0;font-size:11px;font-weight:700}.help-list dd[data-v-4bb9bc76]{margin:0;color:#8795ae;font-size:11px;line-height:1.5}.shortcut-list[data-v-4bb9bc76]{display:grid;gap:7px}.shortcut-list>div[data-v-4bb9bc76]{display:flex;align-items:center;min-height:25px;gap:5px}.shortcut-list kbd[data-v-4bb9bc76]{min-width:24px;box-sizing:border-box;padding:3px 6px;border:1px solid #465470;border-bottom-width:2px;border-radius:5px;background:#101623;color:#e0e7f6;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-align:center}.shortcut-list span[data-v-4bb9bc76]{color:#65728b;font-size:10px}.shortcut-list em[data-v-4bb9bc76]{margin-left:auto;color:#9ca9c1;font-size:11px;font-style:normal}.help-note[data-v-4bb9bc76]{margin:11px 0 0;padding-top:9px;border-top:1px solid #2a3549;color:#697791;font-size:10px;line-height:1.55}.help-export[data-v-4bb9bc76]{margin-top:12px}.help-export p[data-v-4bb9bc76]{margin:0;color:#8997b0;font-size:11px;line-height:1.65}.help-doc-title[data-v-4bb9bc76]{margin:16px 2px 2px;color:#9fb2ff;font-size:13px;font-weight:700;letter-spacing:.5px}.help-module[data-v-4bb9bc76]{margin-top:12px}.help-module-head[data-v-4bb9bc76]{display:flex;align-items:center;gap:8px;width:100%;padding:0;border:0;background:transparent;color:#9fb2ff;font:inherit;font-size:13px;font-weight:700;text-align:left;cursor:pointer}.help-module-head[data-v-4bb9bc76]:hover{color:#fff}.help-module-head .mtitle[data-v-4bb9bc76]{flex:1;min-width:0}.help-module-head .caret[data-v-4bb9bc76]{flex:0 0 auto;color:#7b86a2;font-size:12px}.help-module.open .help-module-head[data-v-4bb9bc76]{color:#fff}.help-module.open .help-module-head .caret[data-v-4bb9bc76]{color:#9fb2ff}.help-module .mnum[data-v-4bb9bc76]{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:linear-gradient(135deg,#4a53b8,#6a5fd0);color:#fff;font-size:12px;font-weight:700}.help-module.help-connector[data-v-4bb9bc76]{border-color:#4a4f8a;background:linear-gradient(135deg,#1d2442,#181d2f)}.help-module.help-connector .help-module-head[data-v-4bb9bc76]{color:#b8c2ff}.help-module .connector-intro[data-v-4bb9bc76]{margin:0 0 10px;color:#9aa8c4;font-size:11px;line-height:1.6}.help-module .connector-intro b[data-v-4bb9bc76]{color:#dce4f7}.help-module .dl-link[data-v-4bb9bc76]{display:inline-flex;flex-direction:column;align-items:flex-start;gap:2px;margin:0 10px 6px 0;padding:6px 12px;border:1px solid #5652b5;border-radius:6px;background:#4540a0;color:#fff;font-size:12px;font-weight:700;text-decoration:none}.help-module .dl-link[data-v-4bb9bc76]:hover{border-color:#8982ff;background:#554dc2}.help-module .dl-link small[data-v-4bb9bc76]{color:#cfe0ff;font-weight:500;font-size:9px}.help-module-body[data-v-4bb9bc76]{padding-top:10px}@media(max-width:760px){.help-overlay[data-v-4bb9bc76]{padding:66px 12px 14px}.help-grid[data-v-4bb9bc76]{grid-template-columns:1fr}}.topbar[data-v-6411b316]{position:absolute;top:10px;left:10px;right:10px;dispLine truncated
@@ -1,4 +1,4 @@
var Ae=Object.defineProperty;var Be=(m,o,e)=>o in m?Ae(m,o,{enumerable:!0,configurable:!0,writable:!0,value:e}):m[o]=e;var a=(m,o,e)=>Be(m,typeof o!="symbol"?o+"":o,e);import{T as Fe,a as he,b as F,S as ue,M as pe,c as oe,W as ne,B as H,d as z,L as Te,E as O,e as ce,p as q,f as Ee,g as fe,A as Q,h as me,i as De,j as V,s as re,G as we,k as $,l as Y,m as Le,P as Ie,n as Re,o as Ne,C as X,q as be,r as _e,t as Ue,u as Ye,v as Xe,w as ee,x as j,y as ke,z as Ve,D as J,F as ge,V as Ge,H as ae,I as We,J as $e,U as xe,K as E,R as ve,N as ye,O as Pe,Q as Oe,X as He,Y as ze,Z as qe,_ as Ke}from"./index-DnfvUUAC.js";import{$ as bt,a0 as kt,a1 as vt,a2 as yt,a3 as Pt,a4 as St,a5 as Mt,a6 as At,a7 as Bt,a8 as Ft,a9 as Et,aa as Dt,ab as Lt,ac as It,ad as Rt,ae as Nt,af as _t,ag as Ut,ah as Yt,ai as Xt,aj as Vt,ak as Gt,al as Wt,am as $t,an as Ot,ao as Ht,ap as zt,aq as qt,ar as Kt,as as Zt,at as Jt,au as Qt,av as jt,aw as es,ax as ts,ay as ss,az as os,aA as ns,aB as as,aC as rs,aD as is,aE as ls,aF as hs,aG as cs,aH as ds,aI as us,aJ as ps,aK as fs,aL as ms,aM as gs,aN as xs,aO as Cs,aP as Ts,aQ as ws,aR as bs,aS as ks,aT as vs,aU as ys,aV as Ps,aW as Ss,aX as Ms,aY as As,aZ as Bs,a_ as Fs,a$ as Es,b0 as Ds,b1 as Ls,b2 as Is,b3 as Rs,b4 as Ns,b5 as _s,b6 as Us,b7 as Ys,b8 as Xs,b9 as Vs,ba as Gs,bb as Ws,bc as $s,bd as Os,be as Hs,bf as zs,bg as qs,bh as Ks,bi as Zs,bj as Js,bk as Qs,bl as js,bm as eo,bn as to,bo as so,bp as oo,bq as no,br as ao,bs as ro,bt as io,bu as lo,bv as ho,bw as co,bx as uo,by as po,bz as fo,bA as mo,bB as go,bC as xo,bD as Co,bE as To,bF as wo,bG as bo,bH as ko,bI as vo,bJ as yo,bK as Po,bL as So,bM as Mo,bN as Ao,bO as Bo,bP as Fo,bQ as Eo,bR as Do,bS as Lo,bT as Io,bU as Ro,bV as No,bW as _o,bX as Uo,bY as Yo,bZ as Xo,b_ as Vo,b$ as Go,c0 as Wo,c1 as $o,c2 as Oo,c3 as Ho}from"./index-DnfvUUAC.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-BSAMR7rU.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-BSAMR7rU.js";if(typeof window<"u"&&window.PIXI){const m=window.require;window.require=o=>{if(m)return m(o);if(o.startsWith("@pixi/"))return window.PIXI}}const _=class _ extends Fe{constructor(e){super(e.resource.source);a(this,"texture");this.texture=he.from(e)}static from(e){return _.textureMap.has(e)?_.textureMap.get(e):new _(e)}setFilters(e,t){this.texture.baseTexture.scaleMode=_.toPixiTextureFilter(e),this.texture.baseTexture.mipmap=_.toPixiMipMap(e)}setWraps(e,t){this.texture.baseTexture.wrapMode=_.toPixiTextureWrap(e)}dispose(){this.texture.destroy()}static toPixiTextureFilter(e){switch(e){case F.Nearest:case F.MipMapNearestLinear:case F.MipMapNearestNearest:return ue.NEAREST;case F.Linear:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return ue.LINEAR;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiMipMap(e){switch(e){case F.Nearest:case F.Linear:return pe.OFF;case F.MipMapNearestLinear:case F.MipMapNearestNearest:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return pe.ON;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiTextureWrap(e){switch(e){case oe.ClampToEdge:return ne.CLAMP;case oe.MirroredRepeat:return ne.MIRRORED_REPEAT;case oe.Repeat:return ne.REPEAT;default:throw new Error(`Unknown texture wrap: ${String(e)}`)}}static toPixiBlending(e){switch(e){case H.Normal:return z.NORMAL;case H.Additive:return z.ADD;case H.Multiply:return z.MULTIPLY;case H.Screen:return z.SCREEN;default:throw new Error(`Unknown blendMode: ${String(e)}`)}}};a(_,"textureMap",new Map);let G=_;const K="spineTextureAtlasLoader",et={extension:O.Asset,resolver:{test:m=>V(m,".atlas"),parse:m=>{var e,t;const o=m.split(".");return{resolution:parseFloat(((t=(e=re.RETINA_PREFIX)==null?void 0:e.exec(m))==null?void 0:t[1])??"1"),format:o[o.length-2],src:m}}},loader:{name:K,extension:{type:O.LoadParser,priority:Te.Normal,name:K},test(m){return V(m,".atlas")},async load(m){const o=await re.ADAPTER.fetch(m);if(!o.ok)throw new Error(`[${K}] Failed to fetch ${m}: ${o.status} ${o.statusText}`);return await o.text()},testParse(m,o){const e=V(o.src,".atlas"),t=typeof m=="string",s=o.loadParser===K;return Promise.resolve((e||s)&&t)},unload(m){m.dispose()},async parse(m,o,e){var p;const t=o.data||{};let s=q.dirname(o.src);s&&s.lastIndexOf("/")!==s.length-1&&(s+="/");const n=new Ee(m);if(t.images instanceof fe||typeof t.images=="string"){const h=t.images;t.images={},t.images[n.pages[0].name]=h}const r=[];let l=!0;for(const h of e.parsers)if(h.name==="loadTextures"){l=(p=h.config)==null?void 0:p.preferCreateImageBitmap;break}Q.setPreferences({preferCreateImageBitmap:!1});for(const h of n.pages){const u=h.name,c=t!=null&&t.images?t.images[u]:void 0;if(c instanceof fe)h.setTexture(G.from(c));else{const i=c??q.normalize([...s.split(q.sep),u].join(q.sep)),g={src:De(i,o.src),data:{...t.imageMetadLine truncated var Ae=Object.defineProperty;var Be=(m,o,e)=>o in m?Ae(m,o,{enumerable:!0,configurable:!0,writable:!0,value:e}):m[o]=e;var a=(m,o,e)=>Be(m,typeof o!="symbol"?o+"":o,e);import{T as Fe,a as he,b as F,S as ue,M as pe,c as oe,W as ne,B as H,d as z,L as Te,E as O,e as ce,p as q,f as Ee,g as fe,A as Q,h as me,i as De,j as V,s as re,G as we,k as $,l as Y,m as Le,P as Ie,n as Re,o as Ne,C as X,q as be,r as _e,t as Ue,u as Ye,v as Xe,w as ee,x as j,y as ke,z as Ve,D as J,F as ge,V as Ge,H as ae,I as We,J as $e,U as xe,K as E,R as ve,N as ye,O as Pe,Q as Oe,X as He,Y as ze,Z as qe,_ as Ke}from"./index-DHREDOlg.js";import{$ as bt,a0 as kt,a1 as vt,a2 as yt,a3 as Pt,a4 as St,a5 as Mt,a6 as At,a7 as Bt,a8 as Ft,a9 as Et,aa as Dt,ab as Lt,ac as It,ad as Rt,ae as Nt,af as _t,ag as Ut,ah as Yt,ai as Xt,aj as Vt,ak as Gt,al as Wt,am as $t,an as Ot,ao as Ht,ap as zt,aq as qt,ar as Kt,as as Zt,at as Jt,au as Qt,av as jt,aw as es,ax as ts,ay as ss,az as os,aA as ns,aB as as,aC as rs,aD as is,aE as ls,aF as hs,aG as cs,aH as ds,aI as us,aJ as ps,aK as fs,aL as ms,aM as gs,aN as xs,aO as Cs,aP as Ts,aQ as ws,aR as bs,aS as ks,aT as vs,aU as ys,aV as Ps,aW as Ss,aX as Ms,aY as As,aZ as Bs,a_ as Fs,a$ as Es,b0 as Ds,b1 as Ls,b2 as Is,b3 as Rs,b4 as Ns,b5 as _s,b6 as Us,b7 as Ys,b8 as Xs,b9 as Vs,ba as Gs,bb as Ws,bc as $s,bd as Os,be as Hs,bf as zs,bg as qs,bh as Ks,bi as Zs,bj as Js,bk as Qs,bl as js,bm as eo,bn as to,bo as so,bp as oo,bq as no,br as ao,bs as ro,bt as io,bu as lo,bv as ho,bw as co,bx as uo,by as po,bz as fo,bA as mo,bB as go,bC as xo,bD as Co,bE as To,bF as wo,bG as bo,bH as ko,bI as vo,bJ as yo,bK as Po,bL as So,bM as Mo,bN as Ao,bO as Bo,bP as Fo,bQ as Eo,bR as Do,bS as Lo,bT as Io,bU as Ro,bV as No,bW as _o,bX as Uo,bY as Yo,bZ as Xo,b_ as Vo,b$ as Go,c0 as Wo,c1 as $o,c2 as Oo,c3 as Ho}from"./index-DHREDOlg.js";import{SkeletonClipping as de,SkeletonPhysicsMovement as Ze,AnimationStateData as Je,AtlasAttachmentLoader as Qe,SkeletonBounds as je}from"./index-B3zzqhYo.js";import{AssetCache as qo,AssetManagerBase as Ko,Downloader as Zo,SkeletonRendererCore as Jo,Triangulator as Qo}from"./index-B3zzqhYo.js";if(typeof window<"u"&&window.PIXI){const m=window.require;window.require=o=>{if(m)return m(o);if(o.startsWith("@pixi/"))return window.PIXI}}const _=class _ extends Fe{constructor(e){super(e.resource.source);a(this,"texture");this.texture=he.from(e)}static from(e){return _.textureMap.has(e)?_.textureMap.get(e):new _(e)}setFilters(e,t){this.texture.baseTexture.scaleMode=_.toPixiTextureFilter(e),this.texture.baseTexture.mipmap=_.toPixiMipMap(e)}setWraps(e,t){this.texture.baseTexture.wrapMode=_.toPixiTextureWrap(e)}dispose(){this.texture.destroy()}static toPixiTextureFilter(e){switch(e){case F.Nearest:case F.MipMapNearestLinear:case F.MipMapNearestNearest:return ue.NEAREST;case F.Linear:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return ue.LINEAR;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiMipMap(e){switch(e){case F.Nearest:case F.Linear:return pe.OFF;case F.MipMapNearestLinear:case F.MipMapNearestNearest:case F.MipMapLinearLinear:case F.MipMapLinearNearest:return pe.ON;default:throw new Error(`Unknown texture filter: ${String(e)}`)}}static toPixiTextureWrap(e){switch(e){case oe.ClampToEdge:return ne.CLAMP;case oe.MirroredRepeat:return ne.MIRRORED_REPEAT;case oe.Repeat:return ne.REPEAT;default:throw new Error(`Unknown texture wrap: ${String(e)}`)}}static toPixiBlending(e){switch(e){case H.Normal:return z.NORMAL;case H.Additive:return z.ADD;case H.Multiply:return z.MULTIPLY;case H.Screen:return z.SCREEN;default:throw new Error(`Unknown blendMode: ${String(e)}`)}}};a(_,"textureMap",new Map);let G=_;const K="spineTextureAtlasLoader",et={extension:O.Asset,resolver:{test:m=>V(m,".atlas"),parse:m=>{var e,t;const o=m.split(".");return{resolution:parseFloat(((t=(e=re.RETINA_PREFIX)==null?void 0:e.exec(m))==null?void 0:t[1])??"1"),format:o[o.length-2],src:m}}},loader:{name:K,extension:{type:O.LoadParser,priority:Te.Normal,name:K},test(m){return V(m,".atlas")},async load(m){const o=await re.ADAPTER.fetch(m);if(!o.ok)throw new Error(`[${K}] Failed to fetch ${m}: ${o.status} ${o.statusText}`);return await o.text()},testParse(m,o){const e=V(o.src,".atlas"),t=typeof m=="string",s=o.loadParser===K;return Promise.resolve((e||s)&&t)},unload(m){m.dispose()},async parse(m,o,e){var p;const t=o.data||{};let s=q.dirname(o.src);s&&s.lastIndexOf("/")!==s.length-1&&(s+="/");const n=new Ee(m);if(t.images instanceof fe||typeof t.images=="string"){const h=t.images;t.images={},t.images[n.pages[0].name]=h}const r=[];let l=!0;for(const h of e.parsers)if(h.name==="loadTextures"){l=(p=h.config)==null?void 0:p.preferCreateImageBitmap;break}Q.setPreferences({preferCreateImageBitmap:!1});for(const h of n.pages){const u=h.name,c=t!=null&&t.images?t.images[u]:void 0;if(c instanceof fe)h.setTexture(G.from(c));else{const i=c??q.normalize([...s.split(q.sep),u].join(q.sep)),g={src:De(i,o.src),data:{...t.imageMetadLine truncated
attribute vec2 aVertexPosition; attribute vec2 aVertexPosition;
attribute vec2 aTextureCoord; attribute vec2 aTextureCoord;
+2 -2
View File
@@ -8,8 +8,8 @@
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; } html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; } #app { display: flex; flex-direction: column; height: 100vh; }
</style> </style>
<script type="module" crossorigin src="/assets/index-DnfvUUAC.js"></script> <script type="module" crossorigin src="/assets/index-DHREDOlg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Q5OZd_mD.css"> <link rel="stylesheet" crossorigin href="/assets/index-D-WRfyaT.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+132
View File
@@ -0,0 +1,132 @@
import { readFile, writeFile } from 'node:fs/promises'
const local = {
'zh-CN': {
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}',
},
'zh-TW': {
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}',
},
en: {
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}',
},
ja: {
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}',
},
ko: {
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}',
},
fr: {
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}',
},
de: {
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}',
},
'pt-BR': {
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}',
},
es: {
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}',
},
}
for (const [locale, values] of Object.entries(local)) {
const path = `src/i18n/locales/${locale}.json`
const messages = JSON.parse(await readFile(path, 'utf8'))
messages.config ||= {}
messages.config.local = values
messages.config.saveTitle = values.saveTitle
await writeFile(path, `${JSON.stringify(messages, null, 2)}\n`)
}
console.log('Applied project library translations.')
+25
View File
@@ -120,6 +120,31 @@ for (const [preset, locale, expected] of presetCases) {
} }
} }
const projectLibraryBundle = await build({
entryPoints: ['src/editor/projectLibrary.ts'],
bundle: true,
format: 'esm',
platform: 'node',
write: false,
logLevel: 'silent',
})
const projectLibrary = await import(
`data:text/javascript;base64,${Buffer.from(projectLibraryBundle.outputFiles[0].contents).toString('base64')}`
)
if (projectLibrary.normalizeProjectName(' Demo.json ') !== 'Demo') {
throw new Error('Project name normalization must trim spaces and the .json suffix')
}
if (projectLibrary.projectJsonFileName('Demo') !== 'Demo.json') {
throw new Error('Project download file name is incorrect')
}
let invalidProjectNameRejected = false
try {
projectLibrary.normalizeProjectName('bad/name')
} catch {
invalidProjectNameRejected = true
}
if (!invalidProjectNameRejected) throw new Error('Invalid project names must be rejected')
const cases = [ const cases = [
[null, ['zh-CN'], 'zh-CN'], [null, ['zh-CN'], 'zh-CN'],
[null, ['zh-Hant-TW'], 'zh-TW'], [null, ['zh-Hant-TW'], 'zh-TW'],
+13 -2
View File
@@ -29,7 +29,7 @@ interface EditorSnapshot {
spineAssets: Map<number, LoadedSpineAsset> spineAssets: Map<number, LoadedSpineAsset>
} }
export const editorHistoryState = reactive({ undoCount: 0 }) export const editorHistoryState = reactive({ undoCount: 0, dirty: false })
let managedStore: EditorStore | null = null let managedStore: EditorStore | null = null
let currentSnapshot: EditorSnapshot | null = null let currentSnapshot: EditorSnapshot | null = null
@@ -37,6 +37,7 @@ let undoStack: EditorSnapshot[] = []
let stopHistoryWatch: WatchStopHandle | null = null let stopHistoryWatch: WatchStopHandle | null = null
let commitTimer: ReturnType<typeof setTimeout> | null = null let commitTimer: ReturnType<typeof setTimeout> | null = null
let applyingSnapshot = false let applyingSnapshot = false
let savedSignature = ''
function captureData(store: EditorStore): EditorSnapshotData { function captureData(store: EditorStore): EditorSnapshotData {
const systems = store.systems.map((system) => { const systems = store.systems.map((system) => {
@@ -95,8 +96,9 @@ function commitCurrentChange() {
trimUndoStack(store.settings.undoLimit) trimUndoStack(store.settings.undoLimit)
} }
function scheduleCommit() { function scheduleCommit(signature: string) {
if (applyingSnapshot) return if (applyingSnapshot) return
editorHistoryState.dirty = signature !== savedSignature
if (commitTimer) clearTimeout(commitTimer) if (commitTimer) clearTimeout(commitTimer)
commitTimer = setTimeout(() => { commitTimer = setTimeout(() => {
commitTimer = null commitTimer = null
@@ -157,6 +159,7 @@ function restoreSnapshot(store: EditorStore, target: EditorSnapshot) {
repairSelection(store) repairSelection(store)
store.recalcTotalFrames() store.recalcTotalFrames()
currentSnapshot = target currentSnapshot = target
editorHistoryState.dirty = target.signature !== savedSignature
} finally { } finally {
applyingSnapshot = false applyingSnapshot = false
releaseSnapshot(previousSnapshot) releaseSnapshot(previousSnapshot)
@@ -172,7 +175,9 @@ export function installEditorHistory(store: EditorStore) {
undoStack = [] undoStack = []
managedStore = store managedStore = store
currentSnapshot = createSnapshot(store) currentSnapshot = createSnapshot(store)
savedSignature = currentSnapshot.signature
editorHistoryState.undoCount = 0 editorHistoryState.undoCount = 0
editorHistoryState.dirty = false
stopHistoryWatch = watch( stopHistoryWatch = watch(
() => dataSignature(captureData(store)), () => dataSignature(captureData(store)),
scheduleCommit, 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) { export function setUndoLimit(store: EditorStore, value: number) {
const limit = Math.min(200, Math.max(1, Math.round(Number(value) || 20))) const limit = Math.min(200, Math.max(1, Math.round(Number(value) || 20)))
store.settings.undoLimit = limit 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' | 'axisFontSize'
| 'tickWidth' | 'tickWidth'
| 'showAxisLabels' | 'showAxisLabels'
| 'unsavedChangesPrompt'
| 'axisColor' | 'axisColor'
> >
@@ -48,6 +49,7 @@ export function captureSystemSettings(settings: SettingsState): SystemSettingsSt
axisFontSize: settings.axisFontSize, axisFontSize: settings.axisFontSize,
tickWidth: settings.tickWidth, tickWidth: settings.tickWidth,
showAxisLabels: settings.showAxisLabels, showAxisLabels: settings.showAxisLabels,
unsavedChangesPrompt: settings.unsavedChangesPrompt,
axisColor: settings.axisColor, axisColor: settings.axisColor,
} }
} }
@@ -71,6 +73,7 @@ function normalizedSystemSettings(value: Partial<SystemSettingsState>, current:
axisFontSize: Math.round(clamp(value.axisFontSize, 8, 40, current.axisFontSize)), axisFontSize: Math.round(clamp(value.axisFontSize, 8, 40, current.axisFontSize)),
tickWidth: clamp(value.tickWidth, 1, 5, current.tickWidth), tickWidth: clamp(value.tickWidth, 1, 5, current.tickWidth),
showAxisLabels: booleanValue('showAxisLabels'), showAxisLabels: booleanValue('showAxisLabels'),
unsavedChangesPrompt: booleanValue('unsavedChangesPrompt'),
axisColor, axisColor,
} }
} }
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "Dicke der Teilstriche", "tickWidth": "Dicke der Teilstriche",
"axisColor": "Achsenfarbe", "axisColor": "Achsenfarbe",
"showAxisLabels": "Skalenwert anzeigen", "showAxisLabels": "Skalenwert anzeigen",
"unsavedPrompt": "Warnung bei ungespeicherten Änderungen",
"cacheNote": "Die gesamten Systemeinstellungen werden automatisch im aktuellen Browser gespeichert" "cacheNote": "Die gesamten Systemeinstellungen werden automatisch im aktuellen Browser gespeichert"
}, },
"config": { "config": {
"title": "Konfiguration", "title": "Konfiguration",
"save": "Konfiguration speichern", "save": "Konfiguration speichern",
"saveTitle": "Alle Editorkonfigurationen speichern (Ctrl+S)", "saveTitle": "Konfiguration im Browser-Cache speichern",
"load": "Konfiguration laden", "load": "Konfiguration laden",
"loadTitle": "Lokale Editorkonfiguration laden", "loadTitle": "Lokale Editorkonfiguration laden",
"presets": "Voreinstellungsliste", "presets": "Voreinstellungsliste",
@@ -98,7 +99,35 @@
"presetRestoring": "Standardressourcen werden analysiert und wiederhergestellt...", "presetRestoring": "Standardressourcen werden analysiert und wiederhergestellt...",
"presetComplete": "Das Laden der Voreinstellungen ist abgeschlossen", "presetComplete": "Das Laden der Voreinstellungen ist abgeschlossen",
"presetListError": "Voreinstellungsliste kann nicht gelesen werden", "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": { "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", "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", "tickWidth": "Tick line width",
"axisColor": "Axis color", "axisColor": "Axis color",
"showAxisLabels": "Show tick values", "showAxisLabels": "Show tick values",
"unsavedPrompt": "Prompt for unsaved changes",
"cacheNote": "Settings are saved automatically in this browser" "cacheNote": "Settings are saved automatically in this browser"
}, },
"config": { "config": {
"title": "Configuration", "title": "Configuration",
"save": "Save Config", "save": "Save Config",
"saveTitle": "Save the complete editor configuration (Ctrl+S)", "saveTitle": "Save configuration to browser cache",
"load": "Load Config", "load": "Load Config",
"loadTitle": "Load an editor configuration file", "loadTitle": "Load an editor configuration file",
"presets": "Preset list", "presets": "Preset list",
@@ -110,7 +111,35 @@
"presetRestoring": "Parsing and restoring preset resources…", "presetRestoring": "Parsing and restoring preset resources…",
"presetComplete": "Preset loaded", "presetComplete": "Preset loaded",
"presetListError": "Unable to read preset list", "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": { "curve": {
"bezierHint": "Drag square handles · Double-click empty space to add · Double-click a node to delete", "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", "tickWidth": "espesor de la marca de verificación",
"axisColor": "color del eje", "axisColor": "color del eje",
"showAxisLabels": "Mostrar valor de escala", "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." "cacheNote": "Toda la configuración del sistema se guarda automáticamente en el navegador actual."
}, },
"config": { "config": {
"title": "Gestión de configuración", "title": "Gestión de configuración",
"save": "Guardar 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", "load": "Cargar configuración",
"loadTitle": "Cargar la configuración del editor local", "loadTitle": "Cargar la configuración del editor local",
"presets": "Lista preestablecida", "presets": "Lista preestablecida",
@@ -98,7 +99,35 @@
"presetRestoring": "Analizando y restaurando recursos predeterminados...", "presetRestoring": "Analizando y restaurando recursos predeterminados...",
"presetComplete": "Carga preestablecida completada", "presetComplete": "Carga preestablecida completada",
"presetListError": "No se puede leer la lista preestablecida", "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": { "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", "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", "tickWidth": "épaisseur de la graduation",
"axisColor": "couleur de l'axe", "axisColor": "couleur de l'axe",
"showAxisLabels": "Afficher la valeur de l'échelle", "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" "cacheNote": "L'ensemble des paramètres du système sont automatiquement enregistrés dans le navigateur actuel"
}, },
"config": { "config": {
"title": "Configuration", "title": "Configuration",
"save": "Enregistrer", "save": "Enregistrer",
"saveTitle": "Enregistrez toutes les configurations de l'éditeur (Ctrl+S)", "saveTitle": "Enregistrer la configuration dans le cache du navigateur",
"load": "Charger", "load": "Charger",
"loadTitle": "Charger la configuration de l'éditeur local", "loadTitle": "Charger la configuration de l'éditeur local",
"presets": "Liste des préréglages", "presets": "Liste des préréglages",
@@ -98,7 +99,35 @@
"presetRestoring": "Analyse et restauration des ressources par défaut...", "presetRestoring": "Analyse et restauration des ressources par défaut...",
"presetComplete": "Chargement du préréglage terminé", "presetComplete": "Chargement du préréglage terminé",
"presetListError": "Impossible de lire la liste des préréglages", "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": { "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", "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": "目盛りの太さ", "tickWidth": "目盛りの太さ",
"axisColor": "軸の色", "axisColor": "軸の色",
"showAxisLabels": "表示スケール値", "showAxisLabels": "表示スケール値",
"unsavedPrompt": "未保存の変更を確認",
"cacheNote": "システム設定全体が現在のブラウザに自動的に保存されます" "cacheNote": "システム設定全体が現在のブラウザに自動的に保存されます"
}, },
"config": { "config": {
"title": "設定管理", "title": "設定管理",
"save": "設定を保存", "save": "設定を保存",
"saveTitle": "すべてのエディタ設定を保存する (Ctrl+S)", "saveTitle": "設定をブラウザキャッシュに保存",
"load": "設定を読み込む", "load": "設定を読み込む",
"loadTitle": "ローカルエディタ設定をロードする", "loadTitle": "ローカルエディタ設定をロードする",
"presets": "プリセットリスト", "presets": "プリセットリスト",
@@ -98,7 +99,35 @@
"presetRestoring": "デフォルトのリソースを解析して復元しています...", "presetRestoring": "デフォルトのリソースを解析して復元しています...",
"presetComplete": "プリセットのロードが完了しました", "presetComplete": "プリセットのロードが完了しました",
"presetListError": "プリセットリストを読み取れません", "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": { "curve": {
"bezierHint": "四角いハンドルをドラッグして円弧を調整します。 · 空白スペースをダブルクリックして新しいスペースを作成します。 · ノードをダブルクリックして削除します。", "bezierHint": "四角いハンドルをドラッグして円弧を調整します。 · 空白スペースをダブルクリックして新しいスペースを作成します。 · ノードをダブルクリックして削除します。",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "눈금 표시 두께", "tickWidth": "눈금 표시 두께",
"axisColor": "축 색상", "axisColor": "축 색상",
"showAxisLabels": "표시 스케일 값", "showAxisLabels": "표시 스케일 값",
"unsavedPrompt": "저장되지 않은 변경 확인",
"cacheNote": "전체 시스템 설정이 현재 브라우저에 자동으로 저장됩니다." "cacheNote": "전체 시스템 설정이 현재 브라우저에 자동으로 저장됩니다."
}, },
"config": { "config": {
"title": "설정 관리", "title": "설정 관리",
"save": "설정 저장", "save": "설정 저장",
"saveTitle": "모든 편집기 구성 저장(Ctrl+S)", "saveTitle": "브라우저 캐시에 설정 저장",
"load": "설정 불러오기", "load": "설정 불러오기",
"loadTitle": "로컬 편집기 구성 로드", "loadTitle": "로컬 편집기 구성 로드",
"presets": "프리셋 목록", "presets": "프리셋 목록",
@@ -98,7 +99,35 @@
"presetRestoring": "기본 리소스를 구문 분석하고 복원하는 중...", "presetRestoring": "기본 리소스를 구문 분석하고 복원하는 중...",
"presetComplete": "프리셋 로딩 완료", "presetComplete": "프리셋 로딩 완료",
"presetListError": "사전 설정 목록을 읽을 수 없습니다.", "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": { "curve": {
"bezierHint": "원호를 조정하려면 사각형 핸들을 드래그하세요. · 새 공간을 만들려면 빈 공간을 두 번 클릭하세요. · 삭제하려면 노드를 두 번 클릭하세요.", "bezierHint": "원호를 조정하려면 사각형 핸들을 드래그하세요. · 새 공간을 만들려면 빈 공간을 두 번 클릭하세요. · 삭제하려면 노드를 두 번 클릭하세요.",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "espessura da marca", "tickWidth": "espessura da marca",
"axisColor": "cor do eixo", "axisColor": "cor do eixo",
"showAxisLabels": "Exibir valor da escala", "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" "cacheNote": "Todas as configurações do sistema são salvas automaticamente no navegador atual"
}, },
"config": { "config": {
"title": "Gerenciamento de configuração", "title": "Gerenciamento de configuração",
"save": "Salvar 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", "load": "Carregar configuração",
"loadTitle": "Carregar configuração do editor local", "loadTitle": "Carregar configuração do editor local",
"presets": "Lista predefinida", "presets": "Lista predefinida",
@@ -98,7 +99,35 @@
"presetRestoring": "Analisando e restaurando recursos padrão...", "presetRestoring": "Analisando e restaurando recursos padrão...",
"presetComplete": "Carregamento predefinido concluído", "presetComplete": "Carregamento predefinido concluído",
"presetListError": "Não é possível ler a lista predefinida", "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": { "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", "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": "刻度线粗细", "tickWidth": "刻度线粗细",
"axisColor": "坐标轴颜色", "axisColor": "坐标轴颜色",
"showAxisLabels": "显示刻度数值", "showAxisLabels": "显示刻度数值",
"unsavedPrompt": "未保存提示",
"cacheNote": "整个系统设置会自动保存到当前浏览器" "cacheNote": "整个系统设置会自动保存到当前浏览器"
}, },
"config": { "config": {
"title": "配置管理", "title": "配置管理",
"save": "保存配置", "save": "保存配置",
"saveTitle": "保存全部编辑器配置 (Ctrl+S)", "saveTitle": "保存配置到浏览器缓存",
"load": "加载配置", "load": "加载配置",
"loadTitle": "加载本地编辑器配置", "loadTitle": "加载本地编辑器配置",
"presets": "预设列表", "presets": "预设列表",
@@ -110,7 +111,35 @@
"presetRestoring": "正在解析并恢复预设资源…", "presetRestoring": "正在解析并恢复预设资源…",
"presetComplete": "预设加载完成", "presetComplete": "预设加载完成",
"presetListError": "无法读取预设列表", "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": { "curve": {
"bezierHint": "拖动方形手柄调整弧度 · 双击空白新建 · 双击节点删除", "bezierHint": "拖动方形手柄调整弧度 · 双击空白新建 · 双击节点删除",
+31 -2
View File
@@ -75,12 +75,13 @@
"tickWidth": "刻度線粗細", "tickWidth": "刻度線粗細",
"axisColor": "座標軸顏色", "axisColor": "座標軸顏色",
"showAxisLabels": "顯示刻度數值", "showAxisLabels": "顯示刻度數值",
"unsavedPrompt": "未儲存提示",
"cacheNote": "整個系統設定會自動儲存到目前瀏覽器" "cacheNote": "整個系統設定會自動儲存到目前瀏覽器"
}, },
"config": { "config": {
"title": "設定管理", "title": "設定管理",
"save": "儲存設定", "save": "儲存設定",
"saveTitle": "儲存全部編輯器配置 (Ctrl+S)", "saveTitle": "儲存設定到瀏覽器快取",
"load": "載入設定", "load": "載入設定",
"loadTitle": "載入本機編輯器配置", "loadTitle": "載入本機編輯器配置",
"presets": "預設列表", "presets": "預設列表",
@@ -98,7 +99,35 @@
"presetRestoring": "正在解析並恢復預設資源…", "presetRestoring": "正在解析並恢復預設資源…",
"presetComplete": "預設載入完成", "presetComplete": "預設載入完成",
"presetListError": "無法讀取預設列表", "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": { "curve": {
"bezierHint": "拖曳方形手把調整弧度 · 雙擊空白新建 · 雙擊節點刪除", "bezierHint": "拖曳方形手把調整弧度 · 雙擊空白新建 · 雙擊節點刪除",
+3
View File
@@ -65,6 +65,8 @@ export interface SettingsState {
tickWidth: number tickWidth: number
/** 是否显示坐标轴刻度数值 */ /** 是否显示坐标轴刻度数值 */
showAxisLabels: boolean showAxisLabels: boolean
/** 有未保存修改时,刷新或关闭页面前显示浏览器原生提示。 */
unsavedChangesPrompt: boolean
/** 坐标轴数值颜色(#rrggbb) */ /** 坐标轴数值颜色(#rrggbb) */
axisColor: string axisColor: string
/** 最大撤回操作数。 */ /** 最大撤回操作数。 */
@@ -143,6 +145,7 @@ export const useParticleStore = defineStore('particle', {
axisFontSize: 12, axisFontSize: 12,
tickWidth: 1, tickWidth: 1,
showAxisLabels: true, showAxisLabels: true,
unsavedChangesPrompt: false,
axisColor: '#7a86ad', axisColor: '#7a86ad',
undoLimit: 20, undoLimit: 20,
backgroundX: 0, backgroundX: 0,
+11 -3
View File
@@ -40,9 +40,9 @@ import { loopPlaybackRange } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds' import { collectSubEmitterIds } from '../core/subEmitterIds'
import { useParticleStore, type CollisionBody, type ParticleSystem } from '../store/particleStore' import { useParticleStore, type CollisionBody, type ParticleSystem } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer' import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
import { installEditorHistory, undoLastOperation } from '../editor/editorHistory' import { editorHistoryState, installEditorHistory, undoLastOperation } from '../editor/editorHistory'
import { useEditorShortcuts } from '../editor/useEditorShortcuts' import { useEditorShortcuts } from '../editor/useEditorShortcuts'
import { downloadEditorConfig } from '../editor/editorConfig' import { PROJECT_SAVE_EVENT } from '../editor/projectLibrary'
import HelpDialog from './HelpDialog.vue' import HelpDialog from './HelpDialog.vue'
import StageToolbar from './StageToolbar.vue' import StageToolbar from './StageToolbar.vue'
import StageSettingsPanel from './StageSettingsPanel.vue' import StageSettingsPanel from './StageSettingsPanel.vue'
@@ -166,6 +166,12 @@ function onStageDoubleClick(e: MouseEvent) {
pathEditing.onStageDoubleClick(e) pathEditing.onStageDoubleClick(e)
} }
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!store.settings.unsavedChangesPrompt || !editorHistoryState.dirty) return
event.preventDefault()
event.returnValue = ''
}
function onWheelEvent(e: WheelEvent) { function onWheelEvent(e: WheelEvent) {
onWheel(stageContext, e) onWheel(stageContext, e)
} }
@@ -182,7 +188,7 @@ useEditorShortcuts({
canvasElement: holder, canvasElement: holder,
canvasFocused, canvasFocused,
undo: undoLastOperation, undo: undoLastOperation,
save: () => downloadEditorConfig(store), save: () => window.dispatchEvent(new Event(PROJECT_SAVE_EVENT)),
togglePlay: () => store.togglePlay(), togglePlay: () => store.togglePlay(),
toStart: () => store.toStart(), toStart: () => store.toStart(),
setTool: (tool) => { activeTool.value = tool }, setTool: (tool) => { activeTool.value = tool },
@@ -553,9 +559,11 @@ onMounted(async () => {
}, },
) )
installEditorHistory(store) installEditorHistory(store)
window.addEventListener('beforeunload', onBeforeUnload)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
spineRuntime?.destroy() spineRuntime?.destroy()
spineRuntime = null spineRuntime = null
+10
View File
@@ -74,6 +74,15 @@ function normalizeBoneDebugSizes() {
</label> </label>
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showAxisLabels" />{{ t('settings.showAxisLabels') }}</label> <label class="sp-chk"><input type="checkbox" v-model="store.settings.showAxisLabels" />{{ t('settings.showAxisLabels') }}</label>
</template> </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 class="sp-cache-note">{{ t('settings.cacheNote') }}</div>
</div> </div>
</template> </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-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-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 { 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 { 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-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; } .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 config-management">
<div class="group-head" @click="toggle('config')"> <div class="group-head" @click="toggle('config')">
<span class="caret">{{ collapsed.config ? '▸' : '▾' }}</span><span>{{ $t('config.title') }}</span> <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>
<div class="group-body config-management-body" v-show="!collapsed.config"> <div class="group-body config-management-body" v-show="!collapsed.config">
<div class="config-toolbar"> <div class="config-toolbar">
@@ -14,8 +15,67 @@
<input ref="configFileInput" hidden type="file" accept=".json,application/json" @change="onConfigFileChange" /> <input ref="configFileInput" hidden type="file" accept=".json,application/json" @change="onConfigFileChange" />
</div> </div>
<div v-if="configMessage" class="config-message" :class="{ error: configError }">{{ configMessage }}</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>
</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> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -28,7 +88,14 @@ import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue' import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
import { useParticlePanelContext } from './particlePanelContext' import { useParticlePanelContext } from './particlePanelContext'
import { resolvePresetDisplayName, type PresetOption } from './presetNames' 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() const { locale } = useI18n()
function presetDisplayName(preset: PresetOption) { function presetDisplayName(preset: PresetOption) {
return resolvePresetDisplayName(preset, locale.value) return resolvePresetDisplayName(preset, locale.value)
@@ -37,6 +104,11 @@ function presetDisplayName(preset: PresetOption) {
<style scoped> <style scoped>
.config-management-body { padding-bottom: 2px; } .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 { display: grid; grid-template-columns: auto auto minmax(0,1fr); gap: 6px; margin-bottom: 10px; }
.config-toolbar button,.config-toolbar select { .config-toolbar button,.config-toolbar select {
min-width: 0; height: 30px; box-sizing: border-box; border: 1px solid #3b4864; border-radius: 6px; 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-toolbar select:disabled { color: #68738a; background: #1b2130; }
.config-message { margin: -4px 0 9px; color: #83b9a2; font-size: 10px; } .config-message { margin: -4px 0 9px; color: #83b9a2; font-size: 10px; }
.config-message.error { color: #e8959e; } .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 { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head { .group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer; 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 store = useParticleStore()
const { t } = useI18n() 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 { 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: '跟随导出' | '线性' | '贝塞尔') { function resolvedCurveEditorMode(mode: '跟随导出' | '线性' | '贝塞尔') {
if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear' if (mode === '跟随导出') return exportSettings.value.keyframeCurve === 'bezier' ? 'bezier' : 'linear'
return mode === '贝塞尔' ? 'bezier' : 'linear' return mode === '贝塞尔' ? 'bezier' : 'linear'
@@ -589,5 +595,5 @@ function onSpawnPathChange() {
// 重置/删除图片 → 回到默认 star.png // 重置/删除图片 → 回到默认 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 ColorGradientEditor from '../ColorGradientEditor.vue'
import ImageResourceCard from '../ImageResourceCard.vue' import ImageResourceCard from '../ImageResourceCard.vue'
import SpineObjectPanel from '../../spine/SpineObjectPanel.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 { beginTransfer, readResponseTextWithProgress } from '../../editor/transferProgress'
import { defaultSpineExportSettings, ensureSpineExportSettings } from '../../export/spineExportSettings' import { defaultSpineExportSettings, ensureSpineExportSettings } from '../../export/spineExportSettings'
import { buildSpineJson, downloadSpineJson } from '../../export/spineJsonExporter' import { buildSpineJson, downloadSpineJson } from '../../export/spineJsonExporter'
@@ -25,7 +25,19 @@ import {
} from '../../export/spineConnectorClient' } from '../../export/spineConnectorClient'
import { automaticLoopTransitionFrames } from '../../core/loopSegmentTiming' import { automaticLoopTransitionFrames } from '../../core/loopSegmentTiming'
import { translate } from '../../i18n' import { translate } from '../../i18n'
import { markEditorSaved } from '../../editor/editorHistory'
import { parsePresetIndex, type PresetOption } from './presetNames' 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>) { export function useProjectPresets(store: ReturnType<typeof useParticleStore>) {
const configFileInput = ref<HTMLInputElement | null>(null) const configFileInput = ref<HTMLInputElement | null>(null)
@@ -34,10 +46,167 @@ const presetOptions = ref<PresetOption[]>([])
const presetLoading = ref(false) const presetLoading = ref(false)
const configMessage = ref('') const configMessage = ref('')
const configError = ref(false) 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() { function saveConfig() {
downloadEditorConfig(store)
configError.value = false 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) { async function onConfigFileChange(event: Event) {
@@ -48,6 +217,7 @@ async function onConfigFileChange(event: Event) {
configError.value = false configError.value = false
try { try {
await loadEditorConfigFile(store, file) await loadEditorConfigFile(store, file)
markEditorSaved(store)
configMessage.value = translate('config.loaded', { name: file.name }) configMessage.value = translate('config.loaded', { name: file.name })
} catch (error) { } catch (error) {
configError.value = true configError.value = true
@@ -85,6 +255,7 @@ async function loadSelectedPreset() {
transfer.update(58, translate('config.presetParsing')) transfer.update(58, translate('config.presetParsing'))
transfer.update(70, translate('config.presetRestoring')) transfer.update(70, translate('config.presetRestoring'))
await loadEditorConfigText(store, text) await loadEditorConfigText(store, text)
markEditorSaved(store)
configMessage.value = translate('config.presetLoaded', { name: file.replace(/\.json$/i, '') }) configMessage.value = translate('config.presetLoaded', { name: file.replace(/\.json$/i, '') })
await transfer.finish(translate('config.presetComplete')) await transfer.finish(translate('config.presetComplete'))
} catch (error) { } 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,
}
} }