Files
BrowserOS/src/lib/browser/ElementFormatter.ts
2025-09-19 16:49:25 -07:00

245 lines
6.9 KiB
TypeScript

import type { InteractiveNode } from "./BrowserOSAdapter";
// ============= Element Formatter =============
export class ElementFormatter {
private simplified: boolean;
constructor(simplified: boolean = false) {
this.simplified = simplified;
}
/**
* Format an array of elements
*/
formatElements(elements: InteractiveNode[]): string {
// Flags for formatting control
const SKIP_OUT_OF_VIEWPORT = false; // Skip out-of-viewport elements entirely
const SEPARATE_BY_VIEWPORT = false; // Separate in/out of viewport
const SORT_BY_NODEID = true; // Sort by nodeId (ascending)
const MAX_ELEMENTS = 0; // 0 means no limit
let filteredElements = [...elements];
if (SKIP_OUT_OF_VIEWPORT) {
filteredElements = filteredElements.filter(
(node) => node.attributes?.in_viewport !== "false",
);
}
// Apply sorting
if (SORT_BY_NODEID) {
filteredElements.sort((a, b) => a.nodeId - b.nodeId);
}
// Apply max elements limit
if (MAX_ELEMENTS > 0) {
filteredElements = filteredElements.slice(0, MAX_ELEMENTS);
}
// Format with viewport separation (only if not skipping out-of-viewport)
if (SEPARATE_BY_VIEWPORT && !SKIP_OUT_OF_VIEWPORT) {
return this._formatWithViewportSeparation(filteredElements);
}
// Format without separation
const lines: string[] = [];
for (const node of filteredElements) {
const formatted = this.formatElement(node);
if (formatted) {
lines.push(formatted);
}
}
return lines.join("\n");
}
/**
* Format a single element
*/
formatElement(node: InteractiveNode): string {
let SHOW_INDENTATION = true;
let SHOW_NODEID = true;
let SHOW_TYPE = true;
let SHOW_TAG = true;
let SHOW_NAME = true;
let SHOW_CONTEXT = true;
let SHOW_PATH = false;
let SHOW_ATTRIBUTES = true;
let SHOW_VALUE_FOR_TYPEABLE = true; // Show value attribute for typeable elements
let APPEND_VIEWPORT_STATUS = true; // Append (visible)/(hidden) to indicate viewport status
let INDENT_SIZE = 2;
if (this.simplified) {
SHOW_CONTEXT = false;
SHOW_ATTRIBUTES = false;
SHOW_PATH = false;
SHOW_INDENTATION = false;
}
const parts: string[] = [];
if (SHOW_INDENTATION) {
const depth = parseInt(node.attributes?.depth || "0", 10);
const indent = " ".repeat(INDENT_SIZE * depth);
parts.push(indent);
}
if (SHOW_NODEID) {
parts.push(`[${node.nodeId}]`);
}
if (SHOW_TYPE) {
parts.push(`<${this._getTypeSymbol(node.type)}>`);
}
if (SHOW_TAG) {
const tag =
node.attributes?.["html-tag"] || node.attributes?.role || "div";
parts.push(`<${tag}>`);
}
if (SHOW_NAME && node.name) {
const truncated = this._truncateText(node.name, 40);
parts.push(`"${truncated}"`);
} else if (node.type === "typeable") {
// For typeable elements without names, show placeholder, id, or input type
const placeholder = node.attributes?.placeholder;
const id = node.attributes?.id;
const inputType = node.attributes?.["input-type"] || "text";
if (placeholder) {
parts.push(`placeholder="${this._truncateText(placeholder, 30)}"`);
} else if (id) {
parts.push(`id="${this._truncateText(id, 10)}"`);
} else {
parts.push(`type="${inputType}"`);
}
}
if (SHOW_CONTEXT && node.attributes?.context) {
const truncated = this._truncateText(node.attributes.context, 60);
parts.push(`ctx:"${truncated}"`);
}
if (SHOW_PATH && node.attributes?.path) {
const formatted = this._formatPath(node.attributes.path);
if (formatted) {
parts.push(`path:"${formatted}"`);
}
}
if (SHOW_ATTRIBUTES) {
const attrString = this._formatAttributes(node);
if (attrString) {
parts.push(`attr:"${attrString}"`);
}
}
// Show value for typeable elements (if not already shown in attributes)
if (
SHOW_VALUE_FOR_TYPEABLE &&
!SHOW_ATTRIBUTES &&
node.type === "typeable" &&
node.attributes?.value
) {
const value = this._truncateText(node.attributes.value, 40);
parts.push(`value="${value}"`);
}
// Append viewport status
if (APPEND_VIEWPORT_STATUS) {
const isInViewport = node.attributes?.in_viewport !== "false";
parts.push(isInViewport ? "(visible)" : "(hidden)");
}
return parts.join(" ");
}
// ============= Private Helper Methods =============
private _formatWithViewportSeparation(elements: InteractiveNode[]): string {
const lines: string[] = [];
const inViewport: InteractiveNode[] = [];
const outOfViewport: InteractiveNode[] = [];
const VIEWPORT_SEPARATOR =
"--- IMPORTANT: OUT OF VIEWPORT ELEMENTS, SCROLL TO INTERACT ---";
// Separate by viewport visibility
for (const node of elements) {
const isInViewport = node.attributes?.in_viewport !== "false";
if (isInViewport) {
inViewport.push(node);
} else {
outOfViewport.push(node);
}
}
// Format in-viewport elements
for (const node of inViewport) {
const formatted = this.formatElement(node);
if (formatted) {
lines.push(formatted);
}
}
// Add separator and out-of-viewport elements
if (outOfViewport.length > 0) {
if (lines.length > 0) {
lines.push(""); // Empty line before separator
}
lines.push(VIEWPORT_SEPARATOR);
for (const node of outOfViewport) {
const formatted = this.formatElement(node);
if (formatted) {
lines.push(formatted);
}
}
}
return lines.join("\n");
}
private _getTypeSymbol(type: string): string {
switch (type) {
case "clickable":
case "selectable":
return "C";
case "typeable":
return "T";
default:
return "O";
}
}
private _truncateText(text: string, maxLength: number): string {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
}
private _formatPath(path: string): string {
if (!path) return "";
const PATH_DEPTH = 3; // Number of path segments to show
const parts = path.split(" > ").filter((p) => p && p !== "root");
const lastParts = parts.slice(-PATH_DEPTH);
return lastParts.length > 0 ? lastParts.join(">") : "";
}
private _formatAttributes(node: InteractiveNode): string {
if (!node.attributes) return "";
const INCLUDE_ATTRIBUTES = ["type", "placeholder", "value", "aria-label"]; // Attributes to show
const pairs: string[] = [];
for (const key of INCLUDE_ATTRIBUTES) {
if (key in node.attributes) {
const value = node.attributes[key];
if (value !== undefined && value !== null && value !== "") {
pairs.push(`${key}=${value}`);
}
}
}
return pairs.join(" ");
}
}