From d758afe87e62e68eeeb471f90ecd07208b662d7f Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Wed, 15 Oct 2025 11:59:12 +0200 Subject: [PATCH] Implement visual selector override of price data Re #3505 --- .../blueprint/ui/templates/edit.html | 8 +- changedetectionio/model/__init__.py | 1 + .../processors/restock_diff/forms.py | 15 +- .../processors/restock_diff/processor.py | 100 +++- changedetectionio/static/js/restock_diff.js | 20 + .../{watch-settings.js => text_json_diff.js} | 17 + .../static/js/visual-selector.js | 546 ++++++++++-------- 7 files changed, 464 insertions(+), 243 deletions(-) create mode 100644 changedetectionio/static/js/restock_diff.js rename changedetectionio/static/js/{watch-settings.js => text_json_diff.js} (84%) diff --git a/changedetectionio/blueprint/ui/templates/edit.html b/changedetectionio/blueprint/ui/templates/edit.html index f6e7f6a08..4ff7f5ae4 100644 --- a/changedetectionio/blueprint/ui/templates/edit.html +++ b/changedetectionio/blueprint/ui/templates/edit.html @@ -29,7 +29,7 @@ const default_system_fetch_backend="{{ settings_application['fetch_backend'] }}"; - + {% if playwright_enabled %} @@ -50,8 +50,10 @@ {% endif %}
  • Browser Steps
  • - {% if watch['processor'] == 'text_json_diff' %} + {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
  • Visual Filter Selector
  • + {% endif %} + {% if watch['processor'] == 'text_json_diff' %}
  • Filters & Triggers
  • Conditions
  • {% endif %} @@ -377,7 +379,7 @@ Math: {{ 1 + 1 }}") }} {{ extra_form_content|safe }} {% endif %} - {% if watch['processor'] == 'text_json_diff' %} + {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'restock_diff' %}
    diff --git a/changedetectionio/model/__init__.py b/changedetectionio/model/__init__.py index 222dafc70..46500f1eb 100644 --- a/changedetectionio/model/__init__.py +++ b/changedetectionio/model/__init__.py @@ -55,6 +55,7 @@ class watch_base(dict): 'previous_md5_before_filters': False, # Used for skipping changedetection entirely 'processor': 'text_json_diff', # could be restock_diff or others from .processors 'price_change_threshold_percent': None, + 'price_change_custom_include_filters': None, # Like 'include_filter' but for price changes only 'proxy': None, # Preferred proxy connection 'remote_server_reply': None, # From 'server' reply header 'sort_text_alphabetically': False, diff --git a/changedetectionio/processors/restock_diff/forms.py b/changedetectionio/processors/restock_diff/forms.py index 39334aa3c..07f44b1a0 100644 --- a/changedetectionio/processors/restock_diff/forms.py +++ b/changedetectionio/processors/restock_diff/forms.py @@ -7,7 +7,7 @@ from wtforms.fields.choices import RadioField from wtforms.fields.form import FormField from wtforms.form import Form -from changedetectionio.forms import processor_text_json_diff_form +from changedetectionio.forms import processor_text_json_diff_form, ValidateCSSJSONXPATHInput, StringListField class RestockSettingsForm(Form): @@ -27,6 +27,8 @@ class RestockSettingsForm(Form): validators.NumberRange(min=0, max=100, message="Should be between 0 and 100"), ], render_kw={"placeholder": "0%", "size": "5"}) + price_change_custom_include_filters = StringListField('Override automatic price detection with these selectors', [ValidateCSSJSONXPATHInput()], default='') + follow_price_changes = BooleanField('Follow price changes', default=True) class processor_settings_form(processor_text_json_diff_form): @@ -74,7 +76,16 @@ class processor_settings_form(processor_text_json_diff_form): {{ render_field(form.restock_settings.price_change_threshold_percent) }} Price must change more than this % to trigger a change since the first check.
    For example, If the product is $1,000 USD originally, 2% would mean it has to change more than $20 since the first check.
    - + +
    + {% set field = render_field(form.restock_settings.price_change_custom_include_filters, + rows=5, + placeholder="#example +xpath://body/div/span[contains(@class, 'example-class')]", + class="m-d") + %} + {{ field }} +
    """ diff --git a/changedetectionio/processors/restock_diff/processor.py b/changedetectionio/processors/restock_diff/processor.py index 1fa81058c..8e413973d 100644 --- a/changedetectionio/processors/restock_diff/processor.py +++ b/changedetectionio/processors/restock_diff/processor.py @@ -125,6 +125,79 @@ def get_itemprop_availability(html_content) -> Restock: return value +def get_price_data_availability(html_content, price_change_custom_include_filters) -> Restock: + """ + Extract price using custom CSS/XPath selectors. + Reuses apply_include_filters logic from text_json_diff processor. + + Args: + html_content: The HTML content to parse + price_change_custom_include_filters: List of CSS/XPath selectors to extract price + + Returns: + Restock dict with 'price' key if found + """ + from changedetectionio import html_tools + from changedetectionio.processors.magic import guess_stream_type + + value = Restock() + + if not price_change_custom_include_filters: + return value + + # Get content type + stream_content_type = guess_stream_type(http_content_header='text/html', content=html_content) + + # Apply filters to extract price element + filtered_content = "" + + for filter_rule in price_change_custom_include_filters: + # XPath filters + if filter_rule[0] == '/' or filter_rule.startswith('xpath:'): + filtered_content += html_tools.xpath_filter( + xpath_filter=filter_rule.replace('xpath:', ''), + html_content=html_content, + append_pretty_line_formatting=False, + is_rss=stream_content_type.is_rss + ) + + # XPath1 filters (first match only) + elif filter_rule.startswith('xpath1:'): + filtered_content += html_tools.xpath1_filter( + xpath_filter=filter_rule.replace('xpath1:', ''), + html_content=html_content, + append_pretty_line_formatting=False, + is_rss=stream_content_type.is_rss + ) + + # CSS selectors, default fallback + else: + filtered_content += html_tools.include_filters( + include_filters=filter_rule, + html_content=html_content, + append_pretty_line_formatting=False + ) + + if filtered_content.strip(): + # Convert HTML to text + price_text = html_tools.html_to_text( + html_content=filtered_content, + render_anchor_tag_content=False, + is_rss=False + ).strip() + + # Parse the price from text + try: + parsed_price = value.parse_currency(price_text) + if parsed_price is not None: + value['price'] = parsed_price + logger.debug(f"Extracted price from custom selector: {parsed_price} (from text: '{price_text}')") + except Exception as e: + logger.warning(f"Failed to parse price from '{price_text}': {e}") + + return value + + def is_between(number, lower=None, upper=None): """ Check if a number is between two values. @@ -185,18 +258,23 @@ class perform_site_check(difference_detection_processor): logger.info(f"Watch {watch.get('uuid')} - Tag '{tag.get('title')}' selected for restock settings override") break - + #if not has custom selector.. itemprop_availability = {} - try: - itemprop_availability = get_itemprop_availability(self.fetcher.content) - except MoreThanOnePriceFound as e: - # Add the real data - raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.", - url=watch.get('url'), - status_code=self.fetcher.get_last_status_code(), - screenshot=self.fetcher.screenshot, - xpath_data=self.fetcher.xpath_data - ) + if restock_settings.get('price_change_custom_include_filters'): + itemprop_availability = get_price_data_availability(html_content=self.fetcher.content, + price_change_custom_include_filters=restock_settings.get('price_change_custom_include_filters') + ) + else: + try: + itemprop_availability = get_itemprop_availability(self.fetcher.content) + except MoreThanOnePriceFound as e: + # Add the real data + raise ProcessorException(message="Cannot run, more than one price detected, this plugin is only for product pages with ONE product, try the content-change detection mode.", + url=watch.get('url'), + status_code=self.fetcher.get_last_status_code(), + screenshot=self.fetcher.screenshot, + xpath_data=self.fetcher.xpath_data + ) # Something valid in get_itemprop_availability() by scraping metadata ? if itemprop_availability.get('price') or itemprop_availability.get('availability'): diff --git a/changedetectionio/static/js/restock_diff.js b/changedetectionio/static/js/restock_diff.js new file mode 100644 index 000000000..e4876e374 --- /dev/null +++ b/changedetectionio/static/js/restock_diff.js @@ -0,0 +1,20 @@ +$(document).ready(function () { + // Initialize Visual Selector plugin + let visualSelectorAPI = null; + if ($('#selector-wrapper').length > 0) { + visualSelectorAPI = $('#selector-wrapper').visualSelector({ + screenshotUrl: screenshot_url, + visualSelectorDataUrl: watch_visual_selector_data_url, + singleSelectorOnly: true, + $includeFiltersElem: $('#restock_settings-price_change_custom_include_filters') + }); + } + // Bootstrap the visual selector when the tab is clicked + $('#visualselector-tab').click(function () { + if (visualSelectorAPI) { + $('img#selector-background').off('load'); + visualSelectorAPI.bootstrap(); + } + }); +}); + diff --git a/changedetectionio/static/js/watch-settings.js b/changedetectionio/static/js/text_json_diff.js similarity index 84% rename from changedetectionio/static/js/watch-settings.js rename to changedetectionio/static/js/text_json_diff.js index 4d8053401..d6f7091ab 100644 --- a/changedetectionio/static/js/watch-settings.js +++ b/changedetectionio/static/js/text_json_diff.js @@ -46,6 +46,23 @@ function request_textpreview_update() { $(document).ready(function () { + // Initialize Visual Selector plugin + let visualSelectorAPI = null; + if ($('#selector-wrapper').length > 0) { + visualSelectorAPI = $('#selector-wrapper').visualSelector({ + screenshotUrl: screenshot_url, + visualSelectorDataUrl: watch_visual_selector_data_url + }); + + // Bootstrap the visual selector when the tab is clicked + $('#visualselector-tab').click(function() { + if (visualSelectorAPI) { + $('img#selector-background').off('load'); + visualSelectorAPI.bootstrap(); + } + }); + } + $('#notification-setting-reset-to-default').click(function (e) { $('#notification_title').val(''); $('#notification_body').val(''); diff --git a/changedetectionio/static/js/visual-selector.js b/changedetectionio/static/js/visual-selector.js index f6f8e79c2..2db12d7ec 100644 --- a/changedetectionio/static/js/visual-selector.js +++ b/changedetectionio/static/js/visual-selector.js @@ -1,260 +1,352 @@ // Copyright (C) 2021 Leigh Morresi (dgtlmoon@gmail.com) // All rights reserved. -// yes - this is really a hack, if you are a front-ender and want to help, please get in touch! +// jQuery plugin for Visual Selector -let runInClearMode = false; +(function($) { + 'use strict'; -$(document).ready(() => { - let currentSelections = []; - let currentSelection = null; - let appendToList = false; - let c, xctx, ctx; - let xScale = 1, yScale = 1; - let selectorImage, selectorImageRect, selectorData; + // Shared across all plugin instances + let runInClearMode = false; + $.fn.visualSelector = function(options) { + // Default settings + const defaults = { + $selectorCanvasElem: $('#selector-canvas'), + $includeFiltersElem: $('#include_filters'), + $selectorBackgroundElem: $('img#selector-background'), + $selectorCurrentXpathElem: $('#selector-current-xpath span'), + $selectorCurrentXpathParentElem: $('#selector-current-xpath'), + $fetchingUpdateNoticeElem: $('.fetching-update-notice'), + $selectorWrapperElem: $('#selector-wrapper'), + $visualSelectorHeadingElem: $('#visual-selector-heading'), + $clearSelectorElem: $('#clear-selector'), + screenshotUrl: window.screenshot_url || '', + visualSelectorDataUrl: window.watch_visual_selector_data_url || '', + currentSelections: [], + singleSelectorOnly: false // When true, only allows selecting one element (disables Shift+Click multi-select) + }; - // Global jQuery selectors with "Elem" appended - const $selectorCanvasElem = $('#selector-canvas'); - const $includeFiltersElem = $("#include_filters"); - const $selectorBackgroundElem = $("img#selector-background"); - const $selectorCurrentXpathElem = $("#selector-current-xpath span"); - const $fetchingUpdateNoticeElem = $('.fetching-update-notice'); - const $selectorWrapperElem = $("#selector-wrapper"); + // Merge options with defaults + const settings = $.extend({}, defaults, options); - // Color constants - const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)'; - const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)'; - const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)'; - const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)'; - const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)'; + // Extract settings for easier access + const $selectorCanvasElem = settings.$selectorCanvasElem; + const $includeFiltersElem = settings.$includeFiltersElem; + const $selectorBackgroundElem = settings.$selectorBackgroundElem; + const $selectorCurrentXpathElem = settings.$selectorCurrentXpathElem; + const $selectorCurrentXpathParentElem = settings.$selectorCurrentXpathParentElem; + const $fetchingUpdateNoticeElem = settings.$fetchingUpdateNoticeElem; + const $selectorWrapperElem = settings.$selectorWrapperElem; + const $visualSelectorHeadingElem = settings.$visualSelectorHeadingElem; + const $clearSelectorElem = settings.$clearSelectorElem; - $('#visualselector-tab').click(() => { - $selectorBackgroundElem.off('load'); - currentSelections = []; - bootstrapVisualSelector(); - }); - - function clearReset() { - ctx.clearRect(0, 0, c.width, c.height); - - if ($includeFiltersElem.val().length) { - alert("Existing filters under the 'Filters & Triggers' tab were cleared."); - } - $includeFiltersElem.val(''); - - currentSelections = []; - - // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector) - runInClearMode = true; - - highlightCurrentSelected(); - } - - function splitToList(v) { - return v.split('\n').map(line => line.trim()).filter(line => line.length > 0); - } - - function sortScrapedElementsBySize() { - // Sort the currentSelections array by area (width * height) in descending order - selectorData['size_pos'].sort((a, b) => { - const areaA = a.width * a.height; - const areaB = b.width * b.height; - return areaB - areaA; - }); - } - - $(document).on('keydown keyup', (event) => { - if (event.code === 'ShiftLeft' || event.code === 'ShiftRight') { - appendToList = event.type === 'keydown'; + // Validate required elements exist (supports both textarea and input[type="text"]) + if (!$includeFiltersElem.length) { + console.error('Visual Selector Error: $includeFiltersElem not found. The visual selector requires a valid textarea or input[type="text"] element to write selections to.'); + console.error('Attempted selector:', settings.$includeFiltersElem.selector || settings.$includeFiltersElem); + return null; } - if (event.type === 'keydown') { - if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") { - clearReset(); + // Verify the element is a textarea or input + const elementType = $includeFiltersElem.prop('tagName').toLowerCase(); + if (elementType !== 'textarea' && elementType !== 'input') { + console.error('Visual Selector Error: $includeFiltersElem must be a textarea or input element, found:', elementType); + return null; + } + + // Plugin instance state + let currentSelections = settings.currentSelections || []; + let currentSelection = null; + let appendToList = false; + let c, xctx, ctx; + let xScale = 1, yScale = 1; + let selectorImage, selectorImageRect, selectorData; + + // Color constants + const FILL_STYLE_HIGHLIGHT = 'rgba(205,0,0,0.35)'; + const FILL_STYLE_GREYED_OUT = 'rgba(205,205,205,0.95)'; + const STROKE_STYLE_HIGHLIGHT = 'rgba(255,0,0, 0.9)'; + const FILL_STYLE_REDLINE = 'rgba(255,0,0, 0.1)'; + const STROKE_STYLE_REDLINE = 'rgba(225,0,0,0.9)'; + + function clearReset() { + ctx.clearRect(0, 0, c.width, c.height); + + if ($includeFiltersElem.val().length) { + alert("Existing filters under the 'Filters & Triggers' tab were cleared."); } - } - }); + $includeFiltersElem.val(''); - $('#clear-selector').on('click', () => { - clearReset(); - }); - // So if they start switching between visualSelector and manual filters, stop it from rendering old filters - $('li.tab a').on('click', () => { - runInClearMode = true; - }); + currentSelections = []; - if (!window.location.hash || window.location.hash !== '#visualselector') { - $selectorBackgroundElem.attr('src', ''); - return; - } + // Means we ignore the xpaths from the scraper marked as sel.highlight_as_custom_filter (it matched a previous selector) + runInClearMode = true; - bootstrapVisualSelector(); - - function bootstrapVisualSelector() { - $selectorBackgroundElem - .on("error", () => { - $fetchingUpdateNoticeElem.html("Ooops! The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.") - .css('color', '#bb0000'); - $('#selector-current-xpath, #clear-selector').hide(); - }) - .on('load', () => { - console.log("Loaded background..."); - c = document.getElementById("selector-canvas"); - xctx = c.getContext("2d"); - ctx = c.getContext("2d"); - fetchData(); - $selectorCanvasElem.off("mousemove mousedown"); - }) - .attr("src", screenshot_url); - - let s = `${$selectorBackgroundElem.attr('src')}?${new Date().getTime()}`; - $selectorBackgroundElem.attr('src', s); - } - - function alertIfFilterNotFound() { - let existingFilters = splitToList($includeFiltersElem.val()); - let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath); - - for (let filter of existingFilters) { - if (!sizePosXpaths.includes(filter)) { - alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`); - break; - } - } - } - - function fetchData() { - $fetchingUpdateNoticeElem.html("Fetching element data.."); - - $.ajax({ - url: watch_visual_selector_data_url, - context: document.body - }).done((data) => { - $fetchingUpdateNoticeElem.html("Rendering.."); - selectorData = data; - - sortScrapedElementsBySize(); - console.log(`Reported browser width from backend: ${data['browser_width']}`); - - // Little sanity check for the user, alert them if something missing - alertIfFilterNotFound(); - - setScale(); - reflowSelector(); - $fetchingUpdateNoticeElem.fadeOut(); - }); - } - - function updateFiltersText() { - // Assuming currentSelections is already defined and contains the selections - let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath))); - - if (currentSelections.length > 0) { - // Convert the Set back to an array and join with newline characters - let textboxFilterText = Array.from(uniqueSelections).join("\n"); - $includeFiltersElem.val(textboxFilterText); - } - } - - function setScale() { - $selectorWrapperElem.show(); - selectorImage = $selectorBackgroundElem[0]; - selectorImageRect = selectorImage.getBoundingClientRect(); - - $selectorCanvasElem.attr({ - 'height': selectorImageRect.height, - 'width': selectorImageRect.width - }); - $selectorWrapperElem.attr('width', selectorImageRect.width); - $('#visual-selector-heading').css('max-width', selectorImageRect.width + "px") - - xScale = selectorImageRect.width / selectorImage.naturalWidth; - yScale = selectorImageRect.height / selectorImage.naturalHeight; - - ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT; - ctx.fillStyle = FILL_STYLE_REDLINE; - ctx.lineWidth = 3; - console.log("Scaling set x: " + xScale + " by y:" + yScale); - $("#selector-current-xpath").css('max-width', selectorImageRect.width); - } - - function reflowSelector() { - $(window).resize(() => { - setScale(); highlightCurrentSelected(); - }); + } - setScale(); + function splitToList(v) { + return v.split('\n').map(line => line.trim()).filter(line => line.length > 0); + } - console.log(selectorData['size_pos'].length + " selectors found"); + function sortScrapedElementsBySize() { + // Sort the currentSelections array by area (width * height) in descending order + selectorData['size_pos'].sort((a, b) => { + const areaA = a.width * a.height; + const areaB = b.width * b.height; + return areaB - areaA; + }); + } - let existingFilters = splitToList($includeFiltersElem.val()); + function alertIfFilterNotFound() { + let existingFilters = splitToList($includeFiltersElem.val()); + let sizePosXpaths = selectorData['size_pos'].map(sel => sel.xpath); - selectorData['size_pos'].forEach(sel => { - if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) { - console.log("highlighting " + c); - currentSelections.push(sel); - } - }); - - - highlightCurrentSelected(); - updateFiltersText(); - - $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5)); - $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5)); - $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5)); - - function handleMouseMove(e) { - if (!e.offsetX && !e.offsetY) { - const targetOffset = $(e.target).offset(); - e.offsetX = e.pageX - targetOffset.left; - e.offsetY = e.pageY - targetOffset.top; - } - - ctx.fillStyle = FILL_STYLE_HIGHLIGHT; - - selectorData['size_pos'].forEach(sel => { - if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale && - e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) { - setCurrentSelectedText(sel.xpath); - drawHighlight(sel); - currentSelections.push(sel); - currentSelection = sel; - highlightCurrentSelected(); - currentSelections.pop(); + for (let filter of existingFilters) { + if (!sizePosXpaths.includes(filter)) { + alert(`One or more of your existing filters was not found and will be removed when a new filter is selected.`); + break; } - }) + } } + function fetchData() { + $fetchingUpdateNoticeElem.html("Fetching element data.."); - function setCurrentSelectedText(s) { - $selectorCurrentXpathElem[0].innerHTML = s; + $.ajax({ + url: settings.visualSelectorDataUrl, + context: document.body + }).done((data) => { + $fetchingUpdateNoticeElem.html("Rendering.."); + selectorData = data; + + sortScrapedElementsBySize(); + console.log(`Reported browser width from backend: ${data['browser_width']}`); + + // Little sanity check for the user, alert them if something missing + alertIfFilterNotFound(); + + setScale(); + reflowSelector(); + $fetchingUpdateNoticeElem.fadeOut(); + }); } - function drawHighlight(sel) { - ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); - ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); + function updateFiltersText() { + // Assuming currentSelections is already defined and contains the selections + let uniqueSelections = new Set(currentSelections.map(sel => (sel[0] === '/' ? `xpath:${sel.xpath}` : sel.xpath))); + + if (currentSelections.length > 0) { + // Convert the Set back to an array and join with newline characters + let textboxFilterText = Array.from(uniqueSelections).join("\n"); + $includeFiltersElem.val(textboxFilterText); + } } - function handleMouseDown() { - // If we are in 'appendToList' mode, grow the list, if not, just 1 - currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection]; + function setScale() { + $selectorWrapperElem.show(); + selectorImage = $selectorBackgroundElem[0]; + selectorImageRect = selectorImage.getBoundingClientRect(); + + $selectorCanvasElem.attr({ + 'height': selectorImageRect.height, + 'width': selectorImageRect.width + }); + $selectorWrapperElem.attr('width', selectorImageRect.width); + $visualSelectorHeadingElem.css('max-width', selectorImageRect.width + "px") + + xScale = selectorImageRect.width / selectorImage.naturalWidth; + yScale = selectorImageRect.height / selectorImage.naturalHeight; + + ctx.strokeStyle = STROKE_STYLE_HIGHLIGHT; + ctx.fillStyle = FILL_STYLE_REDLINE; + ctx.lineWidth = 3; + console.log("Scaling set x: " + xScale + " by y:" + yScale); + $selectorCurrentXpathParentElem.css('max-width', selectorImageRect.width); + } + + function reflowSelector() { + $(window).resize(() => { + setScale(); + highlightCurrentSelected(); + }); + + setScale(); + + console.log(selectorData['size_pos'].length + " selectors found"); + + let existingFilters = splitToList($includeFiltersElem.val()); + + // In singleSelectorOnly mode, only load the first existing filter + if (settings.singleSelectorOnly && existingFilters.length > 1) { + existingFilters = [existingFilters[0]]; + } + + for (let sel of selectorData['size_pos']) { + if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) { + console.log("highlighting " + sel.xpath); + currentSelections.push(sel); + // In singleSelectorOnly mode, stop after finding the first match + if (settings.singleSelectorOnly) { + break; + } + } + } + + highlightCurrentSelected(); updateFiltersText(); + + $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5)); + $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5)); + $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5)); + + function handleMouseMove(e) { + if (!e.offsetX && !e.offsetY) { + const targetOffset = $(e.target).offset(); + e.offsetX = e.pageX - targetOffset.left; + e.offsetY = e.pageY - targetOffset.top; + } + + ctx.fillStyle = FILL_STYLE_HIGHLIGHT; + + selectorData['size_pos'].forEach(sel => { + if (e.offsetY > sel.top * yScale && e.offsetY < sel.top * yScale + sel.height * yScale && + e.offsetX > sel.left * yScale && e.offsetX < sel.left * yScale + sel.width * yScale) { + setCurrentSelectedText(sel.xpath); + drawHighlight(sel); + currentSelections.push(sel); + currentSelection = sel; + highlightCurrentSelected(); + currentSelections.pop(); + } + }) + } + + + function setCurrentSelectedText(s) { + $selectorCurrentXpathElem[0].innerHTML = s; + } + + function drawHighlight(sel) { + ctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); + ctx.fillRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); + } + + function handleMouseDown() { + // In singleSelectorOnly mode, always use single selection (ignore appendToList/Shift) + if (settings.singleSelectorOnly) { + currentSelections = [currentSelection]; + } else { + // If we are in 'appendToList' mode, grow the list, if not, just 1 + currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection]; + } + highlightCurrentSelected(); + updateFiltersText(); + } + } - } + function highlightCurrentSelected() { + xctx.fillStyle = FILL_STYLE_GREYED_OUT; + xctx.strokeStyle = STROKE_STYLE_REDLINE; + xctx.lineWidth = 3; + xctx.clearRect(0, 0, c.width, c.height); - function highlightCurrentSelected() { - xctx.fillStyle = FILL_STYLE_GREYED_OUT; - xctx.strokeStyle = STROKE_STYLE_REDLINE; - xctx.lineWidth = 3; - xctx.clearRect(0, 0, c.width, c.height); + currentSelections.forEach(sel => { + //xctx.clearRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); + xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); + }); + } - currentSelections.forEach(sel => { - //xctx.clearRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); - xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); - }); - } -}); \ No newline at end of file + function bootstrapVisualSelector() { + $selectorBackgroundElem + .on("error", (d) => { + console.error(d) + $fetchingUpdateNoticeElem.html("Ooops! The VisualSelector tool needs at least one fetched page, please unpause the watch and/or wait for the watch to complete fetching and then reload this page.") + .css('color', '#bb0000'); + $selectorCurrentXpathParentElem.hide(); + $clearSelectorElem.hide(); + }) + .on('load', () => { + console.log("Loaded background..."); + c = document.getElementById("selector-canvas"); + xctx = c.getContext("2d"); + ctx = c.getContext("2d"); + fetchData(); + $selectorCanvasElem.off("mousemove mousedown"); + }); + + // Set the src with cache-busting timestamp + let s = `${settings.screenshotUrl}?${new Date().getTime()}`; + console.log(s); + $selectorBackgroundElem.attr('src', s); + } + + // Set up global event handlers (these run once on initialization) + function initializeEventHandlers() { + $(document).on('keydown.visualSelector keyup.visualSelector', (event) => { + // Only enable shift+click multi-select if singleSelectorOnly is false + if (!settings.singleSelectorOnly && (event.code === 'ShiftLeft' || event.code === 'ShiftRight')) { + appendToList = event.type === 'keydown'; + } + + if (event.type === 'keydown') { + if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") { + clearReset(); + } + } + }); + + $clearSelectorElem.on('click.visualSelector', () => { + clearReset(); + }); + + // So if they start switching between visualSelector and manual filters, stop it from rendering old filters + $('li.tab a').on('click.visualSelector', () => { + runInClearMode = true; + }); + } + + // Initialize event handlers + initializeEventHandlers(); + + // Check if we should auto-bootstrap based on URL hash + if (window.location.hash && window.location.hash === '#visualselector') { + // Auto-bootstrap if on the visualselector tab + setTimeout(() => bootstrapVisualSelector(), 100); + } else { + // Clear the background image if not on the visualselector tab + $selectorBackgroundElem.attr('src', ''); + } + + // Return public API + return { + bootstrap: function() { + currentSelections = []; + runInClearMode = false; + bootstrapVisualSelector(); + }, + clear: function() { + clearReset(); + }, + destroy: function() { + // Clean up event handlers + $(document).off('.visualSelector'); + $clearSelectorElem.off('.visualSelector'); + $('li.tab a').off('.visualSelector'); + $selectorCanvasElem.off('mousemove mousedown mouseleave'); + $(window).off('resize'); + }, + getCurrentSelections: function() { + return currentSelections; + }, + setCurrentSelections: function(selections) { + currentSelections = selections; + highlightCurrentSelected(); + updateFiltersText(); + } + }; + }; + +})(jQuery);