431
examples/device/webusb_serial/website/application.js
Normal file
431
examples/device/webusb_serial/website/application.js
Normal file
@@ -0,0 +1,431 @@
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
const connectBtn = document.getElementById('connect');
|
||||
const resetAllBtn = document.getElementById('reset_all');
|
||||
const resetOutputBtn = document.getElementById('reset_output');
|
||||
const senderLines = document.getElementById('sender_lines');
|
||||
const receiverLines = document.getElementById('receiver_lines');
|
||||
const commandLine = document.getElementById('command_line');
|
||||
const status = document.getElementById('status');
|
||||
const newlineModeSelect = document.getElementById('newline_mode');
|
||||
const sendModeBtn = document.getElementById('send_mode');
|
||||
const autoReconnectCheckbox = document.getElementById('auto_reconnect');
|
||||
const forgetDeviceBtn = document.getElementById('forget_device');
|
||||
const forgetAllDevicesBtn = document.getElementById('forget_all_devices');
|
||||
|
||||
const nearTheBottomThreshold = 100; // pixels from the bottom to trigger scroll
|
||||
|
||||
let port = null;
|
||||
let lastPort = null; // for reconnecting and initial connection
|
||||
|
||||
const history = [];
|
||||
let historyIndex = -1;
|
||||
let lastCommand = null;
|
||||
let lastCommandCount = 0;
|
||||
let lastCommandButton = null;
|
||||
|
||||
let sendMode = 'command'; // default mode
|
||||
|
||||
let reconnectIntervalId = null; // track reconnect interval
|
||||
|
||||
// Format incoming data to string based on newline mode
|
||||
const decodeData = (() => {
|
||||
const decoder = new TextDecoder();
|
||||
return dataView => decoder.decode(dataView);
|
||||
})();
|
||||
|
||||
// Normalize newline if mode is ANY
|
||||
const normalizeNewlines = (text, mode) => {
|
||||
switch (mode) {
|
||||
case 'CR':
|
||||
// Only \r: Replace all \n with \r
|
||||
return text.replace(/\r?\n/g, '\r');
|
||||
case 'CRLF':
|
||||
// Replace lone \r or \n with \r\n
|
||||
return text.replace(/\r\n|[\r\n]/g, '\r\n');
|
||||
case 'ANY':
|
||||
// Accept any \r, \n, \r\n. Normalize as \n for display
|
||||
return text.replace(/\r\n|\r/g, '\n');
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
// Append line to container, optionally scroll to bottom
|
||||
const appendLineToReceiver = (container, text, className = '') => {
|
||||
const div = document.createElement('div');
|
||||
if (className) div.className = className;
|
||||
div.textContent = text;
|
||||
container.appendChild(div);
|
||||
|
||||
const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||||
if (distanceFromBottom < nearTheBottomThreshold) {
|
||||
requestAnimationFrame(() => {
|
||||
div.scrollIntoView({ behavior: "instant" });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Append sent command to sender container as a clickable element
|
||||
const appendCommandToSender = (container, text) => {
|
||||
if (text === lastCommand) {
|
||||
// Increment count and update button
|
||||
lastCommandCount++;
|
||||
lastCommandButton.textContent = `${text} ×${lastCommandCount}`;
|
||||
} else {
|
||||
// Reset count and add new button
|
||||
lastCommand = text;
|
||||
lastCommandCount = 1;
|
||||
|
||||
const commandEl = document.createElement('button');
|
||||
commandEl.className = 'sender-entry';
|
||||
commandEl.type = 'button';
|
||||
commandEl.textContent = text;
|
||||
commandEl.addEventListener('click', () => {
|
||||
commandLine.value = text;
|
||||
commandLine.focus();
|
||||
});
|
||||
container.appendChild(commandEl);
|
||||
lastCommandButton = commandEl;
|
||||
|
||||
const distanceFromBottom = container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||||
if (distanceFromBottom < nearTheBottomThreshold) {
|
||||
requestAnimationFrame(() => {
|
||||
commandEl.scrollIntoView({ behavior: 'instant' });
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Update status text and style
|
||||
const setStatus = (msg, level = 'info') => {
|
||||
console.log(msg);
|
||||
status.textContent = msg;
|
||||
status.className = 'status status-' + level;
|
||||
};
|
||||
|
||||
// Disconnect helper
|
||||
const disconnectPort = async () => {
|
||||
if (port) {
|
||||
try {
|
||||
await port.disconnect();
|
||||
} catch (error) {
|
||||
setStatus(`Disconnect error: ${error.message}`, 'error');
|
||||
}
|
||||
port = null;
|
||||
connectBtn.textContent = 'Connect';
|
||||
commandLine.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Connect helper
|
||||
const connectPort = async (initial=false) => {
|
||||
try {
|
||||
let grantedDevices = await serial.getPorts();
|
||||
if (grantedDevices.length === 0 && initial) {
|
||||
return false;
|
||||
}
|
||||
if (grantedDevices.length === 0) {
|
||||
// No previously granted devices, request a new one
|
||||
setStatus('Requesting device...', 'info');
|
||||
port = await serial.requestPort();
|
||||
} else {
|
||||
if (lastPort) {
|
||||
// Try to reconnect to the last used port
|
||||
const matchingPort = grantedDevices.find(p => p.portPointToSameDevice(lastPort));
|
||||
if (matchingPort) {
|
||||
port = matchingPort;
|
||||
setStatus('Reconnecting to last device...', 'info');
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// No last port, just use the first available
|
||||
port = grantedDevices[0];
|
||||
setStatus('Connecting to first device...', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
await port.connect();
|
||||
lastPort = port; // save for reconnecting
|
||||
|
||||
setStatus(`Connected to ${port.device.productName || 'device'}`, 'info');
|
||||
connectBtn.textContent = 'Disconnect';
|
||||
commandLine.disabled = false;
|
||||
commandLine.focus();
|
||||
|
||||
port.onReceive = dataView => {
|
||||
let text = decodeData(dataView);
|
||||
text = normalizeNewlines(text, newlineModeSelect.value);
|
||||
appendLineToReceiver(receiverLines, text, 'received');
|
||||
};
|
||||
|
||||
port.onReceiveError = error => {
|
||||
setStatus(`Read error: ${error.message}`, 'error');
|
||||
// Start auto reconnect on error if enabled
|
||||
tryAutoReconnect();
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
setStatus(`Connection failed: ${error.message}`, 'error');
|
||||
port = null;
|
||||
connectBtn.textContent = 'Connect';
|
||||
commandLine.disabled = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Start auto reconnect interval if checkbox is checked and not already running
|
||||
const tryAutoReconnect = () => {
|
||||
if (!autoReconnectCheckbox.checked) return;
|
||||
if (reconnectIntervalId !== null) return; // already trying
|
||||
setStatus('Attempting to auto-reconnect...', 'info');
|
||||
reconnectIntervalId = setInterval(async () => {
|
||||
if (!autoReconnectCheckbox.checked) {
|
||||
clearInterval(reconnectIntervalId);
|
||||
reconnectIntervalId = null;
|
||||
setStatus('Auto-reconnect stopped.', 'info');
|
||||
return;
|
||||
}
|
||||
await disconnectPort();
|
||||
const success = await connectPort();
|
||||
if (success) {
|
||||
clearInterval(reconnectIntervalId);
|
||||
reconnectIntervalId = null;
|
||||
setStatus('Reconnected successfully.', 'info');
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Stop auto reconnect immediately
|
||||
const stopAutoReconnect = () => {
|
||||
if (reconnectIntervalId !== null) {
|
||||
clearInterval(reconnectIntervalId);
|
||||
reconnectIntervalId = null;
|
||||
setStatus('Auto-reconnect stopped.', 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// Connect button click handler
|
||||
connectBtn.addEventListener('click', async () => {
|
||||
if (!serial.isWebUsbSupported()) {
|
||||
setStatus('WebUSB not supported on this browser', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (port) {
|
||||
// Disconnect
|
||||
stopAutoReconnect();
|
||||
await disconnectPort();
|
||||
setStatus('Disconnected', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
stopAutoReconnect();
|
||||
try {
|
||||
// Connect
|
||||
const success = await connectPort();
|
||||
if (success) {
|
||||
setStatus('Connected', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(`Connection failed: ${error.message}`, 'error');
|
||||
port = null;
|
||||
connectBtn.textContent = 'Connect';
|
||||
commandLine.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Checkbox toggle stops auto reconnect if unchecked
|
||||
autoReconnectCheckbox.addEventListener('change', () => {
|
||||
if (!autoReconnectCheckbox.checked) {
|
||||
stopAutoReconnect();
|
||||
} else {
|
||||
// Start auto reconnect immediately if not connected
|
||||
if (!port) {
|
||||
tryAutoReconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sendModeBtn.addEventListener('click', () => {
|
||||
if (sendMode === 'command') {
|
||||
sendMode = 'instant';
|
||||
sendModeBtn.classList.remove('send-mode-command');
|
||||
sendModeBtn.classList.add('send-mode-instant');
|
||||
sendModeBtn.textContent = 'Instant mode';
|
||||
// In instant mode, we clear the command line
|
||||
commandLine.value = '';
|
||||
} else {
|
||||
sendMode = 'command';
|
||||
sendModeBtn.classList.remove('send-mode-instant');
|
||||
sendModeBtn.classList.add('send-mode-command');
|
||||
sendModeBtn.textContent = 'Command mode';
|
||||
}
|
||||
});
|
||||
|
||||
// Send command line input on Enter
|
||||
commandLine.addEventListener('keydown', async e => {
|
||||
if (!port) return;
|
||||
|
||||
// Instant mode: send key immediately including special keys like Backspace, arrows, enter, etc.
|
||||
if (sendMode === 'instant') {
|
||||
e.preventDefault();
|
||||
|
||||
// Ignore only pure modifier keys without text representation
|
||||
if (e.key.length === 1 ||
|
||||
e.key === 'Enter' ||
|
||||
e.key === 'Backspace' ||
|
||||
e.key === 'Tab' ||
|
||||
e.key === 'Escape' ||
|
||||
e.key === 'Delete' ) {
|
||||
|
||||
let sendText = '';
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
switch (newlineModeSelect.value) {
|
||||
case 'CR': sendText = '\r'; break;
|
||||
case 'CRLF': sendText = '\r\n'; break;
|
||||
default: sendText = '\n'; break;
|
||||
}
|
||||
break;
|
||||
case 'Backspace':
|
||||
// Usually no straightforward char to send for Backspace,
|
||||
// but often ASCII DEL '\x7F' or '\b' (0x08) is sent.
|
||||
sendText = '\x08'; // backspace
|
||||
break;
|
||||
case 'Tab':
|
||||
sendText = '\t';
|
||||
break;
|
||||
case 'Escape':
|
||||
// Ignore or send ESC control char if needed
|
||||
sendText = '\x1B';
|
||||
break;
|
||||
case 'Delete':
|
||||
sendText = '\x7F'; // DEL char
|
||||
break;
|
||||
default:
|
||||
sendText = e.key;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
try {
|
||||
await port.send(encoder.encode(sendText));
|
||||
} catch (error) {
|
||||
setStatus(`Send error: ${error.message}`, 'error');
|
||||
tryAutoReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Command mode: handle up/down arrow keys for history
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (history.length === 0) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (historyIndex === -1) historyIndex = history.length - 1;
|
||||
else if (historyIndex > 0) historyIndex--;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (historyIndex !== -1) historyIndex++;
|
||||
if (historyIndex >= history.length) historyIndex = -1;
|
||||
}
|
||||
commandLine.value = historyIndex === -1 ? '' : history[historyIndex];
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key !== 'Enter' || !port) return;
|
||||
e.preventDefault();
|
||||
const text = commandLine.value;
|
||||
if (!text) return;
|
||||
|
||||
// Add command to history, ignore duplicate consecutive
|
||||
if (history.length === 0 || history[history.length - 1] !== text) {
|
||||
history.push(text);
|
||||
}
|
||||
historyIndex = -1;
|
||||
|
||||
// Convert to Uint8Array with newline based on config
|
||||
let sendText = text;
|
||||
switch (newlineModeSelect.value) {
|
||||
case 'CR':
|
||||
sendText += '\r';
|
||||
break;
|
||||
case 'CRLF':
|
||||
sendText += '\r\n';
|
||||
break;
|
||||
case 'ANY':
|
||||
sendText += '\n';
|
||||
break;
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(sendText);
|
||||
|
||||
try {
|
||||
await port.send(data);
|
||||
appendCommandToSender(senderLines, sendText.replace(/[\r\n]+$/, ''));
|
||||
commandLine.value = '';
|
||||
} catch (error) {
|
||||
setStatus(`Send error: ${error.message}`, 'error');
|
||||
tryAutoReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Forget device button clears stored device info
|
||||
forgetDeviceBtn.addEventListener('click', async () => {
|
||||
if (port) {
|
||||
// Disconnect first
|
||||
await port.disconnect();
|
||||
await port.forgetDevice();
|
||||
stopAutoReconnect();
|
||||
await disconnectPort();
|
||||
|
||||
setStatus('Device forgotten', 'info');
|
||||
} else {
|
||||
setStatus('No device to forget', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Forget all devices button clears all stored device info
|
||||
forgetAllDevicesBtn.addEventListener('click', async () => {
|
||||
stopAutoReconnect();
|
||||
await disconnectPort();
|
||||
let ports = await serial.getPorts();
|
||||
if (ports.length > 0) {
|
||||
for (const p of ports) {
|
||||
await p.forgetDevice();
|
||||
}
|
||||
setStatus('All devices forgotten', 'info');
|
||||
} else {
|
||||
setStatus('No devices to forget', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Reset output button clears receiver
|
||||
resetOutputBtn.addEventListener('click', () => {
|
||||
receiverLines.innerHTML = '';
|
||||
});
|
||||
|
||||
// Reset button clears sender and receiver
|
||||
resetAllBtn.addEventListener('click', () => {
|
||||
senderLines.innerHTML = '';
|
||||
receiverLines.innerHTML = '';
|
||||
lastCommand = null;
|
||||
lastCommandCount = 0;
|
||||
lastCommandButton = null;
|
||||
});
|
||||
|
||||
|
||||
// Disable input on load
|
||||
commandLine.disabled = true;
|
||||
|
||||
// Show warning if no WebUSB support
|
||||
if (!serial.isWebUsbSupported()) {
|
||||
setStatus('WebUSB not supported on this browser', 'error');
|
||||
} else {
|
||||
// try to connect to any available device
|
||||
connectPort(true);
|
||||
}
|
||||
})();
|
67
examples/device/webusb_serial/website/index.html
Normal file
67
examples/device/webusb_serial/website/index.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>TinyUSB WebUSB Serial</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script defer src="serial.js"></script>
|
||||
<script defer src="application.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1 class="title">TinyUSB - WebUSB Serial</h1>
|
||||
<a class="github-link" href="https://github.com/hathach/tinyusb/tree/master/examples/device/webusb_serial/website"
|
||||
target="_blank">
|
||||
Find my source on GitHub
|
||||
</a>
|
||||
</header>
|
||||
<main>
|
||||
<section class="controls-section">
|
||||
<button id="connect" class="btn good">Connect</button>
|
||||
<label for="newline_mode">
|
||||
Newline mode:
|
||||
<select id="newline_mode">
|
||||
<option value="CR">Only \r</option>
|
||||
<option value="CRLF">\r\n</option>
|
||||
<option value="ANY" selected>\r, \n or \r\n</option>
|
||||
</select>
|
||||
</label>
|
||||
<label for="auto_reconnect">
|
||||
<input type="checkbox" id="auto_reconnect" />
|
||||
Auto Reconnect
|
||||
</label>
|
||||
<button id="forget_device" class="btn danger">Forget Device</button>
|
||||
<button id="forget_all_devices" class="btn danger">Forget All Devices</button>
|
||||
<button id="reset_all" class="btn danger">Reset All</button>
|
||||
<button id="reset_output" class="btn danger">Reset Output</button>
|
||||
</section>
|
||||
<section class="status-section">
|
||||
<span id="status" class="status">
|
||||
Click "Connect" to start
|
||||
</span>
|
||||
</section>
|
||||
<div class="io-container">
|
||||
<section class="column sender">
|
||||
<h2>Sender</h2>
|
||||
<div class="scrollbox-wrapper">
|
||||
<div id="sender_lines" class="scrollbox monospaced"></div>
|
||||
</div>
|
||||
<div class="send-container">
|
||||
<input id="command_line" class="input" placeholder="Start typing..." />
|
||||
<button id="send_mode" class="btn send-mode-command">Command Mode</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="column">
|
||||
<h2>Receiver</h2>
|
||||
<div class="scrollbox-wrapper">
|
||||
<div id="receiver_lines" class="scrollbox monospaced"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
124
examples/device/webusb_serial/website/serial.js
Normal file
124
examples/device/webusb_serial/website/serial.js
Normal file
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
|
||||
const serial = {
|
||||
isWebUsbSupported: () => 'usb' in navigator,
|
||||
|
||||
// Returns array of connected devices wrapped as serial.Port instances
|
||||
async getPorts() {
|
||||
const devices = await navigator.usb.getDevices();
|
||||
return devices.map(device => new serial.Port(device));
|
||||
},
|
||||
|
||||
// Prompts user to select a device matching filters and wraps it in serial.Port
|
||||
async requestPort() {
|
||||
const filters = [
|
||||
{ vendorId: 0xcafe }, // TinyUSB
|
||||
{ vendorId: 0x239a }, // Adafruit
|
||||
{ vendorId: 0x2e8a }, // Raspberry Pi
|
||||
{ vendorId: 0x303a }, // Espressif
|
||||
{ vendorId: 0x2341 }, // Arduino
|
||||
];
|
||||
const device = await navigator.usb.requestDevice({ filters });
|
||||
return new serial.Port(device);
|
||||
},
|
||||
|
||||
Port: class {
|
||||
constructor(device) {
|
||||
this.device = device;
|
||||
this.interfaceNumber = 0;
|
||||
this.endpointIn = 0;
|
||||
this.endpointOut = 0;
|
||||
this.readLoopActive = false;
|
||||
}
|
||||
|
||||
portPointToSameDevice(port) {
|
||||
if (this.device.vendorId !== port.device.vendorId) return false;
|
||||
if (this.device.productId !== port.device.productId) return false;
|
||||
if (this.device.serialNumber !== port.device.serialNumber) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Connect and start reading loop
|
||||
async connect() {
|
||||
await this.device.open();
|
||||
|
||||
if (!this.device.configuration) {
|
||||
await this.device.selectConfiguration(1);
|
||||
}
|
||||
|
||||
// Find interface with vendor-specific class (0xFF) and endpoints
|
||||
for (const iface of this.device.configuration.interfaces) {
|
||||
for (const alternate of iface.alternates) {
|
||||
if (alternate.interfaceClass === 0xff) {
|
||||
this.interfaceNumber = iface.interfaceNumber;
|
||||
for (const endpoint of alternate.endpoints) {
|
||||
if (endpoint.direction === 'out') this.endpointOut = endpoint.endpointNumber;
|
||||
else if (endpoint.direction === 'in') this.endpointIn = endpoint.endpointNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.interfaceNumber === undefined) {
|
||||
throw new Error('No suitable interface found.');
|
||||
}
|
||||
|
||||
await this.device.claimInterface(this.interfaceNumber);
|
||||
await this.device.selectAlternateInterface(this.interfaceNumber, 0);
|
||||
|
||||
// Set device to ENABLE (0x22 = SET_CONTROL_LINE_STATE, value 0x01 = activate)
|
||||
await this.device.controlTransferOut({
|
||||
requestType: 'class',
|
||||
recipient: 'interface',
|
||||
request: 0x22,
|
||||
value: 0x01,
|
||||
index: this.interfaceNumber,
|
||||
});
|
||||
|
||||
this.readLoopActive = true;
|
||||
this._readLoop();
|
||||
}
|
||||
|
||||
// Internal continuous read loop
|
||||
async _readLoop() {
|
||||
while (this.readLoopActive) {
|
||||
try {
|
||||
const result = await this.device.transferIn(this.endpointIn, 64);
|
||||
if (result.data && this.onReceive) {
|
||||
this.onReceive(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
this.readLoopActive = false;
|
||||
if (this.onReceiveError) {
|
||||
this.onReceiveError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop reading and release device
|
||||
async disconnect() {
|
||||
this.readLoopActive = false;
|
||||
await this.device.controlTransferOut({
|
||||
requestType: 'class',
|
||||
recipient: 'interface',
|
||||
request: 0x22,
|
||||
value: 0x00,
|
||||
index: this.interfaceNumber,
|
||||
});
|
||||
await this.device.close();
|
||||
}
|
||||
|
||||
// Send data to device
|
||||
send(data) {
|
||||
return this.device.transferOut(this.endpointOut, data);
|
||||
}
|
||||
|
||||
async forgetDevice() {
|
||||
if (this.device.opened) {
|
||||
await this.device.close();
|
||||
}
|
||||
await this.device.forget();
|
||||
}
|
||||
}
|
||||
};
|
188
examples/device/webusb_serial/website/style.css
Normal file
188
examples/device/webusb_serial/website/style.css
Normal file
@@ -0,0 +1,188 @@
|
||||
/* Reset default margins and make html, body full height */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Header row styling */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5em 1em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main is flex column */
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Controls top row in main*/
|
||||
.controls-section, .status-section {
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Container for the two columns */
|
||||
.io-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
/* fill remaining vertical space */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Both columns flex equally and full height */
|
||||
.column {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sender {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sender-entry {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid #ccc;
|
||||
/* light gray line */
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
gap: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sender-entry:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.monospaced {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.scrollbox-wrapper {
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
flex: 1;
|
||||
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.send-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.send-mode-command {
|
||||
background-color: light-gray;
|
||||
}
|
||||
|
||||
.send-mode-instant {
|
||||
background-color: blue;
|
||||
}
|
||||
|
||||
/* UI Styles */
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
flex: 1
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.good {
|
||||
background-color: #2ecc71;
|
||||
/* green */
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.danger {
|
||||
background-color: #e74c3c;
|
||||
/* red */
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 1rem;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
color: #333;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #aaa;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #0078d7;
|
||||
box-shadow: 0 0 6px rgba(0, 120, 215, 0.5);
|
||||
background-color: #fff;
|
||||
}
|
Reference in New Issue
Block a user