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.
-
+
+
"""
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);