| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Browser</title> |
| <style> |
| *{box-sizing:border-box;margin:0;padding:0} |
| |
| :root{ |
| --bg:#dee1e6; |
| --tab-active:#ffffff; |
| --blue:#1a73e8; |
| --text:#202124; |
| --icon:#5f6368; |
| --hover:rgba(0,0,0,0.08); |
| --green:#188038; |
| } |
| |
| html,body{ |
| height:100vh;overflow:hidden; |
| font-family:-apple-system,'Segoe UI',Roboto,sans-serif; |
| font-size:13px;background:#202124; |
| display:flex;flex-direction:column; |
| } |
| |
| |
| #tab-strip{ |
| display:flex;align-items:flex-end; |
| background:var(--bg); |
| padding:6px 0 0 6px;height:36px; |
| -webkit-app-region:drag;user-select:none; |
| } |
| .tab{ |
| display:flex;align-items:center;gap:6px; |
| padding:0 8px 0 10px;height:28px; |
| background:var(--tab-active); |
| border-radius:8px 8px 0 0; |
| min-width:120px;max-width:200px;flex:0 0 auto; |
| position:relative;cursor:default; |
| } |
| .tab-fav{width:16px;height:16px;border-radius:2px;flex-shrink:0} |
| .tab-lbl{ |
| flex:1;overflow:hidden;white-space:nowrap; |
| text-overflow:ellipsis;color:var(--text);font-size:12px; |
| } |
| .tab-x{ |
| width:16px;height:16px;border:none;background:transparent; |
| border-radius:50%;cursor:pointer;color:var(--icon); |
| font-size:16px;line-height:1;display:flex; |
| align-items:center;justify-content:center;flex-shrink:0; |
| } |
| .tab-x:hover{background:rgba(0,0,0,0.12)} |
| |
| |
| #toolbar{ |
| display:flex;align-items:center;gap:2px; |
| background:var(--bg);padding:4px 8px;height:40px; |
| } |
| .nb{ |
| width:32px;height:32px;border:none;background:transparent; |
| border-radius:50%;cursor:pointer;display:flex; |
| align-items:center;justify-content:center; |
| color:var(--icon);flex-shrink:0; |
| } |
| .nb:hover:not([disabled]){background:var(--hover)} |
| .nb[disabled]{opacity:.38;cursor:default} |
| .nb svg{width:18px;height:18px;fill:var(--icon)} |
| |
| |
| #omni{ |
| flex:1;display:flex;align-items:center; |
| background:#fff;border-radius:100px; |
| height:32px;padding:0 10px;gap:6px; |
| border:1.5px solid transparent; |
| transition:border-color .15s,box-shadow .15s; |
| } |
| #omni:focus-within{ |
| border-color:var(--blue); |
| box-shadow:0 0 0 3px rgba(26,115,232,.2); |
| } |
| #lock{display:flex;align-items:center;flex-shrink:0} |
| #lock svg{width:13px;height:13px;fill:var(--green)} |
| #url{ |
| flex:1;border:none;outline:none; |
| font-size:13px;color:var(--text); |
| background:transparent;min-width:0; |
| } |
| #star{flex-shrink:0;cursor:pointer;color:var(--icon);line-height:0} |
| #star svg{width:16px;height:16px;fill:var(--icon)} |
| |
| |
| #vp-wrap{ |
| flex:1;background:#fff;position:relative;overflow:hidden; |
| } |
| #vp{ |
| width:100%;height:100%;display:block; |
| cursor:default;outline:none; |
| } |
| |
| |
| #overlay{ |
| position:absolute;inset:0;background:#fff; |
| display:flex;flex-direction:column; |
| align-items:center;justify-content:center; |
| gap:14px;z-index:9;transition:opacity .3s; |
| } |
| #overlay.gone{opacity:0;pointer-events:none} |
| .spin{ |
| width:36px;height:36px;border:3px solid #dee1e6; |
| border-top-color:var(--blue);border-radius:50%; |
| animation:sp .7s linear infinite; |
| } |
| @keyframes sp{to{transform:rotate(360deg)}} |
| |
| |
| #status{ |
| position:absolute;bottom:8px;right:10px; |
| background:rgba(0,0,0,.5);color:#fff; |
| font-size:11px;padding:2px 8px;border-radius:10px; |
| z-index:10;pointer-events:none;transition:opacity .5s; |
| } |
| #status.ok{opacity:0} |
| </style> |
| </head> |
| <body> |
|
|
| |
| <div id="tab-strip"> |
| <div class="tab"> |
| <img class="tab-fav" id="fav" |
| src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23bbb'/%3E%3C/svg%3E"> |
| <span class="tab-lbl" id="t-title">New Tab</span> |
| <button class="tab-x">β</button> |
| </div> |
| </div> |
|
|
| |
| <div id="toolbar"> |
| |
| <button class="nb" id="b-back" disabled title="Go back"> |
| <svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg> |
| </button> |
| |
| <button class="nb" id="b-fwd" disabled title="Go forward"> |
| <svg viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></svg> |
| </button> |
| |
| <button class="nb" id="b-rel" title="Reload"> |
| <svg viewBox="0 0 24 24"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg> |
| </button> |
|
|
| |
| <div id="omni"> |
| <span id="lock"> |
| <svg viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg> |
| </span> |
| <input id="url" type="text" placeholder="Search or type a URL" |
| autocomplete="off" spellcheck="false"> |
| <span id="star"> |
| <svg viewBox="0 0 24 24"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/></svg> |
| </span> |
| </div> |
|
|
| |
| <button class="nb" title="Chrome menu" style="margin-left:2px"> |
| <svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg> |
| </button> |
| </div> |
|
|
| |
| <div id="vp-wrap"> |
| <canvas id="vp" tabindex="0"></canvas> |
| <div id="overlay"> |
| <div class="spin"></div> |
| <span style="color:#5f6368">Connecting to browserβ¦</span> |
| </div> |
| <div id="status">β</div> |
| </div> |
|
|
| <script> |
| const vp = document.getElementById('vp'); |
| const ctx = vp.getContext('2d'); |
| const urlIn = document.getElementById('url'); |
| const tTitle = document.getElementById('t-title'); |
| const fav = document.getElementById('fav'); |
| const overlay = document.getElementById('overlay'); |
| const statusEl= document.getElementById('status'); |
| const bBack = document.getElementById('b-back'); |
| const bFwd = document.getElementById('b-fwd'); |
| const bRel = document.getElementById('b-rel'); |
| const lock = document.getElementById('lock'); |
| |
| const BW = 1280, BH = 800; |
| |
| |
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| const WS = `${proto}//${location.host}/browser/ws`; |
| let ws = null; |
| |
| function setStatus(msg, ok=false){ |
| statusEl.textContent = msg; |
| statusEl.className = ok ? 'ok' : ''; |
| } |
| |
| function connect(){ |
| ws = new WebSocket(WS); |
| ws.onopen = () => { setStatus('β', true); overlay.classList.add('gone'); }; |
| ws.onclose = () => { setStatus('Reconnectingβ¦'); setTimeout(connect, 2000); }; |
| ws.onerror = () => setStatus('Error'); |
| ws.onmessage = async ({data}) => { |
| const msg = JSON.parse(data); |
| if (msg.type === 'frame'){ |
| const ab = Uint8Array.from(atob(msg.data), c=>c.charCodeAt(0)); |
| const bmp = await createImageBitmap(new Blob([ab],{type:'image/jpeg'})); |
| const cw = vp.parentElement.clientWidth; |
| const ch = vp.parentElement.clientHeight; |
| if (vp.width!==cw) vp.width=cw; |
| if (vp.height!==ch) vp.height=ch; |
| ctx.drawImage(bmp, 0, 0, cw, ch); |
| bmp.close(); |
| } else if (msg.type === 'nav'){ |
| urlIn.value = msg.url||''; |
| document.title = tTitle.textContent = msg.title||'New Tab'; |
| |
| try { |
| const u = new URL(msg.url); |
| fav.src = u.origin+'/favicon.ico'; |
| fav.onerror = () => fav.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23bbb'/%3E%3C/svg%3E"; |
| } catch{} |
| |
| const secure = msg.url?.startsWith('https'); |
| lock.style.color = secure ? '#188038' : '#5f6368'; |
| lock.querySelector('svg').style.fill = lock.style.color; |
| } |
| }; |
| } |
| |
| const send = obj => ws?.readyState===1 && ws.send(JSON.stringify(obj)); |
| |
| |
| const coord = e => { |
| const r = vp.getBoundingClientRect(); |
| return { |
| x: Math.round((e.clientX-r.left) * (BW/r.width)), |
| y: Math.round((e.clientY-r.top) * (BH/r.height)) |
| }; |
| }; |
| |
| |
| vp.onmousedown = e => { vp.focus(); send({type:'mousedown', ...coord(e)}); }; |
| vp.onmouseup = e => send({type:'mouseup', ...coord(e)}); |
| vp.onclick = e => send({type:'click', ...coord(e)}); |
| vp.ondblclick = e => send({type:'dblclick', ...coord(e)}); |
| vp.onmousemove = e => send({type:'mousemove', ...coord(e)}); |
| vp.onwheel = e => { e.preventDefault(); send({type:'wheel',dx:e.deltaX,dy:e.deltaY}); }; |
| vp.oncontextmenu= e => e.preventDefault(); |
| |
| |
| const SPECIALS = new Set([ |
| 'Enter','Backspace','Delete','Tab','Escape', |
| 'ArrowLeft','ArrowRight','ArrowUp','ArrowDown', |
| 'Home','End','PageUp','PageDown', |
| 'F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12', |
| 'Insert','CapsLock' |
| ]); |
| |
| vp.addEventListener('keydown', e => { |
| e.preventDefault(); |
| const k = e.key; |
| if (e.ctrlKey || e.metaKey || e.altKey || SPECIALS.has(k)){ |
| send({type:'keydown', key:k, |
| ctrl:e.ctrlKey, shift:e.shiftKey, alt:e.altKey, meta:e.metaKey}); |
| } else if (k.length===1){ |
| send({type:'type', text:k}); |
| } |
| }); |
| |
| |
| urlIn.addEventListener('keydown', e => { |
| if (e.key==='Enter'){ |
| send({type:'navigate', url:urlIn.value.trim()}); |
| vp.focus(); |
| } |
| e.stopPropagation(); |
| }); |
| urlIn.onclick = e => e.stopPropagation(); |
| |
| |
| bBack.onclick = () => send({type:'back'}); |
| bFwd.onclick = () => send({type:'forward'}); |
| bRel.onclick = () => send({type:'reload'}); |
| |
| |
| new ResizeObserver(() => { |
| vp.width = vp.parentElement.clientWidth; |
| vp.height = vp.parentElement.clientHeight; |
| }).observe(vp.parentElement); |
| |
| connect(); |
| </script> |
| </body> |
| </html> |