尾缀识别
No files matched your search
@@ -49,7 +49,7 @@ SpineViewer/
|
||||
|
||||
1. **跨文件夹资源配对**:Eagle 会把导入的每个文件拆分成独立的元数据文件夹(`images/<id>.info/`,内含资产文件与 `metadata.json`),因此配套文件可能不在同一目录。插件会:
|
||||
- 定位到所选文件的 item 文件夹;
|
||||
- 扫描其**同级的所有 item 文件夹**,根据文件名(忽略 `-pro`、`-ess`、`-pma` 等常见后缀)自动关联出配套的 **骨骼文件** + **atlas** + **贴图**;
|
||||
- 涋描其**同级的所有 item 文件夹**,根据文件名(忽略 `-pro`、`-ess`、`-pma`、`-base`、`-atlas`、`-data`、`-mesh` 等常见尾缀,`-` 与 `_` 均可)自动关联出配套的 **骨骼文件** + **atlas** + **贴图**;尾缀表可在预览顶栏的“尾缀”下拉里追加自定义项;
|
||||
- 从 atlas 中解析它引用的贴图页面,再到库中定位实际贴图文件。
|
||||
2. **版本检测**:读取骨骼数据里的版本号(`.json` 读 `skeleton.spine`,`.skel` 读二进制头部),自动选择匹配的本地运行时。
|
||||
3. **预览**:查看器通过 Node `fs` 直接读取分离在各 item 文件夹中的骨骼/atlas/贴图文件(无需网络、无 CORS),用 `spine-canvas` 渲染器在 `<canvas>` 上实时播放动画,并支持:
|
||||
@@ -63,6 +63,7 @@ SpineViewer/
|
||||
- 零时长动画按单帧静态姿势预览
|
||||
- 自动适配并居中
|
||||
- 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认
|
||||
- 顶栏“尾缀”下拉列出内置尾缀(`pro` `ess` `pma` `base` `atlas` `data` `mesh`)与用户自定义尾缀;选择“+ 添加尾缀…”可新增(仅字母、数字、`-`、`_`,最长 50 字符,上限由 `js/spine-utils.js` 的 `SUFFIX_MAX_LENGTH` 统一定义),选择自定义项可移除。配置保存在 Eagle userData 的 `SpineViewer/suffix-settings.json`,对全部素材与资源库生效,修改后自动重新加载以重新配对
|
||||
4. **缩略图**:导入时不运行插件缩略图生成器,由 Eagle 自身处理。用户打开 Spine 预览后,仍可用原有按钮将当前帧设为**当前素材**的自定义缩略图。左侧的“为关联资源生成缩略图”会先列出已验证的同 stem `json / skel / skel.bytes / atlas / atlas.txt` 和 atlas 引用贴图;骨骼与 atlas 默认勾选,图片和已有自定义缩略图的素材默认不勾选;用户可主动勾选已有自定义缩略图的素材,确认后覆盖,原图无法由插件自动恢复。手动缩略图会添加 Spine badge 和导出版本号。
|
||||
|
||||
---
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "Laden fehlgeschlagen",
|
||||
"notSpineText": "Diese Datei ist keine Spine-Ressource im Format .atlas.txt oder .skel.bytes.",
|
||||
"truncated256": "\n\n(nur die ersten 256 KB werden angezeigt)",
|
||||
"suffix": "Suffix",
|
||||
"suffixTitle": "Dateinamens-Suffixe werden beim Abgleich ignoriert, daher wird hero-mesh.skel mit hero.atlas verknüpft",
|
||||
"suffixManage": "Suffixe verwalten…",
|
||||
"suffixAdd": "+ Suffix hinzufügen…",
|
||||
"suffixDefaults": "Integrierte Suffixe",
|
||||
"suffixCustom": "Eigene Suffixe (Auswahl entfernt)",
|
||||
"suffixAddTitle": "Suffix hinzufügen",
|
||||
"suffixAddNote": "Suffixe werden beim Dateiabgleich ignoriert: Nach dem Hinzufügen von hover wird hero-hover.skel mit hero.atlas verknüpft. Nur Buchstaben, Ziffern, - und _, maximal 50 Zeichen.",
|
||||
"suffixPlaceholder": "z. B. hover",
|
||||
"suffixInvalid": "Nur Buchstaben, Ziffern, - und _, beginnend mit Buchstabe oder Ziffer",
|
||||
"suffixDuplicate": "Dieses Suffix existiert bereits",
|
||||
"suffixConfirm": "Hinzufügen",
|
||||
"suffixRemoveTitle": "Suffix entfernen",
|
||||
"suffixRemoveNote": "Nach dem Entfernen von „{name}“ werden Dateien mit diesem Suffix nicht mehr automatisch verknüpft.",
|
||||
"suffixRemove": "Entfernen",
|
||||
"suffixSaveFailed": "Speichern der Suffix-Liste fehlgeschlagen: {error}",
|
||||
"truncated1m": "\n\n(große Datei; nur das erste 1 MB wird angezeigt)",
|
||||
"groupWillReplaceCustom": "Ersetzt vorhandenes eigenes Vorschaubild",
|
||||
"groupCustomNotSelected": "Eigenes Vorschaubild vorhanden (nicht ausgewählt)"
|
||||
|
||||
@@ -93,6 +93,22 @@
|
||||
"loadFailed": "Failed to load",
|
||||
"notSpineText": "This file is not a .atlas.txt or .skel.bytes Spine asset.",
|
||||
"truncated256": "\n\n(only the first 256 KB is shown)",
|
||||
"suffix": "Suffix",
|
||||
"suffixTitle": "Filename suffixes are ignored when matching, so hero-mesh.skel pairs with hero.atlas",
|
||||
"suffixManage": "Manage suffixes…",
|
||||
"suffixAdd": "+ Add suffix…",
|
||||
"suffixDefaults": "Built-in suffixes",
|
||||
"suffixCustom": "Custom suffixes (select to remove)",
|
||||
"suffixAddTitle": "Add a suffix",
|
||||
"suffixAddNote": "Suffixes are ignored when matching filenames: after adding hover, hero-hover.skel pairs with hero.atlas. Letters, digits, - and _ only, up to 50 characters.",
|
||||
"suffixPlaceholder": "e.g. hover",
|
||||
"suffixInvalid": "Use letters, digits, - and _ only, and start with a letter or digit",
|
||||
"suffixDuplicate": "That suffix already exists",
|
||||
"suffixConfirm": "Add",
|
||||
"suffixRemoveTitle": "Remove a suffix",
|
||||
"suffixRemoveNote": "After removing “{name}”, files carrying that suffix will no longer be paired automatically.",
|
||||
"suffixRemove": "Remove",
|
||||
"suffixSaveFailed": "Saving the suffix list failed: {error}",
|
||||
"truncated1m": "\n\n(large file, only the first 1 MB is shown)"
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "Error al cargar",
|
||||
"notSpineText": "Este archivo no es un recurso Spine en formato .atlas.txt o .skel.bytes.",
|
||||
"truncated256": "\n\n(solo se muestran los primeros 256 KB)",
|
||||
"suffix": "Sufijo",
|
||||
"suffixTitle": "Los sufijos del nombre se ignoran al emparejar, de modo que hero-mesh.skel se une con hero.atlas",
|
||||
"suffixManage": "Gestionar sufijos…",
|
||||
"suffixAdd": "+ Añadir sufijo…",
|
||||
"suffixDefaults": "Sufijos integrados",
|
||||
"suffixCustom": "Sufijos personalizados (selecciona para quitar)",
|
||||
"suffixAddTitle": "Añadir un sufijo",
|
||||
"suffixAddNote": "Los sufijos se ignoran al emparejar nombres: al añadir hover, hero-hover.skel se empareja con hero.atlas. Solo letras, dígitos, - y _, hasta 50 caracteres.",
|
||||
"suffixPlaceholder": "p. ej. hover",
|
||||
"suffixInvalid": "Usa solo letras, dígitos, - y _, y empieza por letra o dígito",
|
||||
"suffixDuplicate": "Ese sufijo ya existe",
|
||||
"suffixConfirm": "Añadir",
|
||||
"suffixRemoveTitle": "Quitar un sufijo",
|
||||
"suffixRemoveNote": "Tras quitar «{name}», los archivos con ese sufijo dejarán de emparejarse automáticamente.",
|
||||
"suffixRemove": "Quitar",
|
||||
"suffixSaveFailed": "No se pudo guardar la lista de sufijos: {error}",
|
||||
"truncated1m": "\n\n(archivo grande; solo se muestra el primer 1 MB)",
|
||||
"groupWillReplaceCustom": "Reemplazará la miniatura personalizada",
|
||||
"groupCustomNotSelected": "Tiene miniatura personalizada (no seleccionado)"
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"notSpineText": "このファイルは .atlas.txt または .skel.bytes 形式の Spine リソースではありません。",
|
||||
"truncated256": "\n\n(先頭 256 KB のみ表示)",
|
||||
"suffix": "接尾辞",
|
||||
"suffixTitle": "ファイル名の接尾辞は照合時に無視され、hero-mesh.skel と hero.atlas が同じセットとして扱われます",
|
||||
"suffixManage": "接尾辞を管理…",
|
||||
"suffixAdd": "+ 接尾辞を追加…",
|
||||
"suffixDefaults": "組み込みの接尾辞",
|
||||
"suffixCustom": "カスタム接尾辞(選択で削除)",
|
||||
"suffixAddTitle": "接尾辞を追加",
|
||||
"suffixAddNote": "接尾辞はファイル名の照合時に無視されます。hover を追加すると hero-hover.skel は hero.atlas と同じセットになります。使用できるのは英数字と - _ のみ、最大 50 文字です。",
|
||||
"suffixPlaceholder": "例: hover",
|
||||
"suffixInvalid": "英数字と - _ のみ使用でき、先頭は英数字である必要があります",
|
||||
"suffixDuplicate": "その接尾辞は既に存在します",
|
||||
"suffixConfirm": "追加",
|
||||
"suffixRemoveTitle": "接尾辞を削除",
|
||||
"suffixRemoveNote": "「{name}」を削除すると、その接尾辞を含むファイルは自動照合されなくなります。",
|
||||
"suffixRemove": "削除",
|
||||
"suffixSaveFailed": "接尾辞設定の保存に失敗しました: {error}",
|
||||
"truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)",
|
||||
"groupWillReplaceCustom": "既存のカスタムサムネイルを置き換えます",
|
||||
"groupCustomNotSelected": "カスタムサムネイルあり(未選択)"
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "불러오기 실패",
|
||||
"notSpineText": "이 파일은 .atlas.txt 또는 .skel.bytes 형식의 Spine 리소스가 아닙니다.",
|
||||
"truncated256": "\n\n(앞부분 256 KB만 표시)",
|
||||
"suffix": "접미사",
|
||||
"suffixTitle": "파일 이름 접미사는 매칭 시 무시되어 hero-mesh.skel 이 hero.atlas 와 같은 세트로 묶입니다",
|
||||
"suffixManage": "접미사 관리…",
|
||||
"suffixAdd": "+ 접미사 추가…",
|
||||
"suffixDefaults": "기본 접미사",
|
||||
"suffixCustom": "사용자 접미사(선택하면 제거)",
|
||||
"suffixAddTitle": "접미사 추가",
|
||||
"suffixAddNote": "접미사는 파일 이름 매칭 시 무시됩니다. hover 를 추가하면 hero-hover.skel 이 hero.atlas 와 같은 세트가 됩니다. 영문, 숫자, - 와 _ 만 사용할 수 있으며 최대 50자입니다.",
|
||||
"suffixPlaceholder": "예: hover",
|
||||
"suffixInvalid": "영문, 숫자, - 와 _ 만 사용할 수 있으며 첫 글자는 영문이나 숫자여야 합니다",
|
||||
"suffixDuplicate": "이미 존재하는 접미사입니다",
|
||||
"suffixConfirm": "추가",
|
||||
"suffixRemoveTitle": "접미사 제거",
|
||||
"suffixRemoveNote": "“{name}”을(를) 제거하면 해당 접미사가 붙은 파일은 더 이상 자동으로 매칭되지 않습니다.",
|
||||
"suffixRemove": "제거",
|
||||
"suffixSaveFailed": "접미사 설정을 저장하지 못했습니다: {error}",
|
||||
"truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)",
|
||||
"groupWillReplaceCustom": "기존 사용자 지정 썸네일 교체",
|
||||
"groupCustomNotSelected": "사용자 지정 썸네일 있음(선택 안 함)"
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "Не удалось загрузить",
|
||||
"notSpineText": "Этот файл не является ресурсом Spine в формате .atlas.txt или .skel.bytes.",
|
||||
"truncated256": "\n\n(показаны только первые 256 КБ)",
|
||||
"suffix": "Суффикс",
|
||||
"suffixTitle": "Суффиксы имён файлов игнорируются при сопоставлении, поэтому hero-mesh.skel объединяется с hero.atlas",
|
||||
"suffixManage": "Управление суффиксами…",
|
||||
"suffixAdd": "+ Добавить суффикс…",
|
||||
"suffixDefaults": "Встроенные суффиксы",
|
||||
"suffixCustom": "Пользовательские суффиксы (выбор — удалить)",
|
||||
"suffixAddTitle": "Добавить суффикс",
|
||||
"suffixAddNote": "Суффиксы игнорируются при сопоставлении имён: добавив hover, файл hero-hover.skel объединится с hero.atlas. Допустимы буквы, цифры, - и _, не более 50 символов.",
|
||||
"suffixPlaceholder": "например hover",
|
||||
"suffixInvalid": "Допустимы только буквы, цифры, - и _; первый символ — буква или цифра",
|
||||
"suffixDuplicate": "Такой суффикс уже есть",
|
||||
"suffixConfirm": "Добавить",
|
||||
"suffixRemoveTitle": "Удалить суффикс",
|
||||
"suffixRemoveNote": "После удаления «{name}» файлы с этим суффиксом больше не будут сопоставляться автоматически.",
|
||||
"suffixRemove": "Удалить",
|
||||
"suffixSaveFailed": "Не удалось сохранить список суффиксов: {error}",
|
||||
"truncated1m": "\n\n(файл большой, показан только первый 1 МБ)",
|
||||
"groupWillReplaceCustom": "Заменит существующую миниатюру",
|
||||
"groupCustomNotSelected": "Есть своя миниатюра (не выбрано)"
|
||||
|
||||
@@ -93,6 +93,22 @@
|
||||
"loadFailed": "加载失败",
|
||||
"notSpineText": "此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。",
|
||||
"truncated256": "\n\n(仅显示前 256 KB)",
|
||||
"suffix": "尾缀",
|
||||
"suffixTitle": "文件名尾缀在匹配时会被忽略,使 hero-mesh.skel 与 hero.atlas 归为同一套资源",
|
||||
"suffixManage": "管理尾缀…",
|
||||
"suffixAdd": "+ 添加尾缀…",
|
||||
"suffixDefaults": "内置尾缀",
|
||||
"suffixCustom": "自定义尾缀(选中即移除)",
|
||||
"suffixAddTitle": "添加尾缀",
|
||||
"suffixAddNote": "尾缀会在文件名匹配时被忽略:添加 hover 后,hero-hover.skel 会与 hero.atlas 归为同一套资源。只能使用字母、数字、- 和 _,最长 50 个字符。",
|
||||
"suffixPlaceholder": "例如 hover",
|
||||
"suffixInvalid": "只能使用字母、数字、- 和 _,且需以字母或数字开头",
|
||||
"suffixDuplicate": "该尾缀已存在",
|
||||
"suffixConfirm": "添加",
|
||||
"suffixRemoveTitle": "移除尾缀",
|
||||
"suffixRemoveNote": "移除“{name}”后,带该尾缀的文件将不再自动配对。",
|
||||
"suffixRemove": "移除",
|
||||
"suffixSaveFailed": "保存尾缀设置失败:{error}",
|
||||
"truncated1m": "\n\n(文件较大,仅显示前 1 MB)"
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,22 @@
|
||||
"loadFailed": "載入失敗",
|
||||
"notSpineText": "此檔案不是 .atlas.txt 或 .skel.bytes 格式的 Spine 資源。",
|
||||
"truncated256": "\n\n(僅顯示前 256 KB)",
|
||||
"suffix": "尾綴",
|
||||
"suffixTitle": "檔名尾綴在比對時會被忽略,讓 hero-mesh.skel 與 hero.atlas 歸為同一套資源",
|
||||
"suffixManage": "管理尾綴…",
|
||||
"suffixAdd": "+ 新增尾綴…",
|
||||
"suffixDefaults": "內建尾綴",
|
||||
"suffixCustom": "自訂尾綴(選取即移除)",
|
||||
"suffixAddTitle": "新增尾綴",
|
||||
"suffixAddNote": "尾綴會在檔名比對時被忽略:新增 hover 後,hero-hover.skel 會與 hero.atlas 歸為同一套資源。僅能使用字母、數字、- 和 _,最長 50 個字元。",
|
||||
"suffixPlaceholder": "例如 hover",
|
||||
"suffixInvalid": "僅能使用字母、數字、- 和 _,且需以字母或數字開頭",
|
||||
"suffixDuplicate": "該尾綴已存在",
|
||||
"suffixConfirm": "新增",
|
||||
"suffixRemoveTitle": "移除尾綴",
|
||||
"suffixRemoveNote": "移除「{name}」後,帶該尾綴的檔案將不再自動配對。",
|
||||
"suffixRemove": "移除",
|
||||
"suffixSaveFailed": "儲存尾綴設定失敗:{error}",
|
||||
"truncated1m": "\n\n(檔案較大,僅顯示前 1 MB)"
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,35 @@ async function resolveDataPath(app) {
|
||||
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle');
|
||||
}
|
||||
|
||||
module.exports = { createStore, resolveDataPath };
|
||||
// 尾缀表是全局偏好(不是每个素材一份):同一台机器上所有素材、所有库共用。
|
||||
// 查看器在加载资源前读取它,用户的增删都会立即落盘。
|
||||
function createSuffixStore(userDataPath) {
|
||||
const directory = path.join(userDataPath, 'SpineViewer');
|
||||
const file = path.join(directory, 'suffix-settings.json');
|
||||
return {
|
||||
file,
|
||||
read() {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return Array.isArray(data.suffixes) ? data.suffixes : [];
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return [];
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
// 仅在用户通过下拉列表增删尾缀时调用。
|
||||
write(suffixes) {
|
||||
if (!Array.isArray(suffixes)) throw new TypeError('尾缀设置必须为数组');
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
|
||||
try {
|
||||
fs.writeFileSync(temp, JSON.stringify({ suffixes }, null, 2) + '\n', { flag: 'wx' });
|
||||
fs.renameSync(temp, file);
|
||||
} finally {
|
||||
try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStore, createSuffixStore, resolveDataPath };
|
||||
@@ -19,8 +19,56 @@ const fileIndexCache = new Map();
|
||||
const skeletonJsonCache = new Map();
|
||||
const JSON_PROBE_BYTES = 256 * 1024;
|
||||
|
||||
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
|
||||
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i;
|
||||
// Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
|
||||
// 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
|
||||
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
|
||||
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
|
||||
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
|
||||
const SUFFIX_MAX_LENGTH = 50;
|
||||
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
|
||||
let activeSuffixes = DEFAULT_SUFFIXES.slice();
|
||||
let suffixRe = buildSuffixRe(activeSuffixes);
|
||||
|
||||
function escapeRegExp(text) {
|
||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function buildSuffixRe(list) {
|
||||
if (!list || list.length === 0) return null;
|
||||
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
|
||||
}
|
||||
|
||||
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
|
||||
function normalizeSuffixName(raw) {
|
||||
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
|
||||
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
|
||||
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
|
||||
*/
|
||||
function setSuffixes(list) {
|
||||
const custom = [];
|
||||
for (const raw of Array.isArray(list) ? list : []) {
|
||||
const name = normalizeSuffixName(raw);
|
||||
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
|
||||
if (custom.indexOf(name) === -1) custom.push(name);
|
||||
}
|
||||
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
|
||||
suffixRe = buildSuffixRe(activeSuffixes);
|
||||
return getSuffixes();
|
||||
}
|
||||
|
||||
/** 当前生效的尾缀(内置 + 自定义) */
|
||||
function getSuffixes() {
|
||||
return activeSuffixes.slice();
|
||||
}
|
||||
|
||||
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
|
||||
function getCustomSuffixes() {
|
||||
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||||
}
|
||||
|
||||
function unsafeAtlasPath(name, reason) {
|
||||
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
|
||||
@@ -132,9 +180,10 @@ function stemOf(name) {
|
||||
return name.slice(0, name.length - ext.length);
|
||||
}
|
||||
|
||||
/** 归一化 stem,去掉 Spine 常见后缀,用于跨文件匹配 */
|
||||
/** 归一化 stem,去掉内置与用户自定义的尾缀,用于跨文件匹配 */
|
||||
function normalizeStem(name) {
|
||||
return stemOf(name).toLowerCase().replace(SUFFIX_RE, '');
|
||||
const lower = stemOf(name).toLowerCase();
|
||||
return suffixRe ? lower.replace(suffixRe, '') : lower;
|
||||
}
|
||||
|
||||
/** 从 JSON 文本中读取 spine 版本 */
|
||||
@@ -760,6 +809,12 @@ module.exports = {
|
||||
isTextureFile,
|
||||
stemOf,
|
||||
normalizeStem,
|
||||
DEFAULT_SUFFIXES,
|
||||
SUFFIX_MAX_LENGTH,
|
||||
normalizeSuffixName,
|
||||
setSuffixes,
|
||||
getSuffixes,
|
||||
getCustomSuffixes,
|
||||
detectVersion,
|
||||
detectVersionFromJson,
|
||||
detectVersionFromBuffer,
|
||||
|
||||
@@ -8,6 +8,7 @@ Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4–4.3,适用
|
||||
2. 打开骨骼或图集文件即可预览动画,并可切换动画、皮肤、速度、循环、背景和 PMA。
|
||||
3. 插件不接管导入阶段的缩略图,由 Eagle 自身处理。
|
||||
4. 打开预览后,原有按钮可把当前帧设为当前素材的自定义缩略图。其左侧的“为关联资源生成缩略图”会列出同 stem 的骨骼、atlas 及已验证贴图;骨骼与 atlas 默认勾选,图片和已有自定义缩略图的素材默认不勾选;用户仍可主动选择并覆盖,原图无法由插件自动恢复。
|
||||
5. 顶栏“尾缀”下拉列出内置尾缀(`pro` `ess` `pma` `base` `atlas` `data` `mesh`)与自定义尾缀:“+ 添加尾缀…”可新增,“选中自定义项”可移除。自定义尾缀保存在本地(Eagle userData),对全部素材生效,用于让 `hero-hover.skel` 这类文件与 `hero.atlas` 配成一套。
|
||||
|
||||
## 安全与隐私
|
||||
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "Laden fehlgeschlagen",
|
||||
"notSpineText": "Diese Datei ist keine Spine-Ressource im Format .atlas.txt oder .skel.bytes.",
|
||||
"truncated256": "\n\n(nur die ersten 256 KB werden angezeigt)",
|
||||
"suffix": "Suffix",
|
||||
"suffixTitle": "Dateinamens-Suffixe werden beim Abgleich ignoriert, daher wird hero-mesh.skel mit hero.atlas verknüpft",
|
||||
"suffixManage": "Suffixe verwalten…",
|
||||
"suffixAdd": "+ Suffix hinzufügen…",
|
||||
"suffixDefaults": "Integrierte Suffixe",
|
||||
"suffixCustom": "Eigene Suffixe (Auswahl entfernt)",
|
||||
"suffixAddTitle": "Suffix hinzufügen",
|
||||
"suffixAddNote": "Suffixe werden beim Dateiabgleich ignoriert: Nach dem Hinzufügen von hover wird hero-hover.skel mit hero.atlas verknüpft. Nur Buchstaben, Ziffern, - und _, maximal 50 Zeichen.",
|
||||
"suffixPlaceholder": "z. B. hover",
|
||||
"suffixInvalid": "Nur Buchstaben, Ziffern, - und _, beginnend mit Buchstabe oder Ziffer",
|
||||
"suffixDuplicate": "Dieses Suffix existiert bereits",
|
||||
"suffixConfirm": "Hinzufügen",
|
||||
"suffixRemoveTitle": "Suffix entfernen",
|
||||
"suffixRemoveNote": "Nach dem Entfernen von „{name}“ werden Dateien mit diesem Suffix nicht mehr automatisch verknüpft.",
|
||||
"suffixRemove": "Entfernen",
|
||||
"suffixSaveFailed": "Speichern der Suffix-Liste fehlgeschlagen: {error}",
|
||||
"truncated1m": "\n\n(große Datei; nur das erste 1 MB wird angezeigt)",
|
||||
"groupWillReplaceCustom": "Ersetzt vorhandenes eigenes Vorschaubild",
|
||||
"groupCustomNotSelected": "Eigenes Vorschaubild vorhanden (nicht ausgewählt)"
|
||||
|
||||
@@ -93,6 +93,22 @@
|
||||
"loadFailed": "Failed to load",
|
||||
"notSpineText": "This file is not a .atlas.txt or .skel.bytes Spine asset.",
|
||||
"truncated256": "\n\n(only the first 256 KB is shown)",
|
||||
"suffix": "Suffix",
|
||||
"suffixTitle": "Filename suffixes are ignored when matching, so hero-mesh.skel pairs with hero.atlas",
|
||||
"suffixManage": "Manage suffixes…",
|
||||
"suffixAdd": "+ Add suffix…",
|
||||
"suffixDefaults": "Built-in suffixes",
|
||||
"suffixCustom": "Custom suffixes (select to remove)",
|
||||
"suffixAddTitle": "Add a suffix",
|
||||
"suffixAddNote": "Suffixes are ignored when matching filenames: after adding hover, hero-hover.skel pairs with hero.atlas. Letters, digits, - and _ only, up to 50 characters.",
|
||||
"suffixPlaceholder": "e.g. hover",
|
||||
"suffixInvalid": "Use letters, digits, - and _ only, and start with a letter or digit",
|
||||
"suffixDuplicate": "That suffix already exists",
|
||||
"suffixConfirm": "Add",
|
||||
"suffixRemoveTitle": "Remove a suffix",
|
||||
"suffixRemoveNote": "After removing “{name}”, files carrying that suffix will no longer be paired automatically.",
|
||||
"suffixRemove": "Remove",
|
||||
"suffixSaveFailed": "Saving the suffix list failed: {error}",
|
||||
"truncated1m": "\n\n(large file, only the first 1 MB is shown)"
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "Error al cargar",
|
||||
"notSpineText": "Este archivo no es un recurso Spine en formato .atlas.txt o .skel.bytes.",
|
||||
"truncated256": "\n\n(solo se muestran los primeros 256 KB)",
|
||||
"suffix": "Sufijo",
|
||||
"suffixTitle": "Los sufijos del nombre se ignoran al emparejar, de modo que hero-mesh.skel se une con hero.atlas",
|
||||
"suffixManage": "Gestionar sufijos…",
|
||||
"suffixAdd": "+ Añadir sufijo…",
|
||||
"suffixDefaults": "Sufijos integrados",
|
||||
"suffixCustom": "Sufijos personalizados (selecciona para quitar)",
|
||||
"suffixAddTitle": "Añadir un sufijo",
|
||||
"suffixAddNote": "Los sufijos se ignoran al emparejar nombres: al añadir hover, hero-hover.skel se empareja con hero.atlas. Solo letras, dígitos, - y _, hasta 50 caracteres.",
|
||||
"suffixPlaceholder": "p. ej. hover",
|
||||
"suffixInvalid": "Usa solo letras, dígitos, - y _, y empieza por letra o dígito",
|
||||
"suffixDuplicate": "Ese sufijo ya existe",
|
||||
"suffixConfirm": "Añadir",
|
||||
"suffixRemoveTitle": "Quitar un sufijo",
|
||||
"suffixRemoveNote": "Tras quitar «{name}», los archivos con ese sufijo dejarán de emparejarse automáticamente.",
|
||||
"suffixRemove": "Quitar",
|
||||
"suffixSaveFailed": "No se pudo guardar la lista de sufijos: {error}",
|
||||
"truncated1m": "\n\n(archivo grande; solo se muestra el primer 1 MB)",
|
||||
"groupWillReplaceCustom": "Reemplazará la miniatura personalizada",
|
||||
"groupCustomNotSelected": "Tiene miniatura personalizada (no seleccionado)"
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"notSpineText": "このファイルは .atlas.txt または .skel.bytes 形式の Spine リソースではありません。",
|
||||
"truncated256": "\n\n(先頭 256 KB のみ表示)",
|
||||
"suffix": "接尾辞",
|
||||
"suffixTitle": "ファイル名の接尾辞は照合時に無視され、hero-mesh.skel と hero.atlas が同じセットとして扱われます",
|
||||
"suffixManage": "接尾辞を管理…",
|
||||
"suffixAdd": "+ 接尾辞を追加…",
|
||||
"suffixDefaults": "組み込みの接尾辞",
|
||||
"suffixCustom": "カスタム接尾辞(選択で削除)",
|
||||
"suffixAddTitle": "接尾辞を追加",
|
||||
"suffixAddNote": "接尾辞はファイル名の照合時に無視されます。hover を追加すると hero-hover.skel は hero.atlas と同じセットになります。使用できるのは英数字と - _ のみ、最大 50 文字です。",
|
||||
"suffixPlaceholder": "例: hover",
|
||||
"suffixInvalid": "英数字と - _ のみ使用でき、先頭は英数字である必要があります",
|
||||
"suffixDuplicate": "その接尾辞は既に存在します",
|
||||
"suffixConfirm": "追加",
|
||||
"suffixRemoveTitle": "接尾辞を削除",
|
||||
"suffixRemoveNote": "「{name}」を削除すると、その接尾辞を含むファイルは自動照合されなくなります。",
|
||||
"suffixRemove": "削除",
|
||||
"suffixSaveFailed": "接尾辞設定の保存に失敗しました: {error}",
|
||||
"truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)",
|
||||
"groupWillReplaceCustom": "既存のカスタムサムネイルを置き換えます",
|
||||
"groupCustomNotSelected": "カスタムサムネイルあり(未選択)"
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "불러오기 실패",
|
||||
"notSpineText": "이 파일은 .atlas.txt 또는 .skel.bytes 형식의 Spine 리소스가 아닙니다.",
|
||||
"truncated256": "\n\n(앞부분 256 KB만 표시)",
|
||||
"suffix": "접미사",
|
||||
"suffixTitle": "파일 이름 접미사는 매칭 시 무시되어 hero-mesh.skel 이 hero.atlas 와 같은 세트로 묶입니다",
|
||||
"suffixManage": "접미사 관리…",
|
||||
"suffixAdd": "+ 접미사 추가…",
|
||||
"suffixDefaults": "기본 접미사",
|
||||
"suffixCustom": "사용자 접미사(선택하면 제거)",
|
||||
"suffixAddTitle": "접미사 추가",
|
||||
"suffixAddNote": "접미사는 파일 이름 매칭 시 무시됩니다. hover 를 추가하면 hero-hover.skel 이 hero.atlas 와 같은 세트가 됩니다. 영문, 숫자, - 와 _ 만 사용할 수 있으며 최대 50자입니다.",
|
||||
"suffixPlaceholder": "예: hover",
|
||||
"suffixInvalid": "영문, 숫자, - 와 _ 만 사용할 수 있으며 첫 글자는 영문이나 숫자여야 합니다",
|
||||
"suffixDuplicate": "이미 존재하는 접미사입니다",
|
||||
"suffixConfirm": "추가",
|
||||
"suffixRemoveTitle": "접미사 제거",
|
||||
"suffixRemoveNote": "“{name}”을(를) 제거하면 해당 접미사가 붙은 파일은 더 이상 자동으로 매칭되지 않습니다.",
|
||||
"suffixRemove": "제거",
|
||||
"suffixSaveFailed": "접미사 설정을 저장하지 못했습니다: {error}",
|
||||
"truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)",
|
||||
"groupWillReplaceCustom": "기존 사용자 지정 썸네일 교체",
|
||||
"groupCustomNotSelected": "사용자 지정 썸네일 있음(선택 안 함)"
|
||||
|
||||
@@ -91,6 +91,22 @@
|
||||
"loadFailed": "Не удалось загрузить",
|
||||
"notSpineText": "Этот файл не является ресурсом Spine в формате .atlas.txt или .skel.bytes.",
|
||||
"truncated256": "\n\n(показаны только первые 256 КБ)",
|
||||
"suffix": "Суффикс",
|
||||
"suffixTitle": "Суффиксы имён файлов игнорируются при сопоставлении, поэтому hero-mesh.skel объединяется с hero.atlas",
|
||||
"suffixManage": "Управление суффиксами…",
|
||||
"suffixAdd": "+ Добавить суффикс…",
|
||||
"suffixDefaults": "Встроенные суффиксы",
|
||||
"suffixCustom": "Пользовательские суффиксы (выбор — удалить)",
|
||||
"suffixAddTitle": "Добавить суффикс",
|
||||
"suffixAddNote": "Суффиксы игнорируются при сопоставлении имён: добавив hover, файл hero-hover.skel объединится с hero.atlas. Допустимы буквы, цифры, - и _, не более 50 символов.",
|
||||
"suffixPlaceholder": "например hover",
|
||||
"suffixInvalid": "Допустимы только буквы, цифры, - и _; первый символ — буква или цифра",
|
||||
"suffixDuplicate": "Такой суффикс уже есть",
|
||||
"suffixConfirm": "Добавить",
|
||||
"suffixRemoveTitle": "Удалить суффикс",
|
||||
"suffixRemoveNote": "После удаления «{name}» файлы с этим суффиксом больше не будут сопоставляться автоматически.",
|
||||
"suffixRemove": "Удалить",
|
||||
"suffixSaveFailed": "Не удалось сохранить список суффиксов: {error}",
|
||||
"truncated1m": "\n\n(файл большой, показан только первый 1 МБ)",
|
||||
"groupWillReplaceCustom": "Заменит существующую миниатюру",
|
||||
"groupCustomNotSelected": "Есть своя миниатюра (не выбрано)"
|
||||
|
||||
@@ -93,6 +93,22 @@
|
||||
"loadFailed": "加载失败",
|
||||
"notSpineText": "此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。",
|
||||
"truncated256": "\n\n(仅显示前 256 KB)",
|
||||
"suffix": "尾缀",
|
||||
"suffixTitle": "文件名尾缀在匹配时会被忽略,使 hero-mesh.skel 与 hero.atlas 归为同一套资源",
|
||||
"suffixManage": "管理尾缀…",
|
||||
"suffixAdd": "+ 添加尾缀…",
|
||||
"suffixDefaults": "内置尾缀",
|
||||
"suffixCustom": "自定义尾缀(选中即移除)",
|
||||
"suffixAddTitle": "添加尾缀",
|
||||
"suffixAddNote": "尾缀会在文件名匹配时被忽略:添加 hover 后,hero-hover.skel 会与 hero.atlas 归为同一套资源。只能使用字母、数字、- 和 _,最长 50 个字符。",
|
||||
"suffixPlaceholder": "例如 hover",
|
||||
"suffixInvalid": "只能使用字母、数字、- 和 _,且需以字母或数字开头",
|
||||
"suffixDuplicate": "该尾缀已存在",
|
||||
"suffixConfirm": "添加",
|
||||
"suffixRemoveTitle": "移除尾缀",
|
||||
"suffixRemoveNote": "移除“{name}”后,带该尾缀的文件将不再自动配对。",
|
||||
"suffixRemove": "移除",
|
||||
"suffixSaveFailed": "保存尾缀设置失败:{error}",
|
||||
"truncated1m": "\n\n(文件较大,仅显示前 1 MB)"
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,22 @@
|
||||
"loadFailed": "載入失敗",
|
||||
"notSpineText": "此檔案不是 .atlas.txt 或 .skel.bytes 格式的 Spine 資源。",
|
||||
"truncated256": "\n\n(僅顯示前 256 KB)",
|
||||
"suffix": "尾綴",
|
||||
"suffixTitle": "檔名尾綴在比對時會被忽略,讓 hero-mesh.skel 與 hero.atlas 歸為同一套資源",
|
||||
"suffixManage": "管理尾綴…",
|
||||
"suffixAdd": "+ 新增尾綴…",
|
||||
"suffixDefaults": "內建尾綴",
|
||||
"suffixCustom": "自訂尾綴(選取即移除)",
|
||||
"suffixAddTitle": "新增尾綴",
|
||||
"suffixAddNote": "尾綴會在檔名比對時被忽略:新增 hover 後,hero-hover.skel 會與 hero.atlas 歸為同一套資源。僅能使用字母、數字、- 和 _,最長 50 個字元。",
|
||||
"suffixPlaceholder": "例如 hover",
|
||||
"suffixInvalid": "僅能使用字母、數字、- 和 _,且需以字母或數字開頭",
|
||||
"suffixDuplicate": "該尾綴已存在",
|
||||
"suffixConfirm": "新增",
|
||||
"suffixRemoveTitle": "移除尾綴",
|
||||
"suffixRemoveNote": "移除「{name}」後,帶該尾綴的檔案將不再自動配對。",
|
||||
"suffixRemove": "移除",
|
||||
"suffixSaveFailed": "儲存尾綴設定失敗:{error}",
|
||||
"truncated1m": "\n\n(檔案較大,僅顯示前 1 MB)"
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,35 @@ async function resolveDataPath(app) {
|
||||
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle');
|
||||
}
|
||||
|
||||
module.exports = { createStore, resolveDataPath };
|
||||
// 尾缀表是全局偏好(不是每个素材一份):同一台机器上所有素材、所有库共用。
|
||||
// 查看器在加载资源前读取它,用户的增删都会立即落盘。
|
||||
function createSuffixStore(userDataPath) {
|
||||
const directory = path.join(userDataPath, 'SpineViewer');
|
||||
const file = path.join(directory, 'suffix-settings.json');
|
||||
return {
|
||||
file,
|
||||
read() {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return Array.isArray(data.suffixes) ? data.suffixes : [];
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return [];
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
// 仅在用户通过下拉列表增删尾缀时调用。
|
||||
write(suffixes) {
|
||||
if (!Array.isArray(suffixes)) throw new TypeError('尾缀设置必须为数组');
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
|
||||
try {
|
||||
fs.writeFileSync(temp, JSON.stringify({ suffixes }, null, 2) + '\n', { flag: 'wx' });
|
||||
fs.renameSync(temp, file);
|
||||
} finally {
|
||||
try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStore, createSuffixStore, resolveDataPath };
|
||||
@@ -19,8 +19,56 @@ const fileIndexCache = new Map();
|
||||
const skeletonJsonCache = new Map();
|
||||
const JSON_PROBE_BYTES = 256 * 1024;
|
||||
|
||||
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
|
||||
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i;
|
||||
// Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
|
||||
// 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
|
||||
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
|
||||
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
|
||||
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
|
||||
const SUFFIX_MAX_LENGTH = 50;
|
||||
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
|
||||
let activeSuffixes = DEFAULT_SUFFIXES.slice();
|
||||
let suffixRe = buildSuffixRe(activeSuffixes);
|
||||
|
||||
function escapeRegExp(text) {
|
||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function buildSuffixRe(list) {
|
||||
if (!list || list.length === 0) return null;
|
||||
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
|
||||
}
|
||||
|
||||
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
|
||||
function normalizeSuffixName(raw) {
|
||||
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
|
||||
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
|
||||
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
|
||||
*/
|
||||
function setSuffixes(list) {
|
||||
const custom = [];
|
||||
for (const raw of Array.isArray(list) ? list : []) {
|
||||
const name = normalizeSuffixName(raw);
|
||||
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
|
||||
if (custom.indexOf(name) === -1) custom.push(name);
|
||||
}
|
||||
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
|
||||
suffixRe = buildSuffixRe(activeSuffixes);
|
||||
return getSuffixes();
|
||||
}
|
||||
|
||||
/** 当前生效的尾缀(内置 + 自定义) */
|
||||
function getSuffixes() {
|
||||
return activeSuffixes.slice();
|
||||
}
|
||||
|
||||
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
|
||||
function getCustomSuffixes() {
|
||||
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||||
}
|
||||
|
||||
function unsafeAtlasPath(name, reason) {
|
||||
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
|
||||
@@ -132,9 +180,10 @@ function stemOf(name) {
|
||||
return name.slice(0, name.length - ext.length);
|
||||
}
|
||||
|
||||
/** 归一化 stem,去掉 Spine 常见后缀,用于跨文件匹配 */
|
||||
/** 归一化 stem,去掉内置与用户自定义的尾缀,用于跨文件匹配 */
|
||||
function normalizeStem(name) {
|
||||
return stemOf(name).toLowerCase().replace(SUFFIX_RE, '');
|
||||
const lower = stemOf(name).toLowerCase();
|
||||
return suffixRe ? lower.replace(suffixRe, '') : lower;
|
||||
}
|
||||
|
||||
/** 从 JSON 文本中读取 spine 版本 */
|
||||
@@ -760,6 +809,12 @@ module.exports = {
|
||||
isTextureFile,
|
||||
stemOf,
|
||||
normalizeStem,
|
||||
DEFAULT_SUFFIXES,
|
||||
SUFFIX_MAX_LENGTH,
|
||||
normalizeSuffixName,
|
||||
setSuffixes,
|
||||
getSuffixes,
|
||||
getCustomSuffixes,
|
||||
detectVersion,
|
||||
detectVersionFromJson,
|
||||
detectVersionFromBuffer,
|
||||
|
||||
@@ -106,6 +106,14 @@
|
||||
.thumb-dialog-row.skipped .thumb-dialog-name,
|
||||
.thumb-dialog-row.skipped .thumb-dialog-state { color: var(--text-dim); }
|
||||
.thumb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.thumb-dialog-input {
|
||||
background: var(--panel-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 7px 9px;
|
||||
}
|
||||
.thumb-dialog-input.hidden { display: none; }
|
||||
.thumb-dialog-error { color: var(--error); min-height: 18px; }
|
||||
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
|
||||
#toolbar #suffixSelect { max-width: 140px; }
|
||||
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
|
||||
#animationPanel {
|
||||
position: absolute;
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
|
||||
</label>
|
||||
<div class="spacer"></div>
|
||||
<label data-i18n-title="viewer.suffixTitle" title="文件名尾缀在匹配时会被忽略,使 hero-mesh.skel 与 hero.atlas 归为同一套资源">
|
||||
<span data-i18n="viewer.suffix">尾缀</span>
|
||||
<select id="suffixSelect"></select>
|
||||
</label>
|
||||
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="为关联资源生成缩略图">为关联资源生成缩略图</button>
|
||||
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="将当前帧设为当前素材的缩略图">设为当前素材缩略图</button>
|
||||
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
|
||||
@@ -71,6 +75,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="suffixDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="suffixDialogTitle">
|
||||
<div class="thumb-dialog-card">
|
||||
<div id="suffixDialogTitle" class="thumb-dialog-title"></div>
|
||||
<div id="suffixDialogNote" class="thumb-dialog-note"></div>
|
||||
<input type="text" id="suffixDialogInput" class="thumb-dialog-input hidden" maxlength="50" autocomplete="off" spellcheck="false">
|
||||
<div id="suffixDialogError" class="thumb-dialog-error"></div>
|
||||
<div class="thumb-dialog-actions">
|
||||
<button id="suffixDialogCancel" data-i18n="viewer.cancel">取消</button>
|
||||
<button id="suffixDialogConfirm" class="primary"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -83,6 +99,7 @@
|
||||
const spineBadge = require('../js/spine-badge.js');
|
||||
const clippingRenderer = require('../js/spine-canvas-clipping.js');
|
||||
const groupThumbnails = require('../js/spine-group-thumbnails.js');
|
||||
const previewSettingsModule = require('../js/spine-preview-settings.js');
|
||||
let spineBadgeImagePromise = null;
|
||||
|
||||
function loadSpineBadgeImage() {
|
||||
@@ -1343,6 +1360,183 @@
|
||||
setStatus(t('viewer.pmaSaveFailed', { error: err.message }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 文件名尾缀设置(全局偏好,实时落盘) ----------
|
||||
const SUFFIX_NONE_VALUE = '__none__';
|
||||
const SUFFIX_ADD_VALUE = '__add__';
|
||||
const SUFFIX_CUSTOM_PREFIX = 'custom:';
|
||||
const suffixSelectEl = document.getElementById('suffixSelect');
|
||||
let suffixSettings = null;
|
||||
let customSuffixes = [];
|
||||
|
||||
// 下拉列表只是入口与状态展示(内置尾缀以禁用条目列出),
|
||||
// 选择「+ 添加尾缀…」=新增,选择自定义项 =移除,两者都会先弹确认框。
|
||||
// 静止项必须是独立的值:选中“已经是当前值的项”不会触发 change,
|
||||
// 若把静止值设成新增项本身,用户点它就会毫无反应。
|
||||
function renderSuffixSelect() {
|
||||
suffixSelectEl.textContent = '';
|
||||
const resting = document.createElement('option');
|
||||
resting.value = SUFFIX_NONE_VALUE;
|
||||
resting.textContent = t('viewer.suffixManage');
|
||||
suffixSelectEl.appendChild(resting);
|
||||
const add = document.createElement('option');
|
||||
add.value = SUFFIX_ADD_VALUE;
|
||||
add.textContent = t('viewer.suffixAdd');
|
||||
suffixSelectEl.appendChild(add);
|
||||
const appendGroup = (labelKey, names, removable) => {
|
||||
if (names.length === 0) return;
|
||||
const group = document.createElement('option');
|
||||
group.disabled = true;
|
||||
group.textContent = '── ' + t(labelKey) + ' ──';
|
||||
suffixSelectEl.appendChild(group);
|
||||
for (const name of names) {
|
||||
const option = document.createElement('option');
|
||||
option.textContent = removable ? '✕ ' + name : name;
|
||||
if (removable) option.value = SUFFIX_CUSTOM_PREFIX + name;
|
||||
else option.disabled = true;
|
||||
suffixSelectEl.appendChild(option);
|
||||
}
|
||||
};
|
||||
appendGroup('viewer.suffixDefaults', utils.DEFAULT_SUFFIXES, false);
|
||||
appendGroup('viewer.suffixCustom', customSuffixes, true);
|
||||
suffixSelectEl.value = SUFFIX_NONE_VALUE;
|
||||
}
|
||||
|
||||
// 与缩略图对话框共用遮罩/卡片样式;withInput 为 false 时是纯确认框。
|
||||
function openSuffixDialog(options) {
|
||||
const dialog = document.getElementById('suffixDialog');
|
||||
const inputEl = document.getElementById('suffixDialogInput');
|
||||
const errorEl = document.getElementById('suffixDialogError');
|
||||
const confirmBtn = document.getElementById('suffixDialogConfirm');
|
||||
const cancelBtn = document.getElementById('suffixDialogCancel');
|
||||
document.getElementById('suffixDialogTitle').textContent = t(options.titleKey);
|
||||
document.getElementById('suffixDialogNote').textContent = t(options.noteKey, options.noteVars);
|
||||
confirmBtn.textContent = t(options.confirmKey);
|
||||
inputEl.classList.toggle('hidden', !options.withInput);
|
||||
inputEl.maxLength = utils.SUFFIX_MAX_LENGTH;
|
||||
inputEl.value = options.withInput ? (options.initialValue || '') : '';
|
||||
inputEl.placeholder = options.withInput ? t('viewer.suffixPlaceholder') : '';
|
||||
errorEl.textContent = '';
|
||||
dialog.classList.remove('hidden');
|
||||
if (options.withInput) { inputEl.focus(); inputEl.select(); }
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (accepted) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
dialog.classList.add('hidden');
|
||||
confirmBtn.removeEventListener('click', accept);
|
||||
cancelBtn.removeEventListener('click', reject);
|
||||
inputEl.removeEventListener('input', clearError);
|
||||
inputEl.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
resolve(accepted ? (options.withInput ? inputEl.value : true) : null);
|
||||
};
|
||||
const accept = () => {
|
||||
if (options.validate) {
|
||||
const message = options.validate(inputEl.value);
|
||||
if (message) { errorEl.textContent = message; return; }
|
||||
}
|
||||
finish(true);
|
||||
};
|
||||
const reject = () => finish(false);
|
||||
const clearError = () => { errorEl.textContent = ''; };
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); reject(); }
|
||||
else if (event.key === 'Enter' && options.withInput) { event.preventDefault(); accept(); }
|
||||
};
|
||||
confirmBtn.addEventListener('click', accept);
|
||||
cancelBtn.addEventListener('click', reject);
|
||||
inputEl.addEventListener('input', clearError);
|
||||
inputEl.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
}
|
||||
|
||||
function applySuffixes(list) {
|
||||
if (!suffixSettings) throw new Error(t('viewer.settingsNotReady'));
|
||||
suffixSettings.write(list);
|
||||
customSuffixes = utils.setSuffixes(list).filter((name) => utils.DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||||
}
|
||||
|
||||
async function addCustomSuffix() {
|
||||
const input = await openSuffixDialog({
|
||||
titleKey: 'viewer.suffixAddTitle',
|
||||
noteKey: 'viewer.suffixAddNote',
|
||||
confirmKey: 'viewer.suffixConfirm',
|
||||
withInput: true,
|
||||
validate: (value) => {
|
||||
const name = utils.normalizeSuffixName(value);
|
||||
if (!name) return t('viewer.suffixInvalid');
|
||||
if (utils.DEFAULT_SUFFIXES.indexOf(name) !== -1 || customSuffixes.indexOf(name) !== -1) return t('viewer.suffixDuplicate');
|
||||
return null;
|
||||
},
|
||||
});
|
||||
if (input === null) return;
|
||||
try {
|
||||
applySuffixes(customSuffixes.concat([utils.normalizeSuffixName(input)]));
|
||||
// 尾缀会改变资源配对结果,直接重载查看器最稳。
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCustomSuffix(name) {
|
||||
const accepted = await openSuffixDialog({
|
||||
titleKey: 'viewer.suffixRemoveTitle',
|
||||
noteKey: 'viewer.suffixRemoveNote',
|
||||
noteVars: { name },
|
||||
confirmKey: 'viewer.suffixRemove',
|
||||
});
|
||||
if (accepted === null) return;
|
||||
try {
|
||||
applySuffixes(customSuffixes.filter((item) => item !== name));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
|
||||
}
|
||||
}
|
||||
|
||||
// 事件回调里没有 await,任何异常都必须在这里兜住,否则会静默失败。
|
||||
function runSuffixAction(promise) {
|
||||
Promise.resolve(promise).catch((error) => {
|
||||
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
|
||||
});
|
||||
}
|
||||
|
||||
suffixSelectEl.addEventListener('change', (event) => {
|
||||
const value = event.target.value;
|
||||
// 先复位到静止项,保证下一次选择一定会产生 change。
|
||||
renderSuffixSelect();
|
||||
if (value === SUFFIX_ADD_VALUE) runSuffixAction(addCustomSuffix());
|
||||
else if (value.indexOf(SUFFIX_CUSTOM_PREFIX) === 0) runSuffixAction(removeCustomSuffix(value.slice(SUFFIX_CUSTOM_PREFIX.length)));
|
||||
});
|
||||
|
||||
async function initSuffixSettings() {
|
||||
try {
|
||||
const userDataPath = await previewSettingsModule.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null);
|
||||
suffixSettings = previewSettingsModule.createSuffixStore(userDataPath);
|
||||
} catch (error) {
|
||||
suffixSettings = null;
|
||||
log('尾缀设置存储不可用: ' + ((error && error.message) || String(error)));
|
||||
}
|
||||
try {
|
||||
if (suffixSettings) {
|
||||
// 磁盘内容不可信:utils 会丢弃非法、重复和内置重名的尾缀。
|
||||
customSuffixes = utils.setSuffixes(suffixSettings.read()).filter((name) => utils.DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||||
} else {
|
||||
utils.setSuffixes([]);
|
||||
customSuffixes = [];
|
||||
}
|
||||
} catch (error) {
|
||||
// 配置损坏时回退到内置尾缀,但保留存储对象,下次写入会覆盖它。
|
||||
customSuffixes = utils.getCustomSuffixes();
|
||||
log('尾缀配置损坏,已回退到内置尾缀: ' + ((error && error.message) || String(error)));
|
||||
}
|
||||
renderSuffixSelect();
|
||||
}
|
||||
|
||||
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
@@ -1434,7 +1628,7 @@
|
||||
} else if (!fs.existsSync(filePath)) {
|
||||
showError(t('viewer.fileNotExist'), filePath);
|
||||
} else {
|
||||
loadAssets().catch((err) => {
|
||||
initSuffixSettings().then(() => loadAssets()).catch((err) => {
|
||||
showError(t('viewer.loadFailed'), (err && err.message) ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const utils = require('../js/spine-utils.js');
|
||||
const settings = require('../js/spine-preview-settings.js');
|
||||
|
||||
// --- 内置尾缀(默认值,用户不可删除)---
|
||||
assert.deepEqual(utils.getSuffixes(), utils.DEFAULT_SUFFIXES);
|
||||
assert.equal(utils.normalizeStem('hero-mesh.skel'), 'hero');
|
||||
assert.equal(utils.normalizeStem('hero-data.json'), 'hero');
|
||||
|
||||
// --- 自定义尾缀生效后才参与归一化 ---
|
||||
assert.equal(utils.normalizeStem('hero-hover.skel'), 'hero-hover');
|
||||
utils.setSuffixes(['hover']);
|
||||
assert.equal(utils.normalizeStem('hero-hover.skel'), 'hero');
|
||||
assert.equal(utils.normalizeStem('hero_hover.atlas.txt'), 'hero');
|
||||
assert.deepEqual(utils.getCustomSuffixes(), ['hover']);
|
||||
assert.deepEqual(utils.getSuffixes(), utils.DEFAULT_SUFFIXES.concat('hover'));
|
||||
|
||||
// --- 长度上限:常量与正则必须一致(改上限时只动一个地方)---
|
||||
assert.equal(utils.SUFFIX_MAX_LENGTH, 50);
|
||||
assert.equal(utils.normalizeSuffixName('a'.repeat(utils.SUFFIX_MAX_LENGTH)), 'a'.repeat(utils.SUFFIX_MAX_LENGTH));
|
||||
assert.equal(utils.normalizeSuffixName('a'.repeat(utils.SUFFIX_MAX_LENGTH + 1)), null);
|
||||
|
||||
// --- 非法、重复、内置重名的尾缀一律丢弃(磁盘内容不可信)---
|
||||
assert.deepEqual(
|
||||
utils.setSuffixes(['hover', 'HOVER', 'mesh', '', ' ', '-', '_x', 'bad name',
|
||||
'a'.repeat(utils.SUFFIX_MAX_LENGTH + 1), 'a'.repeat(utils.SUFFIX_MAX_LENGTH)]),
|
||||
utils.DEFAULT_SUFFIXES.concat(['hover', 'x', 'a'.repeat(utils.SUFFIX_MAX_LENGTH)])
|
||||
);
|
||||
assert.equal(utils.normalizeSuffixName(' Hover '), 'hover');
|
||||
assert.equal(utils.normalizeSuffixName('_outline'), 'outline');
|
||||
assert.equal(utils.normalizeSuffixName(''), null);
|
||||
assert.equal(utils.normalizeSuffixName('a.b'), null);
|
||||
assert.equal(utils.normalizeSuffixName('!'), null);
|
||||
assert.equal(utils.normalizeSuffixName(null), null);
|
||||
|
||||
// --- 尾缀决定配套资源能否配对(strictStem 与导入缩略图共用同一条路径)---
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spineviewer-suffix-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'hero-hover.json'), JSON.stringify({
|
||||
skeleton: { spine: '4.2.05' }, bones: [{ name: 'root' }], slots: [], skins: [], animations: {},
|
||||
}));
|
||||
fs.writeFileSync(path.join(dir, 'hero.atlas'),
|
||||
'hero.png\nsize: 16,16\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nbounds: 0,0,16,16\n');
|
||||
fs.writeFileSync(path.join(dir, 'hero.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
const source = path.join(dir, 'hero-hover.json');
|
||||
const options = { searchRoots: [dir], strictStem: true };
|
||||
|
||||
utils.setSuffixes([]);
|
||||
assert.equal(utils.discoverAssets(source, options).atlasPath, null);
|
||||
|
||||
utils.setSuffixes(['hover']);
|
||||
const assets = utils.discoverAssets(source, options);
|
||||
assert.equal(assets.skeletonPath, source);
|
||||
assert.equal(assets.atlasPath, path.join(dir, 'hero.atlas'));
|
||||
assert.equal(assets.texturePaths['hero.png'], path.join(dir, 'hero.png'));
|
||||
assert.equal(assets.base, 'hero');
|
||||
} finally {
|
||||
utils.setSuffixes([]);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// --- 全局尾缀存储:读取、原子写入、损坏文件不拖垮查看器 ---
|
||||
const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'spineviewer-userdata-'));
|
||||
try {
|
||||
const store = settings.createSuffixStore(userData);
|
||||
assert.deepEqual(store.read(), []);
|
||||
store.write(['hover', 'outline']);
|
||||
assert.deepEqual(store.read(), ['hover', 'outline']);
|
||||
assert.equal(path.dirname(store.file), path.join(userData, 'SpineViewer'));
|
||||
assert.deepEqual(fs.readdirSync(path.dirname(store.file)).filter((name) => name.endsWith('.tmp')), []);
|
||||
|
||||
fs.writeFileSync(store.file, '{ not json');
|
||||
assert.throws(() => store.read());
|
||||
store.write([]); // 损坏文件仍可被下一次写入覆盖
|
||||
assert.deepEqual(store.read(), []);
|
||||
} finally {
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// --- 查看器接线:启动时先读尾缀配置,再加载资源 ---
|
||||
const viewer = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8');
|
||||
assert.match(viewer, /<select id="suffixSelect"><\/select>/);
|
||||
assert.match(viewer, /utils\.DEFAULT_SUFFIXES/);
|
||||
assert.match(viewer, /initSuffixSettings\(\)\.then\(\(\) => loadAssets\(\)\)/);
|
||||
assert.match(viewer, /suffixSettings\.write\(list\)/);
|
||||
assert.match(viewer, /utils\.normalizeSuffixName\(value\)/);
|
||||
|
||||
// 下拉必须停在独立的静止值:选中“已经是当前值的项”不会触发 change,
|
||||
// 否则用户点「+ 添加尾缀…」会毫无反应。
|
||||
assert.match(viewer, /const SUFFIX_NONE_VALUE = '__none__';/);
|
||||
assert.match(viewer, /resting\.value = SUFFIX_NONE_VALUE;/);
|
||||
assert.match(viewer, /suffixSelectEl\.value = SUFFIX_NONE_VALUE;/);
|
||||
assert.doesNotMatch(viewer, /suffixSelectEl\.value = SUFFIX_ADD_VALUE;/);
|
||||
// 事件回调里的异步动作必须自兜底,否则失败会静默
|
||||
assert.match(viewer, /runSuffixAction\(addCustomSuffix\(\)\)/);
|
||||
assert.match(viewer, /runSuffixAction\(removeCustomSuffix\(/);
|
||||
|
||||
// 输入框上限来自 utils 常量,不能再出现第二个硬编码的数字
|
||||
assert.match(viewer, /maxlength="50"/);
|
||||
assert.match(viewer, /inputEl\.maxLength = utils\.SUFFIX_MAX_LENGTH;/);
|
||||
|
||||
// 8 种语言的说明文字都必须提到当前上限
|
||||
for (const file of fs.readdirSync(path.join(__dirname, '../_locales'))) {
|
||||
const table = JSON.parse(fs.readFileSync(path.join(__dirname, '../_locales', file), 'utf8'));
|
||||
assert.ok(table.viewer.suffixAddNote.includes(String(utils.SUFFIX_MAX_LENGTH)), `${file}: suffixAddNote 未提到当前上限`);
|
||||
}
|
||||
|
||||
// 尾缀脚本引用的每个 id 都必须真实存在:缺一个就会在 openSuffixDialog 里抛错,
|
||||
// 而事件回调里的异常不会冒泡到界面,只会表现为“点了没反应”。
|
||||
const suffixSection = viewer.slice(viewer.indexOf('// ---------- 文件名尾缀设置'), viewer.indexOf('// ---------- 启动'));
|
||||
assert.ok(suffixSection.length > 0);
|
||||
for (const match of suffixSection.matchAll(/getElementById\('([^']+)'\)/g)) {
|
||||
assert.ok(viewer.includes(`id="${match[1]}"`), `missing element #${match[1]}`);
|
||||
}
|
||||
for (const id of ['suffixSelect', 'suffixDialog', 'suffixDialogTitle', 'suffixDialogNote',
|
||||
'suffixDialogInput', 'suffixDialogError', 'suffixDialogConfirm', 'suffixDialogCancel']) {
|
||||
assert.ok(viewer.includes(`id="${id}"`), `missing element #${id}`);
|
||||
}
|
||||
|
||||
console.log('PASS: built-in/custom suffixes, input validation, pairing, global store, and viewer wiring');
|
||||
@@ -106,6 +106,14 @@
|
||||
.thumb-dialog-row.skipped .thumb-dialog-name,
|
||||
.thumb-dialog-row.skipped .thumb-dialog-state { color: var(--text-dim); }
|
||||
.thumb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.thumb-dialog-input {
|
||||
background: var(--panel-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 7px 9px;
|
||||
}
|
||||
.thumb-dialog-input.hidden { display: none; }
|
||||
.thumb-dialog-error { color: var(--error); min-height: 18px; }
|
||||
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
|
||||
#toolbar #suffixSelect { max-width: 140px; }
|
||||
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
|
||||
#animationPanel {
|
||||
position: absolute;
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
|
||||
</label>
|
||||
<div class="spacer"></div>
|
||||
<label data-i18n-title="viewer.suffixTitle" title="文件名尾缀在匹配时会被忽略,使 hero-mesh.skel 与 hero.atlas 归为同一套资源">
|
||||
<span data-i18n="viewer.suffix">尾缀</span>
|
||||
<select id="suffixSelect"></select>
|
||||
</label>
|
||||
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="为关联资源生成缩略图">为关联资源生成缩略图</button>
|
||||
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="将当前帧设为当前素材的缩略图">设为当前素材缩略图</button>
|
||||
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
|
||||
@@ -71,6 +75,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="suffixDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="suffixDialogTitle">
|
||||
<div class="thumb-dialog-card">
|
||||
<div id="suffixDialogTitle" class="thumb-dialog-title"></div>
|
||||
<div id="suffixDialogNote" class="thumb-dialog-note"></div>
|
||||
<input type="text" id="suffixDialogInput" class="thumb-dialog-input hidden" maxlength="50" autocomplete="off" spellcheck="false">
|
||||
<div id="suffixDialogError" class="thumb-dialog-error"></div>
|
||||
<div class="thumb-dialog-actions">
|
||||
<button id="suffixDialogCancel" data-i18n="viewer.cancel">取消</button>
|
||||
<button id="suffixDialogConfirm" class="primary"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -83,6 +99,7 @@
|
||||
const spineBadge = require('../js/spine-badge.js');
|
||||
const clippingRenderer = require('../js/spine-canvas-clipping.js');
|
||||
const groupThumbnails = require('../js/spine-group-thumbnails.js');
|
||||
const previewSettingsModule = require('../js/spine-preview-settings.js');
|
||||
let spineBadgeImagePromise = null;
|
||||
|
||||
function loadSpineBadgeImage() {
|
||||
@@ -1343,6 +1360,183 @@
|
||||
setStatus(t('viewer.pmaSaveFailed', { error: err.message }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 文件名尾缀设置(全局偏好,实时落盘) ----------
|
||||
const SUFFIX_NONE_VALUE = '__none__';
|
||||
const SUFFIX_ADD_VALUE = '__add__';
|
||||
const SUFFIX_CUSTOM_PREFIX = 'custom:';
|
||||
const suffixSelectEl = document.getElementById('suffixSelect');
|
||||
let suffixSettings = null;
|
||||
let customSuffixes = [];
|
||||
|
||||
// 下拉列表只是入口与状态展示(内置尾缀以禁用条目列出),
|
||||
// 选择「+ 添加尾缀…」=新增,选择自定义项 =移除,两者都会先弹确认框。
|
||||
// 静止项必须是独立的值:选中“已经是当前值的项”不会触发 change,
|
||||
// 若把静止值设成新增项本身,用户点它就会毫无反应。
|
||||
function renderSuffixSelect() {
|
||||
suffixSelectEl.textContent = '';
|
||||
const resting = document.createElement('option');
|
||||
resting.value = SUFFIX_NONE_VALUE;
|
||||
resting.textContent = t('viewer.suffixManage');
|
||||
suffixSelectEl.appendChild(resting);
|
||||
const add = document.createElement('option');
|
||||
add.value = SUFFIX_ADD_VALUE;
|
||||
add.textContent = t('viewer.suffixAdd');
|
||||
suffixSelectEl.appendChild(add);
|
||||
const appendGroup = (labelKey, names, removable) => {
|
||||
if (names.length === 0) return;
|
||||
const group = document.createElement('option');
|
||||
group.disabled = true;
|
||||
group.textContent = '── ' + t(labelKey) + ' ──';
|
||||
suffixSelectEl.appendChild(group);
|
||||
for (const name of names) {
|
||||
const option = document.createElement('option');
|
||||
option.textContent = removable ? '✕ ' + name : name;
|
||||
if (removable) option.value = SUFFIX_CUSTOM_PREFIX + name;
|
||||
else option.disabled = true;
|
||||
suffixSelectEl.appendChild(option);
|
||||
}
|
||||
};
|
||||
appendGroup('viewer.suffixDefaults', utils.DEFAULT_SUFFIXES, false);
|
||||
appendGroup('viewer.suffixCustom', customSuffixes, true);
|
||||
suffixSelectEl.value = SUFFIX_NONE_VALUE;
|
||||
}
|
||||
|
||||
// 与缩略图对话框共用遮罩/卡片样式;withInput 为 false 时是纯确认框。
|
||||
function openSuffixDialog(options) {
|
||||
const dialog = document.getElementById('suffixDialog');
|
||||
const inputEl = document.getElementById('suffixDialogInput');
|
||||
const errorEl = document.getElementById('suffixDialogError');
|
||||
const confirmBtn = document.getElementById('suffixDialogConfirm');
|
||||
const cancelBtn = document.getElementById('suffixDialogCancel');
|
||||
document.getElementById('suffixDialogTitle').textContent = t(options.titleKey);
|
||||
document.getElementById('suffixDialogNote').textContent = t(options.noteKey, options.noteVars);
|
||||
confirmBtn.textContent = t(options.confirmKey);
|
||||
inputEl.classList.toggle('hidden', !options.withInput);
|
||||
inputEl.maxLength = utils.SUFFIX_MAX_LENGTH;
|
||||
inputEl.value = options.withInput ? (options.initialValue || '') : '';
|
||||
inputEl.placeholder = options.withInput ? t('viewer.suffixPlaceholder') : '';
|
||||
errorEl.textContent = '';
|
||||
dialog.classList.remove('hidden');
|
||||
if (options.withInput) { inputEl.focus(); inputEl.select(); }
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (accepted) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
dialog.classList.add('hidden');
|
||||
confirmBtn.removeEventListener('click', accept);
|
||||
cancelBtn.removeEventListener('click', reject);
|
||||
inputEl.removeEventListener('input', clearError);
|
||||
inputEl.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
resolve(accepted ? (options.withInput ? inputEl.value : true) : null);
|
||||
};
|
||||
const accept = () => {
|
||||
if (options.validate) {
|
||||
const message = options.validate(inputEl.value);
|
||||
if (message) { errorEl.textContent = message; return; }
|
||||
}
|
||||
finish(true);
|
||||
};
|
||||
const reject = () => finish(false);
|
||||
const clearError = () => { errorEl.textContent = ''; };
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); reject(); }
|
||||
else if (event.key === 'Enter' && options.withInput) { event.preventDefault(); accept(); }
|
||||
};
|
||||
confirmBtn.addEventListener('click', accept);
|
||||
cancelBtn.addEventListener('click', reject);
|
||||
inputEl.addEventListener('input', clearError);
|
||||
inputEl.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
}
|
||||
|
||||
function applySuffixes(list) {
|
||||
if (!suffixSettings) throw new Error(t('viewer.settingsNotReady'));
|
||||
suffixSettings.write(list);
|
||||
customSuffixes = utils.setSuffixes(list).filter((name) => utils.DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||||
}
|
||||
|
||||
async function addCustomSuffix() {
|
||||
const input = await openSuffixDialog({
|
||||
titleKey: 'viewer.suffixAddTitle',
|
||||
noteKey: 'viewer.suffixAddNote',
|
||||
confirmKey: 'viewer.suffixConfirm',
|
||||
withInput: true,
|
||||
validate: (value) => {
|
||||
const name = utils.normalizeSuffixName(value);
|
||||
if (!name) return t('viewer.suffixInvalid');
|
||||
if (utils.DEFAULT_SUFFIXES.indexOf(name) !== -1 || customSuffixes.indexOf(name) !== -1) return t('viewer.suffixDuplicate');
|
||||
return null;
|
||||
},
|
||||
});
|
||||
if (input === null) return;
|
||||
try {
|
||||
applySuffixes(customSuffixes.concat([utils.normalizeSuffixName(input)]));
|
||||
// 尾缀会改变资源配对结果,直接重载查看器最稳。
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCustomSuffix(name) {
|
||||
const accepted = await openSuffixDialog({
|
||||
titleKey: 'viewer.suffixRemoveTitle',
|
||||
noteKey: 'viewer.suffixRemoveNote',
|
||||
noteVars: { name },
|
||||
confirmKey: 'viewer.suffixRemove',
|
||||
});
|
||||
if (accepted === null) return;
|
||||
try {
|
||||
applySuffixes(customSuffixes.filter((item) => item !== name));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
|
||||
}
|
||||
}
|
||||
|
||||
// 事件回调里没有 await,任何异常都必须在这里兜住,否则会静默失败。
|
||||
function runSuffixAction(promise) {
|
||||
Promise.resolve(promise).catch((error) => {
|
||||
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
|
||||
});
|
||||
}
|
||||
|
||||
suffixSelectEl.addEventListener('change', (event) => {
|
||||
const value = event.target.value;
|
||||
// 先复位到静止项,保证下一次选择一定会产生 change。
|
||||
renderSuffixSelect();
|
||||
if (value === SUFFIX_ADD_VALUE) runSuffixAction(addCustomSuffix());
|
||||
else if (value.indexOf(SUFFIX_CUSTOM_PREFIX) === 0) runSuffixAction(removeCustomSuffix(value.slice(SUFFIX_CUSTOM_PREFIX.length)));
|
||||
});
|
||||
|
||||
async function initSuffixSettings() {
|
||||
try {
|
||||
const userDataPath = await previewSettingsModule.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null);
|
||||
suffixSettings = previewSettingsModule.createSuffixStore(userDataPath);
|
||||
} catch (error) {
|
||||
suffixSettings = null;
|
||||
log('尾缀设置存储不可用: ' + ((error && error.message) || String(error)));
|
||||
}
|
||||
try {
|
||||
if (suffixSettings) {
|
||||
// 磁盘内容不可信:utils 会丢弃非法、重复和内置重名的尾缀。
|
||||
customSuffixes = utils.setSuffixes(suffixSettings.read()).filter((name) => utils.DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||||
} else {
|
||||
utils.setSuffixes([]);
|
||||
customSuffixes = [];
|
||||
}
|
||||
} catch (error) {
|
||||
// 配置损坏时回退到内置尾缀,但保留存储对象,下次写入会覆盖它。
|
||||
customSuffixes = utils.getCustomSuffixes();
|
||||
log('尾缀配置损坏,已回退到内置尾缀: ' + ((error && error.message) || String(error)));
|
||||
}
|
||||
renderSuffixSelect();
|
||||
}
|
||||
|
||||
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
@@ -1434,7 +1628,7 @@
|
||||
} else if (!fs.existsSync(filePath)) {
|
||||
showError(t('viewer.fileNotExist'), filePath);
|
||||
} else {
|
||||
loadAssets().catch((err) => {
|
||||
initSuffixSettings().then(() => loadAssets()).catch((err) => {
|
||||
showError(t('viewer.loadFailed'), (err && err.message) ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
|
||||