@jacksontian/mwt 1.0.0 → 1.2.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/README.md +24 -6
- package/lib/pty-manager.js +40 -0
- package/lib/ring-buffer.js +1 -1
- package/lib/server.js +145 -159
- package/lib/session-manager.js +66 -0
- package/package.json +8 -3
- package/public/css/style.css +378 -13
- package/public/index.html +77 -12
- package/public/js/app.js +203 -95
- package/public/js/drag-manager.js +42 -0
- package/public/js/font-manager.js +43 -0
- package/public/js/i18n.js +176 -0
- package/public/js/layout-manager.js +99 -15
- package/public/js/shortcut-manager.js +102 -0
- package/public/js/terminal-manager.js +149 -26
- package/public/js/theme-manager.js +6 -6
- package/public/js/ws-client.js +27 -6
package/public/js/app.js
CHANGED
|
@@ -2,14 +2,95 @@ import { WSClient } from './ws-client.js';
|
|
|
2
2
|
import { TerminalManager } from './terminal-manager.js';
|
|
3
3
|
import { LayoutManager } from './layout-manager.js';
|
|
4
4
|
import { ThemeManager } from './theme-manager.js';
|
|
5
|
+
import { FontManager } from './font-manager.js';
|
|
6
|
+
import { ShortcutManager } from './shortcut-manager.js';
|
|
7
|
+
import { i18n, langLabels } from './i18n.js';
|
|
8
|
+
|
|
9
|
+
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
|
10
|
+
const cmdKey = isMac ? 'Cmd' : 'Ctrl';
|
|
11
|
+
|
|
12
|
+
// Initialize i18n
|
|
13
|
+
i18n.applyToDOM();
|
|
14
|
+
|
|
15
|
+
function _applyDynamicTitles() {
|
|
16
|
+
document.getElementById('btn-new-terminal').title = i18n.t('newTerminalTooltip', cmdKey);
|
|
17
|
+
document.getElementById('btn-fullscreen').title = i18n.t('fullscreenTooltip');
|
|
18
|
+
document.getElementById('btn-shortcuts').title = i18n.t('shortcutsTooltip');
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
const wsClient = new WSClient();
|
|
6
22
|
const container = document.getElementById('terminal-container');
|
|
7
23
|
const tabBar = document.getElementById('tab-bar');
|
|
8
|
-
const terminalCount = document.getElementById('terminal-count');
|
|
9
24
|
|
|
10
25
|
const themeManager = new ThemeManager();
|
|
26
|
+
const fontManager = new FontManager();
|
|
11
27
|
const layoutManager = new LayoutManager(container, tabBar);
|
|
12
|
-
const terminalManager = new TerminalManager(wsClient, container, themeManager);
|
|
28
|
+
const terminalManager = new TerminalManager(wsClient, container, themeManager, cmdKey, fontManager, i18n);
|
|
29
|
+
|
|
30
|
+
// Restore layout immediately (like theme/font) so it's consistent on page load
|
|
31
|
+
const LAYOUT_KEY = 'mwt-layout';
|
|
32
|
+
const ACTIVE_TAB_KEY = 'mwt-active-tab';
|
|
33
|
+
{
|
|
34
|
+
const savedLayout = localStorage.getItem(LAYOUT_KEY);
|
|
35
|
+
if (savedLayout) {
|
|
36
|
+
document.querySelectorAll('.layout-btn').forEach(b => {
|
|
37
|
+
b.classList.toggle('active', b.dataset.layout === savedLayout);
|
|
38
|
+
});
|
|
39
|
+
layoutManager.currentLayout = savedLayout;
|
|
40
|
+
container.classList.remove('layout-columns', 'layout-rows', 'layout-grid', 'layout-tabs');
|
|
41
|
+
container.classList.add(`layout-${savedLayout}`);
|
|
42
|
+
if (savedLayout === 'tabs') {
|
|
43
|
+
tabBar.classList.remove('hidden');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
layoutManager.onTabActivate = (id) => {
|
|
49
|
+
terminalManager.focusTerminal(id);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
layoutManager.onLayoutApplied = () => {
|
|
53
|
+
const id = terminalManager.activeId || terminalManager.getIds()[0];
|
|
54
|
+
if (id) {
|
|
55
|
+
terminalManager.focusTerminal(id);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Language dropdown
|
|
60
|
+
const langDropdown = document.getElementById('lang-dropdown');
|
|
61
|
+
const btnLang = document.getElementById('btn-lang');
|
|
62
|
+
const btnLangLabel = document.getElementById('btn-lang-label');
|
|
63
|
+
const langMenu = document.getElementById('lang-menu');
|
|
64
|
+
|
|
65
|
+
function buildLangMenu() {
|
|
66
|
+
langMenu.innerHTML = '';
|
|
67
|
+
for (const [code, label] of Object.entries(langLabels)) {
|
|
68
|
+
const btn = document.createElement('button');
|
|
69
|
+
btn.className = 'lang-option' + (code === i18n.lang ? ' active' : '');
|
|
70
|
+
btn.innerHTML = `<span>${label}</span><svg class="lang-check" width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M1.5 5l2.5 2.5 4.5-4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
71
|
+
btn.addEventListener('click', () => {
|
|
72
|
+
i18n.setLang(code);
|
|
73
|
+
btnLangLabel.textContent = label;
|
|
74
|
+
buildLangMenu();
|
|
75
|
+
langDropdown.classList.remove('open');
|
|
76
|
+
_applyDynamicTitles();
|
|
77
|
+
terminalManager.updateI18n(i18n, cmdKey);
|
|
78
|
+
});
|
|
79
|
+
langMenu.appendChild(btn);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
btnLangLabel.textContent = langLabels[i18n.lang];
|
|
84
|
+
buildLangMenu();
|
|
85
|
+
|
|
86
|
+
btnLang.addEventListener('click', (e) => {
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
langDropdown.classList.toggle('open');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
document.addEventListener('click', () => {
|
|
92
|
+
langDropdown.classList.remove('open');
|
|
93
|
+
});
|
|
13
94
|
|
|
14
95
|
// Theme toggle
|
|
15
96
|
document.getElementById('btn-theme-toggle').addEventListener('click', () => {
|
|
@@ -19,6 +100,7 @@ document.getElementById('btn-theme-toggle').addEventListener('click', () => {
|
|
|
19
100
|
// Cross-wire layout manager and terminal manager
|
|
20
101
|
layoutManager.fitAll = () => terminalManager.fitAll();
|
|
21
102
|
layoutManager.closeTerminal = (id) => terminalManager.closeTerminal(id);
|
|
103
|
+
layoutManager.swapTerminals = (id1, id2) => terminalManager.swapTerminals(id1, id2);
|
|
22
104
|
|
|
23
105
|
// Activity notification: visual indicators + browser notifications + title flash
|
|
24
106
|
const originalTitle = document.title;
|
|
@@ -54,13 +136,12 @@ terminalManager.onActivity((id, cleared) => {
|
|
|
54
136
|
if (now - lastNotify > NOTIFICATION_DEBOUNCE_MS) {
|
|
55
137
|
notificationCooldown.set(id, now);
|
|
56
138
|
const title = terminalManager.getTitle(id);
|
|
57
|
-
const n = new Notification(
|
|
58
|
-
body: '
|
|
139
|
+
const n = new Notification(i18n.t('terminalHasNewOutput', title), {
|
|
140
|
+
body: i18n.t('notificationBody'),
|
|
59
141
|
tag: `mwt-${id}`,
|
|
60
142
|
});
|
|
61
143
|
n.onclick = () => {
|
|
62
144
|
window.focus();
|
|
63
|
-
terminalManager.activeId = id;
|
|
64
145
|
terminalManager.focusTerminal(id);
|
|
65
146
|
if (layoutManager.currentLayout === 'tabs') {
|
|
66
147
|
layoutManager.activateTab(id);
|
|
@@ -92,11 +173,6 @@ document.addEventListener('visibilitychange', () => {
|
|
|
92
173
|
// Request notification permission on first user interaction
|
|
93
174
|
document.addEventListener('click', requestNotificationPermission, { once: true });
|
|
94
175
|
|
|
95
|
-
function updateCount() {
|
|
96
|
-
const count = terminalManager.getCount();
|
|
97
|
-
terminalCount.textContent = `${count} terminal${count !== 1 ? 's' : ''}`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
176
|
terminalManager.onChange((event) => {
|
|
101
177
|
if (event.type === 'add') {
|
|
102
178
|
layoutManager.onTerminalAdded(event.id);
|
|
@@ -104,8 +180,11 @@ terminalManager.onChange((event) => {
|
|
|
104
180
|
layoutManager.onTerminalRemoved(event.id);
|
|
105
181
|
} else if (event.type === 'title') {
|
|
106
182
|
layoutManager.updateTabTitle(event.id, event.title);
|
|
183
|
+
} else if (event.type === 'maximize') {
|
|
184
|
+
layoutManager.onTerminalMaximized();
|
|
185
|
+
} else if (event.type === 'restore') {
|
|
186
|
+
layoutManager.onTerminalRestored();
|
|
107
187
|
}
|
|
108
|
-
updateCount();
|
|
109
188
|
});
|
|
110
189
|
|
|
111
190
|
// New Terminal button
|
|
@@ -113,10 +192,92 @@ document.getElementById('btn-new-terminal').addEventListener('click', () => {
|
|
|
113
192
|
terminalManager.createTerminal();
|
|
114
193
|
});
|
|
115
194
|
|
|
116
|
-
//
|
|
117
|
-
const
|
|
118
|
-
const
|
|
195
|
+
// Settings modal
|
|
196
|
+
const settingsModal = document.getElementById('settings-modal');
|
|
197
|
+
const inputFontSize = document.getElementById('input-font-size');
|
|
198
|
+
const inputFontFamily = document.getElementById('input-font-family');
|
|
199
|
+
const fontSizeValue = document.getElementById('font-size-value');
|
|
200
|
+
|
|
201
|
+
function syncSettingsUI() {
|
|
202
|
+
inputFontSize.value = fontManager.fontSize;
|
|
203
|
+
fontSizeValue.textContent = `${fontManager.fontSize}px`;
|
|
204
|
+
inputFontFamily.value = fontManager.fontFamily;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
document.getElementById('btn-settings').addEventListener('click', () => {
|
|
208
|
+
syncSettingsUI();
|
|
209
|
+
settingsModal.classList.remove('hidden');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
document.getElementById('btn-settings-close').addEventListener('click', () => {
|
|
213
|
+
settingsModal.classList.add('hidden');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
settingsModal.addEventListener('click', (e) => {
|
|
217
|
+
if (e.target === settingsModal) {
|
|
218
|
+
settingsModal.classList.add('hidden');
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
inputFontSize.addEventListener('input', () => {
|
|
223
|
+
fontSizeValue.textContent = `${inputFontSize.value}px`;
|
|
224
|
+
fontManager.setFontSize(parseInt(inputFontSize.value, 10));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
inputFontFamily.addEventListener('change', () => {
|
|
228
|
+
const val = inputFontFamily.value.trim();
|
|
229
|
+
if (val) {fontManager.setFontFamily(val);}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Update button titles with platform-appropriate shortcut hints
|
|
233
|
+
_applyDynamicTitles();
|
|
234
|
+
|
|
235
|
+
// Shortcuts modal
|
|
236
|
+
const shortcutsModal = document.getElementById('shortcuts-modal');
|
|
237
|
+
|
|
238
|
+
document.getElementById('btn-shortcuts').addEventListener('click', () => {
|
|
239
|
+
shortcutsModal.classList.remove('hidden');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
document.getElementById('btn-shortcuts-close').addEventListener('click', () => {
|
|
243
|
+
shortcutsModal.classList.add('hidden');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
shortcutsModal.addEventListener('click', (e) => {
|
|
247
|
+
if (e.target === shortcutsModal) {
|
|
248
|
+
shortcutsModal.classList.add('hidden');
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
document.addEventListener('keydown', (e) => {
|
|
253
|
+
if (e.key === 'Escape') {
|
|
254
|
+
if (!shortcutsModal.classList.contains('hidden')) {shortcutsModal.classList.add('hidden');}
|
|
255
|
+
if (!settingsModal.classList.contains('hidden')) {settingsModal.classList.add('hidden');}
|
|
256
|
+
}
|
|
257
|
+
}, true);
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
// Fill platform-appropriate shortcut keys in modal
|
|
261
|
+
function renderShortcutKeys() {
|
|
262
|
+
document.querySelectorAll('.shortcut-key[data-shortcut]').forEach(el => {
|
|
263
|
+
const raw = el.dataset.shortcut;
|
|
264
|
+
if (isMac) {
|
|
265
|
+
el.textContent = raw
|
|
266
|
+
.replace(/ctrl/gi, '⌃')
|
|
267
|
+
.replace(/alt/gi, '⌥')
|
|
268
|
+
.replace(/shift/gi, '⇧')
|
|
269
|
+
.replace(/\+/g, '');
|
|
270
|
+
} else {
|
|
271
|
+
el.textContent = raw
|
|
272
|
+
.replace(/ctrl/gi, 'Ctrl')
|
|
273
|
+
.replace(/alt/gi, 'Alt')
|
|
274
|
+
.replace(/shift/gi, 'Shift');
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
renderShortcutKeys();
|
|
119
279
|
|
|
280
|
+
// Layout persistence
|
|
120
281
|
function saveLayoutState() {
|
|
121
282
|
localStorage.setItem(LAYOUT_KEY, layoutManager.currentLayout);
|
|
122
283
|
if (layoutManager.activeTabId) {
|
|
@@ -129,6 +290,11 @@ document.querySelectorAll('.layout-btn').forEach(btn => {
|
|
|
129
290
|
btn.addEventListener('click', () => {
|
|
130
291
|
document.querySelectorAll('.layout-btn').forEach(b => b.classList.remove('active'));
|
|
131
292
|
btn.classList.add('active');
|
|
293
|
+
// Sync active terminal to layout manager before switching, so tabs
|
|
294
|
+
// activates the same terminal the user is currently focused on.
|
|
295
|
+
if (terminalManager.activeId) {
|
|
296
|
+
layoutManager.activeTabId = terminalManager.activeId;
|
|
297
|
+
}
|
|
132
298
|
layoutManager.setLayout(btn.dataset.layout);
|
|
133
299
|
saveLayoutState();
|
|
134
300
|
});
|
|
@@ -148,86 +314,7 @@ document.addEventListener('fullscreenchange', () => {
|
|
|
148
314
|
});
|
|
149
315
|
|
|
150
316
|
// Keyboard shortcuts
|
|
151
|
-
|
|
152
|
-
const ctrl = e.ctrlKey || e.metaKey;
|
|
153
|
-
const shift = e.shiftKey;
|
|
154
|
-
const alt = e.altKey;
|
|
155
|
-
|
|
156
|
-
// Ctrl+Shift+T: new terminal
|
|
157
|
-
if (ctrl && shift && e.key === 'T') {
|
|
158
|
-
e.preventDefault();
|
|
159
|
-
terminalManager.createTerminal();
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Ctrl+Shift+W: close active terminal
|
|
164
|
-
if (ctrl && shift && e.key === 'W') {
|
|
165
|
-
e.preventDefault();
|
|
166
|
-
terminalManager.closeActiveTerminal();
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Ctrl+Shift+]: next terminal
|
|
171
|
-
if (ctrl && shift && e.key === '}') {
|
|
172
|
-
e.preventDefault();
|
|
173
|
-
const nextId = terminalManager.focusNext();
|
|
174
|
-
if (nextId) {
|
|
175
|
-
terminalManager.clearActivity(nextId);
|
|
176
|
-
if (layoutManager.currentLayout === 'tabs') {
|
|
177
|
-
layoutManager.activateTab(nextId);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Ctrl+Shift+[: previous terminal
|
|
184
|
-
if (ctrl && shift && e.key === '{') {
|
|
185
|
-
e.preventDefault();
|
|
186
|
-
const prevId = terminalManager.focusPrev();
|
|
187
|
-
if (prevId) {
|
|
188
|
-
terminalManager.clearActivity(prevId);
|
|
189
|
-
if (layoutManager.currentLayout === 'tabs') {
|
|
190
|
-
layoutManager.activateTab(prevId);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Alt+1~9: switch to terminal N
|
|
197
|
-
if (alt && e.key >= '1' && e.key <= '9') {
|
|
198
|
-
e.preventDefault();
|
|
199
|
-
const ids = terminalManager.getIds();
|
|
200
|
-
const idx = parseInt(e.key, 10) - 1;
|
|
201
|
-
if (idx < ids.length) {
|
|
202
|
-
const targetId = ids[idx];
|
|
203
|
-
terminalManager.activeId = targetId;
|
|
204
|
-
terminalManager.focusTerminal(targetId);
|
|
205
|
-
terminalManager.clearActivity(targetId);
|
|
206
|
-
if (layoutManager.currentLayout === 'tabs') {
|
|
207
|
-
layoutManager.activateTab(targetId);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Ctrl+Shift+M: maximize/restore active terminal
|
|
214
|
-
if (ctrl && shift && e.key === 'M') {
|
|
215
|
-
e.preventDefault();
|
|
216
|
-
terminalManager.toggleMaximizeActive();
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// F11: toggle fullscreen
|
|
221
|
-
if (e.key === 'F11') {
|
|
222
|
-
e.preventDefault();
|
|
223
|
-
if (!document.fullscreenElement) {
|
|
224
|
-
document.documentElement.requestFullscreen();
|
|
225
|
-
} else {
|
|
226
|
-
document.exitFullscreen();
|
|
227
|
-
}
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
});
|
|
317
|
+
new ShortcutManager({ terminalManager, layoutManager, cmdKey });
|
|
231
318
|
|
|
232
319
|
// Prevent browser back navigation to avoid losing terminal sessions
|
|
233
320
|
history.pushState(null, '', location.href);
|
|
@@ -269,10 +356,31 @@ wsClient.onSessionRestore((terminalIds) => {
|
|
|
269
356
|
layoutManager.activateTab(savedActiveTab);
|
|
270
357
|
}
|
|
271
358
|
|
|
272
|
-
updateCount();
|
|
273
359
|
});
|
|
274
360
|
|
|
275
361
|
// Unmute terminals after buffer replay completes
|
|
276
362
|
wsClient.onRestoreComplete(() => {
|
|
277
363
|
terminalManager.unmuteAll();
|
|
278
364
|
});
|
|
365
|
+
|
|
366
|
+
// Show overlay when this tab is rejected (another tab already connected)
|
|
367
|
+
wsClient.onAlreadyConnected(() => {
|
|
368
|
+
const overlay = document.createElement('div');
|
|
369
|
+
overlay.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg-primary,#1e1e1e);z-index:9999;';
|
|
370
|
+
const p = document.createElement('p');
|
|
371
|
+
p.style.cssText = 'color:var(--text-primary,#ccc);font-family:monospace;font-size:14px;';
|
|
372
|
+
p.textContent = i18n.t('alreadyConnected');
|
|
373
|
+
overlay.appendChild(p);
|
|
374
|
+
document.body.appendChild(overlay);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Show overlay when reconnection gives up after too many failures
|
|
378
|
+
wsClient.onGiveUp(() => {
|
|
379
|
+
const overlay = document.createElement('div');
|
|
380
|
+
overlay.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg-primary,#1e1e1e);z-index:9999;';
|
|
381
|
+
const p = document.createElement('p');
|
|
382
|
+
p.style.cssText = 'color:var(--text-primary,#ccc);font-family:monospace;font-size:14px;';
|
|
383
|
+
p.textContent = i18n.t('serverDisconnected');
|
|
384
|
+
overlay.appendChild(p);
|
|
385
|
+
document.body.appendChild(overlay);
|
|
386
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class DragManager {
|
|
2
|
+
/**
|
|
3
|
+
* @param {(id1: string, id2: string) => void} swapTerminals
|
|
4
|
+
*/
|
|
5
|
+
constructor(swapTerminals) {
|
|
6
|
+
this._swap = swapTerminals;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Attach drag-to-swap event listeners to a terminal pane and its header. */
|
|
10
|
+
attach(pane, header, id) {
|
|
11
|
+
header.draggable = true;
|
|
12
|
+
header.addEventListener('dragstart', (e) => {
|
|
13
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
14
|
+
e.dataTransfer.setData('text/plain', id);
|
|
15
|
+
header.classList.add('dragging-source');
|
|
16
|
+
});
|
|
17
|
+
header.addEventListener('dragend', () => {
|
|
18
|
+
header.classList.remove('dragging-source');
|
|
19
|
+
pane.closest('#terminal-container')
|
|
20
|
+
?.querySelectorAll('.terminal-pane.drag-over')
|
|
21
|
+
.forEach(el => el.classList.remove('drag-over'));
|
|
22
|
+
});
|
|
23
|
+
pane.addEventListener('dragover', (e) => {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
e.dataTransfer.dropEffect = 'move';
|
|
26
|
+
pane.classList.add('drag-over');
|
|
27
|
+
});
|
|
28
|
+
pane.addEventListener('dragleave', (e) => {
|
|
29
|
+
if (!pane.contains(e.relatedTarget)) {
|
|
30
|
+
pane.classList.remove('drag-over');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
pane.addEventListener('drop', (e) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
pane.classList.remove('drag-over');
|
|
36
|
+
const sourceId = e.dataTransfer.getData('text/plain');
|
|
37
|
+
if (sourceId && sourceId !== id) {
|
|
38
|
+
this._swap(sourceId, id);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const STORAGE_KEY_SIZE = 'mwt-font-size';
|
|
2
|
+
const STORAGE_KEY_FAMILY = 'mwt-font-family';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_FONT_SIZE = 12;
|
|
5
|
+
const DEFAULT_FONT_FAMILY = "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace";
|
|
6
|
+
|
|
7
|
+
export class FontManager {
|
|
8
|
+
constructor() {
|
|
9
|
+
this._listeners = [];
|
|
10
|
+
this._fontSize = parseInt(localStorage.getItem(STORAGE_KEY_SIZE), 10) || DEFAULT_FONT_SIZE;
|
|
11
|
+
this._fontFamily = localStorage.getItem(STORAGE_KEY_FAMILY) || DEFAULT_FONT_FAMILY;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get fontSize() {
|
|
15
|
+
return this._fontSize;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get fontFamily() {
|
|
19
|
+
return this._fontFamily;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
setFontSize(size) {
|
|
23
|
+
this._fontSize = size;
|
|
24
|
+
localStorage.setItem(STORAGE_KEY_SIZE, size);
|
|
25
|
+
this._notify();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setFontFamily(family) {
|
|
29
|
+
this._fontFamily = family;
|
|
30
|
+
localStorage.setItem(STORAGE_KEY_FAMILY, family);
|
|
31
|
+
this._notify();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onChange(callback) {
|
|
35
|
+
this._listeners.push(callback);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_notify() {
|
|
39
|
+
for (const cb of this._listeners) {
|
|
40
|
+
cb({ fontSize: this._fontSize, fontFamily: this._fontFamily });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const LANG_KEY = 'mwt-lang';
|
|
2
|
+
|
|
3
|
+
// Display labels shown in the language menu
|
|
4
|
+
export const langLabels = {
|
|
5
|
+
en: 'English',
|
|
6
|
+
zh: '中文',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const messages = {
|
|
10
|
+
en: {
|
|
11
|
+
newTerminal: 'New Terminal',
|
|
12
|
+
columns: 'Columns',
|
|
13
|
+
rows: 'Rows',
|
|
14
|
+
grid: 'Grid',
|
|
15
|
+
tabs: 'Tabs',
|
|
16
|
+
keyboardShortcuts: 'Keyboard Shortcuts',
|
|
17
|
+
toggleFullscreen: 'Toggle fullscreen',
|
|
18
|
+
toggleTheme: 'Toggle theme',
|
|
19
|
+
settings: 'Settings',
|
|
20
|
+
|
|
21
|
+
// Settings modal
|
|
22
|
+
settingsTitle: 'Settings',
|
|
23
|
+
settingsFont: 'Font',
|
|
24
|
+
settingsFontSize: 'Size',
|
|
25
|
+
settingsFontFamily: 'Family',
|
|
26
|
+
settingsFontFamilyPlaceholder: 'e.g. Menlo, monospace',
|
|
27
|
+
|
|
28
|
+
// Shortcuts modal
|
|
29
|
+
shortcutsTitle: 'Keyboard Shortcuts',
|
|
30
|
+
shortcutsGroupTerminals: 'Terminals',
|
|
31
|
+
shortcutsNewTerminal: 'New Terminal',
|
|
32
|
+
shortcutsCloseTerminal: 'Close Terminal',
|
|
33
|
+
shortcutsMaximizeTerminal: 'Maximize / Restore Terminal',
|
|
34
|
+
shortcutsGroupNavigation: 'Navigation',
|
|
35
|
+
shortcutsNextTerminal: 'Next Terminal',
|
|
36
|
+
shortcutsPrevTerminal: 'Previous Terminal',
|
|
37
|
+
shortcutsSwitchTerminal: 'Switch to Terminal 1–9',
|
|
38
|
+
shortcutsGroupWindow: 'Window',
|
|
39
|
+
shortcutsToggleFullscreen: 'Toggle Fullscreen',
|
|
40
|
+
|
|
41
|
+
// Terminal pane
|
|
42
|
+
terminalTitle: (num) => `Terminal ${num}`,
|
|
43
|
+
maximizeTerminal: (cmdKey) => `Maximize terminal (${cmdKey}+Shift+M)`,
|
|
44
|
+
closeTerminalBtn: (cmdKey) => `Close terminal (${cmdKey}+Shift+\`)`,
|
|
45
|
+
|
|
46
|
+
// Button tooltips with shortcut
|
|
47
|
+
newTerminalTooltip: (cmdKey) => `New Terminal (${cmdKey}+\`)`,
|
|
48
|
+
fullscreenTooltip: 'Toggle fullscreen (F11)',
|
|
49
|
+
shortcutsTooltip: 'Keyboard Shortcuts',
|
|
50
|
+
|
|
51
|
+
// Notifications
|
|
52
|
+
terminalHasNewOutput: (title) => `${title} has new output`,
|
|
53
|
+
notificationBody: 'Click to switch to this terminal',
|
|
54
|
+
|
|
55
|
+
// Overlays
|
|
56
|
+
alreadyConnected: 'mwt is already open in another tab.',
|
|
57
|
+
serverDisconnected: 'Server disconnected. Please restart the server and refresh.',
|
|
58
|
+
},
|
|
59
|
+
zh: {
|
|
60
|
+
newTerminal: '新建终端',
|
|
61
|
+
columns: '并排',
|
|
62
|
+
rows: '横排',
|
|
63
|
+
grid: '网格',
|
|
64
|
+
tabs: '标签页',
|
|
65
|
+
keyboardShortcuts: '键盘快捷键',
|
|
66
|
+
toggleFullscreen: '切换全屏',
|
|
67
|
+
toggleTheme: '切换主题',
|
|
68
|
+
settings: '设置',
|
|
69
|
+
|
|
70
|
+
// Settings modal
|
|
71
|
+
settingsTitle: '设置',
|
|
72
|
+
settingsFont: '字体',
|
|
73
|
+
settingsFontSize: '大小',
|
|
74
|
+
settingsFontFamily: '字体族',
|
|
75
|
+
settingsFontFamilyPlaceholder: '例如 Menlo, monospace',
|
|
76
|
+
|
|
77
|
+
// Shortcuts modal
|
|
78
|
+
shortcutsTitle: '键盘快捷键',
|
|
79
|
+
shortcutsGroupTerminals: '终端',
|
|
80
|
+
shortcutsNewTerminal: '新建终端',
|
|
81
|
+
shortcutsCloseTerminal: '关闭终端',
|
|
82
|
+
shortcutsMaximizeTerminal: '最大化 / 还原终端',
|
|
83
|
+
shortcutsGroupNavigation: '导航',
|
|
84
|
+
shortcutsNextTerminal: '下一个终端',
|
|
85
|
+
shortcutsPrevTerminal: '上一个终端',
|
|
86
|
+
shortcutsSwitchTerminal: '切换到终端 1–9',
|
|
87
|
+
shortcutsGroupWindow: '窗口',
|
|
88
|
+
shortcutsToggleFullscreen: '切换全屏',
|
|
89
|
+
|
|
90
|
+
// Terminal pane
|
|
91
|
+
terminalTitle: (num) => `终端 ${num}`,
|
|
92
|
+
maximizeTerminal: (cmdKey) => `最大化终端 (${cmdKey}+Shift+M)`,
|
|
93
|
+
closeTerminalBtn: (cmdKey) => `关闭终端 (${cmdKey}+Shift+\`)`,
|
|
94
|
+
|
|
95
|
+
// Button tooltips with shortcut
|
|
96
|
+
newTerminalTooltip: (cmdKey) => `新建终端 (${cmdKey}+\`)`,
|
|
97
|
+
fullscreenTooltip: '切换全屏 (F11)',
|
|
98
|
+
shortcutsTooltip: '键盘快捷键',
|
|
99
|
+
|
|
100
|
+
// Notifications
|
|
101
|
+
terminalHasNewOutput: (title) => `${title} 有新输出`,
|
|
102
|
+
notificationBody: '点击切换到此终端',
|
|
103
|
+
|
|
104
|
+
// Overlays
|
|
105
|
+
alreadyConnected: 'mwt 已在另一个标签页中打开。',
|
|
106
|
+
serverDisconnected: '服务器已断开,请重启服务后刷新页面。',
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
class I18n {
|
|
111
|
+
constructor() {
|
|
112
|
+
const saved = localStorage.getItem(LANG_KEY);
|
|
113
|
+
if (saved && messages[saved]) {
|
|
114
|
+
this.lang = saved;
|
|
115
|
+
} else {
|
|
116
|
+
// Auto-detect from browser language
|
|
117
|
+
const browser = navigator.language || 'en';
|
|
118
|
+
this.lang = browser.startsWith('zh') ? 'zh' : 'en';
|
|
119
|
+
}
|
|
120
|
+
this._callbacks = [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
t(key, ...args) {
|
|
124
|
+
const val = messages[this.lang][key];
|
|
125
|
+
if (typeof val === 'function') return val(...args);
|
|
126
|
+
return val ?? key;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setLang(lang) {
|
|
130
|
+
if (!messages[lang] || lang === this.lang) return;
|
|
131
|
+
this.lang = lang;
|
|
132
|
+
localStorage.setItem(LANG_KEY, lang);
|
|
133
|
+
this._applyToDOM();
|
|
134
|
+
for (const cb of this._callbacks) cb(lang);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getLangs() {
|
|
138
|
+
return Object.keys(langLabels);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
onChange(cb) {
|
|
142
|
+
this._callbacks.push(cb);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Apply translations to elements with data-i18n attribute
|
|
146
|
+
_applyToDOM() {
|
|
147
|
+
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
148
|
+
const key = el.dataset.i18n;
|
|
149
|
+
const val = messages[this.lang][key];
|
|
150
|
+
if (typeof val === 'string') {
|
|
151
|
+
el.textContent = val;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
155
|
+
const key = el.dataset.i18nPlaceholder;
|
|
156
|
+
const val = messages[this.lang][key];
|
|
157
|
+
if (typeof val === 'string') {
|
|
158
|
+
el.placeholder = val;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
|
162
|
+
const key = el.dataset.i18nTitle;
|
|
163
|
+
const val = messages[this.lang][key];
|
|
164
|
+
if (typeof val === 'string') {
|
|
165
|
+
el.title = val;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Call once on page load to initialize DOM text
|
|
171
|
+
applyToDOM() {
|
|
172
|
+
this._applyToDOM();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const i18n = new I18n();
|