UNPKG

@browserbox/browserbox

Version:

BrowserBox CLI - Secure, cross-platform RBI. See dosaygo.com

502 lines (446 loc) 20.1 kB
// focus-manager.js import { debugLog, focusLog, newLog, DEBUG } from './log.js'; import { renderedBoxesBySession } from './baby-jaguar.js'; export class FocusManager { constructor(getTabState, getBrowserState) { this.getTabState = getTabState; this.getBrowserState = getBrowserState; this.getCurrentTabState = () => { return this.getTabState(this.getBrowserState().currentSessionId); }; this.focusState = new Map(); this.focusedElement = null; this.previousFocusedElement = null; this.currentFocusIndex = 0; this.tabbableCached = false; this.tabbableCache = []; this.restoredSessions = new Set(); // Track restored sessions } saveFocusState() { const sessionId = this.getBrowserState().currentSessionId; const { focusedElement, previousFocusedElement } = this; if (!focusedElement) { debugLog(`No focused element, clearing focus state for sessionId: ${sessionId}`); focusLog('clear_state', sessionId, { focusedElement, previousFocusedElement }, (new Error).stack); this.focusState.delete(sessionId); this.restoredSessions.delete(sessionId); // Clear restoration flag return; } // Only save if it's a page content element (clickable or input) if (this.isPageContentElement(focusedElement)) { const focusState = { focusedElement, previousFocusedElement }; debugLog(`Saving focus state for sessionId: ${sessionId}, focusedElement: ${focusedElement}`); focusLog('save_state', sessionId, focusState, (new Error).stack); this.focusState.set(sessionId, focusState); } else { debugLog(`Not saving UI element focus: ${focusedElement} for sessionId: ${sessionId}`); } } restoreFocusState(setFocus) { const sessionId = this.getBrowserState().currentSessionId; const focusState = this.focusState.get(sessionId); debugLog(`Restoring focus state for sessionId: ${sessionId}, found state: ${focusState ? JSON.stringify(focusState) : 'none'}`); focusLog('restore_attempt', sessionId, { state: focusState }, (new Error).stack); if (!focusState || !focusState.focusedElement) { debugLog(`No valid focus state found for sessionId: ${sessionId}`); focusLog('restore_failed', sessionId, { reason: 'no_state' }, (new Error).stack); this.restoredSessions.delete(sessionId); return false; } const restoredFocusedElement = focusState.focusedElement; const tabbable = this.computeTabbableElements(); let elementToFocus = tabbable.find(el => { const elId = el.backendNodeId ? `${el.type}:${el.backendNodeId}` : `${el.type}:${el.id || el.index}`; return elId === restoredFocusedElement; }); if (!elementToFocus) { debugLog(`Element ${restoredFocusedElement} no longer exists`); focusLog('restore_failed', sessionId, { reason: 'invalid_element', element: restoredFocusedElement }, (new Error).stack); this.focusState.delete(sessionId); this.restoredSessions.delete(sessionId); return false; } this.tabbableCached = false; this.focusedElement = restoredFocusedElement; this.previousFocusedElement = focusState.previousFocusedElement; this.currentFocusIndex = tabbable.findIndex(el => { const elId = el.backendNodeId ? `${el.type}:${el.backendNodeId}` : `${el.type}:${el.id || el.index}`; return elId === restoredFocusedElement; }); debugLog(`Restoring focus to ${restoredFocusedElement}`); focusLog('restore_success', sessionId, { element: restoredFocusedElement }, (new Error).stack); setFocus(elementToFocus); this.restoredSessions.add(sessionId); // Mark as restored return true; } computeTabbableElements() { if (this.tabbableCached) return this.tabbableCache; const browserState = this.getBrowserState(); const hasClickableDescendants = this.#hasClickableDescendants.bind(this); const sessionId = browserState.currentSessionId; const tabState = this.getTabState(sessionId); const renderedBoxes = renderedBoxesBySession.get(sessionId) || []; const tabbable = []; browserState.targets.forEach((tab, index) => { const x = 1 + index * tabState.tabWidth; tabbable.push({ type: 'tab', index, x, y: 1, targetId: tab.targetId }); }); tabbable.push({ type: 'newTab', x: tabState.termWidth - tabState.NEW_TAB_WIDTH + 1, y: 1 }); tabbable.push({ type: 'back', x: 2, y: tabState.TAB_HEIGHT + 2 }); tabbable.push({ type: 'forward', x: tabState.BACK_WIDTH + 2, y: tabState.TAB_HEIGHT + 2 }); tabbable.push({ type: 'address', x: tabState.BACK_WIDTH + tabState.FORWARD_WIDTH + 2, y: tabState.TAB_HEIGHT + 2 }); tabbable.push({ type: 'go', x: tabState.termWidth - tabState.GO_WIDTH, y: tabState.TAB_HEIGHT + 2 }); if (!tabState || !tabState.nodes || !tabState.layoutToNode || !tabState.nodeToParent) { debugLog('Missing state or layout data'); focusLog('compute_tabbable_failed', sessionId, { reason: 'missing_state' }, (new Error).stack); return tabbable; } debugLog('Rendered Boxes Count:', renderedBoxes.length); focusLog('compute_tabbable_boxes', null, { boxCount: renderedBoxes.length, boxes: renderedBoxes.map(b => ({ backendNodeId: b.backendNodeId, nodeIndex: b.nodeIndex, text: b.text || '', isClickable: b.isClickable, type: b.type, ancestorType: b.ancestorType })) }, (new Error).stack); const elementsByParentId = new Map(); const seenBackendNodeIds = new Set(); renderedBoxes.forEach(box => { if (!box.isClickable && box.type !== 'input' && box.ancestorType !== 'button') return; let parentBackendNodeId = box.backendNodeId; let parentNodeIndex = box.nodeIndex; let currentNodeIndex = box.nodeIndex; const nodePath = [currentNodeIndex]; while (currentNodeIndex !== -1 && currentNodeIndex !== undefined) { if (tabState.nodes.isClickable && tabState.nodes.isClickable.index.includes(currentNodeIndex)) { parentBackendNodeId = tabState.nodes.backendNodeId[currentNodeIndex]; parentNodeIndex = currentNodeIndex; break; } currentNodeIndex = tabState.nodeToParent.get(currentNodeIndex); nodePath.push(currentNodeIndex); } focusLog('compute_tabbable_node', null, { boxBackendNodeId: box.backendNodeId, parentBackendNodeId, nodeIndex: box.nodeIndex, parentNodeIndex, nodePath, isClickable: tabState.nodes.isClickable?.index.includes(parentNodeIndex), boxText: box.text?.slice(0, 50) }, (new Error).stack); const nodeNameIdx = tabState.nodes.nodeName[parentNodeIndex]; const nodeName = nodeNameIdx >= 0 ? tabState.strings[nodeNameIdx] : ''; if (nodeName === '#document' || tabState.nodes.nodeType[parentNodeIndex] === 9) { return; } { if (DEBUG && box.type === 'input') { if (seenBackendNodeIds.has(parentBackendNodeId)) { DEBUG && newLog(`Skipping input due to duplicate backendNodeId: ${parentBackendNodeId}`); } else { const isButton = box.ancestorType === 'button'; if (!isButton && hasClickableDescendants(parentNodeIndex, tabState)) { DEBUG && newLog(`Skipping input due to clickable descendants: backendNodeId=${parentBackendNodeId}, parentNodeIndex=${parentNodeIndex}`); } else { DEBUG && newLog(`Adding input to elementsByParentId: backendNodeId=${parentBackendNodeId}`); } } } } const isButton = box.ancestorType === 'button'; if (!isButton && hasClickableDescendants(parentNodeIndex, tabState)) { return; } if (seenBackendNodeIds.has(parentBackendNodeId)) { debugLog(`Duplicate backendNodeId detected: ${parentBackendNodeId}`); focusLog('compute_tabbable_duplicate', null, { backendNodeId: parentBackendNodeId }, (new Error).stack); } seenBackendNodeIds.add(parentBackendNodeId); if (!elementsByParentId.has(parentBackendNodeId)) { elementsByParentId.set(parentBackendNodeId, { backendNodeId: parentBackendNodeId, type: box.type === 'input' ? 'input' : 'clickable', boxes: [], text: '', ancestorType: box.ancestorType, minX: box.termX, maxX: box.termX + box.termWidth - 1, minY: box.termY, maxY: box.termY, }); } const elem = elementsByParentId.get(parentBackendNodeId); elem.boxes.push(box); elem.text += (elem.text ? ' ' : '') + box.text; elem.minX = Math.min(elem.minX, box.termX); elem.maxX = Math.max(elem.maxX, box.termX + box.termWidth - 1); elem.minY = Math.min(elem.minY, box.termY); elem.maxY = Math.max(elem.maxY, box.termY); }); elementsByParentId.forEach(elem => { tabbable.push({ type: elem.type, backendNodeId: elem.backendNodeId, x: elem.minX, y: elem.minY, width: elem.maxX - elem.minX + 1, height: elem.maxY - elem.minY + 1, text: elem.text, ancestorType: elem.ancestorType, boxes: elem.boxes, }); }); debugLog('Final Tabbable Elements Count:', tabbable.length); focusLog('compute_tabbable_elements', null, { count: tabbable.length, elements: tabbable.map(el => ({ type: el.type, id: el.backendNodeId || el.targetId || el.type, text: el.text || '' })) }, (new Error).stack); this.tabbableCache = tabbable.sort((a, b) => a.y - b.y || a.x - b.x); this.tabbableCached = true; return this.tabbableCache; } #hasClickableDescendants(nodeIdx, tabState) { const descendants = []; const collectDescendants = (idx) => { tabState.nodeToParent.forEach((parentIdx, childIdx) => { if (parentIdx === idx) { descendants.push(childIdx); collectDescendants(childIdx); } }); }; collectDescendants(nodeIdx); // Filter out #text nodes (nodeType: 3) const clickableDescendants = descendants.filter(idx => { const isClickable = tabState.nodes.isClickable?.index.includes(idx); const nodeType = tabState.nodes.nodeType[idx]; return isClickable && nodeType !== 3; }); const trulyHas = clickableDescendants.length > 0; if (DEBUG && trulyHas) { const sessionId = this.getBrowserState().currentSessionId; const renderedBoxes = renderedBoxesBySession.get(sessionId) || []; const descendantDetails = clickableDescendants.map(idx => { const nodeNameIdx = tabState.nodes.nodeName[idx]; const nodeName = nodeNameIdx >= 0 ? tabState.strings[nodeNameIdx] : ''; const backendNodeId = tabState.nodes.backendNodeId[idx] || 'unknown'; // Get innerText from renderedBoxes or nodes.children const box = renderedBoxes.find(b => b.backendNodeId === backendNodeId); let innerText = box?.text || ''; if (!innerText && tabState.nodes.children?.[idx]) { innerText = tabState.nodes.children[idx].map(childIdx => { const childTextIdx = tabState.nodes.nodeValue?.[childIdx]; return childTextIdx >= 0 ? tabState.strings[childTextIdx] : ''; }).join(' ').trim(); } // Get attributes const attributes = tabState.nodes.attributes?.[idx]?.reduce((acc, attrIdx, i, arr) => { if (i % 2 === 0 && attrIdx >= 0 && arr[i + 1] >= 0) { acc[tabState.strings[attrIdx]] = tabState.strings[arr[i + 1]]; } return acc; }, {}) || {}; return { nodeIdx: idx, backendNodeId, nodeName, innerText, attributes, isClickable: tabState.nodes.isClickable?.index.includes(idx), nodeType: tabState.nodes.nodeType[idx], termX: box?.termX || null, termY: box?.termY || null, termWidth: box?.termWidth || null, termHeight: box?.termHeight || null }; }); DEBUG && newLog(`Node ${nodeIdx} has clickable descendants`, JSON.stringify(descendantDetails, null, 2)); } return trulyHas; } isPageContentElement(element) { return element && (element.startsWith('input:') || element.startsWith('clickable:')); } isBrowserUIElement(element) { return ( element && (element.startsWith('tabs:') || element === 'newTab' || element === 'back' || element === 'forward' || element === 'address' || element === 'go') ); } focusNextElement(setFocus) { const sessionId = this.getBrowserState().currentSessionId; // Only attempt restore if not yet restored for this session if (!this.restoredSessions.has(sessionId)) { const restored = this.restoreFocusState(setFocus); if (restored) { debugLog(`Focus restored, skipping cycle for sessionId: ${sessionId}`); return; } } const tabbable = this.computeTabbableElements(); if (!tabbable.length) { focusLog('focus_next_failed', sessionId, { reason: 'no_tabbable_elements' }, (new Error).stack); return; } this.currentFocusIndex = (this.currentFocusIndex + 1) % tabbable.length; const elementToFocus = tabbable[this.currentFocusIndex]; const elementId = elementToFocus.backendNodeId ? `${elementToFocus.type}:${elementToFocus.backendNodeId}` : `${elementToFocus.type}:${elementToFocus.id || elementToFocus.index}`; focusLog('focus_next', sessionId, { from: this.focusedElement, to: elementId, index: this.currentFocusIndex }, (new Error).stack); //DEBUG && newLog(elementToFocus); setFocus(elementToFocus); } focusPreviousElement(setFocus) { const sessionId = this.getBrowserState().currentSessionId; // Only attempt restore if not yet restored for this session if (!this.restoredSessions.has(sessionId)) { const restored = this.restoreFocusState(setFocus); if (restored) { debugLog(`Focus restored, skipping cycle for sessionId: ${sessionId}`); return; } } const tabbable = this.computeTabbableElements(); if (!tabbable.length) { focusLog('focus_previous_failed', sessionId, { reason: 'no_tabbable_elements' }, (new Error).stack); return; } this.currentFocusIndex = (this.currentFocusIndex - 1 + tabbable.length) % tabbable.length; const elementToFocus = tabbable[this.currentFocusIndex]; const elementId = elementToFocus.backendNodeId ? `${elementToFocus.type}:${elementToFocus.backendNodeId}` : `${elementToFocus.type}:${elementToFocus.id || elementToFocus.index}`; focusLog('focus_previous', sessionId, { from: this.focusedElement, to: elementId, index: this.currentFocusIndex }, (new Error).stack); setFocus(elementToFocus); } focusNearestInRow(direction, setFocus, options) { const tabbable = this.computeTabbableElements(); if (!tabbable.length) { focusLog('focus_nearest_failed', null, { reason: 'no_tabbable_elements', direction }, (new Error).stack); return; } let current; if (this.focusedElement.startsWith('tabs:')) { current = tabbable.find(el => el.type === 'tab' && `tabs:${el.targetId}` === this.focusedElement) || tabbable[0]; } else if (this.focusedElement === 'newTab') { current = tabbable.find(el => el.type === 'newTab'); } else if (this.focusedElement.startsWith('input:')) { const id = this.focusedElement.split(':')[1]; current = tabbable.find(el => el.type === 'input' && el.backendNodeId == id); } else if (this.focusedElement.startsWith('clickable:')) { const id = this.focusedElement.split(':')[1]; current = tabbable.find(el => el.type === 'clickable' && el.backendNodeId == id); } else { current = tabbable.find(el => el.type === this.focusedElement) || tabbable[0]; } if (!current) { focusLog('focus_nearest_failed', null, { reason: 'no_current_element', direction, focusedElement: this.focusedElement }, (new Error).stack); return; } const currentY = current.y; const currentCenterX = current.x + (current.width || options.tabWidth) / 2; if (direction === 'down' && currentY === 1) { const omniboxElements = tabbable.filter(el => el.y === options.TAB_HEIGHT + 2); if (omniboxElements.length) { const nearest = omniboxElements.reduce((best, el) => { const elCenterX = el.x + (el.width || options.BACK_WIDTH) / 2; const bestCenterX = best.x + (best.width || options.BACK_WIDTH) / 2; return Math.abs(elCenterX - currentCenterX) < Math.abs(bestCenterX - currentCenterX) ? el : best; }, omniboxElements[0]); this.currentFocusIndex = tabbable.findIndex(el => el === nearest); focusLog('focus_nearest', null, { direction, from: this.focusedElement, to: `${nearest.type}:${nearest.backendNodeId || nearest.targetId || nearest.type}`, index: this.currentFocusIndex }, (new Error).stack); setFocus(nearest); return; } } else if (direction === 'up' && currentY === options.TAB_HEIGHT + 2) { const tabElements = tabbable.filter(el => el.y === 1); if (tabElements.length) { const nearest = tabElements.reduce((best, el) => { const elCenterX = el.x + (el.width || options.tabWidth) / 2; const bestCenterX = best.x + (best.width || options.tabWidth) / 2; return Math.abs(elCenterX - currentCenterX) < Math.abs(bestCenterX - currentCenterX) ? el : best; }, tabElements[0]); this.currentFocusIndex = tabbable.findIndex(el => el === nearest); focusLog('focus_nearest', null, { direction, from: this.focusedElement, to: `${nearest.type}:${nearest.backendNodeId || nearest.targetId || nearest.type}`, index: this.currentFocusIndex }, (new Error).stack); setFocus(nearest); return; } } const targetY = direction === 'down' ? currentY + 1 : currentY - 1; let candidates = tabbable.filter(el => el.y === targetY); if (!candidates.length) { candidates = tabbable.filter(el => direction === 'down' ? el.y > currentY : el.y < currentY); if (!candidates.length) { focusLog('focus_nearest_failed', null, { reason: 'no_candidates', direction, currentY }, (new Error).stack); return; } const nextRowY = direction === 'down' ? Math.min(...candidates.map(el => el.y)) : Math.max(...candidates.map(el => el.y)); candidates = tabbable.filter(el => el.y === nextRowY); } const nearest = candidates.reduce((best, el) => { const elCenterX = el.x + (el.width || options.tabWidth) / 2; const bestCenterX = best.x + (best.width || options.tabWidth) / 2; return Math.abs(elCenterX - currentCenterX) < Math.abs(bestCenterX - currentCenterX) ? el : best; }, candidates[0]); this.currentFocusIndex = tabbable.findIndex(el => el === nearest); focusLog('focus_nearest', null, { direction, from: this.focusedElement, to: `${nearest.type}:${nearest.backendNodeId || nearest.targetId || nearest.type}`, index: this.currentFocusIndex }, (new Error).stack); setFocus(nearest); } getFocusedElement() { return this.focusedElement; } getPreviousFocusedElement() { return this.previousFocusedElement; } setFocusedElement(element) { focusLog('set_focus', null, { from: this.focusedElement, to: element }, (new Error).stack); this.focusedElement = element; } setPreviousFocusedElement(element) { focusLog('set_previous_focus', null, { from: this.previousFocusedElement, to: element }, (new Error).stack); this.previousFocusedElement = element; } }