feat(terminal): add persistent browser font size controls
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
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