From 6cdeb0b0e2af70211e6df3dd69e930a80b0fd59c Mon Sep 17 00:00:00 2001 From: Rohit Rajan Date: Sun, 30 Nov 2025 14:48:03 +0530 Subject: [PATCH] feat: add auto pagination detection --- src/components/browser/BrowserWindow.tsx | 91 ++- .../recorder/DOMBrowserRenderer.tsx | 9 + src/components/recorder/RightSidePanel.tsx | 409 +++++++++++- src/context/browserSteps.tsx | 22 + src/helpers/clientPaginationDetector.ts | 586 ++++++++++++++++++ src/helpers/clientSelectorGenerator.ts | 41 ++ 6 files changed, 1149 insertions(+), 9 deletions(-) create mode 100644 src/helpers/clientPaginationDetector.ts diff --git a/src/components/browser/BrowserWindow.tsx b/src/components/browser/BrowserWindow.tsx index 8bdabaae3..1d4f3ab05 100644 --- a/src/components/browser/BrowserWindow.tsx +++ b/src/components/browser/BrowserWindow.tsx @@ -1242,6 +1242,29 @@ export const BrowserWindow = () => { } }, [browserSteps, getList, listSelector, initialAutoFieldIds, currentListActionId, manuallyAddedFieldIds]); + useEffect(() => { + if (currentListActionId && browserSteps.length > 0) { + const activeStep = browserSteps.find( + s => s.type === 'list' && s.actionId === currentListActionId + ) as ListStep | undefined; + + if (activeStep) { + if (currentListId !== activeStep.id) { + setCurrentListId(activeStep.id); + } + if (listSelector !== activeStep.listSelector) { + setListSelector(activeStep.listSelector); + } + if (JSON.stringify(fields) !== JSON.stringify(activeStep.fields)) { + setFields(activeStep.fields); + } + if (activeStep.pagination?.selector && paginationSelector !== activeStep.pagination.selector) { + setPaginationSelector(activeStep.pagination.selector); + } + } + } + }, [currentListActionId, browserSteps, currentListId, listSelector, fields, paginationSelector]); + useEffect(() => { if (!isDOMMode) { capturedElementHighlighter.clearHighlights(); @@ -1637,6 +1660,22 @@ export const BrowserWindow = () => { paginationType !== "scrollUp" && paginationType !== "none" ) { + let targetListId = currentListId; + let targetFields = fields; + + if ((!targetListId || targetListId === 0) && currentListActionId) { + const activeStep = browserSteps.find( + s => s.type === 'list' && s.actionId === currentListActionId + ) as ListStep | undefined; + + if (activeStep) { + targetListId = activeStep.id; + if (Object.keys(targetFields).length === 0 && Object.keys(activeStep.fields).length > 0) { + targetFields = activeStep.fields; + } + } + } + setPaginationSelector(highlighterData.selector); notify( `info`, @@ -1646,8 +1685,8 @@ export const BrowserWindow = () => { ); addListStep( listSelector!, - fields, - currentListId || 0, + targetFields, + targetListId || 0, currentListActionId || `list-${crypto.randomUUID()}`, { type: paginationType, @@ -1812,6 +1851,8 @@ export const BrowserWindow = () => { socket, t, paginationSelector, + highlighterData, + browserSteps ] ); @@ -1864,6 +1905,22 @@ export const BrowserWindow = () => { paginationType !== "scrollUp" && paginationType !== "none" ) { + let targetListId = currentListId; + let targetFields = fields; + + if ((!targetListId || targetListId === 0) && currentListActionId) { + const activeStep = browserSteps.find( + s => s.type === 'list' && s.actionId === currentListActionId + ) as ListStep | undefined; + + if (activeStep) { + targetListId = activeStep.id; + if (Object.keys(targetFields).length === 0 && Object.keys(activeStep.fields).length > 0) { + targetFields = activeStep.fields; + } + } + } + setPaginationSelector(highlighterData.selector); notify( `info`, @@ -1873,8 +1930,8 @@ export const BrowserWindow = () => { ); addListStep( listSelector!, - fields, - currentListId || 0, + targetFields, + targetListId || 0, currentListActionId || `list-${crypto.randomUUID()}`, { type: paginationType, selector: highlighterData.selector, isShadow: highlighterData.isShadow }, undefined, @@ -2046,6 +2103,31 @@ export const BrowserWindow = () => { } }, [paginationMode, resetPaginationSelector]); + useEffect(() => { + if (paginationMode && currentListActionId) { + const currentListStep = browserSteps.find( + step => step.type === 'list' && step.actionId === currentListActionId + ) as (ListStep & { type: 'list' }) | undefined; + + const currentSelector = currentListStep?.pagination?.selector; + const currentType = currentListStep?.pagination?.type; + + if (['clickNext', 'clickLoadMore'].includes(paginationType)) { + if (!currentSelector || (currentType && currentType !== paginationType)) { + setPaginationSelector(''); + } + } + + const stepSelector = currentListStep?.pagination?.selector; + + if (stepSelector && !paginationSelector) { + setPaginationSelector(stepSelector); + } else if (!stepSelector && paginationSelector) { + setPaginationSelector(''); + } + } + }, [browserSteps, paginationMode, currentListActionId, paginationSelector]); + return (
{ listSelector={listSelector} cachedChildSelectors={cachedChildSelectors} paginationMode={paginationMode} + paginationSelector={paginationSelector} paginationType={paginationType} limitMode={limitMode} isCachingChildSelectors={isCachingChildSelectors} diff --git a/src/components/recorder/DOMBrowserRenderer.tsx b/src/components/recorder/DOMBrowserRenderer.tsx index 9e818e317..10fa4742e 100644 --- a/src/components/recorder/DOMBrowserRenderer.tsx +++ b/src/components/recorder/DOMBrowserRenderer.tsx @@ -100,6 +100,7 @@ interface RRWebDOMBrowserRendererProps { listSelector?: string | null; cachedChildSelectors?: string[]; paginationMode?: boolean; + paginationSelector?: string; paginationType?: string; limitMode?: boolean; isCachingChildSelectors?: boolean; @@ -153,6 +154,7 @@ export const DOMBrowserRenderer: React.FC = ({ listSelector = null, cachedChildSelectors = [], paginationMode = false, + paginationSelector = "", paginationType = "", limitMode = false, isCachingChildSelectors = false, @@ -257,6 +259,13 @@ export const DOMBrowserRenderer: React.FC = ({ else if (listSelector) { if (limitMode) { shouldHighlight = false; + } else if ( + paginationMode && + paginationSelector && + paginationType !== "" && + !["none", "scrollDown", "scrollUp"].includes(paginationType) + ) { + shouldHighlight = false; } else if ( paginationMode && paginationType !== "" && diff --git a/src/components/recorder/RightSidePanel.tsx b/src/components/recorder/RightSidePanel.tsx index d5a7c29c0..8159e149d 100644 --- a/src/components/recorder/RightSidePanel.tsx +++ b/src/components/recorder/RightSidePanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { Button, Paper, Box, TextField, IconButton, Tooltip } from "@mui/material"; import { WorkflowFile } from "maxun-core"; import Typography from "@mui/material/Typography"; @@ -15,9 +15,9 @@ import ActionDescriptionBox from '../action/ActionDescriptionBox'; import { useThemeMode } from '../../context/theme-provider'; import { useTranslation } from 'react-i18next'; import { useBrowserDimensionsStore } from '../../context/browserDimensions'; -import { emptyWorkflow } from '../../shared/constants'; import { clientListExtractor } from '../../helpers/clientListExtractor'; import { clientSelectorGenerator } from '../../helpers/clientSelectorGenerator'; +import { clientPaginationDetector } from '../../helpers/clientPaginationDetector'; const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => { getActiveWorkflow(id).then( @@ -45,6 +45,13 @@ export const RightSidePanel: React.FC = ({ onFinishCapture const [showCaptureText, setShowCaptureText] = useState(true); const { panelHeight } = useBrowserDimensionsStore(); + const [autoDetectedPagination, setAutoDetectedPagination] = useState<{ + type: PaginationType; + selector: string | null; + confidence: 'high' | 'medium' | 'low'; + } | null>(null); + const autoDetectionRunRef = useRef(null); + const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState, resetInterpretationLog, currentListActionId, setCurrentListActionId, currentTextActionId, setCurrentTextActionId, currentScreenshotActionId, setCurrentScreenshotActionId, isDOMMode, setIsDOMMode, currentSnapshot, setCurrentSnapshot, updateDOMMode, initialUrl, setRecordingUrl, currentTextGroupName } = useGlobalInfoStore(); const { getText, startGetText, stopGetText, @@ -62,7 +69,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture startAction, finishAction } = useActionContext(); - const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField, updateListStepLimit, deleteStepsByActionId, updateListStepData, updateScreenshotStepData, emitActionForStep } = useBrowserSteps(); + const { browserSteps, addScreenshotStep, updateListStepLimit, updateListStepPagination, deleteStepsByActionId, updateListStepData, updateScreenshotStepData, emitActionForStep } = useBrowserSteps(); const { id, socket } = useSocketStore(); const { t } = useTranslation(); @@ -72,6 +79,73 @@ export const RightSidePanel: React.FC = ({ onFinishCapture setWorkflow(data); }, [setWorkflow]); + useEffect(() => { + if (!paginationType || !currentListActionId) return; + + const currentListStep = browserSteps.find( + step => step.type === 'list' && step.actionId === currentListActionId + ) as (BrowserStep & { type: 'list' }) | undefined; + + const currentSelector = currentListStep?.pagination?.selector; + const currentType = currentListStep?.pagination?.type; + + if (['clickNext', 'clickLoadMore'].includes(paginationType)) { + const needsSelector = !currentSelector && !currentType; + const typeChanged = currentType && currentType !== paginationType; + + if (typeChanged) { + const iframeElement = document.querySelector('#browser-window iframe') as HTMLIFrameElement; + if (iframeElement?.contentDocument && currentSelector) { + try { + function evaluateSelector(selector: string, doc: Document): Element[] { + if (selector.startsWith('//') || selector.startsWith('(//')) { + try { + const result = doc.evaluate(selector, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + const elements: Element[] = []; + for (let i = 0; i < result.snapshotLength; i++) { + const node = result.snapshotItem(i); + if (node && node.nodeType === Node.ELEMENT_NODE) { + elements.push(node as Element); + } + } + return elements; + } catch (err) { + return []; + } + } else { + try { + return Array.from(doc.querySelectorAll(selector)); + } catch (err) { + return []; + } + } + } + + const elements = evaluateSelector(currentSelector, iframeElement.contentDocument); + elements.forEach((el: Element) => { + (el as HTMLElement).style.outline = ''; + (el as HTMLElement).style.outlineOffset = ''; + (el as HTMLElement).style.zIndex = ''; + }); + } catch (error) { + console.error('Error removing pagination highlight:', error); + } + } + + if (currentListStep) { + updateListStepPagination(currentListStep.id, { + type: paginationType, + selector: null, + }); + } + + startPaginationMode(); + } else if (needsSelector) { + startPaginationMode(); + } + } + }, [paginationType, currentListActionId, browserSteps, updateListStepPagination, startPaginationMode]); + useEffect(() => { if (socket) { const domModeHandler = (data: any) => { @@ -391,7 +465,182 @@ export const RightSidePanel: React.FC = ({ onFinishCapture return; } - startPaginationMode(); + const currentListStepForAutoDetect = browserSteps.find( + step => step.type === 'list' && step.actionId === currentListActionId + ) as (BrowserStep & { type: 'list'; listSelector?: string }) | undefined; + + if (currentListStepForAutoDetect?.listSelector) { + if (autoDetectionRunRef.current !== currentListActionId) { + autoDetectionRunRef.current = currentListActionId; + + notify('info', 'Detecting pagination...'); + + try { + socket?.emit('testPaginationScroll', { + listSelector: currentListStepForAutoDetect.listSelector + }); + + const handleScrollTestResult = (result: any) => { + if (result.success && result.contentLoaded) { + setAutoDetectedPagination({ + type: 'scrollDown', + selector: null, + confidence: 'high' + }); + updatePaginationType('scrollDown'); + + const latestListStep = browserSteps.find( + step => step.type === 'list' && step.actionId === currentListActionId + ); + if (latestListStep) { + updateListStepPagination(latestListStep.id, { + type: 'scrollDown', + selector: null, + isShadow: false + }); + } + } else if (result.success && !result.contentLoaded) { + const iframeElement = document.querySelector('#browser-window iframe') as HTMLIFrameElement; + const iframeDoc = iframeElement?.contentDocument; + + if (iframeDoc) { + const detectionResult = clientPaginationDetector.autoDetectPagination( + iframeDoc, + currentListStepForAutoDetect.listSelector!, + clientSelectorGenerator, + { disableScrollDetection: true } + ); + + if (detectionResult.type) { + setAutoDetectedPagination({ + type: detectionResult.type, + selector: detectionResult.selector, + confidence: detectionResult.confidence + }); + + const latestListStep = browserSteps.find( + step => step.type === 'list' && step.actionId === currentListActionId + ); + if (latestListStep) { + updateListStepPagination(latestListStep.id, { + type: detectionResult.type, + selector: detectionResult.selector, + isShadow: false + }); + } + + updatePaginationType(detectionResult.type); + + if (detectionResult.selector && (detectionResult.type === 'clickNext' || detectionResult.type === 'clickLoadMore')) { + try { + function evaluateSelector(selector: string, doc: Document): Element[] { + try { + const isXPath = selector.startsWith('//') || selector.startsWith('(//'); + if (isXPath) { + const result = doc.evaluate( + selector, + doc, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null + ); + const elements: Element[] = []; + for (let i = 0; i < result.snapshotLength; i++) { + const node = result.snapshotItem(i); + if (node && node.nodeType === Node.ELEMENT_NODE) { + elements.push(node as Element); + } + } + return elements; + } else { + try { + const allElements = Array.from(doc.querySelectorAll(selector)); + if (allElements.length > 0) { + return allElements; + } + } catch (err) { + console.warn('[RightSidePanel] Full chained selector failed, trying individual selectors:', err); + } + + const selectorParts = selector.split(','); + for (const part of selectorParts) { + try { + const elements = Array.from(doc.querySelectorAll(part.trim())); + if (elements.length > 0) { + return elements; + } + } catch (err) { + console.warn('[RightSidePanel] Selector part failed:', part.trim(), err); + continue; + } + } + return []; + } + } catch (err) { + console.error('[RightSidePanel] Selector evaluation failed:', selector, err); + return []; + } + } + + const elements = evaluateSelector(detectionResult.selector, iframeDoc); + if (elements.length > 0) { + elements.forEach((el: Element) => { + (el as HTMLElement).style.outline = '3px dashed #ff00c3'; + (el as HTMLElement).style.outlineOffset = '2px'; + (el as HTMLElement).style.zIndex = '9999'; + }); + + const firstElement = elements[0] as HTMLElement; + const elementRect = firstElement.getBoundingClientRect(); + const iframeWindow = iframeElement.contentWindow; + if (iframeWindow) { + const targetY = elementRect.top + iframeWindow.scrollY - (iframeWindow.innerHeight / 2) + (elementRect.height / 2); + iframeWindow.scrollTo({ top: targetY, behavior: 'smooth' }); + } + + const paginationTypeLabel = detectionResult.type === 'clickNext' ? 'Next Button' : 'Load More Button'; + notify('info', `${paginationTypeLabel} has been auto-detected and highlighted on the page`); + } else { + console.warn(' No elements found for selector:', detectionResult.selector); + } + } catch (error) { + console.error('Error highlighting pagination button:', error); + } + } + } else { + setAutoDetectedPagination(null); + } + } + } else { + console.error('Scroll test failed:', result.error); + setAutoDetectedPagination(null); + } + + socket?.off('paginationScrollTestResult', handleScrollTestResult); + }; + + socket?.on('paginationScrollTestResult', handleScrollTestResult); + + setTimeout(() => { + socket?.off('paginationScrollTestResult', handleScrollTestResult); + }, 5000); + + } catch (error) { + console.error('Scroll test failed:', error); + setAutoDetectedPagination(null); + } + } + } + + const shouldSkipPaginationMode = autoDetectedPagination && ( + ['scrollDown', 'scrollUp'].includes(autoDetectedPagination.type) || + (['clickNext', 'clickLoadMore'].includes(autoDetectedPagination.type) && autoDetectedPagination.selector) + ); + + if (!shouldSkipPaginationMode) { + startPaginationMode(); + } + setShowPaginationOptions(true); setCaptureStage('pagination'); break; @@ -460,6 +709,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture case 'pagination': stopPaginationMode(); setShowPaginationOptions(false); + setAutoDetectedPagination(null); setCaptureStage('initial'); break; } @@ -495,17 +745,58 @@ export const RightSidePanel: React.FC = ({ onFinishCapture socket.emit('removeAction', { actionId: currentListActionId }); } } + + if (autoDetectedPagination?.selector) { + const iframeElement = document.querySelector('#browser-window iframe') as HTMLIFrameElement; + if (iframeElement?.contentDocument) { + try { + function evaluateSelector(selector: string, doc: Document): Element[] { + if (selector.startsWith('//') || selector.startsWith('(//')) { + try { + const result = doc.evaluate(selector, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + const elements: Element[] = []; + for (let i = 0; i < result.snapshotLength; i++) { + const node = result.snapshotItem(i); + if (node && node.nodeType === Node.ELEMENT_NODE) { + elements.push(node as Element); + } + } + return elements; + } catch (err) { + return []; + } + } else { + try { + return Array.from(doc.querySelectorAll(selector)); + } catch (err) { + return []; + } + } + } + + const elements = evaluateSelector(autoDetectedPagination.selector, iframeElement.contentDocument); + elements.forEach((el: Element) => { + (el as HTMLElement).style.outline = ''; + (el as HTMLElement).style.outlineOffset = ''; + (el as HTMLElement).style.zIndex = ''; + }); + } catch (error) { + console.error('Error removing pagination highlight on discard:', error); + } + } + } resetListState(); stopPaginationMode(); stopLimitMode(); setShowPaginationOptions(false); setShowLimitOptions(false); + setAutoDetectedPagination(null); setCaptureStage('initial'); setCurrentListActionId(''); clientSelectorGenerator.cleanup(); notify('error', t('right_panel.errors.capture_list_discarded')); - }, [currentListActionId, browserSteps, stopGetList, deleteStepsByActionId, resetListState, setShowPaginationOptions, setShowLimitOptions, setCaptureStage, notify, t, stopPaginationMode, stopLimitMode, socket]); + }, [currentListActionId, browserSteps, stopGetList, deleteStepsByActionId, resetListState, setShowPaginationOptions, setShowLimitOptions, setCaptureStage, notify, t, stopPaginationMode, stopLimitMode, socket, autoDetectedPagination]); const captureScreenshot = (fullPage: boolean) => { const screenshotCount = browserSteps.filter(s => s.type === 'screenshot').length + 1; @@ -615,6 +906,114 @@ export const RightSidePanel: React.FC = ({ onFinishCapture {showPaginationOptions && ( {t('right_panel.pagination.title')} + + {autoDetectedPagination && autoDetectedPagination.type !== '' && ( + + + ✓ Auto-detected: { + autoDetectedPagination.type === 'clickNext' ? 'Click Next' : + autoDetectedPagination.type === 'clickLoadMore' ? 'Click Load More' : + autoDetectedPagination.type === 'scrollDown' ? 'Scroll Down' : + autoDetectedPagination.type === 'scrollUp' ? 'Scroll Up' : + autoDetectedPagination.type + } + + + You can continue with this or manually select a different pagination type below. + + {autoDetectedPagination.selector && ['clickNext', 'clickLoadMore'].includes(autoDetectedPagination.type) && ( + + )} + + )}