# ANCHOR: New functions


def get_page_bboxes(self, page: Page) -> list[list[float]]:
    """JavaScript code to return bounding boxes and other metadata from HTML elements."""
    js_script = """
    (() => {
        // Auxiliary functions
        const normalizeText = (text) => text.trim().replace(/\s+/g, ' ');

        const isDisabled = (el) => el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";

        const isHidden = (el) => {
            return el.getAttribute("aria-hidden") === "true" || window.getComputedStyle(el).display === "none";
        };
        const isElementOrAncestorHidden = (el) => {
            while(el) {
                if (isHidden(el)) {
                    return true;
                }
                el = el.parentElement;
            }
            return false;
        };

        const rectsIntersect = (a, b) => !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
        const pointInRect = (x, y, r) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
        const isCenterVisibleInScrollContainers = (el, rect) => {
            const cx = rect.left + rect.width / 2;
            const cy = rect.top + rect.height / 2;
            let node = el.parentElement;
            while (node && node !== document.body) {
                const style = window.getComputedStyle(node);
                const hasScrollClip = /(auto|scroll|hidden|clip)/.test(style.overflow + style.overflowY + style.overflowX);
                if (hasScrollClip) {
                    const crect = node.getBoundingClientRect();
                    if (!pointInRect(cx, cy, crect)) return false;
                }
                node = node.parentElement;
            }
            return true;
        };

        // Selectors for interactable elements
        const interactableSelectors = [
            'a[href]:not(:has(img:not([aria-hidden="true"])))', 'a[href] img', 'button', 'input:not([type="hidden"]):not(.hidden)', 'textarea', 'select',
            '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]', '[role="button"]', '[role="link"]',
            '[role="checkbox"]', '[role="menuitem"]', '[role="option"]', '[role="tab"]', '[draggable="true"]',
            '.btn', 'option', '.select2-results__option', 'a[href="/notifications"]', 'a[href*="/submit"]', '.fa.fa-star.is-rating-item', 'input[type="checkbox"]:not(.hidden)',
            'div.review-control-vote label'
        ];

        // Selectors for text elements
        const textSelectors = ['p', 'span', 'div:not(:has(*))', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'article', 'img', 'label'];
        const modifiedTextSelectors = textSelectors.map(selector =>
            `:not(${interactableSelectors.join(', ')}):not(style) > ${selector}`
        );

        const combinedSelectors = [...interactableSelectors, ...modifiedTextSelectors];
        const elements = document.querySelectorAll(combinedSelectors.join(', '));

        const pixelRatio = window.devicePixelRatio;
        let csvContent = "ID,Element,Top,Right,Bottom,Left,Width,Height,Alt,Class,Id,TextContent,Interactable,For,InputType,LabelText,Role\\n";
        let counter = 1;

        // Check if any select element is currently open/active
        const activeSelectElements = [];
        document.querySelectorAll('select').forEach(select => {
            if (document.activeElement === select) {
                activeSelectElements.push(select);
            }
        });

        // First pass: collect all elements and identify label-input associations
        const elementsToProcess = [];
        const capturedTextContent = new Set();

        elements.forEach(element => {
            // Exclude element with href "https://postmill.xyz/". This leads to an invalid URL.
            if (element.getAttribute("href") === "https://postmill.xyz/") return;

            // Skip elements with `disabled` attribute. Exclude disabled elements in general
            if (isDisabled(element)) return;
            if (isElementOrAncestorHidden(element)) return;

            const rect = element.getBoundingClientRect();
            if (rect.width === 0 || rect.height === 0) return;

            // Skip elements that do not intersect the viewport at all
            if (rect.bottom <= 0 || rect.top >= window.innerHeight || rect.right <= 0 || rect.left >= window.innerWidth) return;

            // Enforce strict visibility for interactables; allow StaticText regardless
            // const preIsInteractable = interactableSelectors.some(selector => element.matches(selector));
            // if (preIsInteractable) {
                // Require element center to be inside viewport
            //    const cx = rect.left + rect.width / 2;
            //  const cy = rect.top + rect.height / 2;
                // if (cx < 0 || cx > window.innerWidth || cy < 0 || cy > window.innerHeight) return;
                // Require center to be visible within scrollable parents
            //    if (!isCenterVisibleInScrollContainers(element, rect)) return;
            //}

            elementsToProcess.push({element, rect});
        });

        // Pre-process: identify all label-input associations
        elementsToProcess.forEach(({element}) => {
            if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
                // Check if this is a visible form input (not hidden)
                if (!element.classList.contains('hidden') &&
                    element.style.display !== 'none' &&
                    element.offsetWidth > 0 && element.offsetHeight > 0) {

                    // Skip file inputs for label association
                    if (element.type !== 'file') {
                        // Look for labels with explicit 'for' attribute
                        const labels = document.querySelectorAll(`label[for="${element.id}"]`);
                        let visibleLabel = null;

                        if (labels.length > 0) {
                            visibleLabel = Array.from(labels).find(label =>
                                !label.hasAttribute('hidden') &&
                                label.style.display !== 'none' &&
                                label.getAttribute('aria-hidden') !== 'true'
                            );
                        } else {
                            // Look for sibling labels in the same container
                            const parentElement = element.parentElement;
                            if (parentElement) {
                                const siblingLabels = parentElement.querySelectorAll('label:not([for])');
                                visibleLabel = Array.from(siblingLabels).find(label =>
                                    !label.hasAttribute('hidden') &&
                                    label.style.display !== 'none' &&
                                    label.getAttribute('aria-hidden') !== 'true' &&
                                    // Make sure the label comes before the input in DOM order
                                    label.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING
                                );
                            }
                        }

                        if (visibleLabel) {
                            const labelText = normalizeText(visibleLabel.textContent);
                            if (labelText) {
                                // Mark the label as associated with this input
                                visibleLabel.setAttribute('data-associated-with-input', element.id || 'true');
                                // Store label info on the input for later use
                                element.setAttribute('data-label-text', labelText);
                            }
                        }
                    }
                }
            }
        });

        // Second pass: determine what text will be captured and process elements
        elementsToProcess.forEach(({element, rect}) => {

            // Skip labels that have been associated with inputs
            if (element.tagName === 'LABEL' && element.hasAttribute('data-associated-with-input')) {
                return; // Skip this label since it's already incorporated into an input
            }

            // Check if this is a label associated with a hidden radio/checkbox
            let isLabelForHiddenInput = false;
            if (element.tagName === 'LABEL' && element.getAttribute('for')) {
                const associatedInput = document.getElementById(element.getAttribute('for'));
                if (associatedInput &&
                    (associatedInput.type === 'radio' || associatedInput.type === 'checkbox') &&
                    (associatedInput.classList.contains('hidden') ||
                        associatedInput.style.display === 'none' ||
                        associatedInput.offsetWidth === 0 ||
                        associatedInput.offsetHeight === 0)) {
                    isLabelForHiddenInput = true;
                }
            }

            let textContent = element.textContent ? element.textContent.trim() : "";

            // For form inputs, check the value property for user input
            if (!textContent && (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT')) {

                // For checkboxes and radio buttons, show the checked state
                if (element.type === 'checkbox' || element.type === 'radio') {
                    textContent = element.checked ? 'checked' : 'unchecked';

                // For text-like inputs and textareas, show the value if present
                } else if (element.type === 'text' || element.type === 'email' || element.type === 'password' ||
                            element.type === 'search' || element.type === 'url' || element.type === 'tel' ||
                            element.type === 'number' || element.tagName === 'TEXTAREA') {
                    if (element.value) {
                        textContent = element.value;
                    }

                // For select elements, show the selected option text
                } else if (element.tagName === 'SELECT') {
                    if (element.selectedIndex >= 0) {
                        textContent = element.options[element.selectedIndex].text;
                    }
                }
            }

            // For visible form inputs, combine label and content
            if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
                // Check if this is a visible form input (not hidden)
                if (!element.classList.contains('hidden') &&
                    element.style.display !== 'none' &&
                    element.offsetWidth > 0 && element.offsetHeight > 0) {

                    // Special handling for file inputs - they often have custom UI
                    if (element.type === 'file') {
                        // For file inputs, just show what the user sees (browser's file picker button text)
                        if (!textContent) {
                            // If no files selected, browsers typically show "No file chosen" or similar
                            textContent = element.files && element.files.length > 0
                                ? Array.from(element.files).map(f => f.name).join(', ')
                                : '';
                        }
                    }
                    // Note: Label association is already done in pre-processing step
                }
            }

            // If no text content, use the aria-label, if available.
            if (!textContent) {
                textContent = element.getAttribute("aria-label") ? element.getAttribute("aria-label").trim() : "";
            }
            
            // If still no text content, look for alt text in nested images
            if (!textContent) {
                const nestedImg = element.querySelector('img[alt]');
                if (nestedImg) {
                    textContent = nestedImg.getAttribute("alt").trim();
                }
            }
            
            // Ignore text content that are scripts.
            if (/^require\s*\(\s*\[/.test(textContent) || /<!\[CDATA\[/.test(textContent)) {
                textContent = "";
            }

            // If the element has no text content, use the alt text, if available.
            let altText = element.getAttribute("alt") ? element.getAttribute("alt").trim() : "";
            
            // If no direct alt text, look for alt text in nested images
            if (!altText) {
                const nestedImg = element.querySelector('img[alt]');
                if (nestedImg) {
                    altText = nestedImg.getAttribute("alt").trim();
                }
            }
            
            if (!altText) {
                altText = textContent;
            }

            // Remove double quotes to not break the CSV
            textContent = textContent.replace(/"/g, '');
            altText = altText.replace(/"/g, '');

            const classList = element.className || '';
            const id = element.id || '';
            const forAttribute = element.getAttribute('for') || '';

            // For labels, get the associated input type
            let associatedInputType = '';
            if (isLabelForHiddenInput && element.getAttribute('for')) {
                const associatedInput = document.getElementById(element.getAttribute('for'));
                if (associatedInput) {
                    associatedInputType = associatedInput.type || '';
                }
            }

            // Get label text if stored
            const labelText = element.getAttribute('data-label-text') || '';

            // Determine if the element is interactable
            const isInteractable = interactableSelectors.some(selector => element.matches(selector)) || isLabelForHiddenInput;

            // Skip labels that are associated with hidden inputs (they become buttons instead)
            // But allow labels for visible inputs to appear as StaticText
            if (isLabelForHiddenInput) {
                // This label will be processed as an interactive button-like element
            } else if (element.tagName === 'LABEL' && element.getAttribute('for')) {
                // This is a regular form label - allow it to be processed as StaticText
                // It will appear separately from its associated input
            }

            // Skip container elements when their text is already covered by other captured elements
            if (!isInteractable && ['LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'ARTICLE', 'DIV', 'UL'].includes(element.tagName)) {
                const elementText = normalizeText(element.textContent);
                if (elementText) {
                    // Check if this container's text is already covered by captured elements
                    let textAlreadyCovered = false;

                    // Get all child elements that would be captured separately
                    const childElements = element.querySelectorAll('*');
                    const childTexts = [];

                    for (const child of childElements) {
                        // Skip if child is hidden or disabled
                        if (isDisabled(child) || isElementOrAncestorHidden(child)) continue;

                        // Skip if child is not visible
                        const childRect = child.getBoundingClientRect();
                        if (childRect.width === 0 || childRect.height === 0) continue;

                        // Check if child would be captured as interactive or text element
                        const isChildInteractable = interactableSelectors.some(selector => child.matches(selector));
                        const isChildTextElement = textSelectors.some(selector => child.matches(selector));

                        if (isChildInteractable || isChildTextElement) {
                            let childText = normalizeText(child.textContent);
                            if (childText) {
                                childTexts.push(childText);
                            }
                        }
                    }

                    // Check if container text is just a combination of child texts
                    if (childTexts.length > 0) {
                        const combinedChildText = childTexts.join(' ');
                        const combinedChildTextNormalized = normalizeText(combinedChildText);

                        // Skip if container text is covered by child elements
                        if (elementText === combinedChildTextNormalized ||
                            elementText.length > 0 && combinedChildTextNormalized.includes(elementText)) {
                            return;
                        }

                        // Also skip if container text is just concatenation of child texts (accounting for extra whitespace)
                        // const elementWords = elementText.split(/\s+/).filter(word => word.length > 0);
                        // const childWords = combinedChildTextNormalized.split(/\s+/).filter(word => word.length > 0);
                        // if (elementWords.length > 0 && elementWords.every(word => childWords.includes(word))) {
                        //     return;
                        // }
                    }
                }
            }
            
            const dataString = [
                counter, element.tagName, (rect.top + window.scrollY) * pixelRatio,
                (rect.right + window.scrollX) * pixelRatio, (rect.bottom + window.scrollY) * pixelRatio,
                (rect.left + window.scrollX) * pixelRatio, rect.width * pixelRatio, rect.height * pixelRatio,
                altText, classList, id, textContent, isInteractable, forAttribute, associatedInputType, labelText
            ].map(value => `"${value}"`).join(",");

            csvContent += dataString + "\\n";
            counter++;

            // REVIEW[xxx]: Special logic to interact with dropdown options from `select` elements.
            // See comment in `inject_custom_dropdowns`.
            if (element.tagName === 'SELECT') {
                // Find the injected dropdown associated with this select using the data attribute
                const selectId = element.getAttribute('data-select-id');
                if (selectId) {
                    const dropdown = document.querySelector(`.injected-dropdown[data-select-id="${selectId}"]`);
                    if (dropdown) {
                        const options = dropdown.querySelectorAll('.injected-option');
                        options.forEach(opt => {
                            const optRect = opt.getBoundingClientRect();
                            if (optRect.width === 0 || optRect.height === 0) return;

                            const optText = opt.textContent.trim().replace(/"/g, '');

                            const optionDataString = [
                                counter, 'OPTION', (optRect.top + window.scrollY) * pixelRatio,
                                (optRect.right + window.scrollX) * pixelRatio, (optRect.bottom + window.scrollY) * pixelRatio,
                                (optRect.left + window.scrollX) * pixelRatio, optRect.width * pixelRatio, optRect.height * pixelRatio,
                                optText, '', '', optText, true, '', '', ''
                            ].map(value => `"${value}"`).join(",");

                            csvContent += optionDataString + "\\n";
                            counter++;
                        });
                    }
                }
            }
        });

        return csvContent;
    })();
    """
    # Save the bbox as a CSV
    csv_content = page.evaluate(js_script)
    return csv_content


def draw_bounding_boxes(
    self,
    data_string,
    screenshot_img,
    viewport_size=None,
    add_ids=True,
    bbox_color=None,
    min_width=8,
    min_height=8,
    bbox_padding=0,
    bbox_border=2,
    plot_ids: list[int] | None = None,
):
    """
    min_width and min_height: Minimum dimensions of the bounding box to be plotted.
    """
    # Read CSV data
    df = pd.read_csv(StringIO(data_string), delimiter=",", quotechar='"')
    df["Area"] = df["Width"] * df["Height"]
    # Remove bounding boxes that are clipped.
    b_x, b_y = (self.browser_config["win_left_bound"], self.browser_config["win_upper_bound"])
    if viewport_size is not None:
        df = df[
            (df["Bottom"] - b_y >= 0)
            & (df["Top"] - b_y <= viewport_size["height"])
            & (df["Right"] - b_x >= 0)
            & (df["Left"] - b_x <= viewport_size["width"])
        ]
        viewport_area = viewport_size["width"] * viewport_size["height"]
        # Filter out bounding boxes that too large (more than 80% of the viewport)
        df = df[df["Area"] <= 0.8 * viewport_area]

    # Open the screenshot image
    img = screenshot_img.copy()
    draw = ImageDraw.Draw(img)

    # Load a TTF font with a larger size
    font_path = "media/SourceCodePro-SemiBold.ttf"
    font_size, padding = 16, 2
    font = ImageFont.truetype(font_path, font_size)

    # Create a color cycle using one of the categorical color palettes in matplotlib
    color_cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]
    bbox_id2visid = {}
    bbox_id2desc = {}
    index = 0
    id2center = {}
    existing_text_rectangles = []
    text_to_draw = []
    # Provide [id] textContent inputs to the model as text.
    text_content_elements = []
    text_content_text = set()  # Store text of interactable elements
    id2semantic = {}

    # Iterate through each row in the CSV and draw bounding boxes
    for _, row in df.iterrows():
        if not row["Interactable"]:
            content = ""
            # Add image alt-text to the text representation.
            if row["Element"] == "IMG" and pd.notna(row["Alt"]):
                content += re.sub(r"\s+", " ", row["Alt"]).strip()
            # Add HTML textContent (if any) to the text representation.
            if pd.notna(row["TextContent"]):
                content += re.sub(r"\s+", " ", row["TextContent"]).strip()[
                    :200
                ]  # Limit to 200 characters to avoid having too much text

            # Check if the text is a CSS selector
            if content and not (content.startswith(".") and "{" in content):
                # Add elements which are not interactable as StaticText
                if content not in text_content_text:
                    text_content_elements.append(f"[] [StaticText] [{content}]")
                    text_content_text.add(content)
            continue

        # Skip bounding boxes that are not in the plot_ids list
        if (plot_ids is not None) and (row["ID"] not in plot_ids):
            continue

        unique_id = str(index + 1)
        bbox_id2visid[row["ID"]] = unique_id  # map the bounding box ID to the unique character ID
        top, right, bottom, left, width, height = (
            row["Top"],
            row["Right"],
            row["Bottom"],
            row["Left"],
            row["Width"],
            row["Height"],
        )
        left, right, top, bottom = left - b_x, right - b_x, top - b_y, bottom - b_y
        id2center[unique_id] = ((left + right) / 2, (bottom + top) / 2, width, height)

        if width >= min_width and height >= min_height:
            # Get the next color in the cycle
            color = bbox_color or color_cycle[index % len(color_cycle)]
            draw.rectangle(
                [
                    left - bbox_padding,
                    top - bbox_padding,
                    right + bbox_padding,
                    bottom + bbox_padding,
                ],
                outline=color,
                width=bbox_border,
            )
            bbox_id2desc[row["ID"]] = color

            # Draw the text on top of the rectangle
            if add_ids:
                # Calculate list of possible text positions
                text_positions = [
                    (left - font_size, top - font_size),  # Top-left corner
                    (
                        left,
                        top - font_size,
                    ),  # A little to the right of the top-left corner
                    (right, top - font_size),  # Top-right corner
                    (
                        right - font_size - 2 * padding,
                        top - font_size,
                    ),  # A little to the left of the top-right corner
                    (left - font_size, bottom),  # Bottom-left corner
                    (
                        left,
                        bottom,
                    ),  # A little to the right of the bottom-left corner
                    (
                        right - font_size - 2 * padding,
                        bottom,
                    ),  # A little to the left of the bottom-right corner
                    (
                        left,
                        bottom,
                    ),  # A little to the right of the bottom-left corner
                    (
                        right - font_size - 2 * padding,
                        bottom,
                    ),  # A little to the left of the bottom-right corner
                ]
                text_width = draw.textlength(unique_id, font=font)
                text_height = font_size  # Assume the text is one line

                if viewport_size is not None:
                    for text_position in text_positions:
                        new_text_rectangle = [
                            text_position[0] - padding,
                            text_position[1] - padding,
                            text_position[0] + text_width + padding,
                            text_position[1] + text_height + padding,
                        ]

                        # Check if the new text rectangle is within the viewport
                        if (
                            new_text_rectangle[0] >= 0
                            and new_text_rectangle[1] >= 0
                            and new_text_rectangle[2] <= viewport_size["width"]
                            and new_text_rectangle[3] <= viewport_size["height"]
                        ):
                            # If the rectangle is within the viewport, check for overlaps
                            overlaps = False
                            for existing_rectangle in existing_text_rectangles:
                                if self.rectangles_overlap(
                                    new_text_rectangle,
                                    existing_rectangle,
                                    padding * 2,
                                ):
                                    overlaps = True
                                    break

                            if not overlaps:
                                break
                        else:
                            # If the rectangle is outside the viewport, try the next position
                            continue
                else:
                    # If none of the corners work, move the text rectangle by a fixed amount
                    text_position = (
                        text_positions[0][0] + padding,
                        text_positions[0][1],
                    )
                    new_text_rectangle = [
                        text_position[0] - padding,
                        text_position[1] - padding,
                        text_position[0] + text_width + padding,
                        text_position[1] + text_height + padding,
                    ]

                existing_text_rectangles.append(new_text_rectangle)
                text_to_draw.append((new_text_rectangle, text_position, unique_id, color))

                content = ""
                if row["Element"] == "IMG" and pd.notna(row["Alt"]):
                    content += re.sub(r"\s+", " ", row["Alt"]).strip()
                if pd.notna(row["TextContent"]):
                    content += re.sub(r"\s+", " ", row["TextContent"]).strip()[:200]  # Limit to 200 characters

                # Show labels associated with hidden inputs as the input type for better UX
                element_display = row["Element"]
                input_type = row.get("InputType", None)
                if row["Element"] == "LABEL" and pd.notna(input_type) and input_type:
                    if input_type == "radio":
                        element_display = "BUTTON"
                    elif input_type == "checkbox":
                        element_display = "BUTTON"
                    else:
                        element_display = "BUTTON"  # Default for other input types

                # Check if this element has a label and format accordingly
                if pd.notna(row.get("LabelText", "")) and row.get("LabelText", "").strip():
                    label_text = row["LabelText"].strip()
                    text_content_elements.append(f"[{unique_id}] [{element_display}, label: {label_text}] [{content}]")
                else:
                    text_content_elements.append(f"[{unique_id}] [{element_display}] [{content}]")
                # Update semantic info to include label
                if pd.notna(row.get("LabelText", "")) and row.get("LabelText", "").strip():
                    label_text = row["LabelText"].strip()
                    id2semantic[unique_id] = (
                        f"[{element_display}] element with label '{label_text}' and content [{content}]"
                    )
                else:
                    id2semantic[unique_id] = f"[{element_display}] element with content [{content}]"
                if content in text_content_text:
                    # Remove text_content_elements with content
                    text_content_elements = [element for element in text_content_elements if element.strip() != content]
                text_content_text.add(content)

        index += 1

    for text_rectangle, text_position, unique_id, color in text_to_draw:
        # Draw a background rectangle for the text
        draw.rectangle(text_rectangle, fill=color)
        draw.text(text_position, unique_id, font=font, fill="white")

    content_str = "\n".join(text_content_elements)
    self.som_to_html_bbox = {
        v: k for k, v in bbox_id2visid.items()
    }  # map the SoM unique character ID to the JS bbox box ID
    self.plot_ids = None
    return img, id2center, content_str, id2semantic


# ANCHOR: old functions
def get_page_bboxes(self, page: Page) -> list[list[float]]:
    """JavaScript code to return bounding boxes and other metadata from HTML elements."""
    js_script = """
    (() => {
        const isDisabled = (el) => el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";

        const isHidden = (el) => {
            return el.getAttribute("aria-hidden") === "true" || window.getComputedStyle(el).display === "none";
        };
        const isElementOrAncestorHidden = (el) => {
            while(el) {
                if (isHidden(el)) {
                    return true;
                }
                el = el.parentElement;
            }
            return false;
        };

        const interactableSelectors = [
            'a[href]:not(:has(img:not([aria-hidden="true"])))', 'a[href] img', 'button', 'input:not([type="hidden"])', 'textarea', 'select',
            '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]', '[role="button"]', '[role="link"]',
            '[role="checkbox"]', '[role="menuitem"]', '[role="tab"]', '[draggable="true"]',
            '.btn', 'option', 'a[href="/notifications"]', 'a[href*="/submit"]', '.fa.fa-star.is-rating-item', 'input[type="checkbox"]',
            'div.review-control-vote label'
        ];

        const textSelectors = ['p', 'span', 'div:not(:has(*))', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'article', 'img'];
        const modifiedTextSelectors = textSelectors.map(selector =>
            `:not(${interactableSelectors.join(', ')}):not(style) > ${selector}`
        );

        const combinedSelectors = [...interactableSelectors, ...modifiedTextSelectors];
        const elements = document.querySelectorAll(combinedSelectors.join(', '));

        const pixelRatio = window.devicePixelRatio;
        let csvContent = "ID,Element,Top,Right,Bottom,Left,Width,Height,Alt,Class,Id,TextContent,Interactable\\n";
        let counter = 1;

        // Check if any select element is currently open/active
        const activeSelectElements = [];
        document.querySelectorAll('select').forEach(select => {
            if (document.activeElement === select) {
                activeSelectElements.push(select);
            }
        });

        elements.forEach(element => {
            // Exclude element with href "https://postmill.xyz/". This leads to an invalid URL.
            if (element.getAttribute("href") === "https://postmill.xyz/") return;

            // Skip elements with `disabled` attribute. Exclude disabled elements in general
            if (isDisabled(element)) return;
            if (isElementOrAncestorHidden(element)) return;

            const rect = element.getBoundingClientRect();
            if (rect.width === 0 || rect.height === 0) return;

            let textContent = element.textContent ? element.textContent.trim() : "";
            if (!textContent) {
                textContent = element.getAttribute("aria-label") ? element.getAttribute("aria-label").trim() : "";
            }
            // Ignore text content that are scripts.
            if (/^require\s*\(\s*\[/.test(textContent) || /<!\[CDATA\[/.test(textContent)) {
                textContent = "";
            }

            let altText = element.getAttribute("alt") ? element.getAttribute("alt").trim() : "";
            if (!altText) {
                altText = textContent;
            }

            // Remove double quotes to not break the CSV
            textContent = textContent.replace(/"/g, '');
            altText = altText.replace(/"/g, '');

            const classList = element.className || '';
            const id = element.id || '';

            // Determine if the element is interactable
            const isInteractable = interactableSelectors.some(selector => element.matches(selector));

            const dataString = [
                counter, element.tagName, (rect.top + window.scrollY) * pixelRatio,
                (rect.right + window.scrollX) * pixelRatio, (rect.bottom + window.scrollY) * pixelRatio,
                (rect.left + window.scrollX) * pixelRatio, rect.width * pixelRatio, rect.height * pixelRatio,
                altText, classList, id, textContent, isInteractable
            ].map(value => `"${value}"`).join(",");

            csvContent += dataString + "\\n";
            counter++;

            // REVIEW[xxx]: Special logic to interact with dropdown options from `select` elements.
            // See comment in `inject_custom_dropdowns`.
            if (element.tagName === 'SELECT') {
                // Find the injected dropdown associated with this select using the data attribute
                const selectId = element.getAttribute('data-select-id');
                if (selectId) {
                    const dropdown = document.querySelector(`.injected-dropdown[data-select-id="${selectId}"]`);
                    if (dropdown) {
                        const options = dropdown.querySelectorAll('.injected-option');
                        options.forEach(opt => {
                            const optRect = opt.getBoundingClientRect();
                            if (optRect.width === 0 || optRect.height === 0) return;

                            const optText = opt.textContent.trim().replace(/"/g, '');

                            const optionDataString = [
                                counter, 'OPTION', (optRect.top + window.scrollY) * pixelRatio,
                                (optRect.right + window.scrollX) * pixelRatio, (optRect.bottom + window.scrollY) * pixelRatio,
                                (optRect.left + window.scrollX) * pixelRatio, optRect.width * pixelRatio, optRect.height * pixelRatio,
                                optText, '', '', optText, true
                            ].map(value => `"${value}"`).join(",");

                            csvContent += optionDataString + "\\n";
                            counter++;
                        });
                    }
                }
            }
        });

        return csvContent;
    })();
    """
    # Save the bbox as a CSV
    csv_content = page.evaluate(js_script)
    return csv_content


def draw_bounding_boxes(
    self,
    data_string,
    screenshot_img,
    viewport_size=None,
    add_ids=True,
    bbox_color=None,
    min_width=8,
    min_height=8,
    bbox_padding=0,
    bbox_border=2,
    plot_ids: list[int] | None = None,
):
    """
    min_width and min_height: Minimum dimensions of the bounding box to be plotted.
    """
    # Read CSV data
    df = pd.read_csv(StringIO(data_string), delimiter=",", quotechar='"')
    df["Area"] = df["Width"] * df["Height"]
    # Remove bounding boxes that are clipped.
    b_x, b_y = (self.browser_config["win_left_bound"], self.browser_config["win_upper_bound"])
    if viewport_size is not None:
        df = df[
            (df["Bottom"] - b_y >= 0)
            & (df["Top"] - b_y <= viewport_size["height"])
            & (df["Right"] - b_x >= 0)
            & (df["Left"] - b_x <= viewport_size["width"])
        ]
        viewport_area = viewport_size["width"] * viewport_size["height"]
        # Filter out bounding boxes that too large (more than 80% of the viewport)
        df = df[df["Area"] <= 0.8 * viewport_area]

    # Open the screenshot image
    img = screenshot_img.copy()
    draw = ImageDraw.Draw(img)

    # Load a TTF font with a larger size
    font_path = "media/SourceCodePro-SemiBold.ttf"
    font_size, padding = 16, 2
    font = ImageFont.truetype(font_path, font_size)

    # Create a color cycle using one of the categorical color palettes in matplotlib
    color_cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]
    bbox_id2visid = {}
    bbox_id2desc = {}
    index = 0
    id2center = {}
    existing_text_rectangles = []
    text_to_draw = []
    # Provide [id] textContent inputs to the model as text.
    text_content_elements = []
    text_content_text = set()  # Store text of interactable elements
    id2semantic = {}

    # Iterate through each row in the CSV and draw bounding boxes
    for _, row in df.iterrows():
        if not row["Interactable"]:
            content = ""
            # Add image alt-text to the text representation.
            if row["Element"] == "IMG" and pd.notna(row["Alt"]):
                content += re.sub(r"\s+", " ", row["Alt"]).strip()
            # Add HTML textContent (if any) to the text representation.
            if pd.notna(row["TextContent"]):
                content += re.sub(r"\s+", " ", row["TextContent"]).strip()[
                    :200
                ]  # Limit to 200 characters to avoid having too much text

            # Check if the text is a CSS selector
            if content and not (content.startswith(".") and "{" in content):
                # Add elements which are not interactable as StaticText
                if content not in text_content_text:
                    text_content_elements.append(f"[] [StaticText] [{content}]")
                    text_content_text.add(content)
            continue

        # Skip bounding boxes that are not in the plot_ids list
        if (plot_ids is not None) and (row["ID"] not in plot_ids):
            continue

        unique_id = str(index + 1)
        bbox_id2visid[row["ID"]] = unique_id  # map the bounding box ID to the unique character ID
        top, right, bottom, left, width, height = (
            row["Top"],
            row["Right"],
            row["Bottom"],
            row["Left"],
            row["Width"],
            row["Height"],
        )
        left, right, top, bottom = left - b_x, right - b_x, top - b_y, bottom - b_y
        id2center[unique_id] = ((left + right) / 2, (bottom + top) / 2, width, height)

        if width >= min_width and height >= min_height:
            # Get the next color in the cycle
            color = bbox_color or color_cycle[index % len(color_cycle)]
            draw.rectangle(
                [
                    left - bbox_padding,
                    top - bbox_padding,
                    right + bbox_padding,
                    bottom + bbox_padding,
                ],
                outline=color,
                width=bbox_border,
            )
            bbox_id2desc[row["ID"]] = color

            # Draw the text on top of the rectangle
            if add_ids:
                # Calculate list of possible text positions
                text_positions = [
                    (left - font_size, top - font_size),  # Top-left corner
                    (
                        left,
                        top - font_size,
                    ),  # A little to the right of the top-left corner
                    (right, top - font_size),  # Top-right corner
                    (
                        right - font_size - 2 * padding,
                        top - font_size,
                    ),  # A little to the left of the top-right corner
                    (left - font_size, bottom),  # Bottom-left corner
                    (
                        left,
                        bottom,
                    ),  # A little to the right of the bottom-left corner
                    (
                        right - font_size - 2 * padding,
                        bottom,
                    ),  # A little to the left of the bottom-right corner
                    (
                        left,
                        bottom,
                    ),  # A little to the right of the bottom-left corner
                    (
                        right - font_size - 2 * padding,
                        bottom,
                    ),  # A little to the left of the bottom-right corner
                ]
                text_width = draw.textlength(unique_id, font=font)
                text_height = font_size  # Assume the text is one line

                if viewport_size is not None:
                    for text_position in text_positions:
                        new_text_rectangle = [
                            text_position[0] - padding,
                            text_position[1] - padding,
                            text_position[0] + text_width + padding,
                            text_position[1] + text_height + padding,
                        ]

                        # Check if the new text rectangle is within the viewport
                        if (
                            new_text_rectangle[0] >= 0
                            and new_text_rectangle[1] >= 0
                            and new_text_rectangle[2] <= viewport_size["width"]
                            and new_text_rectangle[3] <= viewport_size["height"]
                        ):
                            # If the rectangle is within the viewport, check for overlaps
                            overlaps = False
                            for existing_rectangle in existing_text_rectangles:
                                if self.rectangles_overlap(
                                    new_text_rectangle,
                                    existing_rectangle,
                                    padding * 2,
                                ):
                                    overlaps = True
                                    break

                            if not overlaps:
                                break
                        else:
                            # If the rectangle is outside the viewport, try the next position
                            continue
                else:
                    # If none of the corners work, move the text rectangle by a fixed amount
                    text_position = (
                        text_positions[0][0] + padding,
                        text_positions[0][1],
                    )
                    new_text_rectangle = [
                        text_position[0] - padding,
                        text_position[1] - padding,
                        text_position[0] + text_width + padding,
                        text_position[1] + text_height + padding,
                    ]

                existing_text_rectangles.append(new_text_rectangle)
                text_to_draw.append((new_text_rectangle, text_position, unique_id, color))

                content = ""
                if row["Element"] == "IMG" and pd.notna(row["Alt"]):
                    content += re.sub(r"\s+", " ", row["Alt"]).strip()
                if pd.notna(row["TextContent"]):
                    content += re.sub(r"\s+", " ", row["TextContent"]).strip()[:200]  # Limit to 200 characters
                text_content_elements.append(f"[{unique_id}] [{row['Element']}] [{content}]")
                id2semantic[unique_id] = f"[{row['Element']}] element with content [{content}]"
                if content in text_content_text:
                    # Remove text_content_elements with content
                    text_content_elements = [element for element in text_content_elements if element.strip() != content]
                text_content_text.add(content)

        index += 1

    for text_rectangle, text_position, unique_id, color in text_to_draw:
        # Draw a background rectangle for the text
        draw.rectangle(text_rectangle, fill=color)
        draw.text(text_position, unique_id, font=font, fill="white")

    content_str = "\n".join(text_content_elements)
    self.som_to_html_bbox = {
        v: k for k, v in bbox_id2visid.items()
    }  # map the SoM unique character ID to the JS bbox box ID
    self.plot_ids = None
    return img, id2center, content_str, id2semantic
