Skip to content

Instantly share code, notes, and snippets.

@angusdev
Last active October 30, 2025 00:25
Show Gist options
  • Select an option

  • Save angusdev/b2abcc7efc1118a2a1b300d24482ea36 to your computer and use it in GitHub Desktop.

Select an option

Save angusdev/b2abcc7efc1118a2a1b300d24482ea36 to your computer and use it in GitHub Desktop.
SplitBrowser – Resizable multi-column browser inside one tab

SplitBrowser

SplitBrowser is a resizable, multi-column mini browser bookmarklet that lets you open and navigate multiple websites side-by-side in a single tab. Each column has independent navigation and history, and URLs are tracked live for easy sharing. Ideal for sharing in Teams, comparing sites, monitoring dashboards, or running parallel tests.

⚠️ This project and README are 100% AI-GENERATED from spec.md and have not been modified by any human.
Developed using a Spec-Driven Development approach.


Features

  • Multi-column layout with independent iframes.
  • Resizable columns via drag-and-drop dividers.
  • Per-column navigation: Back, Forward, Go buttons.
  • Editable address bar for each column.
  • Add or close columns dynamically.
  • Live URL tracking and fragment updates for easy sharing.
  • Separate history maintained per iframe.
  • Lightweight, runs entirely in-browser with no external libraries.
  • Runs as a bookmarklet—no installation needed.

Sample Usage

  • Teams / Zoom: Share only the SplitBrowser window to show multiple sites without exposing your full desktop.
  • Research / Comparison: Compare products, documentation, or news articles side by side.
  • Monitoring Dashboards: Keep multiple dashboards or log pages visible at once.
  • Development / Testing: Test multiple versions of a web app in parallel.
  • Education / Tutorials: Show notes, examples, and live demos together in a single tab.

Installation

  1. Drag the splitbrowser-bookmarklet.js link to your bookmarks bar.
  2. Click the bookmarklet to launch SplitBrowser in your current tab.
  3. Optionally, open your browser's DevTools console and paste the contents of splitbrowser.js to run it manually.

Notes

  • Fully runs in-browser; no server required.
  • Supports dynamic column creation and real-time URL updates.
  • All code, features, and documentation are 100% AI-GENERATED from spec.md; no human modifications.

Create a JavaScript bookmarklet that opens a split browser with multiple resizable columns, each containing an iframe

Functional Requirements

FR001 - Overall Layout

  • No page level header or control. No main close button
  • Multiple column, each column has a navigation bar in white background on the top, and an iframe to fill the height

FR002 - Navigation Bar

  • Each column should include a navigation bar at the top with the following controls:
    • Back Button (<): Navigates back in the iframe's history. - Forward Button (>): Navigates forward in the iframe's history.
    • Address Bar (Input Field): Displays the current URL and allows the user to edit it.
      • Press Enter key will also trigger the navigation (Same as Go Button)
    • Go Button: Navigates to the URL entered in the address bar.
    • Add Column Button (+): Adds a new column next to the current column with the same URL of current column.
    • Close Column Button (X): Closes the current column after user confirmation via confirm().
    • Initially, the "Back" and "Forward" buttons should be disabled.
    • Resize the columns after add or close a column.

FR003 - Dynamic Column Creation

  • If the URL contains a #splitbrowser fragment:
    • Parse the fragment to extract a list of URLs.
    • URLs should be separated by a pipe (|) character and properly URL-encoded.
  • If the #splitbrowser fragment is not present:
    • Prompt the user to input the number of columns (default to 2 if the input is invalid or empty).
    • Load the current page's URL (window.location.href) into all columns.

FR004 - Resizable Columns

  • Allow users to resize columns horizontally by dragging a vertical divider between them.
  • Make the resize divider to 5px wide.
  • Change the cursor to indicate resizing.
  • Ensure that the resizing only affects the two adjacent columns
  • Use the current mouse position compare the left of the column to calculate the width of the column
  • Prevent iframes from capturing mouse events during resizing:
    • Important: Apply CSS pointer-events: none to all iframes during resizing and restore afterward.

FR005 - Dynamic URL Updates

  • Each iframe should track its position (e.g. first, second, etc.) in the split browser.
  • When a user navigates to a new page in an iframe (via the "Go" button, Back/Forward buttons, or direct navigation):
  • Update the corresponding URL in the #splitbrowser fragment in the browser's URL.
  • Ensure the #splitbrowser fragment always reflects the current order of columns and their respective URLs.
  • Important: Do not use iframe.src to obtain the iframe's URL, as it does not update dynamically. Instead, use the load event to capture the updated URL.

FR006 - Real-Time Updates

  • The #splitbrowser fragment in the browser's URL should update in real-time whenever a user navigates to a new page in any iframe.

FR007 - History Management

  • Maintain a separate browser history for each iframe.
  • Update the history when navigating via the "Go" button, Back/Forward buttons, or direct navigation.
  • Enable or disable the "Back" and "Forward" buttons based on the current history state:
  • Disable the "Back" button if the iframe is at the beginning of its history.
  • Disable the "Forward" button if the iframe is at the end of its history.

FR008 - Error Handling

  • Default to 2 columns if the user provides invalid or empty input for the number of columns.
  • Handle cases where the #splitbrowser fragment is malformed or contains invalid URLs.

Non-Functional Requirements

  • Use modern JavaScript (ES6+).
  • Write modular, readable code with minimum comments.
  • Do not use external libraries; implement the solution using plain JavaScript.
  • Avoid creating <style> tag, use inline style when possible.
  • If you are using MutationObserver to minitor column change, make sure it will not fall into infinite loop

Output

  • Create below files:
    • splitbrowser.js: Readable, well-formatted code with comments, runnable by pasting into browser DevTools.
    • splitbrowser-bookmarklet.js: Minified and URL-encoded bookmarklet version, runnable directly from the browser’s URL/bookmark bar.
javascript:(function(){function q(s,c){return(c||document).querySelector(s)}function qa(s,c){return Array.from((c||document).querySelectorAll(s))}function eU(u){return encodeURIComponent(u)}function dU(s){return decodeURIComponent(s)}function normalizeUrl(i){if(!i||i.trim()==='')return null;try{let m=i.trim();if(!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(m))m='https://'+m;const o=new URL(m);return o.href}catch(e){return null}}function parseFragment(){const h=window.location.hash||'';const m=h.match(/#splitbrowser=(.*)/);if(!m)return null;const raw=m[1];if(!raw)return null;const parts=raw.split('|').map(s=>{try{return dU(s)}catch(e){return null}}).filter(Boolean);return parts.length?parts:null}function updateFragment(urls){try{const frag=urls.map(u=>eU(u||'')).join('|');const newHash='#splitbrowser='+frag;if(window.location.hash!==newHash){history.replaceState(null,'',window.location.pathname+window.location.search+newHash)}}catch(e){console.warn('Could not update fragment',e)}}function createShell(){const container=document.createElement('div');Object.assign(container.style,{position:'fixed',top:'0',left:'0',width:'100vw',height:'100vh',display:'flex',flexDirection:'row',background:'#eee',zIndex:2147483647,margin:'0',padding:'0',boxSizing:'border-box',overflow:'hidden'});return container}function makeColumn(index,initialUrl,state){const column=document.createElement('div');column.className='splitcol';Object.assign(column.style,{display:'flex',flexDirection:'column',height:'100%',minWidth:'80px',flexBasis:(100/state.totalColumns)+'%',boxSizing:'border-box',borderLeft:'1px solid rgba(0,0,0,0.08)',overflow:'hidden'});const nav=document.createElement('div');Object.assign(nav.style,{display:'flex',alignItems:'center',padding:'6px',gap:'6px',background:'white',boxSizing:'border-box',flex:'0 0 auto',borderBottom:'1px solid rgba(0,0,0,0.06)'});const backBtn=document.createElement('button');backBtn.textContent='<';backBtn.title='Back';backBtn.disabled=true;Object.assign(backBtn.style,{padding:'4px 8px'});const forwardBtn=document.createElement('button');forwardBtn.textContent='>';forwardBtn.title='Forward';forwardBtn.disabled=true;Object.assign(forwardBtn.style,{padding:'4px 8px'});const address=document.createElement('input');address.type='text';address.value=initialUrl||'';Object.assign(address.style,{flex:'1 1 auto',minWidth:'60px',padding:'6px',border:'1px solid #ccc',borderRadius:'4px'});const goBtn=document.createElement('button');goBtn.textContent='Go';goBtn.title='Go';Object.assign(goBtn.style,{padding:'6px 10px'});const addBtn=document.createElement('button');addBtn.textContent='+';addBtn.title='Add column';Object.assign(addBtn.style,{padding:'6px 10px'});const closeBtn=document.createElement('button');closeBtn.textContent='X';closeBtn.title='Close column';Object.assign(closeBtn.style,{padding:'6px 10px'});nav.appendChild(backBtn);nav.appendChild(forwardBtn);nav.appendChild(address);nav.appendChild(goBtn);nav.appendChild(addBtn);nav.appendChild(closeBtn);const frameWrap=document.createElement('div');Object.assign(frameWrap.style,{flex:'1 1 auto',height:'100%',position:'relative',overflow:'hidden'});const iframe=document.createElement('iframe');iframe.setAttribute('sandbox','allow-forms allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox');iframe.style.width='100%';iframe.style.height='100%';iframe.style.border='0';iframe.style.display='block';iframe.style.boxSizing='border-box';iframe.setAttribute('referrerpolicy','no-referrer-when-downgrade');frameWrap.appendChild(iframe);column.appendChild(nav);column.appendChild(frameWrap);state.columns[index]={el:column,nav,backBtn,forwardBtn,address,goBtn,addBtn,closeBtn,iframe,history:[],historyIndex:-1,isControlledNav:false,lastKnownUrl:initialUrl||'',index};(function(colState){function pushToHistory(url){const h=colState.history;const idx=colState.historyIndex;if(idx>=0&&h[idx]===url)return;if(colState.historyIndex<h.length-1){h.splice(colState.historyIndex+1)}h.push(url);colState.historyIndex=h.length-1}function updateNavButtons(){colState.backBtn.disabled=!(colState.historyIndex>0);colState.forwardBtn.disabled=!(colState.historyIndex<colState.history.length-1)}function navigateTo(url){const normalized=normalizeUrl(url)||url;if(!normalized)return;colState.isControlledNav=true;colState.address.value=normalized;colState.lastKnownUrl=normalized;try{colState.iframe.setAttribute('data-last-url',normalized);colState.iframe.src=normalized;}finally{pushToHistory(normalized);updateNavButtons();updateAllFragments()}}colState.backBtn.addEventListener('click',()=>{if(colState.historyIndex>0){colState.historyIndex-=1;const u=colState.history[colState.historyIndex];colState.isControlledNav=true;colState.iframe.setAttribute('data-last-url',u);colState.iframe.src=u;colState.address.value=u;colState.lastKnownUrl=u;updateNavButtons();updateAllFragments()}});colState.forwardBtn.addEventListener('click',()=>{if(colState.historyIndex<colState.history.length-1){colState.historyIndex+=1;const u=colState.history[colState.historyIndex];colState.isControlledNav=true;colState.iframe.setAttribute('data-last-url',u);colState.iframe.src=u;colState.address.value=u;colState.lastKnownUrl=u;updateNavButtons();updateAllFragments()}});colState.goBtn.addEventListener('click',()=>navigateTo(colState.address.value));colState.address.addEventListener('keydown',ev=>{if(ev.key==='Enter'){ev.preventDefault();navigateTo(colState.address.value)}});colState.addBtn.addEventListener('click',()=>{const urlCopy=colState.lastKnownUrl||colState.address.value||window.location.href;addColumnAfter(colState.index,urlCopy)});colState.closeBtn.addEventListener('click',()=>{if(confirm('Close this column?')){removeColumn(colState.index)}});colState.iframe.addEventListener('load',ev=>{let newUrl=null;try{const cw=colState.iframe.contentWindow;if(cw&&cw.location&&cw.location.href)newUrl=cw.location.href}catch(e){}if(!newUrl){newUrl=colState.iframe.getAttribute('data-last-url')||colState.lastKnownUrl||colState.iframe.src||null}if(newUrl){if(!colState.isControlledNav){const cur=colState.history[colState.historyIndex];if(cur!==newUrl){colState.historyIndex=colState.historyIndex+1; if(colState.historyIndex<colState.history.length)colState.history.splice(colState.historyIndex);colState.history.push(newUrl)}}else{colState.isControlledNav=false}colState.lastKnownUrl=newUrl;colState.address.value=newUrl;colState.iframe.setAttribute('data-last-url',newUrl);updateNavButtons();updateAllFragments()}})})(state.columns[index]);return column}function makeResizer(leftColState,rightColState){const res=document.createElement('div');Object.assign(res.style,{width:'5px',cursor:'ew-resize',flex:'0 0 5px',zIndex:2147483647,userSelect:'none',background:'transparent'});res.addEventListener('mouseover',()=>res.style.background='rgba(0,0,0,0.03)');res.addEventListener('mouseout',()=>res.style.background='transparent');let dragging=false;let startX=0;let startLeftWidth=0;let startRightWidth=0;function onPointerDown(e){e.preventDefault();dragging=true;startX=e.clientX;const leftRect=leftColState.el.getBoundingClientRect();const rightRect=rightColState.el.getBoundingClientRect();startLeftWidth=leftRect.width;startRightWidth=rightRect.width;state.columns.forEach(c=>{try{c.iframe.style.pointerEvents='none'}catch(er){}});document.addEventListener('pointermove',onPointerMove);document.addEventListener('pointerup',onPointerUp)}function onPointerMove(e){if(!dragging)return;const dx=e.clientX-startX;const newLeft=Math.max(50,startLeftWidth+dx);const newRight=Math.max(50,startRightWidth-dx);const containerRect=leftColState.el.parentElement.getBoundingClientRect();const leftPct=(newLeft/containerRect.width)*100;const rightPct=(newRight/containerRect.width)*100;leftColState.el.style.flexBasis=leftPct+'%';rightColState.el.style.flexBasis=rightPct+'%'}function onPointerUp(e){if(!dragging)return;dragging=false;state.columns.forEach(c=>{try{c.iframe.style.pointerEvents=''}catch(er){}});document.removeEventListener('pointermove',onPointerMove);document.removeEventListener('pointerup',onPointerUp)}res.addEventListener('pointerdown',onPointerDown);return res}const state={container:null,columns:[],totalColumns:0,resizers:[]};function addColumnAfter(afterIndex,url){const usable=normalizeUrl(url)||url||window.location.href;const idx=(afterIndex==null)?state.columns.length-1:afterIndex;const urls=state.columns.map(c=>c.lastKnownUrl||c.address.value||'');if(afterIndex==null||afterIndex>=state.columns.length-1){urls.push(usable)}else{urls.splice(afterIndex+1,0,usable)}buildUIFromUrls(urls)}function removeColumn(index){const urls=state.columns.map(c=>c.lastKnownUrl||c.address.value||'');if(urls.length<=1){const cur=urls[0]||window.location.href;buildUIFromUrls([cur,cur]);return}urls.splice(index,1);buildUIFromUrls(urls)}function updateAllFragments(){const urls=state.columns.map(c=>c.lastKnownUrl||c.address.value||'');updateFragment(urls)}function buildUIFromUrls(urlsList){let urls=Array.isArray(urlsList)?urlsList.slice():[];if(!urls||urls.length<1)urls=[window.location.href,window.location.href];if(urls.length===1)urls.push(urls[0]);urls=urls.map(u=>normalizeUrl(u)||u||window.location.href);if(state.container){try{state.container.remove()}catch(e){}state.columns=[];state.resizers=[];state.container=null}state.totalColumns=urls.length;const container=createShell();state.container=container;urls.forEach((u,i)=>{const col=makeColumn(i,u,state);container.appendChild(col);if(i<urls.length-1){const placeholder=document.createElement('div');placeholder.style.flex='0 0 5px';container.appendChild(placeholder)}});const childNodes=Array.from(container.childNodes);childNodes.forEach((n)=>{if(n&&n.style&&n.style.flex==='0 0 5px')n.remove()});container.innerHTML='';for(let i=0;i<state.columns.length;i++){const colEl=state.columns[i].el;container.appendChild(colEl);if(i<state.columns.length-1){const left=state.columns[i];const right=state.columns[i+1];const res=makeResizer(left,right);state.resizers.push(res);container.appendChild(res)}}document.body.appendChild(container);document.documentElement.style.overflow='hidden';document.body.style.overflow='hidden';state.columns.forEach((c,i)=>{c.index=i;c.iframe.src=urls[i];c.iframe.setAttribute('data-last-url',urls[i]);c.lastKnownUrl=urls[i];c.history=[urls[i]];c.historyIndex=0;c.backBtn.disabled=true;c.forwardBtn.disabled=true;c.address.value=urls[i]});const total=state.columns.length;state.columns.forEach(c=>{if(!c.el.style.flexBasis){c.el.style.flexBasis=(100/total)+'%'}});updateAllFragments()}function start(){let urls=parseFragment();if(urls&&urls.length>0){const normalized=urls.map(u=>normalizeUrl(u)||u).filter(Boolean);if(normalized.length===0)urls=null;else urls=normalized}if(!urls){let n=prompt('Number of columns (default 2):','2');let num=parseInt(n,10);if(!Number.isFinite(num)||num<=0)num=2;const cur=window.location.href;urls=Array.from({length:num},()=>cur)}buildUIFromUrls(urls)}start();window.__splitbrowser={state,rebuildFromCurrent:()=>buildUIFromUrls(state.columns.map(c=>c.lastKnownUrl||c.address.value||'')),close:()=>{if(state.container){try{state.container.remove()}catch(e){}state.container=null;document.documentElement.style.overflow='';document.body.style.overflow='';try{delete window.__splitbrowser}catch(e){}}}}})()
// splitbrowser.js
// Paste into browser console to run. ES6+ plain JavaScript. No external libs.
// Main entry
(function () {
// Utilities
const q = (sel, ctx = document) => ctx.querySelector(sel);
const qa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
const encodeUrlForFragment = u => encodeURIComponent(u);
const decodeUrlFromFragment = s => decodeURIComponent(s);
// Validate/normalize URL string to absolute URL; returns null if invalid
function normalizeUrl(input) {
if (!input || input.trim() === '') return null;
try {
// If input lacks scheme, assume https
let maybe = input.trim();
if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(maybe)) {
maybe = 'https://' + maybe;
}
const url = new URL(maybe);
return url.href;
} catch (e) {
return null;
}
}
// Parse fragment: looks for #splitbrowser=... where value is pipe-separated encoded URLs
function parseFragment() {
const h = window.location.hash || '';
const match = h.match(/#splitbrowser=(.*)/);
if (!match) return null;
const raw = match[1];
if (!raw) return null;
const parts = raw.split('|').map(s => {
try {
return decodeUrlFromFragment(s);
} catch (e) {
return null;
}
}).filter(Boolean);
return parts.length ? parts : null;
}
// Update the fragment in real-time with the current URLs (urls array)
function updateFragment(urls) {
try {
const frag = urls.map(u => encodeUrlForFragment(u || '')).join('|');
// Use history.replaceState to avoid navigation and not pollute history
const newHash = '#splitbrowser=' + frag;
if (window.location.hash !== newHash) {
history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
}
} catch (e) {
console.warn('Could not update fragment', e);
}
}
// Create DOM structure
function createShell() {
// container: fixed, full screen, top-left 0, z-index high
const container = document.createElement('div');
Object.assign(container.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'row',
background: '#eee',
zIndex: 2147483647, // max
margin: '0',
padding: '0',
boxSizing: 'border-box',
overflow: 'hidden'
});
return container;
}
// Column factory
function makeColumn(index, initialUrl, state) {
// state will hold per-column data: history array, index, elements refs
const column = document.createElement('div');
column.className = 'splitcol';
Object.assign(column.style, {
display: 'flex',
flexDirection: 'column',
height: '100%',
minWidth: '80px',
flexBasis: (100 / state.totalColumns) + '%',
boxSizing: 'border-box',
borderLeft: '1px solid rgba(0,0,0,0.08)',
overflow: 'hidden'
});
// Nav bar
const nav = document.createElement('div');
Object.assign(nav.style, {
display: 'flex',
alignItems: 'center',
padding: '6px',
gap: '6px',
background: 'white',
boxSizing: 'border-box',
flex: '0 0 auto',
borderBottom: '1px solid rgba(0,0,0,0.06)'
});
const backBtn = document.createElement('button');
backBtn.textContent = '<';
backBtn.title = 'Back';
backBtn.disabled = true;
Object.assign(backBtn.style, { padding: '4px 8px' });
const forwardBtn = document.createElement('button');
forwardBtn.textContent = '>';
forwardBtn.title = 'Forward';
forwardBtn.disabled = true;
Object.assign(forwardBtn.style, { padding: '4px 8px' });
const address = document.createElement('input');
address.type = 'text';
address.value = initialUrl || '';
Object.assign(address.style, {
flex: '1 1 auto',
minWidth: '60px',
padding: '6px',
border: '1px solid #ccc',
borderRadius: '4px'
});
const goBtn = document.createElement('button');
goBtn.textContent = 'Go';
goBtn.title = 'Go';
Object.assign(goBtn.style, { padding: '6px 10px' });
const addBtn = document.createElement('button');
addBtn.textContent = '+';
addBtn.title = 'Add column';
Object.assign(addBtn.style, { padding: '6px 10px' });
const closeBtn = document.createElement('button');
closeBtn.textContent = 'X';
closeBtn.title = 'Close column';
Object.assign(closeBtn.style, { padding: '6px 10px' });
nav.appendChild(backBtn);
nav.appendChild(forwardBtn);
nav.appendChild(address);
nav.appendChild(goBtn);
nav.appendChild(addBtn);
nav.appendChild(closeBtn);
// Iframe
const frameWrap = document.createElement('div');
Object.assign(frameWrap.style, {
flex: '1 1 auto',
height: '100%',
position: 'relative',
overflow: 'hidden'
});
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-forms allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox');
// Note: allow-same-origin here will not override cross-origin restrictions,
// it only allows same-origin content to be treated as same-origin if actually same-origin.
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = '0';
iframe.style.display = 'block';
iframe.style.boxSizing = 'border-box';
iframe.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
frameWrap.appendChild(iframe);
// Build column
column.appendChild(nav);
column.appendChild(frameWrap);
// Column state
state.columns[index] = {
el: column,
nav,
backBtn,
forwardBtn,
address,
goBtn,
addBtn,
closeBtn,
iframe,
history: [],
historyIndex: -1,
isControlledNav: false, // flag to avoid double-pushing history
lastKnownUrl: initialUrl || '',
index // position identifier (0-based)
};
// Event wiring
(function wireHandlers(colState) {
// Navigation helper that records into per-iframe history
function pushToHistory(url) {
const h = colState.history;
const idx = colState.historyIndex;
// If navigation happened due to back/forward (we will call load after setting src),
// do not push duplicate. We manage by comparing lastHistoryEntry.
if (idx >= 0 && h[idx] === url) return;
// Truncate forward entries if we navigated after going back
if (colState.historyIndex < h.length - 1) {
h.splice(colState.historyIndex + 1);
}
h.push(url);
colState.historyIndex = h.length - 1;
}
// Update nav buttons enabled state
function updateNavButtons() {
colState.backBtn.disabled = !(colState.historyIndex > 0);
colState.forwardBtn.disabled = !(colState.historyIndex < colState.history.length - 1);
}
// Navigate to url (from controls)
function navigateTo(url) {
const normalized = normalizeUrl(url) || url; // allow raw if normalize failed
if (!normalized) return;
colState.isControlledNav = true;
colState.address.value = normalized;
colState.lastKnownUrl = normalized;
try {
// set data attribute for fallback
colState.iframe.setAttribute('data-last-url', normalized);
// set src to navigate
colState.iframe.src = normalized;
} finally {
// push into our history structure (will be consistent when load fires)
pushToHistory(normalized);
updateNavButtons();
updateAllFragments();
}
}
// Back / Forward actions
colState.backBtn.addEventListener('click', () => {
if (colState.historyIndex > 0) {
colState.historyIndex -= 1;
const u = colState.history[colState.historyIndex];
colState.isControlledNav = true;
colState.iframe.setAttribute('data-last-url', u);
colState.iframe.src = u;
colState.address.value = u;
colState.lastKnownUrl = u;
updateNavButtons();
updateAllFragments();
}
});
colState.forwardBtn.addEventListener('click', () => {
if (colState.historyIndex < colState.history.length - 1) {
colState.historyIndex += 1;
const u = colState.history[colState.historyIndex];
colState.isControlledNav = true;
colState.iframe.setAttribute('data-last-url', u);
colState.iframe.src = u;
colState.address.value = u;
colState.lastKnownUrl = u;
updateNavButtons();
updateAllFragments();
}
});
// Go button & Enter
colState.goBtn.addEventListener('click', () => navigateTo(colState.address.value));
colState.address.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
navigateTo(colState.address.value);
}
});
// Add column
colState.addBtn.addEventListener('click', () => {
const urlCopy = colState.lastKnownUrl || colState.address.value || window.location.href;
addColumnAfter(colState.index, urlCopy);
});
// Close column
colState.closeBtn.addEventListener('click', () => {
if (confirm('Close this column?')) {
removeColumn(colState.index);
}
});
// Iframe load: try to detect actual navigated URL
colState.iframe.addEventListener('load', (ev) => {
let newUrl = null;
// First try to read contentWindow.location.href (works for same-origin)
try {
const cw = colState.iframe.contentWindow;
if (cw && cw.location && cw.location.href) {
newUrl = cw.location.href;
}
} catch (e) {
// cross-origin; ignore error
}
// If not accessible, try data-last-url attribute which we set for controlled navigations
if (!newUrl) {
newUrl = colState.iframe.getAttribute('data-last-url') || colState.lastKnownUrl || colState.iframe.src || null;
}
if (newUrl) {
// If load caused by uncontrolled in-iframe navigation (e.g., user clicked link),
// and it differs from our last-known and last history entry, push it.
if (!colState.isControlledNav) {
// Only push when different from current history index
const cur = colState.history[colState.historyIndex];
if (cur !== newUrl) {
// If url is valid, push
colState.historyIndex = colState.historyIndex + 1;
// truncate forward if needed
if (colState.historyIndex < colState.history.length) {
colState.history.splice(colState.historyIndex);
}
colState.history.push(newUrl);
}
} else {
// clear flag when we handled a controlled nav
colState.isControlledNav = false;
}
colState.lastKnownUrl = newUrl;
colState.address.value = newUrl;
colState.iframe.setAttribute('data-last-url', newUrl);
updateNavButtons();
updateAllFragments();
} else {
// unknown newUrl: do nothing, keep previous state
}
});
})(state.columns[index]);
return column;
}
// Create resizer element between columns
function makeResizer(leftColState, rightColState) {
const res = document.createElement('div');
Object.assign(res.style, {
width: '5px',
cursor: 'ew-resize',
flex: '0 0 5px',
zIndex: 2147483647,
userSelect: 'none',
background: 'transparent'
});
// On hover show a faint background
res.addEventListener('mouseover', () => res.style.background = 'rgba(0,0,0,0.03)');
res.addEventListener('mouseout', () => res.style.background = 'transparent');
// Dragging logic
let dragging = false;
let startX = 0;
let startLeftWidth = 0;
let startRightWidth = 0;
function onPointerDown(e) {
e.preventDefault();
dragging = true;
startX = e.clientX;
// compute widths in pixels
const leftRect = leftColState.el.getBoundingClientRect();
const rightRect = rightColState.el.getBoundingClientRect();
startLeftWidth = leftRect.width;
startRightWidth = rightRect.width;
// apply pointer-events none to iframes
state.columns.forEach(c => {
try { c.iframe.style.pointerEvents = 'none'; } catch (er) {}
});
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
}
function onPointerMove(e) {
if (!dragging) return;
const dx = e.clientX - startX;
const newLeft = Math.max(50, startLeftWidth + dx);
const newRight = Math.max(50, startRightWidth - dx);
const total = newLeft + newRight;
// convert to percentages relative to both columns and the container
const containerRect = leftColState.el.parentElement.getBoundingClientRect();
const leftPct = (newLeft / containerRect.width) * 100;
const rightPct = (newRight / containerRect.width) * 100;
// Apply new flexBasis to the two columns only; keep others unchanged
leftColState.el.style.flexBasis = leftPct + '%';
rightColState.el.style.flexBasis = rightPct + '%';
}
function onPointerUp(e) {
if (!dragging) return;
dragging = false;
// restore pointer-events on iframes
state.columns.forEach(c => {
try { c.iframe.style.pointerEvents = ''; } catch (er) {}
});
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
}
res.addEventListener('pointerdown', onPointerDown);
return res;
}
// Global state
const state = {
container: null,
columns: [], // array of per-column state
totalColumns: 0,
resizers: []
};
// Add a column at position afterIndex (inserting after that index), or at end if afterIndex === null
function addColumnAfter(afterIndex, url) {
// normalize URL fallback
const usable = normalizeUrl(url) || url || window.location.href;
const idx = (afterIndex == null) ? state.columns.length - 1 : afterIndex;
// we will rebuild the whole UI for simplicity of index bookkeeping
const urls = state.columns.map(c => c.lastKnownUrl || c.address.value || '');
// If adding at end
if (afterIndex == null || afterIndex >= state.columns.length - 1) {
urls.push(usable);
} else {
urls.splice(afterIndex + 1, 0, usable);
}
buildUIFromUrls(urls);
}
// Remove column by position index
function removeColumn(index) {
const urls = state.columns.map(c => c.lastKnownUrl || c.address.value || '');
if (urls.length <= 1) {
// If only one column, default to two columns with current URL duplicated
const cur = urls[0] || window.location.href;
buildUIFromUrls([cur, cur]);
return;
}
urls.splice(index, 1);
buildUIFromUrls(urls);
}
// Update fragment using current columns URLs
function updateAllFragments() {
const urls = state.columns.map(c => c.lastKnownUrl || c.address.value || '');
updateFragment(urls);
}
// Build entire UI from URL list (recreate container to keep code simple and avoid complex mutation observer loops)
function buildUIFromUrls(urlsList) {
// sanitize list: ensure at least 2 columns default
let urls = Array.isArray(urlsList) ? urlsList.slice() : [];
if (!urls || urls.length < 1) urls = [window.location.href, window.location.href];
if (urls.length === 1) urls.push(urls[0]);
// Normalize and validate URLs; default to window.location.href for invalid entries
urls = urls.map(u => {
const n = normalizeUrl(u);
return n || (u ? u : window.location.href);
});
// Clear previous UI if exists
if (state.container) {
try { state.container.remove(); } catch (e) {}
state.columns = [];
state.resizers = [];
state.container = null;
}
state.totalColumns = urls.length;
const container = createShell();
state.container = container;
// For each url create column
urls.forEach((u, i) => {
const col = makeColumn(i, u, state);
container.appendChild(col);
// append resizer between columns except after last
if (i < urls.length - 1) {
// placeholder resizer; we'll create actual resizers after we've filled state.columns
const placeholder = document.createElement('div');
placeholder.style.flex = '0 0 5px';
container.appendChild(placeholder);
}
});
// Now create resizers properly (replace placeholders)
const childNodes = Array.from(container.childNodes);
// columns are at even indices 0,2,4... if resizers were appended as placeholders
// But because we appended column then placeholder, layout is column, placeholder, column...
// We'll iterate columns in state.columns order and insert real resizers between them.
// First remove all placeholder resizers
childNodes.forEach((n) => {
if (n && n.style && n.style.flex === '0 0 5px') n.remove();
});
// Append columns and resizers afresh to ensure order: column, resizer, column, resizer...
container.innerHTML = '';
for (let i = 0; i < state.columns.length; i++) {
const colStatePlaceholder = state.columns[i];
const colEl = colStatePlaceholder.el;
container.appendChild(colEl);
if (i < state.columns.length - 1) {
const left = state.columns[i];
const right = state.columns[i + 1];
const res = makeResizer(left, right);
state.resizers.push(res);
container.appendChild(res);
}
}
// Insert container into document
document.body.appendChild(container);
// Prevent page-level scrolling while split browser is open
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
// After insertion, set iframe srcs and initialize history for each column
state.columns.forEach((c, i) => {
c.index = i;
c.iframe.src = urls[i];
c.iframe.setAttribute('data-last-url', urls[i]);
c.lastKnownUrl = urls[i];
// initialize history arrays
c.history = [urls[i]];
c.historyIndex = 0;
c.backBtn.disabled = true;
c.forwardBtn.disabled = true;
c.address.value = urls[i];
});
// Recalculate flex bases to distribute evenly if none set
const total = state.columns.length;
state.columns.forEach(c => {
if (!c.el.style.flexBasis) {
c.el.style.flexBasis = (100 / total) + '%';
}
});
updateAllFragments();
}
// Start routine: check fragment or prompt for number of columns
function start() {
let urls = parseFragment();
if (urls && urls.length > 0) {
// Validate and normalize; drop invalid entries
const normalized = urls.map(u => normalizeUrl(u) || u).filter(Boolean);
if (normalized.length === 0) {
urls = null;
} else {
urls = normalized;
}
}
if (!urls) {
// prompt for number of columns
let n = prompt('Number of columns (default 2):', '2');
let num = parseInt(n, 10);
if (!Number.isFinite(num) || num <= 0) num = 2;
const cur = window.location.href;
urls = Array.from({ length: num }, () => cur);
}
buildUIFromUrls(urls);
}
// Kickoff
start();
// Expose a small API on window for debugging if needed
window.__splitbrowser = {
state,
rebuildFromCurrent: () => buildUIFromUrls(state.columns.map(c => c.lastKnownUrl || c.address.value || '')),
close: () => {
if (state.container) {
try { state.container.remove(); } catch (e) {}
state.container = null;
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
try { delete window.__splitbrowser; } catch (e) {}
}
}
};
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment