Mobile Overlay: Virtual Ctrl/Tab/Esc/Arow Keys, 4-Way Scroll, Voice Draft & Tmux Macros (Maybe Work Only on Mobile)
// ==UserScript==
// @name ttyd Mobile Controller
// @match <all_urls>
// @description Mobile Overlay: Virtual Ctrl/Tab/Esc/Arow Keys, 4-Way Scroll, Voice Draft & Tmux Macros (Maybe Work Only on Mobile)
// @author djshigel
// @version 25.0
// @license MIT
// @namespace https://greasyfork.org/users/1322886
// ==/UserScript==
(function() {
'use strict';
// --- User Configuration ---
const SENSITIVITY = 30; // Scroll sensitivity (px)
const LONG_PRESS_MS = 500; // Right-click long press duration (ms)
const DRAG_THRESHOLD = 15; // Threshold to detect panel dragging (px)
const ENTER_DELAY_MS = 200; // Delay before sending Enter after text input (ms)
// --- Global State ---
let scrollModeActive = false;
// 1. Startup Observer
// Detects when the terminal is ready and injects the UI.
const TARGET_SELECTOR = '#terminal-container';
const observer = new MutationObserver((mutations, obs) => {
const terminalContainer = document.querySelector(TARGET_SELECTOR);
if (terminalContainer && (window.term || window.socket)) {
console.log('â
TTYD detected. Injecting UI v24.0...');
initUI();
initBackgroundControl(terminalContainer);
obs.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
// 2. Data Transmission
// Sends sequences via WebSocket or xterm.js input handler.
function sendData(sequence) {
const encoder = new TextEncoder();
if (window.socket && window.socket.readyState === 1) {
window.socket.send(encoder.encode(sequence));
} else if (window.term && window.term.input) {
window.term.input(sequence);
}
}
// 3. Trigger Mouse Events
// Simulates native mouse events for the canvas.
function fireMouseEvent(type, target, x, y, buttonCode) {
const canvas = target.querySelector('canvas') || target;
const opts = {
bubbles: true, cancelable: true, view: window,
button: buttonCode, buttons: (buttonCode === 2 ? 2 : 1),
clientX: x, clientY: y
};
canvas.dispatchEvent(new MouseEvent(type, opts));
}
// 4. Background Interaction
// Handles background scrolling (Virtual Arrow Keys) and Right-Click (Long Press).
function initBackgroundControl(target) {
let startX = 0, startY = 0, lastX = 0, lastY = 0, accX = 0, accY = 0, longPressTimer = null;
target.addEventListener('touchstart', (e) => {
// Ignore touches on the floating controller
if (e.target.closest('#vibe-floater')) return;
// Ignore touches if the draft input is focused (prevents accidental unfocus)
if (document.activeElement && document.activeElement.id === 'draft-input') return;
if (scrollModeActive) e.preventDefault();
const touch = e.touches[0];
startX = touch.clientX; startY = touch.clientY;
lastX = startX; lastY = startY;
accX = 0; accY = 0;
// Start long-press timer for Right-Click
longPressTimer = setTimeout(() => {
if (navigator.vibrate) navigator.vibrate([40]);
fireMouseEvent('mousedown', target, startX, startY, 2);
setTimeout(() => fireMouseEvent('mouseup', target, startX, startY, 2), 50);
}, LONG_PRESS_MS);
}, { passive: false });
target.addEventListener('touchmove', (e) => {
if (e.target.closest('#vibe-floater')) return;
if (document.activeElement && document.activeElement.id === 'draft-input') return;
const touch = e.touches[0];
// Cancel long-press if finger moves
if (Math.hypot(touch.clientX - startX, touch.clientY - startY) > 10) clearTimeout(longPressTimer);
// Handle Scroll Mode (Virtual Arrow Keys)
if (scrollModeActive) {
e.preventDefault();
accX += (touch.clientX - lastX);
accY += (touch.clientY - lastY);
lastX = touch.clientX; lastY = touch.clientY;
if (Math.abs(accX) >= SENSITIVITY) {
if (accX > 0) { sendData('\x1b[C'); accX -= SENSITIVITY; } // Right
else { sendData('\x1b[D'); accX += SENSITIVITY; } // Left
}
if (Math.abs(accY) >= SENSITIVITY) {
if (accY > 0) { sendData('\x1b[B'); accY -= SENSITIVITY; } // Down
else { sendData('\x1b[A'); accY += SENSITIVITY; } // Up
}
}
}, { passive: false });
target.addEventListener('touchend', () => clearTimeout(longPressTimer), { passive: false });
}
// 5. UI Construction
function initUI() {
const style = document.createElement('style');
style.innerHTML = `
#vibe-floater {
position: fixed; top: 10vh; right: 2vw;
width: 76vw; min-width: 200px; max-width: 400px;
background: rgba(10, 10, 10, 0.97);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px; padding: 6px; z-index: 2147483647;
backdrop-filter: blur(20px); box-shadow: 0 15px 50px rgba(0, 0, 0, 0.8);
touch-action: none; user-select: none; -webkit-user-select: none;
display: flex; flex-direction: column; gap: 4px;
transition: height 0.2s;
}
#vibe-floater.mode-ctrl { width: 95vw; max-width: 800px; }
.layer-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; width: 100%; }
#layer-ctrl { display: none; grid-template-columns: repeat(10, 1fr); gap: 3px; }
.vb-btn {
background: rgba(255, 255, 255, 0.08); color: #eee; border-radius: 8px;
font-family: monospace; font-weight: 700; display: flex;
justify-content: center; align-items: center; box-shadow: 0 2px 4px rgba(0,0,0,0.4);
grid-column: span 2; aspect-ratio: 5/3; font-size: clamp(16px, 5.5vw, 22px);
}
.vb-btn.active-touch { background: rgba(255, 255, 255, 0.3); transform: scale(0.94); }
.vb-btn.row-ctrl { grid-column: span 3; height: 60px; aspect-ratio: auto; font-size: 18px; }
.icon-lg { font-size: 36px !important; }
/* Input Area */
#draft-container {
grid-column: span 6;
display: flex; gap: 6px;
margin-top: 2px;
}
#draft-input {
flex: 1;
height: 40px; /* Default height */
background: rgba(40, 40, 40, 0.9);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
font-family: monospace;
font-size: 16px; /* Larger font for visibility and focus */
padding: 10px;
resize: none;
transition: height 0.2s, background 0.2s;
}
#draft-input:focus {
height: 100px; /* Expanded height on focus */
background: rgba(0, 0, 0, 0.95);
border-color: #00aaff;
outline: none;
}
#draft-send-btn {
width: 50px; height: auto;
background: #0077aa; color: white;
border: none; border-radius: 8px;
display: flex; justify-content: center; align-items: center;
font-size: 24px;
font-weight: bold;
padding-bottom: 4px;
}
#draft-send-btn:active { background: #0099cc; }
#btn-scroll-toggle.toggle-on { background: rgba(0, 150, 255, 0.5) !important; color: #fff; border: 1px solid #00aaff; }
#layer-ctrl .vb-btn { grid-column: span 1; height: 50px; font-size: 18px; background: rgba(50, 70, 90, 0.6); aspect-ratio: auto; }
.vb-btn[data-color="red"] { background: rgba(200, 50, 50, 0.35); }
.vb-btn[data-color="blue"] { background: rgba(30, 100, 200, 0.35); }
.span-full { grid-column: 1 / -1; height: 50px; background: #333 !important; aspect-ratio: auto; margin-top: 5px; }
`;
document.head.appendChild(style);
const floater = document.createElement('div');
floater.id = 'vibe-floater';
document.body.appendChild(floater);
// --- Main Layer ---
const mainLayer = document.createElement('div');
mainLayer.id = 'layer-main';
mainLayer.className = 'layer-grid';
floater.appendChild(mainLayer);
// Grid Layout (6 columns)
const mainKeys = [
// Row 1
{ label: 'Esc', seq: '\x1b' },
{ label: 'C-c', seq: '\x03', color: 'red' },
{ label: 'Ent', seq: '\r' },
// Row 2
{ label: 'Tab', seq: '\t' },
{ label: 'â²', seq: '\x1b[A' },
{ label: 'C-b', seq: '\x02', color: 'blue' },
// Row 3
{ label: 'â', seq: '\x1b[D' },
{ label: 'â¼', seq: '\x1b[B' },
{ label: 'â¶', seq: '\x1b[C' },
// Row 4
{ label: 'CTRL / MENU', action: 'showCtrl', cls: 'row-ctrl', color: 'blue' },
{ label: 'â¥', action: 'toggleScroll', cls: 'row-ctrl icon-lg', id: 'btn-scroll-toggle' }
];
mainKeys.forEach((k, i) => {
const btn = document.createElement('div');
btn.className = `vb-btn ${k.cls || ''}`;
if (k.id) btn.id = k.id;
btn.textContent = k.label;
if (k.color) btn.dataset.color = k.color;
if (k.action) btn.dataset.action = k.action;
btn.dataset.layer = 'main';
btn.dataset.index = i;
mainLayer.appendChild(btn);
});
// --- Input Row (Always visible) ---
const draftContainer = document.createElement('div');
draftContainer.id = 'draft-container';
draftContainer.innerHTML = `
<textarea id="draft-input" placeholder="Draft / Voice Input"></textarea>
<button id="draft-send-btn">â²</button>
`;
mainLayer.appendChild(draftContainer);
// --- Ctrl Layer (QWERTY) ---
const ctrlLayer = document.createElement('div');
ctrlLayer.id = 'layer-ctrl';
ctrlLayer.className = 'layer-grid';
floater.appendChild(ctrlLayer);
const getCode = (c) => String.fromCharCode(c.charCodeAt(0) - 64);
const rows = ['QWERTYUIOP', 'ASDFGHJKL', 'ZXCVBNM'];
rows[0].split('').forEach(c => addKey('^' + c, getCode(c)));
rows[1].split('').forEach(c => addKey('^' + c, getCode(c)));
addKey(';', ';'); addKey('^B[', '\x02['); addKey('^B]', '\x02]'); addKey('â§Tab', '\x1b[Z'); addKey('^_', '\x1f'); addKey('Esc', '\x1b');
rows[2].split('').forEach(c => addKey('^' + c, getCode(c)));
addKey('â«', '\x7f'); addKey('â£', ' '); addKey('â', '\x1b[5~'); addKey('â', '\x1b[6~');
const backBtn = document.createElement('div');
backBtn.className = 'vb-btn span-full';
backBtn.textContent = 'BACK';
backBtn.dataset.layer = 'ctrl';
backBtn.dataset.action = 'hideCtrl';
ctrlLayer.appendChild(backBtn);
function addKey(c, customSeq) {
const code = customSeq || (c.length === 1 && /[A-Z]/.test(c) ? getCode(c) : c);
const btn = document.createElement('div');
btn.className = 'vb-btn';
btn.textContent = c;
btn.dataset.layer = 'ctrl';
btn.dataset.seq = code;
ctrlLayer.appendChild(btn);
}
// --- Input Logic ---
const draftInput = document.getElementById('draft-input');
const draftSendBtn = document.getElementById('draft-send-btn');
const submitDraft = () => {
const text = draftInput.value;
if (text) {
// Send text first
sendData(text);
// Wait briefly before sending Enter to prevent line break issues
setTimeout(() => {
sendData('\r');
}, ENTER_DELAY_MS);
if (navigator.vibrate) navigator.vibrate(50);
draftInput.value = '';
draftInput.blur();
}
};
draftInput.addEventListener('keydown', (e) => {
// Stop propagation to prevent conflict with panel controls
e.stopPropagation();
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitDraft();
}
});
draftSendBtn.addEventListener('click', (e) => {
e.stopPropagation();
submitDraft();
});
// --- Panel Control Logic ---
let startX, startY, initialLeft, initialTop, isDragging = false, activeBtn = null;
const toggleLayer = (showCtrl) => {
mainLayer.style.display = showCtrl ? 'none' : 'grid';
ctrlLayer.style.display = showCtrl ? 'grid' : 'none';
if (showCtrl) floater.classList.add('mode-ctrl');
else floater.classList.remove('mode-ctrl');
if (navigator.vibrate) navigator.vibrate(20);
};
floater.addEventListener('touchstart', (e) => {
// Important: Allow native behavior for the input container
if (e.target.closest('#draft-container')) return;
e.preventDefault(); e.stopPropagation();
const touch = e.touches[0];
startX = touch.clientX; startY = touch.clientY;
const rect = floater.getBoundingClientRect();
initialLeft = rect.left; initialTop = rect.top;
isDragging = false;
const target = e.target.closest('.vb-btn');
if (target) { activeBtn = target; activeBtn.classList.add('active-touch'); }
}, { passive: false });
floater.addEventListener('touchmove', (e) => {
if (e.target.closest('#draft-container')) return;
e.preventDefault(); e.stopPropagation();
const touch = e.touches[0];
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
if (!isDragging && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
isDragging = true;
if (activeBtn) { activeBtn.classList.remove('active-touch'); activeBtn = null; }
}
if (isDragging) {
floater.style.left = `${initialLeft + dx}px`;
floater.style.top = `${initialTop + dy}px`;
floater.style.right = 'auto';
}
}, { passive: false });
floater.addEventListener('touchend', (e) => {
if (e.target.closest('#draft-container')) return;
e.preventDefault(); e.stopPropagation();
if (!isDragging && activeBtn) {
activeBtn.classList.remove('active-touch');
if (activeBtn.dataset.action === 'showCtrl') toggleLayer(true);
else if (activeBtn.dataset.action === 'hideCtrl') toggleLayer(false);
else if (activeBtn.dataset.action === 'toggleScroll') {
scrollModeActive = !scrollModeActive;
activeBtn.classList.toggle('toggle-on', scrollModeActive);
if (navigator.vibrate) navigator.vibrate([30, 50]);
} else {
let k = activeBtn.dataset.layer === 'main' ? mainKeys[activeBtn.dataset.index] : { seq: activeBtn.dataset.seq };
if (k) {
sendData(k.seq || k);
if (navigator.vibrate) navigator.vibrate(10);
if (activeBtn.dataset.layer === 'ctrl') setTimeout(() => toggleLayer(false), 150);
}
}
}
activeBtn = null; isDragging = false;
}, { passive: false });
}
})();