diff --git a/release/SpineViewer1560x1040.psd b/release/SpineViewer1560x1040.psd index 751737d..2769ba6 100644 Binary files a/release/SpineViewer1560x1040.psd and b/release/SpineViewer1560x1040.psd differ diff --git a/release/SpineViewer1560x1040英文.png b/release/SpineViewer1560x1040英文.png new file mode 100644 index 0000000..13849a3 Binary files /dev/null and b/release/SpineViewer1560x1040英文.png differ diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.html b/release/UNIVERSAL_SpineViewer/viewer/spine.html index e74d254..1ccb756 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.html +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.html @@ -14,6 +14,13 @@ --accent: #4fc1ff; --border: #3c3c3c; --error: #f14c4c; + /* 悬浮层填充与滚动条配色。Eagle 内置 Electron 22 / Chromium 108 不支持 color-mix(), + 这些必须写成 rgba(),否则整条颜色声明会被丢弃。 */ + --hover-fill: rgba(255, 255, 255, .07); + --scroll-thumb: rgba(255, 255, 255, .34); + --scroll-thumb-hover: rgba(255, 255, 255, .55); + --scroll-thumb-active: rgba(255, 255, 255, .72); + --scroll-track: rgba(255, 255, 255, .07); } [data-theme="light"] { --bg: #f3f4f6; @@ -24,6 +31,11 @@ --accent: #0078d4; --border: #ddd; --error: #d13438; + --hover-fill: rgba(0, 0, 0, .06); + --scroll-thumb: rgba(0, 0, 0, .30); + --scroll-thumb-hover: rgba(0, 0, 0, .46); + --scroll-thumb-active: rgba(0, 0, 0, .62); + --scroll-track: rgba(0, 0, 0, .06); } * { box-sizing: border-box; @@ -182,6 +194,50 @@ font: 12px/1.6 "SFMono-Regular", Consolas, "Liberation Mono", monospace; color: var(--text-dim); white-space: pre; word-break: normal; } + /* 纯文本 / 普通 JSON 预览允许拖动选择与 Ctrl+C 复制;画布与查看器控件仍保持不可选中 */ + #overlay.text-preview .title, + #overlay.text-preview .msg { + -webkit-user-select: text; user-select: text; + } + #overlay.text-preview .msg { cursor: text; } + /* 预览区滚动条:不使用原生样式。宽度 14px、滑块可见 8px,保证看得清。 + 颜色走 --scroll-* 变量(rgba 写死),不能用 color-mix():Eagle 内置 Chromium 108 + 不支持该函数,声明会整条失效,滑块就变成看不见。也不要写 scrollbar-width / + scrollbar-color,那两个标准属性在 108 上无效且易与 ::-webkit-scrollbar 混淆。 */ + #overlay.text-preview .box::-webkit-scrollbar { width: 14px; height: 14px; } + #overlay.text-preview .box::-webkit-scrollbar-track { background: var(--scroll-track); } + #overlay.text-preview .box::-webkit-scrollbar-thumb { + min-height: 36px; + border: 3px solid transparent; border-radius: 999px; + background: var(--scroll-thumb); + background-clip: padding-box; + } + #overlay.text-preview .box::-webkit-scrollbar-thumb:hover { + background: var(--scroll-thumb-hover); + background-clip: padding-box; + } + #overlay.text-preview .box::-webkit-scrollbar-thumb:active { + background: var(--scroll-thumb-active); + background-clip: padding-box; + } + #overlay.text-preview .box::-webkit-scrollbar-corner { background: transparent; } + /* JSON 字段树:折叠标记与计数只用符号 / 数字,无需新增界面文案 */ + #overlay.json-preview .msg { white-space: normal; } + .jt-node > summary { + list-style: none; cursor: pointer; border-radius: 4px; + } + .jt-node > summary::-webkit-details-marker { display: none; } + .jt-node > summary:hover { background: var(--hover-fill); } + .jt-node > summary::before { + content: '▾'; display: inline-block; width: 14px; color: var(--text-dim); + } + .jt-node:not([open]) > summary::before { content: '▸'; } + .jt-children { margin-left: 7px; padding-left: 11px; border-left: 1px solid var(--border); } + .jt-leaf { padding-left: 14px; } + .jt-key { color: var(--accent); } + .jt-str { color: var(--text); } + .jt-brace, .jt-count { color: var(--text-dim); } + .jt-count { margin: 0 3px; font-size: 11px; } #overlay.hidden { display: none; } #loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 4; background: var(--bg); } #loading.hidden { display: none; } @@ -421,6 +477,8 @@ function showError(title, msg) { loadingEl.classList.add('hidden'); + overlay.classList.remove('text-preview'); + overlay.classList.remove('json-preview'); overlay.classList.remove('hidden'); document.getElementById('errorTitle').textContent = title; document.getElementById('errorMsg').textContent = msg; @@ -432,8 +490,78 @@ timelineBarEl.style.display = 'none'; animationPanel.style.display = 'none'; statusEl.style.display = 'none'; - overlay.classList.add('text-preview'); showError(title, text); + overlay.classList.add('text-preview'); + } + + // ---------- 普通 JSON:轻量字段树(点击折叠 / 展开) ---------- + const JSON_TREE_MAX_NODES = 20000; // 超过此规模退回纯文本,避免构建 DOM 卡顿 + const JSON_TREE_OPEN_DEPTH = 1; // 默认展开的层级,更深的折叠 + + function jsonNodeCount(value, budget) { + let n = 0; + const walk = (node) => { + if (!node || typeof node !== 'object' || n > budget) return; + for (const key of Object.keys(node)) { n++; walk(node[key]); if (n > budget) return; } + }; + walk(value); + return n; + } + + function jsonKeySpan(key) { + const span = document.createElement('span'); + span.className = 'jt-key'; + span.textContent = (typeof key === 'number' ? '[' + key + ']: ' : JSON.stringify(key) + ': '); + return span; + } + + function buildJsonTree(key, value, depth) { + if (value === null || typeof value !== 'object') { + const leaf = document.createElement('div'); + leaf.className = 'jt-leaf'; + if (key !== null) leaf.appendChild(jsonKeySpan(key)); + const val = document.createElement('span'); + val.textContent = typeof value === 'string' ? JSON.stringify(value) : String(value); + if (typeof value === 'string') val.className = 'jt-str'; + leaf.appendChild(val); + return leaf; + } + const isArray = Array.isArray(value); + const keys = isArray ? value.map((_, i) => i) : Object.keys(value); + const node = document.createElement('details'); + node.className = 'jt-node'; + if (depth <= JSON_TREE_OPEN_DEPTH) node.open = true; + const summary = document.createElement('summary'); + if (key !== null) summary.appendChild(jsonKeySpan(key)); + const open = document.createElement('span'); + open.className = 'jt-brace'; + open.textContent = isArray ? '[' : '{'; + const count = document.createElement('span'); + count.className = 'jt-count'; + count.textContent = String(keys.length); + const close = document.createElement('span'); + close.className = 'jt-brace'; + close.textContent = isArray ? ']' : '}'; + summary.append(open, count, close); + node.appendChild(summary); + const children = document.createElement('div'); + children.className = 'jt-children'; + for (const k of keys) children.appendChild(buildJsonTree(k, value[k], depth + 1)); + node.appendChild(children); + return node; + } + + function showJsonPreview(title, value) { + const box = document.getElementById('errorMsg'); + showTextPreview(title, ''); + if (jsonNodeCount(value, JSON_TREE_MAX_NODES) > JSON_TREE_MAX_NODES) { + // 超大对象仍以纯文本呈现,保证打开速度与可选择性 + box.textContent = JSON.stringify(value, null, 2); + return; + } + overlay.classList.add('json-preview'); + box.textContent = ''; + box.appendChild(buildJsonTree(null, value, 0)); } function setStatus(msg, holdMs = 0) { @@ -1063,10 +1191,13 @@ let count = 0; try { count = fs.readSync(fd, buffer, 0, buffer.length, 0); } finally { fs.closeSync(fd); } let text = buffer.subarray(0, count).toString('utf8'); + const name = path.basename(filePath); + let parsed; if (stat.size <= maxPreviewBytes) { - try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 保留 JSONC/原始文本 */ } + try { parsed = JSON.parse(text); } catch (e) { /* 保留 JSONC/原始文本 */ } } else text += t('viewer.truncated1m'); - showTextPreview(path.basename(filePath), text); + if (parsed !== null && typeof parsed === 'object') showJsonPreview(name, parsed); + else showTextPreview(name, parsed === undefined ? text : JSON.stringify(parsed, null, 2)); return; } const assets = utils.discoverAssets(filePath); diff --git a/viewer/spine.html b/viewer/spine.html index e74d254..1ccb756 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -14,6 +14,13 @@ --accent: #4fc1ff; --border: #3c3c3c; --error: #f14c4c; + /* 悬浮层填充与滚动条配色。Eagle 内置 Electron 22 / Chromium 108 不支持 color-mix(), + 这些必须写成 rgba(),否则整条颜色声明会被丢弃。 */ + --hover-fill: rgba(255, 255, 255, .07); + --scroll-thumb: rgba(255, 255, 255, .34); + --scroll-thumb-hover: rgba(255, 255, 255, .55); + --scroll-thumb-active: rgba(255, 255, 255, .72); + --scroll-track: rgba(255, 255, 255, .07); } [data-theme="light"] { --bg: #f3f4f6; @@ -24,6 +31,11 @@ --accent: #0078d4; --border: #ddd; --error: #d13438; + --hover-fill: rgba(0, 0, 0, .06); + --scroll-thumb: rgba(0, 0, 0, .30); + --scroll-thumb-hover: rgba(0, 0, 0, .46); + --scroll-thumb-active: rgba(0, 0, 0, .62); + --scroll-track: rgba(0, 0, 0, .06); } * { box-sizing: border-box; @@ -182,6 +194,50 @@ font: 12px/1.6 "SFMono-Regular", Consolas, "Liberation Mono", monospace; color: var(--text-dim); white-space: pre; word-break: normal; } + /* 纯文本 / 普通 JSON 预览允许拖动选择与 Ctrl+C 复制;画布与查看器控件仍保持不可选中 */ + #overlay.text-preview .title, + #overlay.text-preview .msg { + -webkit-user-select: text; user-select: text; + } + #overlay.text-preview .msg { cursor: text; } + /* 预览区滚动条:不使用原生样式。宽度 14px、滑块可见 8px,保证看得清。 + 颜色走 --scroll-* 变量(rgba 写死),不能用 color-mix():Eagle 内置 Chromium 108 + 不支持该函数,声明会整条失效,滑块就变成看不见。也不要写 scrollbar-width / + scrollbar-color,那两个标准属性在 108 上无效且易与 ::-webkit-scrollbar 混淆。 */ + #overlay.text-preview .box::-webkit-scrollbar { width: 14px; height: 14px; } + #overlay.text-preview .box::-webkit-scrollbar-track { background: var(--scroll-track); } + #overlay.text-preview .box::-webkit-scrollbar-thumb { + min-height: 36px; + border: 3px solid transparent; border-radius: 999px; + background: var(--scroll-thumb); + background-clip: padding-box; + } + #overlay.text-preview .box::-webkit-scrollbar-thumb:hover { + background: var(--scroll-thumb-hover); + background-clip: padding-box; + } + #overlay.text-preview .box::-webkit-scrollbar-thumb:active { + background: var(--scroll-thumb-active); + background-clip: padding-box; + } + #overlay.text-preview .box::-webkit-scrollbar-corner { background: transparent; } + /* JSON 字段树:折叠标记与计数只用符号 / 数字,无需新增界面文案 */ + #overlay.json-preview .msg { white-space: normal; } + .jt-node > summary { + list-style: none; cursor: pointer; border-radius: 4px; + } + .jt-node > summary::-webkit-details-marker { display: none; } + .jt-node > summary:hover { background: var(--hover-fill); } + .jt-node > summary::before { + content: '▾'; display: inline-block; width: 14px; color: var(--text-dim); + } + .jt-node:not([open]) > summary::before { content: '▸'; } + .jt-children { margin-left: 7px; padding-left: 11px; border-left: 1px solid var(--border); } + .jt-leaf { padding-left: 14px; } + .jt-key { color: var(--accent); } + .jt-str { color: var(--text); } + .jt-brace, .jt-count { color: var(--text-dim); } + .jt-count { margin: 0 3px; font-size: 11px; } #overlay.hidden { display: none; } #loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 4; background: var(--bg); } #loading.hidden { display: none; } @@ -421,6 +477,8 @@ function showError(title, msg) { loadingEl.classList.add('hidden'); + overlay.classList.remove('text-preview'); + overlay.classList.remove('json-preview'); overlay.classList.remove('hidden'); document.getElementById('errorTitle').textContent = title; document.getElementById('errorMsg').textContent = msg; @@ -432,8 +490,78 @@ timelineBarEl.style.display = 'none'; animationPanel.style.display = 'none'; statusEl.style.display = 'none'; - overlay.classList.add('text-preview'); showError(title, text); + overlay.classList.add('text-preview'); + } + + // ---------- 普通 JSON:轻量字段树(点击折叠 / 展开) ---------- + const JSON_TREE_MAX_NODES = 20000; // 超过此规模退回纯文本,避免构建 DOM 卡顿 + const JSON_TREE_OPEN_DEPTH = 1; // 默认展开的层级,更深的折叠 + + function jsonNodeCount(value, budget) { + let n = 0; + const walk = (node) => { + if (!node || typeof node !== 'object' || n > budget) return; + for (const key of Object.keys(node)) { n++; walk(node[key]); if (n > budget) return; } + }; + walk(value); + return n; + } + + function jsonKeySpan(key) { + const span = document.createElement('span'); + span.className = 'jt-key'; + span.textContent = (typeof key === 'number' ? '[' + key + ']: ' : JSON.stringify(key) + ': '); + return span; + } + + function buildJsonTree(key, value, depth) { + if (value === null || typeof value !== 'object') { + const leaf = document.createElement('div'); + leaf.className = 'jt-leaf'; + if (key !== null) leaf.appendChild(jsonKeySpan(key)); + const val = document.createElement('span'); + val.textContent = typeof value === 'string' ? JSON.stringify(value) : String(value); + if (typeof value === 'string') val.className = 'jt-str'; + leaf.appendChild(val); + return leaf; + } + const isArray = Array.isArray(value); + const keys = isArray ? value.map((_, i) => i) : Object.keys(value); + const node = document.createElement('details'); + node.className = 'jt-node'; + if (depth <= JSON_TREE_OPEN_DEPTH) node.open = true; + const summary = document.createElement('summary'); + if (key !== null) summary.appendChild(jsonKeySpan(key)); + const open = document.createElement('span'); + open.className = 'jt-brace'; + open.textContent = isArray ? '[' : '{'; + const count = document.createElement('span'); + count.className = 'jt-count'; + count.textContent = String(keys.length); + const close = document.createElement('span'); + close.className = 'jt-brace'; + close.textContent = isArray ? ']' : '}'; + summary.append(open, count, close); + node.appendChild(summary); + const children = document.createElement('div'); + children.className = 'jt-children'; + for (const k of keys) children.appendChild(buildJsonTree(k, value[k], depth + 1)); + node.appendChild(children); + return node; + } + + function showJsonPreview(title, value) { + const box = document.getElementById('errorMsg'); + showTextPreview(title, ''); + if (jsonNodeCount(value, JSON_TREE_MAX_NODES) > JSON_TREE_MAX_NODES) { + // 超大对象仍以纯文本呈现,保证打开速度与可选择性 + box.textContent = JSON.stringify(value, null, 2); + return; + } + overlay.classList.add('json-preview'); + box.textContent = ''; + box.appendChild(buildJsonTree(null, value, 0)); } function setStatus(msg, holdMs = 0) { @@ -1063,10 +1191,13 @@ let count = 0; try { count = fs.readSync(fd, buffer, 0, buffer.length, 0); } finally { fs.closeSync(fd); } let text = buffer.subarray(0, count).toString('utf8'); + const name = path.basename(filePath); + let parsed; if (stat.size <= maxPreviewBytes) { - try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 保留 JSONC/原始文本 */ } + try { parsed = JSON.parse(text); } catch (e) { /* 保留 JSONC/原始文本 */ } } else text += t('viewer.truncated1m'); - showTextPreview(path.basename(filePath), text); + if (parsed !== null && typeof parsed === 'object') showJsonPreview(name, parsed); + else showTextPreview(name, parsed === undefined ? text : JSON.stringify(parsed, null, 2)); return; } const assets = utils.discoverAssets(filePath);