feat(terminal): add persistent browser font size controls
This commit is contained in:
@@ -57,6 +57,11 @@ http {
|
||||
auth_request /api/auth/check;
|
||||
error_page 401 = @term_login;
|
||||
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_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE, FONT_SIZE_KEY, readFontSize, saveFontSize } from './terminal-preferences.js';
|
||||
|
||||
let state = {
|
||||
gpus: [],
|
||||
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();
|
||||
setInterval(refresh, 60000);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Local Kanban</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=20260728d">
|
||||
<link rel="stylesheet" href="/styles.css?v=20261003-font-size">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-glow" aria-hidden="true"></div>
|
||||
@@ -55,6 +55,10 @@
|
||||
<h2>Live Agents</h2>
|
||||
<span id="liveAgentSummary" class="badge"></span>
|
||||
</div>
|
||||
<label class="terminal-font-preference">
|
||||
<span>终端字号</span>
|
||||
<select id="terminalFontSize" aria-label="终端字号"></select>
|
||||
</label>
|
||||
</div>
|
||||
<form id="liveAgentForm" class="live-agent-form">
|
||||
<label class="select-field">
|
||||
@@ -84,6 +88,6 @@
|
||||
</section>
|
||||
</main>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@@ -1369,3 +1369,6 @@ details.task-card[open] > summary .task-summary-chevron {
|
||||
.agent-card { flex-wrap: wrap; }
|
||||
.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); }
|
||||
|
||||
27
public/terminal-preferences.js
Normal file
27
public/terminal-preferences.js
Normal 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;
|
||||
}
|
||||
}
|
||||
32
public/terminal-settings.css
Normal file
32
public/terminal-settings.css
Normal 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; } }
|
||||
62
public/terminal-settings.js
Normal file
62
public/terminal-settings.js
Normal 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);
|
||||
29
test/terminal-preferences.test.js
Normal file
29
test/terminal-preferences.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user