Skip to content

Instantly share code, notes, and snippets.

@steren
Created March 10, 2026 01:48
Show Gist options
  • Select an option

  • Save steren/e66b9e396c0f46ee104da479cb23126e to your computer and use it in GitHub Desktop.

Select an option

Save steren/e66b9e396c0f46ee104da479cb23126e to your computer and use it in GitHub Desktop.
Web web browser
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CanvasSurf Browser</title>
<style>
/* Basic styling using vanilla CSS */
html, body {
height: 100%; /* Ensure html and body take full height */
margin: 0;
padding: 0;
overflow: hidden; /* Prevent body scrollbars */
}
body {
font-family: Arial, Helvetica, sans-serif;
/* Default body background, used if no CSS overrides */
background-color: #f0f0f0;
display: flex;
flex-direction: column; /* Stack elements vertically */
color: #333;
}
/* Window Title Bar Styles - Spans full width */
#titleBar {
background: linear-gradient(to bottom, #eaeaea, #d5d5d5);
padding: 6px 10px;
border-bottom: 1px solid #aaa;
display: flex;
justify-content: space-between;
align-items: center;
cursor: default;
font-size: 0.9rem;
color: #444;
height: 30px;
box-sizing: border-box;
flex-shrink: 0; /* Prevent title bar from shrinking */
}
.window-title {
font-weight: bold;
flex-grow: 1;
text-align: center;
padding-right: 60px; /* Make space for controls */
}
.window-controls {
display: flex;
gap: 6px;
}
.window-button {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.2);
display: inline-block;
}
.close { background-color: #ff5f57; }
.minimize { background-color: #ffbd2e; }
.maximize { background-color: #28c940; }
/* Controls Bar Styles - Spans full width below title bar */
.controls {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem; /* Padding for controls */
flex-wrap: wrap;
align-items: center;
background-color: #fff; /* White background for controls area */
border-bottom: 1px solid #ccc; /* Separator line */
flex-shrink: 0; /* Prevent controls from shrinking */
}
#urlInput {
flex-grow: 1;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
min-width: 150px;
}
.control-button {
padding: 0.75rem 1.5rem;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
transition: background-color 0.2s ease, opacity 0.2s ease;
white-space: nowrap;
min-width: 50px;
text-align: center;
}
#backButton {
background-color: #6c757d;
margin-right: 0.5rem;
padding: 0.75rem 1rem;
}
#backButton:hover:not(:disabled) {
background-color: #5a6268;
}
#backButton:disabled {
opacity: 0.65;
cursor: not-allowed;
}
/* Render Container Styles - Fills remaining space */
#renderContainer {
flex-grow: 1; /* Allow container to take remaining vertical space */
overflow: auto; /* Add scrollbars IF canvas content overflows */
/* Background color set to match body default, actual canvas bg set in JS */
background-color: #f0f0f0;
box-sizing: border-box;
display: flex; /* Use flex for canvas alignment */
justify-content: center; /* Center canvas if it's narrower than container */
align-items: flex-start; /* Align canvas to top */
}
#renderCanvas {
display: block;
/* background-color set dynamically via fillRect */
cursor: default;
/* Width and height set in JS to match container */
}
#errorMessage {
color: #d9534f;
margin: 0.5rem 1rem; /* Position error message appropriately */
font-weight: bold;
display: none;
background-color: #fff;
padding: 0.5rem;
border-radius: 4px;
border: 1px solid #d9534f;
flex-shrink: 0; /* Prevent error message from shrinking */
}
</style>
</head>
<body>
<div id="titleBar">
<div class="window-controls">
<span class="window-button close"></span>
<span class="window-button minimize"></span>
<span class="window-button maximize"></span>
</div>
<div class="window-title">CanvasSurf</div>
</div>
<div class="controls">
<button id="backButton" class="control-button" disabled title="Back">←</button>
<input type="text" id="urlInput" placeholder="Enter URL and press Enter">
</div>
<div id="errorMessage"></div>
<div id="renderContainer">
<canvas id="renderCanvas">Your browser does not support the canvas element.</canvas>
</div>
<script>
// --- DOM Elements ---
const urlInput = document.getElementById('urlInput');
const backButton = document.getElementById('backButton');
const renderCanvas = document.getElementById('renderCanvas');
const renderContainer = document.getElementById('renderContainer');
const errorMessage = document.getElementById('errorMessage');
const ctx = renderCanvas.getContext('2d');
// --- Constants ---
const PROXY_URL = 'https://api.allorigins.win/raw?url=';
const DEFAULT_FONT_SIZE = 14;
const LINE_HEIGHT_MULTIPLIER = 1.5;
const PADDING = 30; // Internal canvas padding
const BLOCK_SPACING_MULTIPLIER = 0.7;
const PARAGRAPH_MARGIN_MULTIPLIER = 1.0;
const DEFAULT_BODY_BACKGROUND = '#ffffff'; // Default canvas background is white if none parsed
// --- Global State ---
let clickableLinks = [];
let currentPageUrl = '';
let navigationHistory = [];
let isNavigatingBack = false;
let currentHtmlContent = '';
// --- Canvas Setup ---
/**
* Sets up the canvas dimensions and clears it with a specific background color.
* @param {string} backgroundColor - The background color to clear the canvas with.
*/
function setupCanvas(backgroundColor = DEFAULT_BODY_BACKGROUND) { // Default to white
const containerWidth = renderContainer.clientWidth;
renderCanvas.width = Math.max(10, containerWidth);
renderCanvas.height = 2000; // Fixed large drawing height
if (renderCanvas.width <= 0) {
console.warn("Canvas has zero or negative width during setup.");
}
renderCanvas.style.width = `${containerWidth}px`;
// Clear with the provided background color
ctx.fillStyle = backgroundColor; // Use the passed color
ctx.fillRect(0, 0, renderCanvas.width, renderCanvas.height);
// Reset default drawing styles
ctx.fillStyle = '#333333'; // Default text color
ctx.font = `${DEFAULT_FONT_SIZE}px Arial, Helvetica, sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
clickableLinks = [];
renderCanvas.style.cursor = 'default';
}
// --- Update Back Button State ---
function updateBackButtonState() {
backButton.disabled = navigationHistory.length <= 1;
}
// --- Basic Inline CSS Parser ---
function parseInlineCSS(cssText) {
const styles = {};
const bodyRuleRegex = /body\s*\{([^}]*)\}/i;
const bgColorRegex = /background-color\s*:\s*([^;]+);?/i;
const bodyMatch = cssText.match(bodyRuleRegex);
if (bodyMatch && bodyMatch[1]) {
const bodyStyles = bodyMatch[1];
const bgColorMatch = bodyStyles.match(bgColorRegex);
if (bgColorMatch && bgColorMatch[1]) {
styles.body = {
backgroundColor: bgColorMatch[1].trim()
};
console.log("Parsed body background-color:", styles.body.backgroundColor);
}
}
return styles;
}
// --- HTML Parsing and Rendering Logic ---
function parseAndRenderHTML(htmlString) {
// Determine background color *before* setting up canvas
let bodyBackgroundColor = DEFAULT_BODY_BACKGROUND; // Start with default
const parser = new DOMParser();
try {
const doc = parser.parseFromString(htmlString, 'text/html');
const body = doc.body;
if (!body) throw new Error("Could not find body element.");
// --- Parse Inline Styles ---
const styleTags = doc.querySelectorAll('style');
styleTags.forEach(tag => {
const stylesFromTag = parseInlineCSS(tag.textContent);
if (stylesFromTag.body && stylesFromTag.body.backgroundColor) {
// Use the parsed color if found
bodyBackgroundColor = stylesFromTag.body.backgroundColor;
}
});
// --- Apply Background Color to Container (optional, for area around canvas) ---
// renderContainer.style.backgroundColor = bodyBackgroundColor; // Or use a fixed gray like #f0f0f0
// --- Setup Canvas with determined background color ---
setupCanvas(bodyBackgroundColor); // Pass the color here
const availableWidth = renderCanvas.width - 2 * PADDING;
if (availableWidth <= 0) {
console.warn("Calculated available width for text is zero or negative.");
return;
}
// --- Render Content ---
const finalPos = traverseDOM(body, PADDING, PADDING, availableWidth, {
fontSize: DEFAULT_FONT_SIZE,
fontWeight: 'normal',
fillStyle: '#333333', // Default text color, could be parsed too
isLink: false,
href: null
});
console.log(`Content drawn up to y=${finalPos.y}`);
} catch (error) {
console.error("Error parsing or rendering HTML:", error);
errorMessage.textContent = `Error rendering content: ${error.message}`;
errorMessage.style.display = 'block';
// Ensure canvas is set up even on error to draw message
setupCanvas(DEFAULT_BODY_BACKGROUND); // Use default bg on error
ctx.fillStyle = '#d9534f';
ctx.font = `bold ${DEFAULT_FONT_SIZE}px Arial, Helvetica, sans-serif`;
ctx.fillText(`Rendering Error: ${error.message}`, PADDING, PADDING);
}
}
function traverseDOM(node, currentX, currentY, availableWidth, style) {
// This function remains unchanged.
// ... (full traverseDOM code) ...
let lineStartX = PADDING;
let y = currentY;
let x = currentX;
// 1. Handle Element Nodes
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toUpperCase();
let elementStyle = { ...style };
let blockElement = false;
let isListItem = false;
let isParagraph = false;
// Ignore tags
if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'HEAD', 'META', 'LINK', 'TITLE'].includes(tagName)) {
return { x, y };
}
// Style Modifications based on Tag
switch (tagName) {
case 'H1': elementStyle.fontSize = DEFAULT_FONT_SIZE * 2.0; elementStyle.fontWeight = 'bold'; blockElement = true; break;
case 'H2': elementStyle.fontSize = DEFAULT_FONT_SIZE * 1.7; elementStyle.fontWeight = 'bold'; blockElement = true; break;
case 'H3': elementStyle.fontSize = DEFAULT_FONT_SIZE * 1.4; elementStyle.fontWeight = 'bold'; blockElement = true; break;
case 'H4': elementStyle.fontSize = DEFAULT_FONT_SIZE * 1.2; elementStyle.fontWeight = 'bold'; blockElement = true; break;
case 'H5': elementStyle.fontSize = DEFAULT_FONT_SIZE * 1.0; elementStyle.fontWeight = 'bold'; blockElement = true; break;
case 'H6': elementStyle.fontSize = DEFAULT_FONT_SIZE * 0.9; elementStyle.fontWeight = 'bold'; blockElement = true; break;
case 'B': case 'STRONG': elementStyle.fontWeight = 'bold'; break;
case 'A':
elementStyle.fillStyle = '#0000EE';
elementStyle.isLink = true;
elementStyle.href = node.getAttribute('href');
break;
case 'P':
isParagraph = true;
blockElement = true;
break;
case 'DIV': case 'UL': case 'OL':
case 'BLOCKQUOTE': case 'PRE':
blockElement = true;
break;
case 'LI':
blockElement = true;
isListItem = true;
break;
case 'BR':
blockElement = true;
break;
}
const elementLineHeight = elementStyle.fontSize * LINE_HEIGHT_MULTIPLIER;
const topMarginMultiplier = isParagraph ? PARAGRAPH_MARGIN_MULTIPLIER : BLOCK_SPACING_MULTIPLIER;
const bottomMarginMultiplier = isParagraph ? PARAGRAPH_MARGIN_MULTIPLIER : BLOCK_SPACING_MULTIPLIER;
// --- Vertical spacing BEFORE block elements ---
if (blockElement && y > PADDING) {
y += elementLineHeight * topMarginMultiplier;
x = lineStartX;
} else if (blockElement && y === PADDING) {
x = lineStartX;
}
// --- Handle List Item Marker ---
if (isListItem) {
const marker = '\u2022 ';
ctx.font = `${elementStyle.fontWeight} ${elementStyle.fontSize}px Arial, Helvetica, sans-serif`;
ctx.fillStyle = elementStyle.fillStyle;
const markerWidth = ctx.measureText(marker).width;
ctx.fillText(marker, lineStartX, y);
x = lineStartX + markerWidth;
} else if (blockElement) {
x = lineStartX;
}
// --- Recursively process child nodes ---
let lastChildPos = { x: x, y: y };
node.childNodes.forEach(child => {
const childAvailableWidth = availableWidth - (x - lineStartX);
lastChildPos = traverseDOM(child, lastChildPos.x, lastChildPos.y, childAvailableWidth, elementStyle);
});
y = lastChildPos.y;
// --- Vertical spacing AFTER block elements ---
if (blockElement) {
const blockContentHeight = lastChildPos.y - currentY;
const minYAfterBlock = currentY + Math.max(blockContentHeight, elementLineHeight);
y = minYAfterBlock;
y += elementLineHeight * bottomMarginMultiplier;
x = lineStartX;
} else {
x = lastChildPos.x;
}
}
// 2. Handle Text Nodes
else if (node.nodeType === Node.TEXT_NODE) {
let text = node.textContent.trim().replace(/\s+/g, ' ');
if (text.length > 0) {
ctx.font = `${style.fontWeight} ${style.fontSize}px Arial, Helvetica, sans-serif`;
ctx.fillStyle = style.fillStyle;
const lineHeight = style.fontSize * LINE_HEIGHT_MULTIPLIER;
const words = text.split(' ');
let currentLine = '';
let lineStartXForText = x;
for (let i = 0; i < words.length; i++) {
const word = words[i];
const testLine = currentLine + (currentLine ? ' ' : '') + word;
const metrics = ctx.measureText(testLine);
const testWidth = metrics.width;
if (lineStartXForText + testWidth > lineStartX + availableWidth && currentLine !== '') {
const lineMetrics = ctx.measureText(currentLine);
ctx.fillText(currentLine, lineStartXForText, y);
if (style.isLink && style.href) {
const linkArea = { href: style.href, x: lineStartXForText, y: y, width: lineMetrics.width, height: lineHeight };
clickableLinks.push(linkArea);
const underlineY = y + lineHeight - (lineHeight * 0.2);
ctx.save();
ctx.fillStyle = style.fillStyle;
ctx.fillRect(lineStartXForText, underlineY, lineMetrics.width, 1);
ctx.restore();
}
y += lineHeight;
x = lineStartX;
lineStartXForText = x;
currentLine = word;
} else {
currentLine = testLine;
// Update x based on the current line's width if it fits
x = lineStartXForText + ctx.measureText(currentLine).width;
}
}
if (currentLine) {
const lineMetrics = ctx.measureText(currentLine);
ctx.fillText(currentLine, lineStartXForText, y);
if (style.isLink && style.href) {
const linkArea = { href: style.href, x: lineStartXForText, y: y, width: lineMetrics.width, height: lineHeight };
clickableLinks.push(linkArea);
const underlineY = y + lineHeight - (lineHeight * 0.2);
ctx.save();
ctx.fillStyle = style.fillStyle;
ctx.fillRect(lineStartXForText, underlineY, lineMetrics.width, 1);
ctx.restore();
}
x = lineStartXForText + lineMetrics.width;
}
}
}
return { x, y };
}
// --- Fetching Logic ---
async function fetchUrlContent(urlToFetchProvided = null) {
const urlToFetch = urlToFetchProvided || urlInput.value.trim();
errorMessage.style.display = 'none';
errorMessage.textContent = '';
// Don't setup canvas yet, wait until styles are parsed
urlInput.placeholder = 'Fetching...'; // Indicate fetching in placeholder
if (!urlToFetch) {
renderContainer.style.backgroundColor = '#f0f0f0'; // Ensure default container bg
setupCanvas(DEFAULT_BODY_BACKGROUND); // Use default canvas bg
ctx.fillStyle = '#d9534f';
ctx.font = `bold ${DEFAULT_FONT_SIZE}px Arial, Helvetica, sans-serif`;
ctx.fillText('Please enter a URL.', PADDING, PADDING);
updateBackButtonState();
urlInput.placeholder = 'Enter URL and press Enter'; // Reset placeholder
return;
}
let fullUrl = urlToFetch;
if (!urlToFetch.startsWith('http://') && !urlToFetch.startsWith('https://')) {
fullUrl = 'https://' + urlToFetch;
}
const finalUrlToFetch = fullUrl;
try {
const requestUrl = `${PROXY_URL}${encodeURIComponent(finalUrlToFetch)}`;
console.log(`Requesting via proxy: ${requestUrl}`);
const response = await fetch(requestUrl);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status} - ${response.statusText}`);
}
currentHtmlContent = await response.text();
currentPageUrl = finalUrlToFetch;
if (!isNavigatingBack) {
if (navigationHistory.length === 0 || navigationHistory[navigationHistory.length - 1] !== currentPageUrl) {
navigationHistory.push(currentPageUrl);
console.log("History updated:", navigationHistory);
}
}
urlInput.value = currentPageUrl;
// Parse and render AFTER getting content
parseAndRenderHTML(currentHtmlContent);
} catch (error) {
console.error('Fetch error:', error);
errorMessage.textContent = `Error fetching content: ${error.message}. Check URL/proxy.`;
errorMessage.style.display = 'block';
// Setup canvas to show error
renderContainer.style.backgroundColor = '#f0f0f0'; // Ensure default container bg
setupCanvas(DEFAULT_BODY_BACKGROUND); // Use default canvas bg
ctx.fillStyle = '#d9534f';
ctx.font = `bold ${DEFAULT_FONT_SIZE}px Arial, Helvetica, sans-serif`;
ctx.fillText('Failed to load content.', PADDING, PADDING);
currentPageUrl = '';
currentHtmlContent = '';
} finally {
isNavigatingBack = false;
updateBackButtonState();
urlInput.placeholder = 'Enter URL and press Enter'; // Reset placeholder
}
}
// --- Event Listeners ---
urlInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
fetchUrlContent();
}
});
backButton.addEventListener('click', () => {
if (navigationHistory.length > 1) {
isNavigatingBack = true;
navigationHistory.pop();
const previousUrl = navigationHistory[navigationHistory.length - 1];
console.log("Navigating back to:", previousUrl);
urlInput.value = previousUrl;
fetchUrlContent(previousUrl);
}
});
// Debounced resize handler
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
console.log("Window resized.");
if (currentHtmlContent) {
console.log("Re-rendering cached content.");
// Re-parse and render needed as styles might affect layout/bg
parseAndRenderHTML(currentHtmlContent);
} else {
renderContainer.style.backgroundColor = '#f0f0f0';
setupCanvas(DEFAULT_BODY_BACKGROUND);
ctx.fillStyle = '#888888';
ctx.font = `16px Arial, Helvetica, sans-serif`;
ctx.fillText('Enter a URL above and press Enter to start browsing with CanvasSurf.', PADDING, PADDING);
}
}, 250);
});
// Click listener for canvas
renderCanvas.addEventListener('click', handleCanvasClick);
function handleCanvasClick(event) {
const rect = renderCanvas.getBoundingClientRect();
const scaleX = renderCanvas.width / rect.width;
const scaleY = renderCanvas.height / rect.height;
const clickX = (event.clientX - rect.left) * scaleX;
const clickY = (event.clientY - rect.top) * scaleY;
for (let i = clickableLinks.length - 1; i >= 0; i--) {
const link = clickableLinks[i];
if (clickX >= link.x && clickX <= link.x + link.width &&
clickY >= link.y && clickY <= link.y + link.height) {
console.log(`Clicked link: href="${link.href}", base="${currentPageUrl}"`);
if (!link.href) continue;
try {
const absoluteUrl = new URL(link.href, currentPageUrl || window.location.href).href;
console.log(`Resolved URL: ${absoluteUrl}`);
urlInput.value = absoluteUrl;
fetchUrlContent(absoluteUrl);
} catch (e) {
console.error("Error resolving or fetching clicked URL:", e);
errorMessage.textContent = `Error: Could not navigate to link (${link.href}). ${e.message}`;
errorMessage.style.display = 'block';
}
return;
}
}
}
// Mouse move listener for cursor change
renderCanvas.addEventListener('mousemove', handleCanvasMouseMove);
function handleCanvasMouseMove(event) {
const rect = renderCanvas.getBoundingClientRect();
const scaleX = renderCanvas.width / rect.width;
const scaleY = renderCanvas.height / rect.height;
const moveX = (event.clientX - rect.left) * scaleX;
const moveY = (event.clientY - rect.top) * scaleY;
let hoveringLink = false;
for (let i = clickableLinks.length - 1; i >= 0; i--) {
const link = clickableLinks[i];
if (moveX >= link.x && moveX <= link.x + link.width &&
moveY >= link.y && moveY <= link.y + link.height) {
hoveringLink = true;
break;
}
}
renderCanvas.style.cursor = hoveringLink ? 'pointer' : 'default';
}
// --- Initial Setup ---
document.addEventListener('DOMContentLoaded', () => {
urlInput.value = 'https://example.com';
fetchUrlContent(urlInput.value); // Fetch initial content
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment