尾缀识别
This commit is contained in:
1 parent
d9f7bd2f89
commit
94e9304d9e
34 files changed
+971
-13
No files matched your search
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user