@jacksontian/mwt 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ring-buffer.js +1 -1
- package/lib/server.js +148 -148
- package/package.json +7 -2
- package/public/css/style.css +263 -3
- package/public/index.html +57 -2
- package/public/js/app.js +107 -14
- package/public/js/font-manager.js +43 -0
- package/public/js/layout-manager.js +94 -13
- package/public/js/terminal-manager.js +136 -24
- package/public/js/theme-manager.js +5 -5
- package/public/js/ws-client.js +4 -4
package/lib/ring-buffer.js
CHANGED
package/lib/server.js
CHANGED
|
@@ -28,185 +28,185 @@ const OUTPUT_BUFFER_SIZE = 100 * 1024; // 100KB per terminal
|
|
|
28
28
|
function openBrowser(url) {
|
|
29
29
|
const cmd = process.platform === 'darwin' ? 'open'
|
|
30
30
|
: process.platform === 'win32' ? 'start'
|
|
31
|
-
|
|
31
|
+
: 'xdg-open';
|
|
32
32
|
exec(`${cmd} ${url}`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function start(port = 1987, host = '127.0.0.1', options = {}) {
|
|
36
36
|
|
|
37
|
-
const startCwd = process.cwd();
|
|
38
|
-
const sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
|
|
39
|
-
|
|
40
|
-
// HTTP server for static files
|
|
41
|
-
const server = createServer(async (req, res) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (url === '/') {
|
|
46
|
-
filePath = join(__dirname, 'public', 'index.html');
|
|
47
|
-
} else {
|
|
48
|
-
filePath = join(__dirname, 'public', url);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const data = await readFile(filePath);
|
|
53
|
-
const ext = extname(filePath);
|
|
54
|
-
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
|
|
55
|
-
res.end(data);
|
|
56
|
-
} catch {
|
|
57
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
58
|
-
res.end('Not Found');
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// WebSocket server
|
|
63
|
-
const wss = new WebSocketServer({ server });
|
|
64
|
-
|
|
65
|
-
wss.on('connection', (ws, req) => {
|
|
66
|
-
const url = new URL(req.url, 'http://localhost');
|
|
67
|
-
const sessionId = url.searchParams.get('sessionId');
|
|
68
|
-
if (!sessionId) {
|
|
69
|
-
ws.close(4001, 'Missing sessionId');
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
let session = sessions.get(sessionId);
|
|
74
|
-
if (!session) {
|
|
75
|
-
session = { terminals: new Map(), ws: null, disconnectedAt: null };
|
|
76
|
-
sessions.set(sessionId, session);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Detach old WS if still open
|
|
80
|
-
if (session.ws && session.ws !== ws && session.ws.readyState === 1) {
|
|
81
|
-
session.ws.close();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
session.ws = ws;
|
|
85
|
-
session.disconnectedAt = null;
|
|
86
|
-
|
|
87
|
-
// Send session-restore with existing terminal IDs
|
|
88
|
-
const terminalIds = [...session.terminals.keys()];
|
|
89
|
-
ws.send(JSON.stringify({ type: 'session-restore', terminals: terminalIds }));
|
|
90
|
-
|
|
91
|
-
// Send buffered output for each existing terminal
|
|
92
|
-
for (const [id, entry] of session.terminals) {
|
|
93
|
-
const buffered = entry.outputBuffer.read();
|
|
94
|
-
if (buffered) {
|
|
95
|
-
ws.send(JSON.stringify({ type: 'buffer', id, data: buffered }));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
37
|
+
const startCwd = process.cwd();
|
|
38
|
+
const sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
|
|
39
|
+
|
|
40
|
+
// HTTP server for static files
|
|
41
|
+
const server = createServer(async (req, res) => {
|
|
42
|
+
let filePath;
|
|
43
|
+
const url = req.url.split('?')[0];
|
|
98
44
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
45
|
+
if (url === '/') {
|
|
46
|
+
filePath = join(__dirname, 'public', 'index.html');
|
|
47
|
+
} else {
|
|
48
|
+
filePath = join(__dirname, 'public', url);
|
|
49
|
+
}
|
|
103
50
|
|
|
104
|
-
ws.on('message', async (raw) => {
|
|
105
|
-
let msg;
|
|
106
51
|
try {
|
|
107
|
-
|
|
52
|
+
const data = await readFile(filePath);
|
|
53
|
+
const ext = extname(filePath);
|
|
54
|
+
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
|
|
55
|
+
res.end(data);
|
|
108
56
|
} catch {
|
|
57
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
58
|
+
res.end('Not Found');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// WebSocket server
|
|
63
|
+
const wss = new WebSocketServer({ server });
|
|
64
|
+
|
|
65
|
+
wss.on('connection', (ws, req) => {
|
|
66
|
+
const url = new URL(req.url, 'http://localhost');
|
|
67
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
68
|
+
if (!sessionId) {
|
|
69
|
+
ws.close(4001, 'Missing sessionId');
|
|
109
70
|
return;
|
|
110
71
|
}
|
|
111
72
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
ptyProcess = pty.spawn(shell, [], {
|
|
118
|
-
name: 'xterm-256color',
|
|
119
|
-
cols: msg.cols || 80,
|
|
120
|
-
rows: msg.rows || 24,
|
|
121
|
-
cwd: startCwd,
|
|
122
|
-
env: process.env,
|
|
123
|
-
});
|
|
124
|
-
} catch (err) {
|
|
125
|
-
console.error(`Failed to spawn shell "${shell}":`, err.message);
|
|
126
|
-
if (ws.readyState === 1) {
|
|
127
|
-
ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode: 1 }));
|
|
128
|
-
}
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
73
|
+
let session = sessions.get(sessionId);
|
|
74
|
+
if (!session) {
|
|
75
|
+
session = { terminals: new Map(), ws: null, disconnectedAt: null };
|
|
76
|
+
sessions.set(sessionId, session);
|
|
77
|
+
}
|
|
131
78
|
|
|
132
|
-
|
|
133
|
-
|
|
79
|
+
// Detach old WS if still open
|
|
80
|
+
if (session.ws && session.ws !== ws && session.ws.readyState === 1) {
|
|
81
|
+
session.ws.close();
|
|
82
|
+
}
|
|
134
83
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (session.ws && session.ws.readyState === 1) {
|
|
138
|
-
session.ws.send(JSON.stringify({ type: 'data', id: msg.id, data }));
|
|
139
|
-
}
|
|
140
|
-
});
|
|
84
|
+
session.ws = ws;
|
|
85
|
+
session.disconnectedAt = null;
|
|
141
86
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
87
|
+
// Send session-restore with existing terminal IDs
|
|
88
|
+
const terminalIds = [...session.terminals.keys()];
|
|
89
|
+
ws.send(JSON.stringify({ type: 'session-restore', terminals: terminalIds }));
|
|
90
|
+
|
|
91
|
+
// Send buffered output for each existing terminal
|
|
92
|
+
for (const [id, entry] of session.terminals) {
|
|
93
|
+
const buffered = entry.outputBuffer.read();
|
|
94
|
+
if (buffered) {
|
|
95
|
+
ws.send(JSON.stringify({ type: 'buffer', id, data: buffered }));
|
|
149
96
|
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Signal that all buffer messages have been sent
|
|
100
|
+
if (terminalIds.length > 0) {
|
|
101
|
+
ws.send(JSON.stringify({ type: 'restore-complete' }));
|
|
102
|
+
}
|
|
150
103
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
104
|
+
ws.on('message', async (raw) => {
|
|
105
|
+
let msg;
|
|
106
|
+
try {
|
|
107
|
+
msg = JSON.parse(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
155
110
|
}
|
|
156
111
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
112
|
+
switch (msg.type) {
|
|
113
|
+
case 'create': {
|
|
114
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
115
|
+
let ptyProcess;
|
|
160
116
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
117
|
+
ptyProcess = pty.spawn(shell, [], {
|
|
118
|
+
name: 'xterm-256color',
|
|
119
|
+
cols: msg.cols || 80,
|
|
120
|
+
rows: msg.rows || 24,
|
|
121
|
+
cwd: startCwd,
|
|
122
|
+
env: process.env,
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`Failed to spawn shell "${shell}":`, err.message);
|
|
126
|
+
if (ws.readyState === 1) {
|
|
127
|
+
ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode: 1 }));
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const outputBuffer = new RingBuffer(OUTPUT_BUFFER_SIZE);
|
|
133
|
+
session.terminals.set(msg.id, { pty: ptyProcess, outputBuffer });
|
|
134
|
+
|
|
135
|
+
ptyProcess.onData((data) => {
|
|
136
|
+
outputBuffer.write(data);
|
|
137
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
138
|
+
session.ws.send(JSON.stringify({ type: 'data', id: msg.id, data }));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
143
|
+
session.terminals.delete(msg.id);
|
|
144
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
145
|
+
session.ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode }));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case 'data': {
|
|
152
|
+
const entry = session.terminals.get(msg.id);
|
|
153
|
+
if (entry) { entry.pty.write(msg.data); }
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case 'resize': {
|
|
158
|
+
const entry = session.terminals.get(msg.id);
|
|
159
|
+
if (entry) {
|
|
160
|
+
try {
|
|
161
|
+
entry.pty.resize(msg.cols, msg.rows);
|
|
162
|
+
} catch {
|
|
163
|
+
// ignore invalid resize
|
|
164
|
+
}
|
|
164
165
|
}
|
|
166
|
+
break;
|
|
165
167
|
}
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
case 'close': {
|
|
170
|
+
const entry = session.terminals.get(msg.id);
|
|
171
|
+
if (entry) {
|
|
172
|
+
entry.pty.kill();
|
|
173
|
+
session.terminals.delete(msg.id);
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
174
176
|
}
|
|
175
|
-
|
|
177
|
+
|
|
176
178
|
}
|
|
179
|
+
});
|
|
177
180
|
|
|
178
|
-
|
|
181
|
+
ws.on('close', () => {
|
|
182
|
+
if (session.ws === ws) {
|
|
183
|
+
session.ws = null;
|
|
184
|
+
session.disconnectedAt = Date.now();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
179
187
|
});
|
|
180
188
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
189
|
+
server.listen(port, host, () => {
|
|
190
|
+
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
|
|
191
|
+
console.log(`mwt running at ${url}`);
|
|
192
|
+
if (options.open !== false) {
|
|
193
|
+
openBrowser(url);
|
|
185
194
|
}
|
|
186
195
|
});
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const cleanupTimer = setInterval(() => {
|
|
199
|
-
const now = Date.now();
|
|
200
|
-
for (const [sessionId, session] of sessions) {
|
|
201
|
-
if (session.disconnectedAt && (now - session.disconnectedAt) > SESSION_TIMEOUT_MS) {
|
|
202
|
-
for (const [, entry] of session.terminals) {
|
|
203
|
-
entry.pty.kill();
|
|
196
|
+
|
|
197
|
+
// Clean up sessions that have been disconnected too long
|
|
198
|
+
const cleanupTimer = setInterval(() => {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
for (const [sessionId, session] of sessions) {
|
|
201
|
+
if (session.disconnectedAt && (now - session.disconnectedAt) > SESSION_TIMEOUT_MS) {
|
|
202
|
+
for (const [, entry] of session.terminals) {
|
|
203
|
+
entry.pty.kill();
|
|
204
|
+
}
|
|
205
|
+
session.terminals.clear();
|
|
206
|
+
sessions.delete(sessionId);
|
|
204
207
|
}
|
|
205
|
-
session.terminals.clear();
|
|
206
|
-
sessions.delete(sessionId);
|
|
207
208
|
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
cleanupTimer.unref();
|
|
209
|
+
}, CLEANUP_INTERVAL_MS);
|
|
210
|
+
cleanupTimer.unref();
|
|
211
211
|
|
|
212
212
|
} // end start
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacksontian/mwt",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Multi-window terminal with side-by-side, grid, and tab layouts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "mocha",
|
|
23
23
|
"cov": "c8 mocha",
|
|
24
|
-
"cov:report": "c8 report --reporter=text --reporter=html"
|
|
24
|
+
"cov:report": "c8 report --reporter=text --reporter=html",
|
|
25
|
+
"lint": "eslint .",
|
|
26
|
+
"lint:fix": "eslint . --fix"
|
|
25
27
|
},
|
|
26
28
|
"engines": {
|
|
27
29
|
"node": ">=18"
|
|
@@ -31,7 +33,10 @@
|
|
|
31
33
|
"ws": "^8.18.0"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
36
|
+
"@eslint/js": "^10.0.1",
|
|
34
37
|
"c8": "^11.0.0",
|
|
38
|
+
"eslint": "^10.1.0",
|
|
39
|
+
"globals": "^17.4.0",
|
|
35
40
|
"mocha": "^11.7.5"
|
|
36
41
|
},
|
|
37
42
|
"publishConfig": {
|
package/public/css/style.css
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
--accent: #6c8cff;
|
|
22
22
|
--accent-hover: #8aa4ff;
|
|
23
23
|
--text-primary: #d4d8e8;
|
|
24
|
-
--text-secondary: #
|
|
25
|
-
--text-dim: #
|
|
24
|
+
--text-secondary: #9aa3bc;
|
|
25
|
+
--text-dim: #6f788f;
|
|
26
26
|
--border: #252a3d;
|
|
27
|
+
--pane-border-idle: #1e2333;
|
|
27
28
|
--danger: #e05560;
|
|
28
29
|
--danger-hover: #ff6b76;
|
|
29
30
|
--terminal-bg: #0a0c12;
|
|
@@ -43,6 +44,7 @@
|
|
|
43
44
|
--text-secondary: #5a6178;
|
|
44
45
|
--text-dim: #9098b0;
|
|
45
46
|
--border: #d0d5e0;
|
|
47
|
+
--pane-border-idle: #c5cad6;
|
|
46
48
|
--danger: #d43d4e;
|
|
47
49
|
--danger-hover: #c02838;
|
|
48
50
|
--terminal-bg: #ffffff;
|
|
@@ -291,12 +293,30 @@ body {
|
|
|
291
293
|
.terminal-pane {
|
|
292
294
|
display: flex;
|
|
293
295
|
flex-direction: column;
|
|
296
|
+
position: relative;
|
|
294
297
|
background: var(--terminal-bg);
|
|
295
|
-
border: 1px solid var(--border);
|
|
298
|
+
border: 1px solid var(--pane-border-idle);
|
|
296
299
|
border-radius: var(--radius-sm);
|
|
297
300
|
overflow: hidden;
|
|
298
301
|
min-width: 0;
|
|
299
302
|
min-height: 0;
|
|
303
|
+
transition: border-color var(--transition), box-shadow var(--transition);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.terminal-pane.pane-focused {
|
|
307
|
+
border-color: var(--accent);
|
|
308
|
+
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
|
|
309
|
+
z-index: 1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.terminal-pane.pane-focused .pane-header {
|
|
313
|
+
background: color-mix(in srgb, var(--accent) 12%, var(--pane-header));
|
|
314
|
+
border-bottom-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.terminal-pane.pane-focused .pane-title {
|
|
318
|
+
color: var(--text-primary);
|
|
319
|
+
font-weight: 500;
|
|
300
320
|
}
|
|
301
321
|
|
|
302
322
|
.pane-header {
|
|
@@ -309,11 +329,13 @@ body {
|
|
|
309
329
|
border-bottom: 1px solid var(--border);
|
|
310
330
|
flex-shrink: 0;
|
|
311
331
|
user-select: none;
|
|
332
|
+
transition: background var(--transition), border-color var(--transition);
|
|
312
333
|
}
|
|
313
334
|
|
|
314
335
|
.pane-title {
|
|
315
336
|
font-size: 11px;
|
|
316
337
|
color: var(--text-secondary);
|
|
338
|
+
transition: color var(--transition), font-weight var(--transition);
|
|
317
339
|
overflow: hidden;
|
|
318
340
|
text-overflow: ellipsis;
|
|
319
341
|
white-space: nowrap;
|
|
@@ -387,6 +409,8 @@ body {
|
|
|
387
409
|
flex: 1 !important;
|
|
388
410
|
min-width: 0 !important;
|
|
389
411
|
min-height: 0 !important;
|
|
412
|
+
grid-column: 1 / -1;
|
|
413
|
+
grid-row: 1 / -1;
|
|
390
414
|
}
|
|
391
415
|
|
|
392
416
|
.terminal-body {
|
|
@@ -402,6 +426,8 @@ body {
|
|
|
402
426
|
|
|
403
427
|
.terminal-body .xterm-viewport {
|
|
404
428
|
overflow-y: auto !important;
|
|
429
|
+
/* 与 theme 背景一致,避免 vendor 默认 #000 与画布色差导致边缘发闷 */
|
|
430
|
+
background-color: var(--terminal-bg) !important;
|
|
405
431
|
}
|
|
406
432
|
|
|
407
433
|
/* ========== Layout: Side by Side ========== */
|
|
@@ -441,6 +467,11 @@ body {
|
|
|
441
467
|
inset: 0;
|
|
442
468
|
border-radius: 0;
|
|
443
469
|
border: none;
|
|
470
|
+
box-shadow: none;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.layout-tabs .terminal-pane.pane-focused.active {
|
|
474
|
+
box-shadow: inset 0 0 0 1px var(--accent);
|
|
444
475
|
}
|
|
445
476
|
|
|
446
477
|
.layout-tabs .terminal-pane.active {
|
|
@@ -568,6 +599,235 @@ body {
|
|
|
568
599
|
display: block;
|
|
569
600
|
}
|
|
570
601
|
|
|
602
|
+
/* ========== Drag to swap ========== */
|
|
603
|
+
.pane-header.dragging-source {
|
|
604
|
+
opacity: 0.5;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
.terminal-pane.drag-over {
|
|
608
|
+
outline: 2px solid var(--accent);
|
|
609
|
+
outline-offset: -2px;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.pane-header[draggable="true"] {
|
|
613
|
+
cursor: grab;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.pane-header[draggable="true"]:active {
|
|
617
|
+
cursor: grabbing;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.tab[draggable="true"] {
|
|
621
|
+
cursor: grab;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.tab[draggable="true"]:active {
|
|
625
|
+
cursor: grabbing;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.tab.drag-over {
|
|
629
|
+
outline: 2px solid var(--accent);
|
|
630
|
+
outline-offset: -2px;
|
|
631
|
+
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/* ========== Shortcuts Button ========== */
|
|
635
|
+
#btn-shortcuts {
|
|
636
|
+
display: flex;
|
|
637
|
+
align-items: center;
|
|
638
|
+
justify-content: center;
|
|
639
|
+
width: 30px;
|
|
640
|
+
height: 30px;
|
|
641
|
+
background: var(--bg-secondary);
|
|
642
|
+
border: 1px solid var(--border);
|
|
643
|
+
border-radius: var(--radius);
|
|
644
|
+
color: var(--text-secondary);
|
|
645
|
+
cursor: pointer;
|
|
646
|
+
transition: all var(--transition);
|
|
647
|
+
margin-right: 4px;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
#btn-shortcuts:hover {
|
|
651
|
+
background: var(--bg-hover);
|
|
652
|
+
color: var(--text-primary);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/* ========== Shortcuts Modal ========== */
|
|
656
|
+
.modal-overlay {
|
|
657
|
+
position: fixed;
|
|
658
|
+
inset: 0;
|
|
659
|
+
background: rgba(0, 0, 0, 0.5);
|
|
660
|
+
display: flex;
|
|
661
|
+
align-items: center;
|
|
662
|
+
justify-content: center;
|
|
663
|
+
z-index: 1000;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.modal-overlay.hidden {
|
|
667
|
+
display: none;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.modal {
|
|
671
|
+
background: var(--bg-secondary);
|
|
672
|
+
border: 1px solid var(--border);
|
|
673
|
+
border-radius: var(--radius);
|
|
674
|
+
width: 400px;
|
|
675
|
+
max-width: calc(100vw - 32px);
|
|
676
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.modal-header {
|
|
680
|
+
display: flex;
|
|
681
|
+
align-items: center;
|
|
682
|
+
justify-content: space-between;
|
|
683
|
+
padding: 14px 16px 12px;
|
|
684
|
+
border-bottom: 1px solid var(--border);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.modal-title {
|
|
688
|
+
font-size: 13px;
|
|
689
|
+
font-weight: 600;
|
|
690
|
+
color: var(--text-primary);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.modal-close {
|
|
694
|
+
display: flex;
|
|
695
|
+
align-items: center;
|
|
696
|
+
justify-content: center;
|
|
697
|
+
width: 22px;
|
|
698
|
+
height: 22px;
|
|
699
|
+
background: transparent;
|
|
700
|
+
border: none;
|
|
701
|
+
border-radius: var(--radius-sm);
|
|
702
|
+
color: var(--text-dim);
|
|
703
|
+
font-size: 18px;
|
|
704
|
+
cursor: pointer;
|
|
705
|
+
line-height: 1;
|
|
706
|
+
transition: all var(--transition);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
.modal-close:hover {
|
|
710
|
+
background: var(--bg-hover);
|
|
711
|
+
color: var(--text-primary);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.modal-body {
|
|
715
|
+
padding: 12px 16px 16px;
|
|
716
|
+
display: flex;
|
|
717
|
+
flex-direction: column;
|
|
718
|
+
gap: 16px;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.shortcut-group {
|
|
722
|
+
display: flex;
|
|
723
|
+
flex-direction: column;
|
|
724
|
+
gap: 6px;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.shortcut-group-title {
|
|
728
|
+
font-size: 11px;
|
|
729
|
+
font-weight: 600;
|
|
730
|
+
color: var(--text-dim);
|
|
731
|
+
text-transform: uppercase;
|
|
732
|
+
letter-spacing: 0.06em;
|
|
733
|
+
margin-bottom: 2px;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
.shortcut-row {
|
|
737
|
+
display: flex;
|
|
738
|
+
align-items: center;
|
|
739
|
+
justify-content: space-between;
|
|
740
|
+
gap: 12px;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.shortcut-desc {
|
|
744
|
+
font-size: 12px;
|
|
745
|
+
color: var(--text-secondary);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.shortcut-key {
|
|
749
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
750
|
+
font-size: 11px;
|
|
751
|
+
color: var(--text-secondary);
|
|
752
|
+
background: var(--bg-tertiary);
|
|
753
|
+
border: 1px solid var(--border);
|
|
754
|
+
border-radius: var(--radius-sm);
|
|
755
|
+
padding: 2px 7px;
|
|
756
|
+
white-space: nowrap;
|
|
757
|
+
flex-shrink: 0;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/* ========== Settings Modal ========== */
|
|
761
|
+
#btn-settings {
|
|
762
|
+
display: flex;
|
|
763
|
+
align-items: center;
|
|
764
|
+
justify-content: center;
|
|
765
|
+
width: 28px;
|
|
766
|
+
height: 28px;
|
|
767
|
+
border: none;
|
|
768
|
+
border-radius: var(--radius-sm);
|
|
769
|
+
background: transparent;
|
|
770
|
+
color: var(--text-secondary);
|
|
771
|
+
cursor: pointer;
|
|
772
|
+
transition: background var(--transition), color var(--transition);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
#btn-settings:hover {
|
|
776
|
+
background: var(--bg-hover);
|
|
777
|
+
color: var(--text-primary);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.settings-group {
|
|
781
|
+
display: flex;
|
|
782
|
+
flex-direction: column;
|
|
783
|
+
gap: 12px;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
.settings-group-title {
|
|
787
|
+
font-size: 11px;
|
|
788
|
+
font-weight: 600;
|
|
789
|
+
text-transform: uppercase;
|
|
790
|
+
letter-spacing: 0.06em;
|
|
791
|
+
color: var(--text-dim);
|
|
792
|
+
margin-bottom: 2px;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
.settings-row {
|
|
796
|
+
display: flex;
|
|
797
|
+
align-items: center;
|
|
798
|
+
justify-content: space-between;
|
|
799
|
+
gap: 12px;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.settings-label {
|
|
803
|
+
font-size: 12px;
|
|
804
|
+
color: var(--text-secondary);
|
|
805
|
+
white-space: nowrap;
|
|
806
|
+
min-width: 70px;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
input[type="range"]#input-font-size {
|
|
810
|
+
flex: 1;
|
|
811
|
+
accent-color: var(--accent);
|
|
812
|
+
cursor: pointer;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
.settings-text-input {
|
|
816
|
+
flex: 1;
|
|
817
|
+
background: var(--bg-tertiary);
|
|
818
|
+
border: 1px solid var(--border);
|
|
819
|
+
border-radius: var(--radius-sm);
|
|
820
|
+
color: var(--text-primary);
|
|
821
|
+
font-size: 12px;
|
|
822
|
+
padding: 4px 8px;
|
|
823
|
+
outline: none;
|
|
824
|
+
transition: border-color var(--transition);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
.settings-text-input:focus {
|
|
828
|
+
border-color: var(--accent);
|
|
829
|
+
}
|
|
830
|
+
|
|
571
831
|
/* ========== Keyboard shortcut hint ========== */
|
|
572
832
|
@media (max-width: 600px) {
|
|
573
833
|
.layout-btn span {
|