feat: implement multi-session support with sidebar management and dynamic terminal switching
This commit is contained in:
+395
-126
@@ -14,7 +14,7 @@ const paritySelect = document.getElementById('parity');
|
||||
// Controls & Console
|
||||
const scrollModeSelect = document.getElementById('scroll-mode');
|
||||
const displayFormatSelect = document.getElementById('display-format');
|
||||
const terminalOutput = document.getElementById('terminal-output');
|
||||
const terminalContainer = document.getElementById('terminal-container');
|
||||
|
||||
// Sending Inputs
|
||||
const sendInput = document.getElementById('send-input');
|
||||
@@ -22,19 +22,99 @@ const sendFormatSelect = document.getElementById('send-format');
|
||||
const sendEolSelect = document.getElementById('send-eol');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const addSessionBtn = document.getElementById('add-session-btn');
|
||||
const sessionListContainer = document.getElementById('session-list');
|
||||
|
||||
// App Serial State Variables
|
||||
let port = null;
|
||||
let reader = null;
|
||||
let writer = null;
|
||||
let keepReading = false;
|
||||
let receivedLogBuffer = []; // Accumulates all received characters/data for download log
|
||||
let isTerminalEmpty = true;
|
||||
// PortSession Class definition
|
||||
class PortSession {
|
||||
constructor(id, name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
|
||||
// Web Serial State
|
||||
this.port = null;
|
||||
this.reader = null;
|
||||
this.writer = null;
|
||||
this.keepReading = false;
|
||||
this.receivedLogBuffer = [];
|
||||
this.isTerminalEmpty = true;
|
||||
this.deviceInfo = '';
|
||||
|
||||
// Settings state
|
||||
this.baudRate = '115200';
|
||||
this.dataBits = '8';
|
||||
this.stopBits = '1';
|
||||
this.parity = 'none';
|
||||
|
||||
// Console controls
|
||||
this.scrollMode = 'auto';
|
||||
this.displayFormat = 'auto';
|
||||
this.sendFormat = 'ascii';
|
||||
this.sendEol = 'crlf';
|
||||
this.sendText = '';
|
||||
|
||||
// Dedicated Terminal Element
|
||||
this.terminalOutputElement = document.createElement('div');
|
||||
this.terminalOutputElement.id = `terminal-output-${this.id}`;
|
||||
this.terminalOutputElement.className = 'terminal-output empty';
|
||||
this.terminalOutputElement.tabIndex = 0;
|
||||
this.terminalOutputElement.textContent = 'No data received yet...';
|
||||
}
|
||||
}
|
||||
|
||||
// Global Application State
|
||||
let sessions = [];
|
||||
let activeSession = null;
|
||||
|
||||
// Initialize theme and browser support on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
checkWebSerialSupport();
|
||||
|
||||
// Wire up main event listeners
|
||||
addSessionBtn.addEventListener('click', addSession);
|
||||
|
||||
baudRateSelect.addEventListener('change', () => {
|
||||
if (activeSession) {
|
||||
activeSession.baudRate = baudRateSelect.value;
|
||||
renderSessionsList();
|
||||
}
|
||||
});
|
||||
|
||||
dataBitsSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.dataBits = dataBitsSelect.value;
|
||||
});
|
||||
|
||||
stopBitsSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.stopBits = stopBitsSelect.value;
|
||||
});
|
||||
|
||||
paritySelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.parity = paritySelect.value;
|
||||
});
|
||||
|
||||
scrollModeSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.scrollMode = scrollModeSelect.value;
|
||||
});
|
||||
|
||||
displayFormatSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.displayFormat = displayFormatSelect.value;
|
||||
});
|
||||
|
||||
sendFormatSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.sendFormat = sendFormatSelect.value;
|
||||
});
|
||||
|
||||
sendEolSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.sendEol = sendEolSelect.value;
|
||||
});
|
||||
|
||||
sendInput.addEventListener('input', () => {
|
||||
if (activeSession) activeSession.sendText = sendInput.value;
|
||||
});
|
||||
|
||||
// Initialize with a default session
|
||||
addSession();
|
||||
});
|
||||
|
||||
// Toast notification helper
|
||||
@@ -78,20 +158,27 @@ function checkWebSerialSupport() {
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.style.opacity = '0.5';
|
||||
connectBtn.style.cursor = 'not-allowed';
|
||||
addSessionBtn.disabled = true;
|
||||
addSessionBtn.style.opacity = '0.5';
|
||||
addSessionBtn.style.cursor = 'not-allowed';
|
||||
showToast('Web Serial API is not supported in this browser.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateConnectionUI(connected, errorMsg = '') {
|
||||
if (connected) {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (connected && activeSession.port) {
|
||||
connectBtn.className = 'connect-btn connected';
|
||||
connectBtn.title = 'Disconnect from serial port';
|
||||
statusBanner.className = 'status-banner connected';
|
||||
|
||||
const info = port.getInfo();
|
||||
const portLabel = info.usbVendorId ? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})` : 'Serial Device';
|
||||
const info = activeSession.port.getInfo();
|
||||
const portLabel = info.usbVendorId
|
||||
? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})`
|
||||
: 'Serial Device';
|
||||
|
||||
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${baudRateSelect.value} bps.`;
|
||||
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${activeSession.baudRate} bps.`;
|
||||
} else {
|
||||
connectBtn.className = 'connect-btn disconnected';
|
||||
connectBtn.title = 'Connect to serial port';
|
||||
@@ -107,92 +194,287 @@ function updateConnectionUI(connected, errorMsg = '') {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3. Serial Port Connection & Reader Loop
|
||||
// 3. Serial Session Management
|
||||
// -------------------------------------------------------------
|
||||
async function connectSerial() {
|
||||
function addSession() {
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
||||
const name = `Session ${sessions.length + 1}`;
|
||||
const newSession = new PortSession(id, name);
|
||||
|
||||
sessions.push(newSession);
|
||||
terminalContainer.appendChild(newSession.terminalOutputElement);
|
||||
|
||||
selectSession(newSession);
|
||||
showToast(`${name} created.`, 'success');
|
||||
}
|
||||
|
||||
function selectSession(session) {
|
||||
if (activeSession) {
|
||||
activeSession.sendText = sendInput.value;
|
||||
}
|
||||
|
||||
activeSession = session;
|
||||
renderSessionsList();
|
||||
|
||||
// Show only active session's terminal DOM element
|
||||
const terminals = terminalContainer.querySelectorAll('.terminal-output');
|
||||
terminals.forEach(el => {
|
||||
if (el.id === activeSession.terminalOutputElement.id) {
|
||||
el.classList.remove('hidden');
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Sync dropdown UI values
|
||||
baudRateSelect.value = activeSession.baudRate;
|
||||
dataBitsSelect.value = activeSession.dataBits;
|
||||
stopBitsSelect.value = activeSession.stopBits;
|
||||
paritySelect.value = activeSession.parity;
|
||||
|
||||
scrollModeSelect.value = activeSession.scrollMode;
|
||||
displayFormatSelect.value = activeSession.displayFormat;
|
||||
sendFormatSelect.value = activeSession.sendFormat;
|
||||
sendEolSelect.value = activeSession.sendEol;
|
||||
sendInput.value = activeSession.sendText;
|
||||
|
||||
downloadBtn.disabled = activeSession.isTerminalEmpty;
|
||||
|
||||
// Sync status banner and buttons
|
||||
const isConnected = activeSession.port !== null && activeSession.port.readable;
|
||||
updateConnectionUI(isConnected);
|
||||
}
|
||||
|
||||
async function deleteSession(session, event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (sessions.length <= 1) {
|
||||
showToast('Cannot delete the last remaining session.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect if connected
|
||||
if (session.port) {
|
||||
await disconnectSession(session);
|
||||
}
|
||||
|
||||
// Remove terminal from DOM
|
||||
if (session.terminalOutputElement && session.terminalOutputElement.parentNode) {
|
||||
session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement);
|
||||
}
|
||||
|
||||
const index = sessions.indexOf(session);
|
||||
if (index > -1) {
|
||||
sessions.splice(index, 1);
|
||||
}
|
||||
|
||||
if (activeSession === session) {
|
||||
const nextActive = sessions[Math.max(0, index - 1)];
|
||||
selectSession(nextActive);
|
||||
} else {
|
||||
renderSessionsList();
|
||||
}
|
||||
|
||||
showToast(`${session.name} deleted.`, 'success');
|
||||
}
|
||||
|
||||
function renderSessionsList() {
|
||||
sessionListContainer.innerHTML = '';
|
||||
|
||||
sessions.forEach(session => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `session-item ${session === activeSession ? 'active' : ''}`;
|
||||
item.addEventListener('click', () => selectSession(session));
|
||||
|
||||
const leftGroup = document.createElement('div');
|
||||
leftGroup.className = 'session-item-left';
|
||||
|
||||
// Status dot
|
||||
const dot = document.createElement('span');
|
||||
const isConnected = session.port !== null && session.port.readable;
|
||||
dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`;
|
||||
leftGroup.appendChild(dot);
|
||||
|
||||
// Session labels
|
||||
const info = document.createElement('div');
|
||||
info.className = 'session-info';
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'session-name';
|
||||
nameEl.textContent = session.name;
|
||||
|
||||
// Double click to rename
|
||||
nameEl.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = session.name;
|
||||
input.className = 'session-rename-input';
|
||||
|
||||
const saveRename = () => {
|
||||
const val = input.value.trim();
|
||||
if (val) {
|
||||
session.name = val;
|
||||
}
|
||||
renderSessionsList();
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
saveRename();
|
||||
} else if (evt.key === 'Escape') {
|
||||
renderSessionsList();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', saveRename);
|
||||
|
||||
info.innerHTML = '';
|
||||
info.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
|
||||
const detailEl = document.createElement('span');
|
||||
detailEl.className = 'session-detail';
|
||||
if (isConnected) {
|
||||
detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`;
|
||||
} else {
|
||||
detailEl.textContent = `${session.baudRate} bps | Disconnected`;
|
||||
}
|
||||
|
||||
info.appendChild(nameEl);
|
||||
info.appendChild(detailEl);
|
||||
leftGroup.appendChild(info);
|
||||
item.appendChild(leftGroup);
|
||||
|
||||
// Close / Delete Session button
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'session-delete-btn';
|
||||
deleteBtn.title = 'Delete Session';
|
||||
deleteBtn.innerHTML = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
`;
|
||||
deleteBtn.addEventListener('click', (e) => deleteSession(session, e));
|
||||
item.appendChild(deleteBtn);
|
||||
|
||||
sessionListContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 4. Serial Port Connection & Reader Loop
|
||||
// -------------------------------------------------------------
|
||||
async function connectSession(session) {
|
||||
try {
|
||||
// Request a port from the user
|
||||
port = await navigator.serial.requestPort();
|
||||
session.port = await navigator.serial.requestPort();
|
||||
|
||||
const options = {
|
||||
baudRate: parseInt(baudRateSelect.value, 10),
|
||||
dataBits: parseInt(dataBitsSelect.value, 10),
|
||||
stopBits: parseInt(stopBitsSelect.value, 10),
|
||||
parity: paritySelect.value
|
||||
baudRate: parseInt(session.baudRate, 10),
|
||||
dataBits: parseInt(session.dataBits, 10),
|
||||
stopBits: parseInt(session.stopBits, 10),
|
||||
parity: session.parity
|
||||
};
|
||||
|
||||
// Open connection
|
||||
await port.open(options);
|
||||
await session.port.open(options);
|
||||
|
||||
updateConnectionUI(true);
|
||||
keepReading = true;
|
||||
startReadingLoop();
|
||||
const info = session.port.getInfo();
|
||||
if (info.usbVendorId) {
|
||||
session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`;
|
||||
} else {
|
||||
session.deviceInfo = 'Serial Device';
|
||||
}
|
||||
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(true);
|
||||
}
|
||||
renderSessionsList();
|
||||
|
||||
session.keepReading = true;
|
||||
startSessionReadingLoop(session);
|
||||
|
||||
showToast(`${session.name} connected.`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Connection failed:', err);
|
||||
updateConnectionUI(false, `Connection failed: ${err.message}`);
|
||||
showToast('Failed to open port.', 'error');
|
||||
session.port = null;
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false, `Connection failed: ${err.message}`);
|
||||
}
|
||||
showToast(`Failed to open port for ${session.name}.`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSerial() {
|
||||
keepReading = false;
|
||||
async function disconnectSession(session) {
|
||||
session.keepReading = false;
|
||||
|
||||
if (reader) {
|
||||
if (session.reader) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
await session.reader.cancel();
|
||||
} catch (e) {
|
||||
console.error('Error cancelling reader:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a short moment for loops to clear
|
||||
setTimeout(async () => {
|
||||
if (port) {
|
||||
try {
|
||||
await port.close();
|
||||
} catch (e) {
|
||||
console.error('Error closing port:', e);
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(async () => {
|
||||
if (session.port) {
|
||||
try {
|
||||
await session.port.close();
|
||||
} catch (e) {
|
||||
console.error('Error closing port:', e);
|
||||
}
|
||||
session.port = null;
|
||||
}
|
||||
port = null;
|
||||
}
|
||||
updateConnectionUI(false);
|
||||
showToast('Disconnected from port.', 'success');
|
||||
}, 100);
|
||||
session.deviceInfo = '';
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false);
|
||||
}
|
||||
renderSessionsList();
|
||||
showToast(`${session.name} disconnected.`, 'success');
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Background read loop
|
||||
async function startReadingLoop() {
|
||||
async function startSessionReadingLoop(session) {
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (port && port.readable && keepReading) {
|
||||
while (session.port && session.port.readable && session.keepReading) {
|
||||
try {
|
||||
reader = port.readable.getReader();
|
||||
session.reader = session.port.readable.getReader();
|
||||
|
||||
while (keepReading) {
|
||||
const { value, done } = await reader.read();
|
||||
while (session.keepReading) {
|
||||
const { value, done } = await session.reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
handleIncomingData(value, decoder);
|
||||
handleIncomingSessionData(session, value, decoder);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Read error:', err);
|
||||
updateConnectionUI(false, `Port disconnected: ${err.message}`);
|
||||
showToast('Serial device disconnected.', 'error');
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false, `Port disconnected: ${err.message}`);
|
||||
}
|
||||
showToast(`${session.name} disconnected unexpectedly.`, 'error');
|
||||
session.port = null;
|
||||
renderSessionsList();
|
||||
break;
|
||||
} finally {
|
||||
if (reader) {
|
||||
reader.releaseLock();
|
||||
reader = null;
|
||||
if (session.reader) {
|
||||
session.reader.releaseLock();
|
||||
session.reader = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming bytes
|
||||
function handleIncomingData(uint8Array, decoder) {
|
||||
const format = displayFormatSelect.value;
|
||||
function handleIncomingSessionData(session, uint8Array, decoder) {
|
||||
const format = session.displayFormat;
|
||||
let formattedText = '';
|
||||
|
||||
if (format === 'hex') {
|
||||
@@ -200,35 +482,32 @@ function handleIncomingData(uint8Array, decoder) {
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(' ') + ' ';
|
||||
} else {
|
||||
// text or auto mode
|
||||
formattedText = decoder.decode(uint8Array);
|
||||
}
|
||||
|
||||
// Save to download log buffer
|
||||
// Always store string representation
|
||||
receivedLogBuffer.push(formattedText);
|
||||
|
||||
// Display in terminal UI
|
||||
appendTerminalText(formattedText, 'received');
|
||||
session.receivedLogBuffer.push(formattedText);
|
||||
appendSessionTerminalText(session, formattedText, 'received');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 4. Writing Data (Transmission)
|
||||
// 5. Writing Data (Transmission)
|
||||
// -------------------------------------------------------------
|
||||
async function sendData() {
|
||||
if (!port || !port.writable) {
|
||||
showToast('Cannot send. Serial port is not connected.', 'error');
|
||||
async function sendSessionData() {
|
||||
if (!activeSession) return;
|
||||
const session = activeSession;
|
||||
|
||||
if (!session.port || !session.port.writable) {
|
||||
showToast(`Cannot send. ${session.name} is not connected.`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const rawInput = sendInput.value;
|
||||
if (!rawInput) return;
|
||||
|
||||
const format = sendFormatSelect.value;
|
||||
const eol = sendEolSelect.value;
|
||||
const format = session.sendFormat;
|
||||
const eol = session.sendEol;
|
||||
let bytesToSend;
|
||||
|
||||
// Determine EOL bytes/string
|
||||
let eolText = '';
|
||||
let eolBytes = new Uint8Array(0);
|
||||
if (eol === 'lf') {
|
||||
@@ -257,10 +536,9 @@ async function sendData() {
|
||||
}
|
||||
|
||||
try {
|
||||
writer = port.writable.getWriter();
|
||||
await writer.write(bytesToSend);
|
||||
session.writer = session.port.writable.getWriter();
|
||||
await session.writer.write(bytesToSend);
|
||||
|
||||
// Log transmission visually on the terminal
|
||||
let displaySent = '';
|
||||
if (format === 'hex') {
|
||||
displaySent = Array.from(bytesToSend)
|
||||
@@ -273,23 +551,22 @@ async function sendData() {
|
||||
else if (eol === 'crlf') displaySent += '\\r\\n';
|
||||
}
|
||||
|
||||
appendTerminalText(`\n[Sent] ${displaySent}\n`, 'sent');
|
||||
sendInput.value = ''; // clear input field
|
||||
appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent');
|
||||
sendInput.value = '';
|
||||
session.sendText = '';
|
||||
|
||||
} catch (err) {
|
||||
console.error('Send error:', err);
|
||||
showToast(`Failed to send: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (writer) {
|
||||
writer.releaseLock();
|
||||
writer = null;
|
||||
if (session.writer) {
|
||||
session.writer.releaseLock();
|
||||
session.writer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hex Parsing Utility
|
||||
function parseHexString(hexString) {
|
||||
// Remove all white spaces
|
||||
const clean = hexString.replace(/\s+/g, '');
|
||||
|
||||
if (!/^[0-9a-fA-F]*$/.test(clean)) {
|
||||
@@ -308,45 +585,44 @@ function parseHexString(hexString) {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 5. Terminal Console Operations (Append, Clear, Scroll)
|
||||
// 6. Terminal Console Operations
|
||||
// -------------------------------------------------------------
|
||||
function appendTerminalText(text, className = '') {
|
||||
if (isTerminalEmpty) {
|
||||
terminalOutput.textContent = '';
|
||||
terminalOutput.classList.remove('empty');
|
||||
isTerminalEmpty = false;
|
||||
downloadBtn.disabled = false;
|
||||
function appendSessionTerminalText(session, text, className = '') {
|
||||
if (session.isTerminalEmpty) {
|
||||
session.terminalOutputElement.textContent = '';
|
||||
session.terminalOutputElement.classList.remove('empty');
|
||||
session.isTerminalEmpty = false;
|
||||
if (session === activeSession) {
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create text container
|
||||
const span = document.createElement('span');
|
||||
if (className) {
|
||||
span.className = `terminal-line ${className}`;
|
||||
}
|
||||
span.textContent = text;
|
||||
terminalOutput.appendChild(span);
|
||||
session.terminalOutputElement.appendChild(span);
|
||||
|
||||
// Auto-scroll handler
|
||||
if (scrollModeSelect.value === 'auto') {
|
||||
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
||||
if (session.scrollMode === 'auto') {
|
||||
session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear terminal function
|
||||
function clearTerminal() {
|
||||
terminalOutput.innerHTML = 'No data received yet...';
|
||||
terminalOutput.classList.add('empty');
|
||||
isTerminalEmpty = true;
|
||||
receivedLogBuffer = [];
|
||||
if (!activeSession) return;
|
||||
activeSession.terminalOutputElement.innerHTML = 'No data received yet...';
|
||||
activeSession.terminalOutputElement.classList.add('empty');
|
||||
activeSession.isTerminalEmpty = true;
|
||||
activeSession.receivedLogBuffer = [];
|
||||
downloadBtn.disabled = true;
|
||||
showToast('Terminal cleared.', 'success');
|
||||
}
|
||||
|
||||
// Download received console logs
|
||||
function downloadLog() {
|
||||
if (receivedLogBuffer.length === 0) return;
|
||||
if (!activeSession || activeSession.receivedLogBuffer.length === 0) return;
|
||||
|
||||
const content = receivedLogBuffer.join('');
|
||||
const content = activeSession.receivedLogBuffer.join('');
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -354,7 +630,7 @@ function downloadLog() {
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `uart_log_${dateStr}.txt`;
|
||||
a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${dateStr}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
@@ -367,52 +643,45 @@ function downloadLog() {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 6. Event Listeners & Keyboard Shortcuts
|
||||
// 7. Event Listeners & Keyboard Shortcuts
|
||||
// -------------------------------------------------------------
|
||||
connectBtn.addEventListener('click', () => {
|
||||
if (port) {
|
||||
disconnectSerial();
|
||||
} else {
|
||||
connectSerial();
|
||||
if (activeSession) {
|
||||
if (activeSession.port) {
|
||||
disconnectSession(activeSession);
|
||||
} else {
|
||||
connectSession(activeSession);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', sendData);
|
||||
sendBtn.addEventListener('click', sendSessionData);
|
||||
clearBtn.addEventListener('click', clearTerminal);
|
||||
downloadBtn.addEventListener('click', downloadLog);
|
||||
|
||||
// Input Keyboard Shortcuts
|
||||
sendInput.addEventListener('keydown', (e) => {
|
||||
// Ctrl + Enter or Enter (standard text input submit) to send
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendData();
|
||||
sendSessionData();
|
||||
}
|
||||
// Ctrl + Tab to insert actual tab characters
|
||||
if (e.key === 'Tab' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const start = sendInput.selectionStart;
|
||||
const end = sendInput.selectionEnd;
|
||||
|
||||
// Set input value: [text before tab] + \t + [text after tab]
|
||||
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
|
||||
|
||||
// Put caret in right position
|
||||
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
|
||||
if (activeSession) activeSession.sendText = sendInput.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-detect browser USB connect/disconnect events
|
||||
// Auto-detect browser USB disconnect events globally
|
||||
if ('serial' in navigator) {
|
||||
navigator.serial.addEventListener('connect', (event) => {
|
||||
showToast('Serial device connected to port.', 'success');
|
||||
});
|
||||
|
||||
navigator.serial.addEventListener('disconnect', (event) => {
|
||||
if (port && event.target === port) {
|
||||
disconnectSerial();
|
||||
updateConnectionUI(false, 'Selected serial device was disconnected.');
|
||||
showToast('Device connection lost.', 'error');
|
||||
navigator.serial.addEventListener('disconnect', async (event) => {
|
||||
const disconnectedSession = sessions.find(s => s.port === event.target);
|
||||
if (disconnectedSession) {
|
||||
await disconnectSession(disconnectedSession);
|
||||
showToast(`${disconnectedSession.name} device disconnected.`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# UART Terminal Multi-Port Implementation Plan
|
||||
|
||||
The goal is to modify the UART Terminal to support multiple concurrent serial port sessions. We will introduce a sidebar panel on the left to display all sessions (e.g., active/inactive state, baud rate, and custom names) and a main workspace on the right to manage the configuration and terminal log of the currently selected session.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - **Default Session**: On app load, a default "Session 1" will be created and selected. This ensures that users who just want to use a single port don't have to perform any additional clicks.
|
||||
> - **Concurrent Connections**: Each session will maintain its own active connection, serial reader loop, terminal logs, and configurations. This allows the user to have multiple serial devices connected and communicating simultaneously, switching between them at will without losing context or scroll history.
|
||||
> - **Session Renaming**: Users can double-click a session name in the sidebar to rename it to something descriptive (e.g., "MCU 1", "GPS Module").
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### UI / Layout Configuration
|
||||
|
||||
#### [MODIFY] [index.html](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.html)
|
||||
- Wrap the `<main class="app-main">` and a new `<aside class="app-sidebar">` inside a container `<div class="app-layout">`.
|
||||
- Adjust container structure:
|
||||
- Increase `.app-container` max-width to `1200px` to comfortably host the sidebar and terminal split.
|
||||
- Implement dynamic placeholders for the terminal output elements. Instead of a single `#terminal-output` div, we'll make `#terminal-container` host dynamically created terminal-output elements for each session, toggling their visibility based on the active session.
|
||||
- Add an "Add Session" (`#add-session-btn`) button to the sidebar.
|
||||
|
||||
#### [MODIFY] [index.css](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.css)
|
||||
- Increase `.app-container` `max-width` to `1200px`.
|
||||
- Add rules for `.app-layout` (CSS Grid or Flexbox layout, split `260px` and `1fr`).
|
||||
- Add styles for the sidebar (`.app-sidebar`):
|
||||
- `.sidebar-header` (flex row, title, add-session button).
|
||||
- `.session-list` (flex column, scrolling).
|
||||
- `.session-item` (states: hover, active, status-dots, title/subtitle, hover delete button).
|
||||
- `.session-rename-input` (input box replacing title on double-click).
|
||||
- Add support for displaying/hiding terminal sessions via class `.hidden { display: none !important; }`.
|
||||
|
||||
### State & Logic Configuration
|
||||
|
||||
#### [MODIFY] [app.js](file:///home/roy/zData/WTPCode/bodeplot/uart_example/app.js)
|
||||
- Define a `PortSession` class to encapsulate all states of a single UART connection:
|
||||
- ID, name, connection state, terminal DOM element, log buffers, input text cache.
|
||||
- Port options: `baudRate`, `dataBits`, `stopBits`, `parity`.
|
||||
- Terminal configs: `scrollMode`, `displayFormat`, `sendFormat`, `sendEol`.
|
||||
- Maintain global variables:
|
||||
- `sessions` array.
|
||||
- `activeSession` pointer.
|
||||
- Refactor connection functions (`connectSerial`, `disconnectSerial`, `startReadingLoop`, `sendData`) to execute against the active session's parameters.
|
||||
- Re-route UI change listeners (Baud Rate selector, display formats, send input, etc.) to update properties on `activeSession` when changed.
|
||||
- Build functions to:
|
||||
- Add a session (`addSession()`).
|
||||
- Select/Activate a session (`activateSession(session)`).
|
||||
- Delete/Remove a session (`deleteSession(session)`).
|
||||
- Sync UI selectors with active session states.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
We will run `npm run dev` or a local static files server to review the layout, and use our understanding of the DOM state to ensure multiple concurrent sessions behave correctly.
|
||||
|
||||
### Manual Verification
|
||||
1. **Multiple Tabs**: Add 3 ports in the sidebar. Verify they are named Session 1, Session 2, Session 3.
|
||||
2. **Settings Persistence**: Change Baud Rate in Session 1 to `9600` and Session 2 to `115200`. Switch between them and verify that the dropdown value updates to reflect the active session's settings.
|
||||
3. **Session Renaming**: Double-click "Session 1" name in the sidebar, type "Test Port", and press Enter. Verify the label changes.
|
||||
4. **Delete Session**: Select a session and click the `x` delete icon. Verify it gets removed. If connected, it should disconnect safely first.
|
||||
5. **Console logs isolation**: Verify that sending data or logs in one session does not bleed into another session's terminal panel.
|
||||
+213
-1
@@ -83,7 +83,7 @@ body {
|
||||
/* App container */
|
||||
.app-container {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
max-width: 1200px;
|
||||
padding: 2.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -684,3 +684,215 @@ body.light-theme .terminal-line.sent {
|
||||
border-color: var(--accent-danger);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
/* Sidebar and Split Layout */
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
transition: background-color var(--transition-normal), border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.add-session-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background-color: var(--accent-purple);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.add-session-btn:hover {
|
||||
background-color: var(--accent-purple-hover);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.add-session-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Session List */
|
||||
.session-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
border-color: var(--border-hover);
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body.light-theme .session-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
.session-item.active {
|
||||
border-color: var(--border-focus);
|
||||
background-color: rgba(99, 102, 241, 0.08);
|
||||
box-shadow: 0 0 0 1px var(--border-focus);
|
||||
}
|
||||
|
||||
body.light-theme .session-item.active {
|
||||
background-color: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.session-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background-color: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green);
|
||||
}
|
||||
|
||||
.session-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-detail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-item:hover .session-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-delete-btn:hover {
|
||||
color: var(--accent-danger);
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.session-rename-input {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-focus);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
+173
-153
@@ -35,176 +35,196 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
<main class="app-main">
|
||||
|
||||
<!-- Connection Controls & Status -->
|
||||
<section class="control-row">
|
||||
<div class="action-buttons">
|
||||
<button id="connect-btn" class="connect-btn disconnected" title="Connect to serial port">
|
||||
<!-- Play Icon -->
|
||||
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="6,4 20,12 6,20"></polygon>
|
||||
</svg>
|
||||
<!-- Stop Icon -->
|
||||
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="5" y="5" width="14" height="14" rx="2"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button id="download-btn" class="download-btn" title="Download received log" disabled>
|
||||
<!-- Download Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
<!-- App Layout Split -->
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar Panel -->
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Sessions</h2>
|
||||
<button id="add-session-btn" class="add-session-btn" title="Add new session">
|
||||
<!-- Plus Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status Banner -->
|
||||
<div id="status-banner" class="status-banner warn">
|
||||
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<span id="status-text">Not connected. Click the connect button to select a port.</span>
|
||||
<div id="session-list" class="session-list">
|
||||
<!-- Session items dynamically injected -->
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<!-- Settings Form Grid -->
|
||||
<section class="settings-grid">
|
||||
<div class="settings-group">
|
||||
<label for="baud-rate">Baud Rate</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="baud-rate">
|
||||
<option value="300">300</option>
|
||||
<option value="1200">1200</option>
|
||||
<option value="2400">2400</option>
|
||||
<option value="4800">4800</option>
|
||||
<option value="9600">9600</option>
|
||||
<option value="19200">19200</option>
|
||||
<option value="38400">38400</option>
|
||||
<option value="57600">57600</option>
|
||||
<option value="74880">74880</option>
|
||||
<option value="115200" selected>115200</option>
|
||||
<option value="230400">230400</option>
|
||||
<option value="250000">250000</option>
|
||||
<option value="500000">500000</option>
|
||||
<option value="1000000">1000000</option>
|
||||
<option value="2000000">2000000</option>
|
||||
</select>
|
||||
<!-- Main Workspace -->
|
||||
<main class="app-main">
|
||||
|
||||
<!-- Connection Controls & Status -->
|
||||
<section class="control-row">
|
||||
<div class="action-buttons">
|
||||
<button id="connect-btn" class="connect-btn disconnected" title="Connect to serial port">
|
||||
<!-- Play Icon -->
|
||||
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="6,4 20,12 6,20"></polygon>
|
||||
</svg>
|
||||
<!-- Stop Icon -->
|
||||
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="5" y="5" width="14" height="14" rx="2"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button id="download-btn" class="download-btn" title="Download received log" disabled>
|
||||
<!-- Download Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="data-bits">Data Bits</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="data-bits">
|
||||
<option value="8" selected>8</option>
|
||||
<option value="7">7</option>
|
||||
<option value="6">6</option>
|
||||
<option value="5">5</option>
|
||||
</select>
|
||||
<!-- Connection Status Banner -->
|
||||
<div id="status-banner" class="status-banner warn">
|
||||
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<span id="status-text">Not connected. Click the connect button to select a port.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="stop-bits">Stop Bits</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="stop-bits">
|
||||
<option value="1" selected>1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="parity">Parity</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="parity">
|
||||
<option value="none" selected>none</option>
|
||||
<option value="even">even</option>
|
||||
<option value="odd">odd</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Received Data Section -->
|
||||
<section class="received-data-section">
|
||||
<div class="received-header">
|
||||
<h2>Received Data</h2>
|
||||
<div class="received-controls">
|
||||
<div class="control-select-group">
|
||||
<label for="scroll-mode">Scroll</label>
|
||||
<div class="select-wrapper select-wrapper-sm">
|
||||
<select id="scroll-mode">
|
||||
<option value="auto" selected>Auto-scroll</option>
|
||||
<option value="none">No scroll</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Settings Form Grid -->
|
||||
<section class="settings-grid">
|
||||
<div class="settings-group">
|
||||
<label for="baud-rate">Baud Rate</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="baud-rate">
|
||||
<option value="300">300</option>
|
||||
<option value="1200">1200</option>
|
||||
<option value="2400">2400</option>
|
||||
<option value="4800">4800</option>
|
||||
<option value="9600">9600</option>
|
||||
<option value="19200">19200</option>
|
||||
<option value="38400">38400</option>
|
||||
<option value="57600">57600</option>
|
||||
<option value="74880">74880</option>
|
||||
<option value="115200" selected>115200</option>
|
||||
<option value="230400">230400</option>
|
||||
<option value="250000">250000</option>
|
||||
<option value="500000">500000</option>
|
||||
<option value="1000000">1000000</option>
|
||||
<option value="2000000">2000000</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-select-group">
|
||||
<label for="display-format">Display Format</label>
|
||||
<div class="select-wrapper select-wrapper-sm">
|
||||
<select id="display-format">
|
||||
<option value="auto" selected>Auto</option>
|
||||
<option value="text">Text</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="data-bits">Data Bits</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="data-bits">
|
||||
<option value="8" selected>8</option>
|
||||
<option value="7">7</option>
|
||||
<option value="6">6</option>
|
||||
<option value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="stop-bits">Stop Bits</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="stop-bits">
|
||||
<option value="1" selected>1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="parity">Parity</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="parity">
|
||||
<option value="none" selected>none</option>
|
||||
<option value="even">even</option>
|
||||
<option value="odd">odd</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Received Data Section -->
|
||||
<section class="received-data-section">
|
||||
<div class="received-header">
|
||||
<h2>Received Data</h2>
|
||||
<div class="received-controls">
|
||||
<div class="control-select-group">
|
||||
<label for="scroll-mode">Scroll</label>
|
||||
<div class="select-wrapper select-wrapper-sm">
|
||||
<select id="scroll-mode">
|
||||
<option value="auto" selected>Auto-scroll</option>
|
||||
<option value="none">No scroll</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-select-group">
|
||||
<label for="display-format">Display Format</label>
|
||||
<div class="select-wrapper select-wrapper-sm">
|
||||
<select id="display-format">
|
||||
<option value="auto" selected>Auto</option>
|
||||
<option value="text">Text</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Console -->
|
||||
<div class="terminal-wrapper">
|
||||
<div id="terminal-output" class="terminal-output empty" tabindex="0">No data received yet...</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Terminal Console -->
|
||||
<div id="terminal-container" class="terminal-wrapper">
|
||||
<!-- Terminal elements will be dynamically appended here per session -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Send Data Input Area -->
|
||||
<section class="send-data-section">
|
||||
<div class="send-bar">
|
||||
<div class="input-container">
|
||||
<input type="text" id="send-input" placeholder="Enter data to send... (Ctrl+Tab to insert tab)" autocomplete="off">
|
||||
<!-- Send Data Input Area -->
|
||||
<section class="send-data-section">
|
||||
<div class="send-bar">
|
||||
<div class="input-container">
|
||||
<input type="text" id="send-input" placeholder="Enter data to send... (Ctrl+Tab to insert tab)" autocomplete="off">
|
||||
</div>
|
||||
<div class="select-wrapper select-wrapper-sm send-format-wrapper">
|
||||
<select id="send-format">
|
||||
<option value="ascii" selected>ASCII</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select-wrapper select-wrapper-sm send-eol-wrapper">
|
||||
<select id="send-eol">
|
||||
<option value="none">None</option>
|
||||
<option value="lf">LF (\n)</option>
|
||||
<option value="cr">CR (\r)</option>
|
||||
<option value="crlf" selected>CRLF (\r\n)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="send-btn" class="send-btn" title="Send Data (Ctrl+Enter)">
|
||||
<!-- Send / Paper Airplane Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="clear-btn" class="clear-btn" title="Clear Terminal Console">
|
||||
<!-- Trash Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="select-wrapper select-wrapper-sm send-format-wrapper">
|
||||
<select id="send-format">
|
||||
<option value="ascii" selected>ASCII</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select-wrapper select-wrapper-sm send-eol-wrapper">
|
||||
<select id="send-eol">
|
||||
<option value="none">None</option>
|
||||
<option value="lf">LF (\n)</option>
|
||||
<option value="cr">CR (\r)</option>
|
||||
<option value="crlf" selected>CRLF (\r\n)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="send-btn" class="send-btn" title="Send Data (Ctrl+Enter)">
|
||||
<!-- Send / Paper Airplane Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="clear-btn" class="clear-btn" title="Clear Terminal Console">
|
||||
<!-- Trash Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user