204 lines
8.3 KiB
HTML
204 lines
8.3 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Spine缩略图</title>
|
||
<style>
|
||
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; }
|
||
html, body { margin: 0; background: var(--bg); color: var(--text);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
|
||
body { display: flex; flex-direction: column; gap: 8px; padding: 14px; box-sizing: border-box; }
|
||
h1 { font-size: 13px; margin: 0; font-weight: 600; text-align: center; }
|
||
.bar { height: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
|
||
/* 不加过渡:窗口被遮挡时 CSS 过渡会停在中途,进度条就不跟着走了。 */
|
||
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); }
|
||
#count { color: var(--text); }
|
||
#name { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||
border-radius: 6px; padding: 5px 24px; font: inherit; cursor: pointer; }
|
||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||
#action { align-self: center; }
|
||
#confirm { display: none; flex-direction: column; gap: 8px; }
|
||
#confirmText { text-align: center; color: var(--text); }
|
||
#confirmButtons { display: flex; gap: 10px; justify-content: center; }
|
||
/* 继续 = 默认项(绿色、自动获得焦点);确认取消 = 危险操作(红色)。 */
|
||
#resume { background: #2ea043; border-color: #2ea043; color: #fff; }
|
||
#resume:hover { background: #3fb950; border-color: #3fb950; color: #fff; }
|
||
#cancel { background: #d64545; border-color: #d64545; color: #fff; }
|
||
#cancel:hover { background: #e5534b; border-color: #e5534b; color: #fff; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>正在生成动态缩略图……</h1>
|
||
<div class="bar"><i id="bar"></i></div>
|
||
<div id="count">等待开始…</div>
|
||
<div id="name"></div>
|
||
<div id="confirm">
|
||
<div id="confirmText">是否确认取消?</div>
|
||
<div id="confirmButtons">
|
||
<button id="resume" type="button">继续</button>
|
||
<button id="cancel" type="button">确认取消</button>
|
||
</div>
|
||
</div>
|
||
<button id="action" type="button">停止</button>
|
||
<script>
|
||
// 这个页面是宿主用 Eagle 的 create-plugin-window 打开的插件窗口:
|
||
// 无标题栏、置顶、挂在 Eagle 主窗口上;进度从临时目录的状态文件轮询。
|
||
// 按钮往回写控制文件:生成中 = 停止(暂停并就地询问),生成完 = 确认(关窗)。
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
|
||
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
|
||
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
|
||
const WIDTH = 380;
|
||
const bar = document.getElementById('bar');
|
||
const countEl = document.getElementById('count');
|
||
const nameEl = document.getElementById('name');
|
||
const actionEl = document.getElementById('action');
|
||
const confirmEl = document.getElementById('confirm');
|
||
const confirmTextEl = document.getElementById('confirmText');
|
||
const resumeEl = document.getElementById('resume');
|
||
let finished = false;
|
||
let paused = false;
|
||
let doneCount = 0;
|
||
let lastHeight = 0;
|
||
let placedAt = null; // 上次由我们设置的位置,用来判断用户是不是手动拖过
|
||
|
||
/** Eagle 的 userData 目录;eagle.app.userDataPath 注入得比较晚,所以留一个兜底。 */
|
||
function userDataPath() {
|
||
if (eagle.app && eagle.app.userDataPath) return eagle.app.userDataPath;
|
||
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Eagle');
|
||
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Eagle');
|
||
}
|
||
|
||
/** 算出"Eagle 主窗口正中心"对应的弹窗位置;读不到就返回 null。 */
|
||
function centerTarget(width, height) {
|
||
let state = null;
|
||
try {
|
||
state = JSON.parse(fs.readFileSync(path.join(userDataPath(), 'window-state.json'), 'utf8'));
|
||
} catch (error) { /* 读不到就用不了 */ }
|
||
if (!state || !Number.isFinite(state.x)) return null;
|
||
// 主窗口最大化/全屏时记录的是还原后的位置,拿它当基准会偏,改用所在显示器居中。
|
||
const area = (state.isMaximized || state.isFullScreen) && state.displayBounds ? state.displayBounds : state;
|
||
return [
|
||
Math.round(area.x + (area.width - width) / 2),
|
||
Math.round(area.y + (area.height - height) / 2),
|
||
];
|
||
}
|
||
|
||
/** 跟着 Eagle 主窗口居中;一旦用户手动拖过窗口,就只改尺寸、不再动位置。 */
|
||
async function reCenter(width, height) {
|
||
try {
|
||
const current = await eagle.window.getPosition();
|
||
const movedByUser = placedAt && (Math.abs(current[0] - placedAt[0]) > 4 || Math.abs(current[1] - placedAt[1]) > 4);
|
||
if (movedByUser) return;
|
||
} catch (error) { /* 拿不到位置就照常居中 */ }
|
||
const target = centerTarget(width, height);
|
||
if (!target) {
|
||
await eagle.window.center();
|
||
return;
|
||
}
|
||
placedAt = target;
|
||
await eagle.window.setPosition(target[0], target[1]);
|
||
}
|
||
|
||
/** 窗口高度跟着内容走。 */
|
||
function fitHeight() {
|
||
const height = Math.ceil(document.body.scrollHeight);
|
||
if (Math.abs(height - lastHeight) < 2) return;
|
||
lastHeight = height;
|
||
eagle.window.setSize(WIDTH, height)
|
||
.then(() => reCenter(WIDTH, height))
|
||
.catch(() => { /* 调整失败不影响显示 */ });
|
||
}
|
||
|
||
/** 三种状态:生成中显示「停止」,暂停询问时显示「继续 / 取消」,跑完显示「确认」。 */
|
||
function syncButtons() {
|
||
actionEl.textContent = finished ? '确认' : '停止';
|
||
actionEl.style.display = paused ? 'none' : '';
|
||
confirmEl.style.display = paused ? 'flex' : 'none';
|
||
fitHeight();
|
||
}
|
||
|
||
function writeControl(action) {
|
||
try {
|
||
fs.writeFileSync(CONTROL_FILE, JSON.stringify({ action, at: Date.now() }), 'utf8');
|
||
} catch (error) { /* 写不进去就等于没按 */ }
|
||
}
|
||
|
||
function render(state) {
|
||
const total = Math.max(1, state.total || 0);
|
||
const done = state.done || 0;
|
||
finished = state.finished === true;
|
||
if (finished) paused = false;
|
||
doneCount = done;
|
||
confirmTextEl.textContent = '已生成 ' + doneCount + ' 张缩略图,是否确认取消?';
|
||
if (finished) {
|
||
bar.style.width = '100%';
|
||
countEl.textContent = state.text || '已完成';
|
||
nameEl.textContent = '';
|
||
} else {
|
||
bar.style.width = Math.min(100, done / total * 100) + '%';
|
||
countEl.textContent = '已完成 ' + done + ' / ' + (state.total || 0) +
|
||
'(' + Math.round(done / total * 100) + '%)';
|
||
nameEl.textContent = state.name || '';
|
||
}
|
||
syncButtons();
|
||
}
|
||
|
||
actionEl.addEventListener('click', () => {
|
||
if (finished) {
|
||
window.close();
|
||
return;
|
||
}
|
||
// 先让宿主暂停(它会在当前素材做完后停在原地),再就地问要不要继续。
|
||
paused = true;
|
||
writeControl('pause');
|
||
syncButtons();
|
||
resumeEl.focus(); // 默认选中「继续」
|
||
});
|
||
|
||
resumeEl.addEventListener('click', () => {
|
||
paused = false;
|
||
writeControl('resume');
|
||
syncButtons();
|
||
});
|
||
|
||
document.getElementById('cancel').addEventListener('click', () => {
|
||
// 同步写控制文件再关窗:窗口没了宿主也能读到 cancel,把剩余队列丢掉。
|
||
writeControl('cancel');
|
||
window.close();
|
||
});
|
||
|
||
// 窗口尺寸/置顶/居中:Eagle 的 window-state.json 会覆盖 manifest 里的位置和宽高,这里再对齐一次。
|
||
(async () => {
|
||
try {
|
||
await eagle.window.setResizable(false);
|
||
} catch (error) { /* ignore */ }
|
||
syncButtons();
|
||
})();
|
||
|
||
// 心跳:让宿主知道这个窗口还开着;窗口关掉后心跳就停了,下次需要时会重新开。
|
||
const beat = () => {
|
||
try { fs.writeFileSync(ALIVE_FILE, String(Date.now()), 'utf8'); } catch (error) { /* ignore */ }
|
||
};
|
||
beat();
|
||
setInterval(beat, 1000);
|
||
|
||
let lastStamp = 0;
|
||
setInterval(() => {
|
||
let state;
|
||
try {
|
||
state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||
} catch (error) {
|
||
return; // 状态文件还没写出来
|
||
}
|
||
if (state.updatedAt === lastStamp) return;
|
||
lastStamp = state.updatedAt;
|
||
render(state);
|
||
}, 400);
|
||
</script>
|
||
</body>
|
||
</html>
|