feat(terminal): add persistent browser font size controls

This commit is contained in:
2026-10-03 16:10:42 +08:00
parent 298a3c8a58
commit a8afaf3b80
8 changed files with 178 additions and 2 deletions

View File

@@ -57,6 +57,11 @@ http {
auth_request /api/auth/check; auth_request /api/auth/check;
error_page 401 = @term_login; error_page 401 = @term_login;
proxy_pass http://127.0.0.1:7681; proxy_pass http://127.0.0.1:7681;
# Add controls to ttyd's existing client without rebuilding its embedded frontend.
proxy_set_header Accept-Encoding "";
sub_filter '</head>' '<link rel="stylesheet" href="/terminal-settings.css"></head>';
sub_filter '</body>' '<script type="module" src="/terminal-settings.js"></script></body>';
sub_filter_once on;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";

View File

@@ -1,3 +1,5 @@
import { MIN_FONT_SIZE, MAX_FONT_SIZE, FONT_SIZE_KEY, readFontSize, saveFontSize } from './terminal-preferences.js';
let state = { let state = {
gpus: [], gpus: [],
liveAgents: { ok: false, agents: [] }, liveAgents: { ok: false, agents: [] },
@@ -342,5 +344,17 @@ $('liveAgentForm').addEventListener('submit', async (event) => {
} }
}); });
const terminalFontSize = $('terminalFontSize');
for (let size = MIN_FONT_SIZE; size <= MAX_FONT_SIZE; size++) {
terminalFontSize.add(new Option(`${size} px`, String(size)));
}
terminalFontSize.value = String(readFontSize());
terminalFontSize.addEventListener('change', () => {
if (!saveFontSize(terminalFontSize.value)) toast('无法保存终端字号,请允许浏览器存储设置。', 'error');
});
window.addEventListener('storage', (event) => {
if (event.key === FONT_SIZE_KEY || event.key === null) terminalFontSize.value = String(readFontSize());
});
refresh(); refresh();
setInterval(refresh, 60000); setInterval(refresh, 60000);

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Local Kanban</title> <title>Local Kanban</title>
<link rel="stylesheet" href="/styles.css?v=20260728d"> <link rel="stylesheet" href="/styles.css?v=20261003-font-size">
</head> </head>
<body> <body>
<div class="bg-glow" aria-hidden="true"></div> <div class="bg-glow" aria-hidden="true"></div>
@@ -55,6 +55,10 @@
<h2>Live Agents</h2> <h2>Live Agents</h2>
<span id="liveAgentSummary" class="badge"></span> <span id="liveAgentSummary" class="badge"></span>
</div> </div>
<label class="terminal-font-preference">
<span>终端字号</span>
<select id="terminalFontSize" aria-label="终端字号"></select>
</label>
</div> </div>
<form id="liveAgentForm" class="live-agent-form"> <form id="liveAgentForm" class="live-agent-form">
<label class="select-field"> <label class="select-field">
@@ -84,6 +88,6 @@
</section> </section>
</main> </main>
<div id="toastHost" class="toast-host" aria-live="polite"></div> <div id="toastHost" class="toast-host" aria-live="polite"></div>
<script type="module" src="/app.js?v=20260728d"></script> <script type="module" src="/app.js?v=20261003-font-size"></script>
</body> </body>
</html> </html>

View File

@@ -1369,3 +1369,6 @@ details.task-card[open] > summary .task-summary-chevron {
.agent-card { flex-wrap: wrap; } .agent-card { flex-wrap: wrap; }
.agent-card-cwd { flex-basis: 100%; } .agent-card-cwd { flex-basis: 100%; }
} }
.terminal-font-preference { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
.terminal-font-preference select { padding: 6px 8px; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--bg-elev); }

View File

@@ -0,0 +1,27 @@
export const DEFAULT_FONT_SIZE = 10;
export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 32;
export const FONT_SIZE_KEY = 'kanban.terminalFontSize';
export function validatedFontSize(value) {
const size = Number(value);
return Number.isInteger(size) && size >= MIN_FONT_SIZE && size <= MAX_FONT_SIZE
? size : DEFAULT_FONT_SIZE;
}
export function readFontSize(storage) {
try {
return validatedFontSize((storage ?? globalThis.localStorage).getItem(FONT_SIZE_KEY));
} catch {
return DEFAULT_FONT_SIZE;
}
}
export function saveFontSize(value, storage) {
try {
(storage ?? globalThis.localStorage).setItem(FONT_SIZE_KEY, String(validatedFontSize(value)));
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,32 @@
html, body { min-height: 100%; }
#terminal-container { width: 100%; height: calc(100% - 48px); margin: 0; }
.terminal-settings {
box-sizing: border-box;
height: 48px;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 8px;
background: #111827;
border-bottom: 1px solid #283457;
color: #c0caf5;
font: 14px/1.4 system-ui, -apple-system, sans-serif;
}
.terminal-settings a { color: #a0adfc; text-decoration: none; margin-right: 8px; }
.terminal-settings button, .terminal-settings select {
font: inherit;
color: inherit;
background: #1a1b26;
border: 1px solid #414868;
border-radius: 6px;
height: 34px;
padding: 0 8px;
cursor: pointer;
}
.terminal-settings button:disabled { opacity: 0.4; cursor: default; }
.terminal-settings button:focus-visible, .terminal-settings select:focus-visible, .terminal-settings a:focus-visible {
outline: 2px solid #7aa2f7;
outline-offset: 2px;
}
#fontSizeStatus { margin-left: auto; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 600px) { #fontSizeStatus { display: none; } }

View File

@@ -0,0 +1,62 @@
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, FONT_SIZE_KEY, readFontSize, saveFontSize } from './terminal-preferences.js';
const toolbar = document.createElement('header');
toolbar.className = 'terminal-settings';
toolbar.innerHTML = `
<a href="/">← 看板</a>
<label for="terminalFontSize">字号</label>
<button id="smallerFont" type="button" aria-label="减小终端字号">A−</button>
<select id="terminalFontSize" aria-label="终端字号"></select>
<button id="largerFont" type="button" aria-label="增大终端字号">A+</button>
<button id="resetFont" type="button">重置</button>
<span id="fontSizeStatus" role="status">保存在当前浏览器</span>`;
document.body.prepend(toolbar);
const select = toolbar.querySelector('#terminalFontSize');
const smaller = toolbar.querySelector('#smallerFont');
const larger = toolbar.querySelector('#largerFont');
const status = toolbar.querySelector('#fontSizeStatus');
for (let size = MIN_FONT_SIZE; size <= MAX_FONT_SIZE; size++) {
select.add(new Option(`${size} px`, String(size)));
}
let fontSize = readFontSize();
let fittedTerminal;
function updateControls() {
select.value = String(fontSize);
smaller.disabled = fontSize <= MIN_FONT_SIZE;
larger.disabled = fontSize >= MAX_FONT_SIZE;
}
function applyFontSize() {
const term = window.term;
if (!term) return;
if (term.options.fontSize !== fontSize || fittedTerminal !== term) {
term.options.fontSize = fontSize;
term.fit();
fittedTerminal = term;
}
}
function changeFontSize(size) {
fontSize = size;
status.textContent = saveFontSize(size) ? '已保存到当前浏览器' : '无法保存设置,仅当前页面生效';
updateControls();
applyFontSize();
window.term?.focus();
}
select.addEventListener('change', () => changeFontSize(Number(select.value)));
smaller.addEventListener('click', () => changeFontSize(Math.max(MIN_FONT_SIZE, fontSize - 1)));
larger.addEventListener('click', () => changeFontSize(Math.min(MAX_FONT_SIZE, fontSize + 1)));
toolbar.querySelector('#resetFont').addEventListener('click', () => changeFontSize(DEFAULT_FONT_SIZE));
window.addEventListener('storage', (event) => {
if (event.key !== FONT_SIZE_KEY && event.key !== null) return;
fontSize = readFontSize();
updateControls();
applyFontSize();
});
updateControls();
applyFontSize();
// ttyd applies server preferences again on connect/reconnect; restore this browser's choice.
setInterval(applyFontSize, 250);

View File

@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FONT_SIZE_KEY, readFontSize, saveFontSize } from '../public/terminal-preferences.js';
test('terminal font size persists and falls back to 10 for missing or corrupt preferences', () => {
const values = new Map();
const storage = {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value)
};
assert.equal(readFontSize(storage), 10);
assert.equal(saveFontSize(18, storage), true);
assert.equal(readFontSize(storage), 18);
for (const value of ['garbage', '7', '33', '12.5', 'Infinity']) {
values.set(FONT_SIZE_KEY, value);
assert.equal(readFontSize(storage), 10);
}
assert.equal(saveFontSize(32, storage), true);
assert.equal(readFontSize(storage), 32);
});
test('blocked browser storage preserves the default and reports that saving failed', () => {
const storage = {
getItem: () => { throw new Error('Storage blocked'); },
setItem: () => { throw new Error('Storage blocked'); }
};
assert.equal(readFontSize(storage), 10);
assert.equal(saveFontSize(16, storage), false);
});