@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/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(`${title} has new output`, {
58
- body: 'Click to switch to this terminal',
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
- // Layout persistence
117
- const LAYOUT_KEY = 'myterminal-layout';
118
- const ACTIVE_TAB_KEY = 'myterminal-active-tab';
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
- document.addEventListener('keydown', (e) => {
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();