New terminal renderer WIP

This commit is contained in:
Mario Zechner 2025-06-17 20:47:04 +02:00
parent 9e0b4b8b3c
commit b4d63fc922
10 changed files with 1397 additions and 100 deletions

4
CLAUDE.md Normal file
View file

@ -0,0 +1,4 @@
- Never commit and/or push before the user has tested your changes!
- You do not need to manually build the web project, the user has npm run dev running in a separate terminal
- Never screenshot via puppeteer. always query the DOM to see what's what.
- NEVER EVER USE SETTIMEOUT FOR ANYTHING IN THE FRONTEND UNLESS EXPLICITELY PERMITTED

7
web/package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^5.5.0",
"asciinema-player": "^3.7.0",
"express": "^4.18.2",
@ -2671,6 +2672,12 @@
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/headless": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",

View file

@ -15,7 +15,7 @@
"bundle": "npm run bundle:client && npm run bundle:renderer && npm run bundle:test",
"bundle:client": "esbuild src/client/app-entry.ts --bundle --outfile=public/bundle/client-bundle.js --format=esm --sourcemap --external:@xterm/xterm/css/xterm.css",
"bundle:renderer": "esbuild src/client/renderer-entry.ts --bundle --outfile=public/bundle/renderer.js --format=esm --sourcemap",
"bundle:test": "esbuild src/client/components/mobile-terminal.ts --bundle --outfile=public/bundle/mobile-terminal.js --format=esm --sourcemap",
"bundle:test": "esbuild src/client/test-terminals-entry.ts --bundle --outfile=public/bundle/terminal.js --format=esm --sourcemap",
"bundle:watch": "concurrently \"npm run bundle:client -- --watch\" \"npm run bundle:renderer -- --watch\" \"npm run bundle:test -- --watch\"",
"start": "node dist/server.js",
"test": "jest",
@ -31,6 +31,7 @@
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^5.5.0",
"asciinema-player": "^3.7.0",
"express": "^4.18.2",

View file

@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<title>DOM Terminal Test</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh; /* Use dynamic viewport height for mobile */
margin: 0;
overflow: hidden;
font-family: monospace;
background: #1e1e1e;
}
header {
padding: 10px;
background: #333;
border-bottom: 1px solid #555;
display: flex;
flex-direction: column;
gap: 10px;
justify-content: center;
}
button {
padding: 8px 12px;
background: #555;
color: white;
border: 1px solid #777;
border-radius: 3px;
cursor: pointer;
}
button:hover {
background: #666;
}
button.active {
background: #007acc;
border-color: #007acc;
}
.size-btn {
background: #555;
color: white;
border: 1px solid #777;
padding: 8px 12px;
border-radius: 3px;
cursor: pointer;
font-family: monospace;
font-size: 12px;
}
.size-btn:hover {
background: #666;
}
.size-btn.active {
background: rgba(0, 100, 200, 0.8);
border-color: #0066cc;
}
main {
flex: 1;
overflow: hidden;
}
/* Ensure the dom-terminal element takes full height of its container */
dom-terminal {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
}
</style>
</head>
<body>
<header>
<div style="padding: 4px; text-align: center; color: white; font-size: 12px;">
DOM Terminal Test - Native Browser Selection & Scrolling
</div>
<div style="padding: 8px; display: flex; gap: 8px; justify-content: center;">
<button class="size-btn" data-cols="60" data-rows="15">60x15</button>
<button class="size-btn" data-cols="80" data-rows="20">80x20</button>
<button class="size-btn active" data-cols="120" data-rows="40">120x40</button>
<button class="size-btn" data-cols="160" data-rows="50">160x50</button>
</div>
<div style="padding: 4px; text-align: center; color: #ccc; font-size: 10px;">
Try text selection, scrolling (wheel/touch), and different viewport sizes
</div>
</header>
<main>
<dom-terminal
id="main-terminal"
cols="120"
rows="40"
show-controls="false">
</dom-terminal>
</main>
<!-- Load XTerm.js -->
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
<!-- Load the component bundle -->
<script type="module" src="../bundle/terminal.js"></script>
<script type="module">
console.log('DOM Terminal Test Page Loaded');
console.log('XTerm available:', typeof Terminal !== 'undefined');
// WebSocket hot reload for development
const ws = new WebSocket('ws://localhost:3000?hotReload=true');
ws.onmessage = () => {
console.log('Hot reload triggered');
location.reload();
};
ws.onerror = () => console.log('WebSocket connection failed (normal if not running dev server)');
let terminal = document.getElementById('main-terminal');
setupSizeControls();
generateMockData();
function generateMockData() {
if (!terminal) return;
console.log('Generating mock data from HTML...');
const contentCols = 120;
const contentRows = 40;
const numPages = 10;
let lineNumber = 1;
let content = "";
for (let page = 1; page <= numPages; page++) {
// Page header
const headerLine = '◄' + '='.repeat(contentCols - 2) + '►';
content += (`\x1b[43m${headerLine}\x1b[0m\n`);
lineNumber++;
// Fill the page with numbered lines
const contentLines = contentRows - 2;
for (let line = 1; line <= contentLines; line++) {
content += (`Line ${lineNumber.toString().padStart(4, '0')}: Content originally sized for ${contentCols}x${contentRows} terminal - watch it reflow!\n`);
lineNumber++;
}
// Page footer
const footerLine = '◄' + '='.repeat(contentCols - 2) + '►';
content += (`\x1b[43m${footerLine}\x1b[0m\n`);
lineNumber++;
}
content += ('\x1b[1;31m>>> END OF ALL CONTENT - THIS IS THE BOTTOM <<<\x1b[0m\n');
content += ('\x1b[1;33mIf you can see this, you reached the end. Scroll up to see all pages.\x1b[0m\n');
terminal.write(content);
console.log('Mock data generation completed from HTML');
}
function setupSizeControls() {
// Add click handlers for terminal size buttons
const buttons = document.querySelectorAll('.size-btn');
buttons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const cols = parseInt(button.dataset.cols);
const rows = parseInt(button.dataset.rows);
if (cols && rows && terminal) {
// Update active button
buttons.forEach(b => b.classList.remove('active'));
button.classList.add('active');
// Change viewport size
terminal.setViewportSize(cols, rows);
console.log(`Terminal viewport changed to ${cols}x${rows} - watch the content reflow!`);
}
});
});
console.log('Size controls setup complete - buttons will change viewport size and reflow existing content');
}
</script>
</body>
</html>

View file

@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<title>Mobile Text Selection Test</title>
<style>
body {
font-family: monospace;
padding: 20px;
line-height: 1.5;
}
.test-text {
background: #f0f0f0;
padding: 20px;
margin: 10px 0;
border-radius: 5px;
}
.selectable {
user-select: text;
-webkit-user-select: text;
-webkit-touch-callout: default;
}
.non-selectable {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
button {
padding: 10px;
margin: 5px;
font-size: 16px;
}
#log {
background: #000;
color: #0f0;
padding: 10px;
height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<h1>Mobile Text Selection Test</h1>
<div class="test-text selectable" id="test1">
This text should be selectable normally. Try long pressing on it.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
You should see selection handles and context menu.
</div>
<div class="test-text non-selectable" id="test2">
This text is NOT selectable by default (user-select: none).
We'll try to programmatically select it on long press.
</div>
<button onclick="testProgrammaticSelection()">Test Programmatic Selection</button>
<button onclick="testTouchEvents()">Test Touch Events</button>
<button onclick="testMouseEvents()">Test Mouse Events</button>
<button onclick="clearSelection()">Clear Selection</button>
<button onclick="selectAll()">Select All</button>
<div id="log"></div>
<script>
function log(message) {
const logDiv = document.getElementById('log');
logDiv.innerHTML += new Date().toLocaleTimeString() + ': ' + message + '\n';
logDiv.scrollTop = logDiv.scrollHeight;
}
function testProgrammaticSelection() {
const element = document.getElementById('test2');
// Temporarily enable selection
element.style.userSelect = 'text';
element.style.webkitUserSelect = 'text';
element.style.webkitTouchCallout = 'default';
try {
const range = document.createRange();
const selection = window.getSelection();
selection.removeAllRanges();
if (element.firstChild) {
range.setStart(element.firstChild, 5);
range.setEnd(element.firstChild, 20);
selection.addRange(range);
log('Created programmatic selection: "' + selection.toString() + '"');
log('Range count: ' + selection.rangeCount);
// Try to trigger selection UI
setTimeout(() => {
// Method 1: Focus the element
element.focus();
log('Focused element');
// Method 2: Dispatch selectstart event
const selectStartEvent = new Event('selectstart', { bubbles: true });
element.dispatchEvent(selectStartEvent);
log('Dispatched selectstart event');
// Method 3: Dispatch selectionchange on document
const selectionChangeEvent = new Event('selectionchange');
document.dispatchEvent(selectionChangeEvent);
log('Dispatched selectionchange event');
}, 100);
} else {
log('No text node found');
}
} catch (error) {
log('Error: ' + error.message);
}
}
function clearSelection() {
const selection = window.getSelection();
selection.removeAllRanges();
log('Cleared selection');
}
function testTouchEvents() {
const element = document.getElementById('test2');
// Enable selection
element.style.userSelect = 'text';
element.style.webkitUserSelect = 'text';
element.style.webkitTouchCallout = 'default';
const rect = element.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
try {
// Create touch events
const touchStart = new TouchEvent('touchstart', {
bubbles: true,
cancelable: true,
touches: [new Touch({
identifier: 1,
target: element,
clientX: x,
clientY: y
})]
});
element.dispatchEvent(touchStart);
log('Dispatched touchstart event');
// Hold for long press duration
setTimeout(() => {
const touchEnd = new TouchEvent('touchend', {
bubbles: true,
cancelable: true,
changedTouches: [new Touch({
identifier: 1,
target: element,
clientX: x,
clientY: y
})]
});
element.dispatchEvent(touchEnd);
log('Dispatched touchend after 600ms');
}, 600);
} catch (error) {
log('Touch events error: ' + error.message);
}
}
function testMouseEvents() {
const element = document.getElementById('test2');
// Enable selection
element.style.userSelect = 'text';
element.style.webkitUserSelect = 'text';
element.style.webkitTouchCallout = 'default';
const rect = element.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
try {
// Mouse down
const mouseDown = new MouseEvent('mousedown', {
clientX: x,
clientY: y,
bubbles: true,
cancelable: true
});
element.dispatchEvent(mouseDown);
log('Dispatched mousedown');
// Small mouse move to start selection
setTimeout(() => {
const mouseMove = new MouseEvent('mousemove', {
clientX: x + 5,
clientY: y,
bubbles: true,
cancelable: true
});
element.dispatchEvent(mouseMove);
log('Dispatched mousemove');
// Mouse up
setTimeout(() => {
const mouseUp = new MouseEvent('mouseup', {
clientX: x + 5,
clientY: y,
bubbles: true,
cancelable: true
});
element.dispatchEvent(mouseUp);
log('Dispatched mouseup');
}, 100);
}, 50);
} catch (error) {
log('Mouse events error: ' + error.message);
}
}
function selectAll() {
const element = document.getElementById('test1');
try {
const range = document.createRange();
const selection = window.getSelection();
selection.removeAllRanges();
range.selectNodeContents(element);
selection.addRange(range);
log('Selected all content in test1');
} catch (error) {
log('Error: ' + error.message);
}
}
// Long press detection
let longPressTimer = null;
let startX, startY;
document.getElementById('test2').addEventListener('touchstart', function(e) {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
longPressTimer = setTimeout(() => {
log('Long press detected on test2');
testProgrammaticSelection();
}, 500);
log('Touch start on test2');
});
document.getElementById('test2').addEventListener('touchmove', function(e) {
const deltaX = Math.abs(e.touches[0].clientX - startX);
const deltaY = Math.abs(e.touches[0].clientY - startY);
if (deltaX > 5 || deltaY > 5) {
clearTimeout(longPressTimer);
log('Touch moved, cancelling long press');
}
});
document.getElementById('test2').addEventListener('touchend', function(e) {
clearTimeout(longPressTimer);
log('Touch end on test2');
});
// Log selection changes
document.addEventListener('selectionchange', function() {
const selection = window.getSelection();
if (selection.toString()) {
log('Selection changed: "' + selection.toString() + '"');
} else {
log('Selection cleared');
}
});
log('Mobile selection test loaded');
</script>
</body>
</html>

View file

@ -118,6 +118,16 @@
console.log('Responsive Terminal Test Page Loaded');
console.log('XTerm available:', typeof Terminal !== 'undefined');
// WebSocket hot reload for development
const ws = new WebSocket('ws://localhost:3000');
ws.onmessage = (event) => {
if (event.data === 'reload') {
console.log('Hot reload triggered');
location.reload();
}
};
ws.onerror = () => console.log('WebSocket connection failed (normal if not running dev server)');
let terminal = null;
// Wait for components to be defined

View file

@ -0,0 +1,531 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Terminal } from '@xterm/xterm';
@customElement('dom-terminal')
export class DomTerminal extends LitElement {
// Disable shadow DOM for Tailwind compatibility and native text selection
createRenderRoot() {
return this as unknown as HTMLElement;
}
@property({ type: String }) sessionId = '';
@property({ type: Number }) cols = 80;
@property({ type: Number }) rows = 24;
@property({ type: Number }) fontSize = 14;
@state() private terminal: Terminal | null = null;
@state() private viewportY = 0; // Current scroll position
@state() private actualRows = 24; // Rows that fit in viewport
private container: HTMLElement | null = null;
private textContainer: HTMLElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private resizeTimeout: NodeJS.Timeout | null = null;
// Virtual scrolling optimization
private lineElements: HTMLElement[] = [];
private renderPending = false;
connectedCallback() {
super.connectedCallback();
}
disconnectedCallback() {
this.cleanup();
super.disconnectedCallback();
}
private cleanup() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('cols') || changedProperties.has('rows')) {
if (this.terminal) {
this.reinitializeTerminal();
}
}
}
firstUpdated() {
this.initializeTerminal();
}
private async initializeTerminal() {
try {
this.requestUpdate();
this.container = this.querySelector('#dom-terminal-container') as HTMLElement;
this.textContainer = this.querySelector('#dom-text-container') as HTMLElement;
if (!this.container || !this.textContainer) {
throw new Error('Terminal container not found');
}
await this.setupTerminal();
this.setupResize();
this.setupScrolling();
this.requestUpdate();
} catch (_error: unknown) {
this.requestUpdate();
}
}
private async reinitializeTerminal() {
if (this.terminal) {
this.terminal.resize(this.cols, this.rows);
this.fitTerminal();
this.renderTerminalContent();
}
}
private async setupTerminal() {
try {
console.log('Creating terminal for headless use...');
// Create regular terminal but don't call .open() to make it headless
this.terminal = new Terminal({
cursorBlink: false,
fontSize: this.fontSize,
fontFamily: 'Fira Code, ui-monospace, SFMono-Regular, monospace',
lineHeight: 1.2,
scrollback: 10000,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#00ff00',
black: '#000000',
red: '#f14c4c',
green: '#23d18b',
yellow: '#f5f543',
blue: '#3b8eea',
magenta: '#d670d6',
cyan: '#29b8db',
white: '#e5e5e5',
},
});
console.log('Terminal created successfully (no DOM attachment)');
console.log('Terminal object:', this.terminal);
console.log('Buffer available:', !!this.terminal.buffer);
// Set terminal size - don't call .open() to keep it headless
this.terminal.resize(this.cols, this.rows);
console.log('Terminal resized to:', this.cols, 'x', this.rows);
} catch (error) {
console.error('Failed to create terminal:', error);
throw error;
}
}
private fitTerminal() {
if (!this.terminal || !this.container) return;
// Calculate how many rows fit in the viewport
const containerHeight = this.container.clientHeight;
const lineHeight = this.fontSize * 1.2;
this.actualRows = Math.max(1, Math.floor(containerHeight / lineHeight));
console.log(`Viewport fits ${this.actualRows} rows`);
this.requestUpdate();
}
private setupResize() {
if (!this.container) return;
this.resizeObserver = new ResizeObserver(() => {
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeTimeout = setTimeout(() => {
this.fitTerminal();
this.renderTerminalContent();
}, 50);
});
this.resizeObserver.observe(this.container);
window.addEventListener('resize', () => {
this.fitTerminal();
this.renderTerminalContent();
});
}
private setupScrolling() {
if (!this.container || !this.textContainer) return;
// Handle wheel events
this.container.addEventListener(
'wheel',
(e) => {
e.preventDefault();
const deltaLines = Math.round(e.deltaY / (this.fontSize * 1.2));
this.scrollViewport(deltaLines);
},
{ passive: false }
);
// Handle touch events for mobile scrolling - use shared variables
let touchStartY = 0;
let lastY = 0;
let velocity = 0;
let lastTouchTime = 0;
const handleTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0].clientY;
lastY = e.touches[0].clientY;
velocity = 0;
lastTouchTime = Date.now();
console.log('TouchStart:', {
startY: touchStartY,
target: (e.target as HTMLElement)?.tagName,
targetClass: (e.target as HTMLElement)?.className,
});
};
const handleTouchMove = (e: TouchEvent) => {
const currentY = e.touches[0].clientY;
const deltaY = lastY - currentY; // Change since last move, not since start
const currentTime = Date.now();
// Calculate velocity for momentum (based on total movement from start)
const totalDelta = touchStartY - currentY;
const timeDelta = currentTime - lastTouchTime;
if (timeDelta > 0) {
velocity = totalDelta / (currentTime - (lastTouchTime - timeDelta));
}
lastTouchTime = currentTime;
const deltaLines = Math.round(deltaY / (this.fontSize * 1.2));
console.log('TouchMove:', {
currentY,
lastY,
deltaY,
totalDelta,
fontSize: this.fontSize,
lineHeight: this.fontSize * 1.2,
deltaLines,
velocity: velocity.toFixed(3),
timeDelta,
});
if (Math.abs(deltaLines) > 0) {
console.log('Scrolling:', deltaLines, 'lines');
this.scrollViewport(deltaLines);
}
lastY = currentY; // Update for next move event
};
const handleTouchEnd = () => {
console.log('TouchEnd:', {
finalVelocity: velocity.toFixed(3),
willStartMomentum: Math.abs(velocity) > 0.5,
});
// Add momentum scrolling if needed
if (Math.abs(velocity) > 0.5) {
this.startMomentumScroll(velocity);
}
};
// Use event delegation on container with capture phase to catch all touch events
this.container.addEventListener('touchstart', handleTouchStart, {
passive: true,
capture: true,
});
this.container.addEventListener('touchmove', handleTouchMove, {
passive: false,
capture: true,
});
this.container.addEventListener('touchend', handleTouchEnd, { passive: true, capture: true });
}
private startMomentumScroll(initialVelocity: number) {
let velocity = initialVelocity;
const animate = () => {
if (Math.abs(velocity) < 0.01) return;
const deltaLines = Math.round(velocity * 16); // 16ms frame time
if (Math.abs(deltaLines) > 0) {
this.scrollViewport(deltaLines);
}
velocity *= 0.95; // Friction
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}
private scrollViewport(deltaLines: number) {
if (!this.terminal) return;
const buffer = this.terminal.buffer.active;
const maxScroll = Math.max(0, buffer.length - this.actualRows);
const newViewportY = Math.max(0, Math.min(maxScroll, this.viewportY + deltaLines));
// Only render if we actually moved
if (newViewportY !== this.viewportY) {
this.viewportY = newViewportY;
// Use requestAnimationFrame to throttle rendering
if (!this.renderPending) {
this.renderPending = true;
requestAnimationFrame(() => {
this.renderTerminalContent();
this.renderPending = false;
});
}
}
}
private renderTerminalContent() {
if (!this.terminal || !this.textContainer) return;
const buffer = this.terminal.buffer.active;
const bufferLength = buffer.length;
const startRow = Math.min(this.viewportY, Math.max(0, bufferLength - this.actualRows));
// const endRow = Math.min(bufferLength, startRow + this.actualRows);
// Ensure we have enough line elements
while (this.lineElements.length < this.actualRows) {
const lineEl = document.createElement('div');
lineEl.className = 'terminal-line';
this.lineElements.push(lineEl);
this.textContainer.appendChild(lineEl);
}
// Hide extra line elements
for (let i = this.actualRows; i < this.lineElements.length; i++) {
this.lineElements[i].style.display = 'none';
}
// Render visible lines
for (let i = 0; i < this.actualRows; i++) {
const row = startRow + i;
const lineEl = this.lineElements[i];
lineEl.style.display = 'block';
if (row >= bufferLength) {
lineEl.innerHTML = '';
continue;
}
const line = buffer.getLine(row);
if (!line) {
lineEl.innerHTML = '';
continue;
}
// Always re-render the line
const content = this.renderLine(line);
lineEl.innerHTML = content || '';
}
}
private renderLine(line: any): string {
let html = '';
let currentChars = '';
let currentClasses = '';
let currentStyle = '';
const flushGroup = () => {
if (currentChars) {
html += `<span class="${currentClasses}"${currentStyle ? ` style="${currentStyle}"` : ''}>${currentChars}</span>`;
currentChars = '';
}
};
// Process each cell in the line
for (let col = 0; col < line.length; col++) {
const cell = line.getCell(col);
if (!cell) continue;
// XTerm.js cell API
const char = cell.getChars() || ' ';
const width = cell.getWidth();
// Skip zero-width cells (part of wide characters)
if (width === 0) continue;
// Get styling attributes
let classes = 'terminal-char';
let style = '';
// Get foreground color
const fg = cell.getFgColor();
if (fg !== undefined) {
if (typeof fg === 'number') {
style += `color: var(--terminal-color-${fg});`;
} else if (fg.css) {
style += `color: ${fg.css};`;
}
}
// Get background color
const bg = cell.getBgColor();
if (bg !== undefined) {
if (typeof bg === 'number') {
style += `background-color: var(--terminal-color-${bg});`;
} else if (bg.css) {
style += `background-color: ${bg.css};`;
}
}
// Get text attributes/flags
const isBold = cell.isBold();
const isItalic = cell.isItalic();
const isUnderline = cell.isUnderline();
const isDim = cell.isDim();
if (isBold) classes += ' bold';
if (isItalic) classes += ' italic';
if (isUnderline) classes += ' underline';
if (isDim) classes += ' dim';
// Check if styling changed - if so, flush current group
if (classes !== currentClasses || style !== currentStyle) {
flushGroup();
currentClasses = classes;
currentStyle = style;
}
// Add character to current group
currentChars += char;
}
// Flush final group
flushGroup();
return html;
}
// Public API methods
public write(data: string) {
if (this.terminal) {
this.terminal.write(data, () => {
this.renderTerminalContent();
});
}
}
public clear() {
if (this.terminal) {
this.terminal.clear();
this.viewportY = 0;
this.renderTerminalContent();
}
}
public getTerminal(): Terminal | null {
return this.terminal;
}
public setViewportSize(cols: number, rows: number) {
this.cols = cols;
this.rows = rows;
if (this.terminal) {
this.terminal.resize(cols, rows);
this.fitTerminal();
this.renderTerminalContent();
}
this.requestUpdate();
}
render() {
return html`
<style>
/* Terminal color variables */
:root {
--terminal-color-0: #000000;
--terminal-color-1: #f14c4c;
--terminal-color-2: #23d18b;
--terminal-color-3: #f5f543;
--terminal-color-4: #3b8eea;
--terminal-color-5: #d670d6;
--terminal-color-6: #29b8db;
--terminal-color-7: #e5e5e5;
--terminal-color-8: #666666;
--terminal-color-9: #ff6b6b;
--terminal-color-10: #5af78e;
--terminal-color-11: #f4f99d;
--terminal-color-12: #70a5ed;
--terminal-color-13: #d670d6;
--terminal-color-14: #5fb3d3;
--terminal-color-15: #ffffff;
}
.dom-terminal-container {
color: #d4d4d4;
font-family: 'Fira Code', ui-monospace, SFMono-Regular, monospace;
font-size: ${this.fontSize}px;
line-height: ${this.fontSize}px;
}
.terminal-line {
display: block;
height: ${this.fontSize * 1.2}px;
line-height: ${this.fontSize * 1.2}px;
}
.terminal-char {
font-family: inherit;
}
.terminal-char.bold {
font-weight: bold;
}
.terminal-char.dim {
opacity: 0.5;
}
.terminal-char.italic {
font-style: italic;
}
.terminal-char.underline {
text-decoration: underline;
}
.terminal-char.strikethrough {
text-decoration: line-through;
}
.terminal-char.inverse {
filter: invert(1);
}
.terminal-char.invisible {
opacity: 0;
}
/* Mobile touch improvements */
@media (max-width: 768px) {
.dom-terminal-container {
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
}
</style>
<div id="dom-terminal-container" class="dom-terminal-container w-full h-full">
<div id="dom-text-container" class="w-full h-full"></div>
</div>
`;
}
}

View file

@ -1,7 +1,6 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Terminal } from '@xterm/xterm';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { ScaleFitAddon } from '../scale-fit-addon.js';
// Simplified - only fit-both mode
@ -9,7 +8,9 @@ import { ScaleFitAddon } from '../scale-fit-addon.js';
@customElement('responsive-terminal')
export class ResponsiveTerminal extends LitElement {
// Disable shadow DOM for Tailwind compatibility
createRenderRoot() { return this; }
createRenderRoot() {
return this;
}
@property({ type: String }) sessionId = '';
@property({ type: Number }) cols = 80;
@ -22,7 +23,6 @@ export class ResponsiveTerminal extends LitElement {
@state() private terminal: Terminal | null = null;
@state() private scaleFitAddon: ScaleFitAddon | null = null;
@state() private webLinksAddon: WebLinksAddon | null = null;
@state() private isMobile = false;
@state() private touches = new Map<number, any>();
@state() private currentTerminalSize = { cols: 80, rows: 24 };
@ -30,6 +30,16 @@ export class ResponsiveTerminal extends LitElement {
@state() private touchCount = 0;
@state() private terminalStatus = 'Initializing...';
// Inertial scrolling state
private velocity = 0;
private lastTouchTime = 0;
private momentumAnimationId: number | null = null;
// Long press detection for text selection
private longPressTimer: number | null = null;
private longPressThreshold = 500; // ms
private isInSelectionMode = false;
private container: HTMLElement | null = null;
private wrapper: HTMLElement | null = null;
private resizeObserver: ResizeObserver | null = null;
@ -65,8 +75,17 @@ export class ResponsiveTerminal extends LitElement {
document.removeEventListener('copy', this.boundCopyHandler);
this.boundCopyHandler = null;
}
// Stop momentum animation
if (this.momentumAnimationId) {
cancelAnimationFrame(this.momentumAnimationId);
this.momentumAnimationId = null;
}
// Clear long press timer
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.scaleFitAddon = null;
this.webLinksAddon = null;
}
updated(changedProperties: Map<string, any>) {
@ -102,7 +121,6 @@ export class ResponsiveTerminal extends LitElement {
this.terminalStatus = 'Ready';
this.requestUpdate();
} catch (error) {
console.error('Failed to initialize terminal:', error);
this.terminalStatus = `Error: ${error instanceof Error ? error.message : String(error)}`;
this.requestUpdate();
}
@ -124,6 +142,9 @@ export class ResponsiveTerminal extends LitElement {
fontFamily: 'Consolas, "Liberation Mono", monospace',
lineHeight: 1.2,
scrollback: 10000,
// Disable built-in link handling completely
linkHandler: null,
windowOptions: {},
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
@ -136,15 +157,13 @@ export class ResponsiveTerminal extends LitElement {
magenta: '#d670d6',
cyan: '#29b8db',
white: '#e5e5e5',
}
},
});
// No addons - keep it simple like working version
// Open terminal
// Open terminal first
this.terminal.open(this.wrapper!);
// Always disable default terminal handlers like working version
// Always disable default terminal handlers first
this.disableDefaultTerminalHandlers();
// Fit terminal to container
@ -154,7 +173,7 @@ export class ResponsiveTerminal extends LitElement {
private disableDefaultTerminalHandlers() {
if (!this.terminal || !this.wrapper) return;
// EXACT copy from working file
// Back to aggressive approach - disable all XTerm touch handling so we can control everything
const terminalEl = this.wrapper.querySelector('.xterm');
const screenEl = this.wrapper.querySelector('.xterm-screen');
const rowsEl = this.wrapper.querySelector('.xterm-rows');
@ -162,7 +181,7 @@ export class ResponsiveTerminal extends LitElement {
if (terminalEl) {
// Disable all default behaviors on terminal elements
[terminalEl, screenEl, rowsEl].forEach(el => {
[terminalEl, screenEl, rowsEl].forEach((el) => {
if (el) {
el.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
el.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
@ -181,10 +200,7 @@ export class ResponsiveTerminal extends LitElement {
e.preventDefault();
textareaEl.blur();
});
console.log('Disabled XTerm input textarea');
}
console.log('Disabled XTerm default touch behaviors');
}
}
@ -193,17 +209,23 @@ export class ResponsiveTerminal extends LitElement {
// ONLY attach touch handlers to the terminal wrapper (the XTerm content area)
// This way buttons and other elements outside aren't affected
this.wrapper.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
this.wrapper.addEventListener('touchstart', this.handleTouchStart.bind(this), {
passive: false,
});
this.wrapper.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
this.wrapper.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false });
this.wrapper.addEventListener('touchcancel', this.handleTouchEnd.bind(this), { passive: false });
this.wrapper.addEventListener('touchcancel', this.handleTouchEnd.bind(this), {
passive: false,
});
// Prevent context menu ONLY on terminal wrapper
this.wrapper.addEventListener('contextmenu', (e) => e.preventDefault());
// Mouse wheel ONLY on terminal wrapper
this.wrapper.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false });
this.wrapper.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, {
passive: false,
});
// Copy support for desktop
this.boundCopyHandler = this.handleCopy.bind(this);
document.addEventListener('copy', this.boundCopyHandler);
@ -214,7 +236,6 @@ export class ResponsiveTerminal extends LitElement {
// Use debounced ResizeObserver to avoid infinite resize loops
this.resizeObserver = new ResizeObserver(() => {
console.log('ResizeObserver triggered, container size:', this.container?.clientWidth, 'x', this.container?.clientHeight);
// Debounce to avoid resize loops
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
@ -224,7 +245,6 @@ export class ResponsiveTerminal extends LitElement {
this.resizeObserver.observe(this.container);
window.addEventListener('resize', () => {
console.log('Window resize event triggered');
this.detectMobile();
this.fitTerminal();
});
@ -238,68 +258,45 @@ export class ResponsiveTerminal extends LitElement {
const containerWidth = hostElement.clientWidth;
const containerHeight = hostElement.clientHeight;
console.log(`Host element actual dimensions: ${containerWidth}x${containerHeight}px`);
console.log('Host element details:', {
element: hostElement,
clientHeight: hostElement.clientHeight,
offsetHeight: hostElement.offsetHeight,
boundingRect: hostElement.getBoundingClientRect()
});
// Mobile viewport debugging
console.log('Mobile viewport debug:', {
windowInnerHeight: window.innerHeight,
visualViewportHeight: window.visualViewport?.height || 'not supported',
documentElementClientHeight: document.documentElement.clientHeight,
bodyClientHeight: document.body.clientHeight,
screenHeight: screen.height,
isMobile: this.isMobile
});
// EXACT copy of working fitTerminal() method from mobile-terminal-test-fixed.html
// Resize to target dimensions first
this.terminal.resize(this.currentTerminalSize.cols, this.currentTerminalSize.rows);
// Calculate font size to fit the target columns exactly in container width
const charWidthRatio = 0.6; // More conservative estimate
const calculatedFontSize = containerWidth / (this.currentTerminalSize.cols * charWidthRatio);
const fontSize = Math.max(1, calculatedFontSize); // Allow very small fonts
// Apply font size
this.terminal.options.fontSize = fontSize;
if (this.terminal.element) {
this.terminal.element.style.fontSize = `${fontSize}px`;
}
// Calculate line height and rows
const lineHeight = fontSize * 1.2;
this.actualLineHeight = lineHeight;
const rows = Math.max(1, Math.floor(containerHeight / lineHeight));
console.log(`Height calc: container=${containerHeight}px, lineHeight=${lineHeight.toFixed(2)}px, calculated rows=${rows}`);
console.log(`Fitting terminal: ${this.currentTerminalSize.cols}x${rows}, fontSize: ${fontSize.toFixed(1)}px`);
this.terminal.resize(this.currentTerminalSize.cols, rows);
// Force a refresh to apply the new sizing
requestAnimationFrame(() => {
if (this.terminal) {
this.terminal.refresh(0, this.terminal.rows - 1);
// After rendering, check if we need to adjust row count
setTimeout(() => {
const xtermRows = this.terminal.element?.querySelector('.xterm-rows');
const firstRow = xtermRows?.children[0];
if (firstRow && xtermRows) {
// Use the REAL CSS line-height, not offsetHeight
const realLineHeight = parseFloat(getComputedStyle(firstRow).lineHeight) || firstRow.offsetHeight;
const realLineHeight =
parseFloat(getComputedStyle(firstRow).lineHeight) || firstRow.offsetHeight;
const rowsThatFit = Math.floor(containerHeight / realLineHeight);
console.log(`Mobile debug: container=${containerHeight}px, realLineHeight=${realLineHeight}px, shouldFit=${rowsThatFit}, current=${this.terminal.rows}`);
if (rowsThatFit !== this.terminal.rows) {
console.log(`Adjusting from ${this.terminal.rows} to ${rowsThatFit} rows using real line height`);
this.actualLineHeight = realLineHeight;
this.terminal.resize(this.currentTerminalSize.cols, rowsThatFit);
}
@ -312,8 +309,27 @@ export class ResponsiveTerminal extends LitElement {
// Removed - not needed in simplified version
private handleTouchStart(e: TouchEvent) {
// If in selection mode, let browser handle it
if (this.isInSelectionMode) {
return;
}
e.preventDefault();
// Stop any ongoing momentum scrolling
if (this.momentumAnimationId) {
cancelAnimationFrame(this.momentumAnimationId);
this.momentumAnimationId = null;
}
this.velocity = 0;
this.lastTouchTime = Date.now();
// Clear any existing long press timer
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
this.touches.set(touch.identifier, {
@ -321,17 +337,39 @@ export class ResponsiveTerminal extends LitElement {
y: touch.clientY,
startX: touch.clientX,
startY: touch.clientY,
startTime: Date.now()
startTime: Date.now(),
});
}
// Start long press detection for single finger
if (this.touches.size === 1 && this.isMobile) {
this.longPressTimer = setTimeout(() => {
this.startTextSelection();
}, this.longPressThreshold);
}
this.touchCount = this.touches.size;
this.requestUpdate();
}
private handleTouchMove(e: TouchEvent) {
e.preventDefault();
// If we moved, cancel long press detection (unless already in selection mode)
if (this.longPressTimer && !this.isInSelectionMode) {
const touch = Array.from(this.touches.values())[0];
if (touch) {
const deltaX = Math.abs(e.changedTouches[0].clientX - touch.startX);
const deltaY = Math.abs(e.changedTouches[0].clientY - touch.startY);
// Cancel long press if significant movement (more than 5px)
if (deltaX > 5 || deltaY > 5) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
}
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
const stored = this.touches.get(touch.identifier);
@ -340,22 +378,39 @@ export class ResponsiveTerminal extends LitElement {
stored.y = touch.clientY;
}
}
if (this.touches.size === 1) {
// Only handle scrolling if we're NOT in selection mode
if (this.touches.size === 1 && !this.isInSelectionMode) {
this.handleSingleTouch();
}
this.requestUpdate();
}
private handleTouchEnd(e: TouchEvent) {
e.preventDefault();
// Clear long press timer if still running
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
this.touches.delete(touch.identifier);
}
// Start inertial scrolling if we had velocity on mobile and NOT in selection mode
if (
this.touches.size === 0 &&
this.isMobile &&
Math.abs(this.velocity) > 0.5 &&
!this.isInSelectionMode
) {
this.startMomentumScroll();
}
this.touchCount = this.touches.size;
this.requestUpdate();
}
@ -363,9 +418,17 @@ export class ResponsiveTerminal extends LitElement {
private handleSingleTouch() {
const touch = Array.from(this.touches.values())[0];
const deltaY = touch.y - touch.startY;
const currentTime = Date.now();
// Only handle vertical scroll
if (Math.abs(deltaY) > 2) {
// Calculate velocity for inertial scrolling
const timeDelta = currentTime - this.lastTouchTime;
if (timeDelta > 0) {
this.velocity = deltaY / timeDelta; // pixels per millisecond
}
this.lastTouchTime = currentTime;
this.handleScroll(deltaY);
touch.startY = touch.y; // Update immediately for smooth tracking
}
@ -373,25 +436,111 @@ export class ResponsiveTerminal extends LitElement {
private handleScroll(deltaY: number) {
if (!this.terminal) return;
// Simple, direct scroll calculation like the working version
const scrollLines = Math.round(deltaY / this.actualLineHeight);
if (scrollLines !== 0) {
this.terminal.scrollLines(-scrollLines); // Negative for natural scroll direction
}
}
private startMomentumScroll() {
// Stop any existing momentum animation
if (this.momentumAnimationId) {
cancelAnimationFrame(this.momentumAnimationId);
}
// Start momentum animation
const animate = () => {
if (!this.terminal || Math.abs(this.velocity) < 0.01) {
this.momentumAnimationId = null;
return;
}
// Apply momentum scroll
const scrollDelta = this.velocity * 16; // 16ms frame time
this.handleScroll(scrollDelta);
// Apply friction to slow down
this.velocity *= 0.95; // Friction coefficient
// Continue animation
this.momentumAnimationId = requestAnimationFrame(animate);
};
this.momentumAnimationId = requestAnimationFrame(animate);
}
private startTextSelection() {
if (!this.terminal || !this.wrapper) return;
// Provide haptic feedback
if (navigator.vibrate) {
navigator.vibrate(50);
}
const touch = Array.from(this.touches.values())[0];
if (!touch) return;
// Enable browser selection for both desktop and mobile
this.isInSelectionMode = true;
this.wrapper.classList.add('selection-mode');
const terminalEl = this.wrapper.querySelector('.xterm');
const screenEl = this.wrapper.querySelector('.xterm-screen');
const rowsEl = this.wrapper.querySelector('.xterm-rows');
[terminalEl, screenEl, rowsEl].forEach((el) => {
if (el) {
const element = el as HTMLElement;
element.style.pointerEvents = 'auto';
element.style.webkitUserSelect = 'text';
element.style.userSelect = 'text';
}
});
console.log('Text selection mode enabled');
}
private exitTextSelection() {
this.isInSelectionMode = false;
// Clean up browser selection
if (this.wrapper) {
this.wrapper.classList.remove('selection-mode');
const terminalEl = this.wrapper.querySelector('.xterm');
const screenEl = this.wrapper.querySelector('.xterm-screen');
const rowsEl = this.wrapper.querySelector('.xterm-rows');
[terminalEl, screenEl, rowsEl].forEach((el) => {
if (el) {
const element = el as HTMLElement;
element.style.pointerEvents = '';
element.style.webkitUserSelect = '';
element.style.userSelect = '';
element.style.webkitTouchCallout = '';
}
});
const selection = window.getSelection();
selection?.removeAllRanges();
}
console.log('Text selection mode deactivated');
}
private handleWheel(e: Event) {
e.preventDefault();
e.stopPropagation();
const wheelEvent = e as WheelEvent;
if (this.terminal) {
// EXACT same logic as working version
let scrollLines = 0;
if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_LINE) {
// deltaY is already in lines
scrollLines = Math.round(wheelEvent.deltaY);
@ -402,7 +551,7 @@ export class ResponsiveTerminal extends LitElement {
// deltaY is in pages, convert to lines
scrollLines = Math.round(wheelEvent.deltaY * this.terminal.rows);
}
if (scrollLines !== 0) {
this.terminal.scrollLines(scrollLines);
}
@ -411,14 +560,13 @@ export class ResponsiveTerminal extends LitElement {
private handleCopy(e: ClipboardEvent) {
if (!this.terminal) return;
// Get selected text from XTerm regardless of focus
const selection = this.terminal.getSelection();
if (selection && selection.trim()) {
e.preventDefault();
e.clipboardData?.setData('text/plain', selection);
console.log('Copied terminal text:', selection);
}
}
@ -427,7 +575,7 @@ export class ResponsiveTerminal extends LitElement {
this.cols = cols;
this.rows = rows;
this.currentTerminalSize = { cols, rows };
if (this.terminal) {
this.terminal.clear();
this.fitTerminal();
@ -437,17 +585,15 @@ export class ResponsiveTerminal extends LitElement {
// New method for viewport size changes without content regeneration
public setViewportSize(cols: number, rows: number) {
console.log(`Setting viewport size to ${cols}x${rows} (keeping existing content)`);
this.cols = cols;
this.rows = rows;
this.currentTerminalSize = { cols, rows };
if (this.terminal) {
// Just resize the viewport - XTerm will reflow the existing content
this.fitTerminal();
}
this.requestUpdate(); // Update the UI to show new size in status
}
@ -456,13 +602,11 @@ export class ResponsiveTerminal extends LitElement {
private generateMockData() {
if (!this.terminal) return;
console.log('Generating page-based content for 120x40 (content will reflow for other sizes)...');
// Always generate content for 120x40, regardless of current viewport size
// This way we can see XTerm reflow the same content for different viewport sizes
const contentCols = 120;
const contentRows = 40;
const numPages = 5;
const numPages = 10;
let lineNumber = 1;
@ -475,7 +619,9 @@ export class ResponsiveTerminal extends LitElement {
// Fill the page with numbered lines (rows - 2 for header/footer only)
const contentLines = contentRows - 2;
for (let line = 1; line <= contentLines; line++) {
this.terminal.writeln(`Line ${lineNumber.toString().padStart(4, '0')}: Content originally sized for ${contentCols}x${contentRows} terminal - watch it reflow!`);
this.terminal.writeln(
`Line ${lineNumber.toString().padStart(4, '0')}: Content originally sized for ${contentCols}x${contentRows} terminal - watch it reflow!`
);
lineNumber++;
}
@ -486,12 +632,12 @@ export class ResponsiveTerminal extends LitElement {
}
this.terminal.writeln('\x1b[1;31m>>> END OF ALL CONTENT - THIS IS THE BOTTOM <<<\x1b[0m');
this.terminal.writeln('\x1b[1;33mIf you can see this, you reached the end. Scroll up to see all pages.\x1b[0m');
this.terminal.writeln(
'\x1b[1;33mIf you can see this, you reached the end. Scroll up to see all pages.\x1b[0m'
);
// Ensure we can scroll to the bottom
this.terminal.scrollToBottom();
console.log(`Generated ${numPages} pages of content for ${contentCols}x${contentRows}`);
}
// Public API methods
@ -535,7 +681,31 @@ export class ResponsiveTerminal extends LitElement {
-webkit-touch-callout: none;
}
/* Ensure all XTerm elements don't interfere with touch */
/* When in selection mode, allow text selection */
.selection-mode * {
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
user-select: text !important;
}
/* Ensure XTerm selection styling works */
.selection-mode .xterm-selection {
position: absolute !important;
background-color: rgba(255, 255, 255, 0.3) !important;
pointer-events: none !important;
z-index: 10 !important;
}
/* In selection mode, allow pointer events on XTerm elements */
.selection-mode .xterm-screen,
.selection-mode .xterm-rows,
.selection-mode .xterm-row,
.selection-mode .xterm span {
pointer-events: auto !important;
}
/* Ensure all XTerm elements don't interfere with touch by default */
.xterm-screen,
.xterm-rows,
.xterm-row,
@ -586,8 +756,7 @@ export class ResponsiveTerminal extends LitElement {
return html`
${aggressiveXTermStyles}
<div id="terminal-container" class="relative w-full h-full bg-gray-900">
${this.showControls ? this.renderControls() : nothing}
${this.renderStatus()}
${this.showControls ? this.renderControls() : nothing} ${this.renderStatus()}
<div id="terminal-wrapper" class="w-full h-full touch-none"></div>
</div>
`;
@ -604,15 +773,19 @@ export class ResponsiveTerminal extends LitElement {
// Use position: fixed like working version so controls don't affect layout
return html`
<div class="fixed top-2 left-2 z-50 flex gap-1 flex-wrap max-w-xs">
${sizeOptions.map(size => html`
<button
class="px-2 py-1 text-xs font-mono bg-black/80 text-white border border-gray-600 rounded hover:bg-gray-800 cursor-pointer
${this.cols === size.cols && this.rows === size.rows ? 'bg-blue-600 border-blue-400' : ''}"
@click=${() => this.handleSizeChange(size.cols, size.rows)}
>
${size.label}
</button>
`)}
${sizeOptions.map(
(size) => html`
<button
class="px-2 py-1 text-xs font-mono bg-black/80 text-white border border-gray-600 rounded hover:bg-gray-800 cursor-pointer
${this.cols === size.cols && this.rows === size.rows
? 'bg-blue-600 border-blue-400'
: ''}"
@click=${() => this.handleSizeChange(size.cols, size.rows)}
>
${size.label}
</button>
`
)}
</div>
`;
}
@ -620,7 +793,9 @@ export class ResponsiveTerminal extends LitElement {
private renderStatus() {
// Position relative to the component, not the viewport
return html`
<div class="absolute top-2 left-2 bg-black/80 text-white p-2 rounded text-xs z-40 pointer-events-none font-mono">
<div
class="absolute top-2 left-2 bg-black/80 text-white p-2 rounded text-xs z-40 pointer-events-none font-mono"
>
<div>Size: ${this.currentTerminalSize.cols}x${this.currentTerminalSize.rows}</div>
<div>Font: ${this.terminal?.options.fontSize?.toFixed(1) || 14}px</div>
<div>Touch: ${this.touchCount}</div>
@ -635,4 +810,4 @@ declare global {
interface HTMLElementTagNameMap {
'responsive-terminal': ResponsiveTerminal;
}
}
}

View file

@ -0,0 +1,69 @@
import { Terminal, ITerminalAddon } from '@xterm/xterm';
export class CustomWebLinksAddon implements ITerminalAddon {
private _terminal?: Terminal;
private _linkMatcher?: number;
constructor(private _handler?: (event: MouseEvent, uri: string) => void) {}
public activate(terminal: Terminal): void {
this._terminal = terminal;
// URL regex pattern - matches http/https URLs
const urlRegex = /(https?:\/\/[^\s]+)/gi;
this._linkMatcher = this._terminal.registerLinkMatcher(
urlRegex,
(event: MouseEvent, uri: string) => {
console.log('Custom WebLinks click:', uri);
if (this._handler) {
this._handler(event, uri);
} else {
window.open(uri, '_blank');
}
},
{
// Custom styling options
hover: (
event: MouseEvent,
uri: string,
location: { start: { x: number; y: number }; end: { x: number; y: number } }
) => {
console.log('Custom WebLinks hover:', uri);
// Style the link on hover
const linkElement = event.target as HTMLElement;
if (linkElement) {
linkElement.style.backgroundColor = 'rgba(59, 142, 234, 0.2)';
linkElement.style.color = '#ffffff';
linkElement.style.textDecoration = 'underline';
}
},
leave: (event: MouseEvent, uri: string) => {
console.log('Custom WebLinks leave:', uri);
// Remove hover styling
const linkElement = event.target as HTMLElement;
if (linkElement) {
linkElement.style.backgroundColor = '';
linkElement.style.color = '#3b8eea';
linkElement.style.textDecoration = 'underline';
}
},
priority: 1,
willLinkActivate: (event: MouseEvent, uri: string) => {
console.log('Custom WebLinks will activate:', uri);
return true;
},
}
);
console.log('Custom WebLinks addon activated with matcher ID:', this._linkMatcher);
}
public dispose(): void {
if (this._linkMatcher !== undefined && this._terminal) {
this._terminal.deregisterLinkMatcher(this._linkMatcher);
this._linkMatcher = undefined;
}
this._terminal = undefined;
}
}

View file

@ -0,0 +1,3 @@
// Entry point for test pages - includes both terminal implementations
import './components/mobile-terminal.js';
import './components/dom-terminal.js';