|
// 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) {} |
|
} |
|
} |
|
}; |
|
})(); |