尾缀识别

This commit is contained in:
Yeomai committed 2026-09-17 23:38:30 +08:00
1 parent d9f7bd2f89
commit 94e9304d9e
34 files changed
+971 -13

No files matched your search

+2 -1
View File
@@ -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 和导出版本号。
---
+16
View File
@@ -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)"
+16
View File
@@ -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)"
}
}
+16
View File
@@ -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)"
+16
View File
@@ -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": "カスタムサムネイルあり(未選択)"
+16
View File
@@ -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": "사용자 지정 썸네일 있음(선택 안 함)"
+16
View File
@@ -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": "Есть своя миниатюра (не выбрано)"
+16
View File
@@ -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)"
}
}
+16
View File
@@ -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)"
}
}
+32 -1
View File
@@ -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 };
+59 -4
View File
@@ -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,
Binary file not shown.
+1
View File
@@ -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;
+195 -1
View File
@@ -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));
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 121 KiB

+124
View File
@@ -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');
+8
View File
@@ -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;
+195 -1
View File
@@ -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));
});
}