From b4d63fc922dd19f568e143fedaeeb77ce2abfd98 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 17 Jun 2025 20:47:04 +0200 Subject: [PATCH] New terminal renderer WIP --- CLAUDE.md | 4 + web/package-lock.json | 7 + web/package.json | 3 +- web/public/tests/dom-terminal-test.html | 197 +++++++ web/public/tests/mobile-selection-test.html | 300 ++++++++++ .../tests/responsive-terminal-test.html | 10 + web/src/client/components/dom-terminal.ts | 531 ++++++++++++++++++ web/src/client/components/mobile-terminal.ts | 373 ++++++++---- web/src/client/custom-weblinks-addon.ts | 69 +++ web/src/client/test-terminals-entry.ts | 3 + 10 files changed, 1397 insertions(+), 100 deletions(-) create mode 100644 CLAUDE.md create mode 100644 web/public/tests/dom-terminal-test.html create mode 100644 web/public/tests/mobile-selection-test.html create mode 100644 web/src/client/components/dom-terminal.ts create mode 100644 web/src/client/custom-weblinks-addon.ts create mode 100644 web/src/client/test-terminals-entry.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..228f5676 --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index 0a93298b..9080f356 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index ec7e36eb..db93d917 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/public/tests/dom-terminal-test.html b/web/public/tests/dom-terminal-test.html new file mode 100644 index 00000000..8ebd23c2 --- /dev/null +++ b/web/public/tests/dom-terminal-test.html @@ -0,0 +1,197 @@ + + + + + + DOM Terminal Test + + + + + + + +
+
+ DOM Terminal Test - Native Browser Selection & Scrolling +
+
+ + + + +
+
+ Try text selection, scrolling (wheel/touch), and different viewport sizes +
+
+ +
+ + +
+ + + + + + + + + + \ No newline at end of file diff --git a/web/public/tests/mobile-selection-test.html b/web/public/tests/mobile-selection-test.html new file mode 100644 index 00000000..6c2c9842 --- /dev/null +++ b/web/public/tests/mobile-selection-test.html @@ -0,0 +1,300 @@ + + + + + + Mobile Text Selection Test + + + + +

Mobile Text Selection Test

+ +
+ 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. +
+ +
+ This text is NOT selectable by default (user-select: none). + We'll try to programmatically select it on long press. +
+ + + + + + + +
+ + + + \ No newline at end of file diff --git a/web/public/tests/responsive-terminal-test.html b/web/public/tests/responsive-terminal-test.html index 3f790041..2ae4d456 100644 --- a/web/public/tests/responsive-terminal-test.html +++ b/web/public/tests/responsive-terminal-test.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 diff --git a/web/src/client/components/dom-terminal.ts b/web/src/client/components/dom-terminal.ts new file mode 100644 index 00000000..5a3e25c6 --- /dev/null +++ b/web/src/client/components/dom-terminal.ts @@ -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) { + 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 += `${currentChars}`; + 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` + + +
+
+
+ `; + } +} \ No newline at end of file diff --git a/web/src/client/components/mobile-terminal.ts b/web/src/client/components/mobile-terminal.ts index f42f4a0d..d607c639 100644 --- a/web/src/client/components/mobile-terminal.ts +++ b/web/src/client/components/mobile-terminal.ts @@ -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(); @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) { @@ -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}
- ${this.showControls ? this.renderControls() : nothing} - ${this.renderStatus()} + ${this.showControls ? this.renderControls() : nothing} ${this.renderStatus()}
`; @@ -604,15 +773,19 @@ export class ResponsiveTerminal extends LitElement { // Use position: fixed like working version so controls don't affect layout return html`
- ${sizeOptions.map(size => html` - - `)} + ${sizeOptions.map( + (size) => html` + + ` + )}
`; } @@ -620,7 +793,9 @@ export class ResponsiveTerminal extends LitElement { private renderStatus() { // Position relative to the component, not the viewport return html` -
+
Size: ${this.currentTerminalSize.cols}x${this.currentTerminalSize.rows}
Font: ${this.terminal?.options.fontSize?.toFixed(1) || 14}px
Touch: ${this.touchCount}
@@ -635,4 +810,4 @@ declare global { interface HTMLElementTagNameMap { 'responsive-terminal': ResponsiveTerminal; } -} \ No newline at end of file +} diff --git a/web/src/client/custom-weblinks-addon.ts b/web/src/client/custom-weblinks-addon.ts new file mode 100644 index 00000000..2bf06538 --- /dev/null +++ b/web/src/client/custom-weblinks-addon.ts @@ -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; + } +} diff --git a/web/src/client/test-terminals-entry.ts b/web/src/client/test-terminals-entry.ts new file mode 100644 index 00000000..c1bcaf4f --- /dev/null +++ b/web/src/client/test-terminals-entry.ts @@ -0,0 +1,3 @@ +// Entry point for test pages - includes both terminal implementations +import './components/mobile-terminal.js'; +import './components/dom-terminal.js';