Last active
November 9, 2025 05:25
-
-
Save rbreaves/ae07b384e3a2a16a0be6844eb644737f to your computer and use it in GitHub Desktop.
One-handed keyboard w/ touchpad for Chrome Remote Desktop on Mobile Devices
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // use site to convert to bookmarklet for mobile devices | |
| // https://infocatcher.github.io/Bookmarklets/scriptToBookmarklet.html | |
| (() => { | |
| // === LocalStorage helpers === | |
| const STORAGE_KEY = 'crd_layout_settings'; | |
| function loadSettings() { | |
| try { | |
| return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| function saveSetting(key, value) { | |
| const s = loadSettings(); | |
| s[key] = value; | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); | |
| } | |
| const settings = loadSettings(); | |
| // ===== Locate CRD Elements (tolerant selectors) ===== | |
| const s = document.querySelector('.xHUvi.qr01Jb') || | |
| document.querySelector('.xHUvi.REbh3b.qr01Jb') || | |
| document.querySelector('.xHUvi.REbh3b.FKhztc.qr01Jb'); | |
| const sp = document.querySelector('section.MboOLb[aria-label="Displays"]'); | |
| const c = document.querySelector('canvas.sshIvb'); | |
| const vc = document.querySelector('div.VCqmx'); | |
| const crdSurface = document.querySelector('.SaCab'); | |
| const landscapeDiv = document.querySelector('.n9VB4d.LgVU8b'); // landscape keyboard container | |
| if (!(sp && c && vc && crdSurface)) { | |
| console.warn('⚠️ Missing required CRD elements.'); | |
| return; | |
| } | |
| // --- Ensure keyboard is expanded before proceeding --- | |
| let k = document.querySelector('.V9Tle.JZG3Fc.hdWt0'); | |
| if (!k) { | |
| const collapsed = document.querySelector('.V9Tle.JZG3Fc'); | |
| if (collapsed) { | |
| console.log('🟡 Expanding collapsed keyboard…'); | |
| collapsed.classList.add('hdWt0'); | |
| const start = Date.now(); | |
| while (Date.now() - start < 500) {} // half-second delay for DOM update | |
| } | |
| } | |
| k = document.querySelector('.V9Tle.JZG3Fc.hdWt0'); | |
| if (!k) { | |
| console.error('❌ Could not find or expand keyboard — aborting.'); | |
| return; | |
| } | |
| const baseKeyboardHeight = k?.offsetHeight || 0; | |
| console.log(`🎹 Keyboard height detected: ${baseKeyboardHeight}px`); | |
| const originalParent = k?.parentElement || null; | |
| const originalNextSibling = k?.nextSibling || null; | |
| let ns = null; | |
| const insertKeyboard = () => { | |
| if (!k || ns) return; | |
| ns = document.createElement('section'); | |
| ns.className = 'MboOLb injected-keyboard-section'; | |
| ns.setAttribute('role', 'region'); | |
| ns.setAttribute('aria-label', 'Keyboard'); | |
| ns.appendChild(k); | |
| sp.insertAdjacentElement('afterend', ns); | |
| Object.assign(k.style, { | |
| width: '100%', | |
| background: 'rgba(0,0,0,0.25)', | |
| position: 'relative', | |
| transform: 'none', | |
| left: '0', | |
| bottom: '0', | |
| zIndex: 'auto' | |
| }); | |
| console.log('✅ Keyboard relocated (sidebar).'); | |
| }; | |
| const removeKeyboard = () => { | |
| if (!k) return; | |
| if (originalParent && k.parentElement !== originalParent) { | |
| if (originalNextSibling) originalParent.insertBefore(k, originalNextSibling); | |
| else originalParent.appendChild(k); | |
| console.log('↩️ Keyboard restored to original (main area).'); | |
| } | |
| if (ns) { ns.remove(); ns = null; } | |
| k.removeAttribute('style'); | |
| }; | |
| // ===== Touchpad Section (sidebar pad) ===== | |
| const padSection = document.createElement('section'); | |
| padSection.className = 'MboOLb injected-pad-section'; | |
| padSection.setAttribute('role', 'region'); | |
| padSection.setAttribute('aria-label', 'Touchpad Section'); | |
| const padContainer = document.createElement('div'); | |
| Object.assign(padContainer.style, { | |
| height: '400px', | |
| background: 'transparent', | |
| display: 'flex', | |
| justifyContent: 'center', | |
| alignItems: 'center', | |
| flexDirection: 'column', | |
| gap: '12px' | |
| }); | |
| const padLabel = document.createElement('div'); | |
| padLabel.textContent = 'Touchpad Overlay'; | |
| Object.assign(padLabel.style, { | |
| color: '#fff', | |
| fontSize: '16px', | |
| marginBottom: '8px' | |
| }); | |
| const touchpad = document.createElement('div'); | |
| Object.assign(touchpad.style, { | |
| width: '90%', | |
| maxWidth: '360px', | |
| height: '240px', | |
| background: 'rgba(255,255,255,0.08)', | |
| border: '1px solid rgba(255,255,255,0.2)', | |
| borderRadius: '8px', | |
| touchAction: 'none', | |
| position: 'relative', | |
| overflow: 'hidden', | |
| backdropFilter: 'blur(4px)', | |
| transition: 'background 0.15s' | |
| }); | |
| padContainer.append(padLabel, touchpad); | |
| padSection.append(padContainer); | |
| insertKeyboard(); | |
| const kbSection = document.querySelector('.injected-keyboard-section'); | |
| if (kbSection) kbSection.insertAdjacentElement('afterend', padSection); | |
| else sp.insertAdjacentElement('afterend', padSection); | |
| console.log('✅ Touchpad overlay inserted below keyboard (sidebar mode).'); | |
| // ===== Offset / Controls Section (below touchpad) ===== | |
| const o = document.createElement('section'); | |
| o.className = 'MboOLb injected-offset-section'; | |
| o.setAttribute('role', 'region'); | |
| o.setAttribute('aria-label', 'Sidebar Offset'); | |
| const w = document.createElement('div'); | |
| w.style.padding = '6px 8px'; | |
| const l = document.createElement('label'); | |
| l.textContent = 'Sidebar Offset (px):'; | |
| l.style.display = 'block'; | |
| l.style.marginBottom = '4px'; | |
| const i = document.createElement('input'); | |
| i.type = 'number'; | |
| i.placeholder = 'e.g. 100 or -100'; | |
| i.value = settings.sidebarOffset ?? ''; | |
| i.addEventListener('input', () => saveSetting('sidebarOffset', i.value)); | |
| Object.assign(i.style, { width: '100%', boxSizing: 'border-box', padding: '4px' }); | |
| w.append(l, i); | |
| // Landscape offset | |
| const l2 = document.createElement('label'); | |
| l2.textContent = 'Landscape Offset (px):'; | |
| Object.assign(l2.style, { display: 'block', marginTop: '8px', marginBottom: '4px' }); | |
| const i2 = document.createElement('input'); | |
| i2.type = 'number'; | |
| i2.placeholder = 'e.g. 50 or -50'; | |
| i2.value = settings.landscapeOffset ?? ''; | |
| i2.addEventListener('input', () => saveSetting('landscapeOffset', i2.value)); | |
| Object.assign(i2.style, { width: '100%', boxSizing: 'border-box', padding: '4px' }); | |
| const chkWrap = document.createElement('div'); | |
| chkWrap.style.marginTop = '6px'; | |
| const chk = document.createElement('input'); | |
| chk.type = 'checkbox'; | |
| chk.id = 'useRightMargin'; | |
| const chkLabel = document.createElement('label'); | |
| chkLabel.htmlFor = 'useRightMargin'; | |
| chkLabel.textContent = ' Use Right Margin'; | |
| chk.checked = settings.useRightMargin ? true : false; | |
| chk.addEventListener('change', () => saveSetting('useRightMargin', chk.checked)); | |
| chkWrap.append(chk, chkLabel); | |
| // Keyboard Height Override | |
| const l3 = document.createElement('label'); | |
| l3.textContent = 'Landscape Keyboard Height (px):'; | |
| Object.assign(l3.style, { display: 'block', marginTop: '8px', marginBottom: '4px' }); | |
| const i3 = document.createElement('input'); | |
| i3.type = 'number'; | |
| // i3.value = baseKeyboardHeight; | |
| i3.value = settings.keyboardHeight ?? baseKeyboardHeight; | |
| i3.addEventListener('input', () => saveSetting('keyboardHeight', i3.value)); | |
| Object.assign(i3.style, { width: '100%', boxSizing: 'border-box', padding: '4px' }); | |
| const resetBtn = document.createElement('button'); | |
| resetBtn.textContent = 'Reset Height'; | |
| Object.assign(resetBtn.style, { | |
| marginTop: '6px', | |
| padding: '4px 8px', | |
| background: '#333', | |
| color: '#fff', | |
| border: '1px solid #555', | |
| borderRadius: '4px', | |
| cursor: 'pointer' | |
| }); | |
| resetBtn.addEventListener('click', () => { | |
| if (!k) return; | |
| k.style.height = ''; | |
| if (landscapeDiv) { | |
| landscapeDiv.style.removeProperty('--keyboard-height'); | |
| landscapeDiv.style.height = ''; | |
| } | |
| const currentHeight = k.offsetHeight || baseKeyboardHeight; | |
| i3.value = currentHeight; | |
| const inlinePad = document.querySelector('.touchpad-inline'); | |
| if (inlinePad) inlinePad.style.height = `${currentHeight}px`; | |
| console.log(`↩️ Keyboard height reset to default (${currentHeight}px).`); | |
| updateDockLayout(); | |
| }); | |
| // Placement selector | |
| const layoutLabel = document.createElement('label'); | |
| layoutLabel.textContent = 'Touchpad & Keyboard Placement:'; | |
| Object.assign(layoutLabel.style, { display: 'block', marginTop: '10px', marginBottom: '4px' }); | |
| const layoutSelect = document.createElement('select'); | |
| Object.assign(layoutSelect.style, { | |
| width: '100%', boxSizing: 'border-box', padding: '4px', marginBottom: '6px' | |
| }); | |
| layoutSelect.innerHTML = ` | |
| <option value="sidebar" selected>Sidebar (default)</option> | |
| <option value="land-left">Left Landscape (keyboard left, touchpad right)</option> | |
| <option value="land-right">Right Landscape (keyboard right, touchpad left)</option> | |
| <option value="land-kb-only">Landscape Keyboard Only (no touchpad)</option> | |
| `; | |
| const layoutNote = document.createElement('div'); | |
| layoutNote.textContent = '(Landscape options require the landscape keyboard to be visible)'; | |
| Object.assign(layoutNote.style, { | |
| fontSize: '12px', | |
| color: '#aaa', | |
| marginTop: '-2px', | |
| marginBottom: '6px' | |
| }); | |
| if (!landscapeDiv) { | |
| Array.from(layoutSelect.options).forEach(opt => { | |
| if (opt.value.startsWith('land-')) opt.disabled = true; | |
| }); | |
| layoutNote.style.color = '#f77'; | |
| } | |
| // Restore saved layout value (or default to 'sidebar') | |
| layoutSelect.value = settings.layoutMode ? settings.layoutMode : 'sidebar'; | |
| // Save to localStorage when changed | |
| layoutSelect.addEventListener('change', () => { | |
| saveSetting('layoutMode', layoutSelect.value); | |
| }); | |
| // Landscape touchpad width | |
| const l4 = document.createElement('label'); | |
| l4.textContent = 'Landscape Touchpad Width (px):'; | |
| Object.assign(l4.style, { display: 'block', marginTop: '6px', marginBottom: '4px' }); | |
| const i4 = document.createElement('input'); | |
| i4.type = 'number'; | |
| // i4.value = 320; | |
| i4.value = settings.touchpadWidth ?? 320; | |
| i4.addEventListener('input', () => saveSetting('touchpadWidth', i4.value)); | |
| Object.assign(i4.style, { width: '100%', boxSizing: 'border-box', padding: '4px' }); | |
| const clearBtn = document.createElement('button'); | |
| clearBtn.textContent = 'Clear Saved Settings'; | |
| Object.assign(clearBtn.style, { | |
| marginTop: '6px', | |
| padding: '4px 8px', | |
| background: '#552222', | |
| color: '#fff', | |
| border: '1px solid #844', | |
| borderRadius: '4px', | |
| cursor: 'pointer' | |
| }); | |
| clearBtn.addEventListener('click', () => { | |
| localStorage.removeItem('crd_layout_settings'); | |
| console.log('🧹 LocalStorage cleared for crd_layout_settings'); | |
| alert('Saved layout settings cleared. Reload or re-run the script to reset.'); | |
| }); | |
| w.append(l, i, l2, i2, chkWrap, l3, i3, resetBtn, l4, i4, layoutLabel, layoutSelect, layoutNote, clearBtn); | |
| o.append(w); | |
| padSection.insertAdjacentElement('afterend', o); | |
| // ===== Dock Layout Handler ===== | |
| let dockPad = null; | |
| const GAP = 12; | |
| const ensureDockPad = () => { | |
| if (dockPad && document.body.contains(dockPad)) return dockPad; | |
| dockPad = touchpad.cloneNode(true); | |
| dockPad.classList.add('touchpad-inline'); | |
| Object.assign(dockPad.style, { | |
| position: 'absolute', | |
| top: '0', | |
| bottom: '0', | |
| width: `${Math.max(120, parseInt(i4.value || '320', 10))}px`, | |
| margin: '0', | |
| maxWidth: 'none', | |
| border: '1px solid rgba(255,255,255,0.2)', | |
| borderRadius: '8px', | |
| background: 'rgba(255,255,255,0.08)' | |
| }); | |
| ['pointerdown','pointermove','pointerup','touchstart','touchmove','touchend'].forEach(evt => { | |
| dockPad.addEventListener(evt, e => { | |
| const clone = new e.constructor(evt, e); | |
| crdSurface.dispatchEvent(clone); | |
| e.preventDefault(); | |
| }, { passive: false }); | |
| }); | |
| return dockPad; | |
| }; | |
| const removeDockPad = () => { | |
| if (dockPad && dockPad.parentElement) dockPad.remove(); | |
| dockPad = null; | |
| }; | |
| function updateDockLayout() { | |
| const mode = layoutSelect.value; | |
| if (!landscapeDiv || mode === 'sidebar') { | |
| padSection.style.display = ''; | |
| touchpad.style.height = '240px'; | |
| removeDockPad(); | |
| insertKeyboard(); | |
| k.style.width = '100%'; | |
| k.style.height = ''; | |
| landscapeDiv.style.marginLeft = ''; | |
| landscapeDiv.style.marginRight = ''; | |
| landscapeDiv.style.width = ''; | |
| console.log('↩️ Sidebar layout restored.'); | |
| return; | |
| } | |
| // Landscape Keyboard Only mode | |
| if (mode === 'land-kb-only') { | |
| // padSection.style.display = 'none'; | |
| landscapeDiv.style.marginLeft = ''; | |
| landscapeDiv.style.marginRight = ''; | |
| removeDockPad(); | |
| removeKeyboard(); | |
| console.log('⌨️ Landscape keyboard only mode enabled.'); | |
| return; | |
| } | |
| // Landscape with touchpad modes | |
| padSection.style.display = 'none'; | |
| touchpad.style.height = '100%'; | |
| removeKeyboard(); | |
| const container = landscapeDiv.parentElement; | |
| if (!container) return; | |
| if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; | |
| const dp = ensureDockPad(); | |
| if (!dp.parentElement) container.appendChild(dp); | |
| const padW = Math.max(120, parseInt(i4.value || '320', 10)); | |
| dp.style.width = `${padW}px`; | |
| landscapeDiv.style.marginLeft = ''; | |
| landscapeDiv.style.marginRight = ''; | |
| if (mode === 'land-right') { | |
| dp.style.left = '0'; dp.style.right = ''; | |
| landscapeDiv.style.marginLeft = `${padW + GAP}px`; | |
| } else if (mode === 'land-left') { | |
| dp.style.right = '0'; dp.style.left = ''; | |
| landscapeDiv.style.marginRight = `${padW + GAP}px`; | |
| } | |
| landscapeDiv.style.width = ''; | |
| } | |
| // ===== Show docked touchpad again when keyboard icon is clicked ===== | |
| const showKeyboardBtn = document.querySelector('div[role="button"][aria-label="Show remote keyboard"]'); | |
| if (showKeyboardBtn) { | |
| showKeyboardBtn.addEventListener('click', () => { | |
| setTimeout(() => { | |
| const dock = document.querySelector('.touchpad-inline'); | |
| if (!dock) return; | |
| dock.style.display = ''; | |
| console.log('⌨️ Keyboard reopened — touchpad shown.'); | |
| }, 300); // small delay to let CRD render keyboard | |
| }); | |
| } | |
| // ===== Auto-hide docked touchpad when "Compose text locally" is toggled ===== | |
| const textInputBtn = document.querySelector('div[role="button"][data-key="TextInput"]'); | |
| if (textInputBtn) { | |
| textInputBtn.addEventListener('click', () => { | |
| // Wait a tiny bit for CRD to resize/minify keyboard | |
| setTimeout(() => { | |
| const dock = document.querySelector('.touchpad-inline'); | |
| if (!dock) return; | |
| // Determine if keyboard is minimized by checking height or visibility | |
| const kbHeight = landscapeDiv ? landscapeDiv.offsetHeight : 0; | |
| if (kbHeight < 100) { | |
| dock.style.display = 'none'; | |
| console.log('📉 Keyboard minimized — touchpad hidden.'); | |
| } else { | |
| dock.style.display = ''; | |
| console.log('📈 Keyboard expanded — touchpad restored.'); | |
| } | |
| }, 200); | |
| }); | |
| } | |
| // ===== Apply Keyboard Height (syncs landscape + inline pad) ===== | |
| const applyKeyboardHeight = () => { | |
| const val = parseInt(i3.value, 10); | |
| if (!Number.isNaN(val) && val > 0) { | |
| if (k) k.style.height = `${val}px`; | |
| if (landscapeDiv) { | |
| landscapeDiv.style.setProperty('--keyboard-height', `${val}px`); | |
| landscapeDiv.style.height = `${val}px`; | |
| } | |
| const inlinePad = document.querySelector('.touchpad-inline'); | |
| if (inlinePad) inlinePad.style.height = `${val}px`; | |
| console.log(`🎛️ Keyboard height overridden: ${val}px`); | |
| } else { | |
| if (k) k.style.height = ''; | |
| if (landscapeDiv) { | |
| landscapeDiv.style.removeProperty('--keyboard-height'); | |
| landscapeDiv.style.height = ''; | |
| } | |
| const inlinePad = document.querySelector('.touchpad-inline'); | |
| if (inlinePad) inlinePad.style.height = ''; | |
| } | |
| updateDockLayout(); | |
| }; | |
| i3.addEventListener('input', applyKeyboardHeight); | |
| layoutSelect.addEventListener('change', updateDockLayout); | |
| i4.addEventListener('input', updateDockLayout); | |
| // === Apply saved values on load === | |
| if (settings.keyboardHeight && settings.layoutMode=='land-kb-only') { | |
| i3.value = settings.keyboardHeight; | |
| applyKeyboardHeight(); | |
| } | |
| if (settings.layoutMode) layoutSelect.value = settings.layoutMode; | |
| if (settings.touchpadWidth) i4.value = settings.touchpadWidth; | |
| if (settings.useRightMargin) chk.checked = true; | |
| if (settings.sidebarOffset) i.value = settings.sidebarOffset; | |
| if (settings.landscapeOffset) i2.value = settings.landscapeOffset; | |
| updateDockLayout(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment