mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-13 12:35:54 +00:00
New terminal renderer WIP
This commit is contained in:
parent
9e0b4b8b3c
commit
b4d63fc922
10 changed files with 1397 additions and 100 deletions
4
CLAUDE.md
Normal file
4
CLAUDE.md
Normal 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
7
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
197
web/public/tests/dom-terminal-test.html
Normal file
197
web/public/tests/dom-terminal-test.html
Normal 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>
|
||||
300
web/public/tests/mobile-selection-test.html
Normal file
300
web/public/tests/mobile-selection-test.html
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
531
web/src/client/components/dom-terminal.ts
Normal file
531
web/src/client/components/dom-terminal.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
web/src/client/custom-weblinks-addon.ts
Normal file
69
web/src/client/custom-weblinks-addon.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
web/src/client/test-terminals-entry.ts
Normal file
3
web/src/client/test-terminals-entry.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Entry point for test pages - includes both terminal implementations
|
||||
import './components/mobile-terminal.js';
|
||||
import './components/dom-terminal.js';
|
||||
Loading…
Reference in a new issue