mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-11-03 20:44:37 +00:00
WIP
This commit is contained in:
1 parent
3b51d03be5
commit
fbb019ddf3
13 files changed
+707
-43
No files matched your search
@@ -96,6 +96,25 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
form.datastore = datastore
|
||||
form.watch = default
|
||||
|
||||
# Load processor-specific config from JSON file for GET requests
|
||||
if request.method == 'GET' and processor_name:
|
||||
try:
|
||||
# Create a processor instance to access config methods
|
||||
processor_instance = processors.difference_detection_processor(datastore, uuid)
|
||||
# Use processor name as filename so each processor keeps its own config
|
||||
config_filename = f'{processor_name}.json'
|
||||
processor_config = processor_instance.get_extra_watch_config(config_filename)
|
||||
|
||||
if processor_config:
|
||||
# Populate processor-config-* fields from JSON
|
||||
for config_key, config_value in processor_config.items():
|
||||
field_name = f'processor_config_{config_key}'
|
||||
if hasattr(form, field_name):
|
||||
getattr(form, field_name).data = config_value
|
||||
logger.debug(f"Loaded processor config from {config_filename}: {field_name} = {config_value}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load processor config: {e}")
|
||||
|
||||
for p in datastore.extra_browsers:
|
||||
form.fetch_backend.choices.append(p)
|
||||
|
||||
@@ -129,7 +148,34 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
extra_update_obj['time_between_check'] = form.time_between_check.data
|
||||
|
||||
# Ignore text
|
||||
# Handle processor-config-* fields separately (save to JSON, not datastore)
|
||||
processor_config_data = {}
|
||||
fields_to_remove = []
|
||||
for field_name, field_value in form.data.items():
|
||||
if field_name.startswith('processor_config_'):
|
||||
config_key = field_name.replace('processor_config_', '')
|
||||
if field_value: # Only save non-empty values
|
||||
processor_config_data[config_key] = field_value
|
||||
fields_to_remove.append(field_name)
|
||||
|
||||
# Save processor config to JSON file if any config data exists
|
||||
if processor_config_data:
|
||||
try:
|
||||
processor_name = form.data.get('processor')
|
||||
# Create a processor instance to access config methods
|
||||
processor_instance = processors.difference_detection_processor(datastore, uuid)
|
||||
# Use processor name as filename so each processor keeps its own config
|
||||
config_filename = f'{processor_name}.json'
|
||||
processor_instance.update_extra_watch_config(config_filename, processor_config_data)
|
||||
logger.debug(f"Saved processor config to {config_filename}: {processor_config_data}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save processor config: {e}")
|
||||
|
||||
# Remove processor-config-* fields from form.data before updating datastore
|
||||
for field_name in fields_to_remove:
|
||||
form.data.pop(field_name, None)
|
||||
|
||||
# Ignore text
|
||||
form_ignore_text = form.ignore_text.data
|
||||
datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text
|
||||
|
||||
|
||||
@@ -387,6 +387,22 @@ Math: {{ 1 + 1 }}") }}
|
||||
The Visual Selector tool lets you select the <i>text</i> elements that will be used for the change detection. It automatically fills-in the filters in the "CSS/JSONPath/JQ/XPath Filters" box of the <a href="#filters-and-triggers">Filters & Triggers</a> tab. Use <strong>Shift+Click</strong> to select multiple items.
|
||||
</span>
|
||||
|
||||
{% if watch['processor'] == 'image_ssim_diff' %}
|
||||
<div id="selection-mode-controls" style="margin: 10px 0; padding: 10px; background: var(--color-background-tab); border-radius: 5px;">
|
||||
<label style="font-weight: 600; margin-right: 15px;">Selection Mode:</label>
|
||||
<label style="margin-right: 15px;">
|
||||
<input type="radio" name="selector-mode" value="element" style="margin-right: 5px;">
|
||||
Select by element
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="selector-mode" value="draw" checked style="margin-right: 5px;">
|
||||
Draw area
|
||||
</label>
|
||||
{{ render_field(form.processor_config_bounding_box) }}
|
||||
{{ render_field(form.processor_config_selection_mode) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="selector-header">
|
||||
<a id="clear-selector" class="pure-button button-secondary button-xsmall" style="font-size: 70%">Clear selection</a>
|
||||
<!-- visual selector IMG will try to load, it will either replace this or on error replace it with some handy text -->
|
||||
|
||||
@@ -6,6 +6,7 @@ from flask_login import current_user
|
||||
from flask_paginate import Pagination, get_page_parameter
|
||||
|
||||
from changedetectionio import forms
|
||||
from changedetectionio import processors
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
|
||||
@@ -90,6 +91,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
hosted_sticky=os.getenv("SALTED_PASS", False) == False,
|
||||
now_time_server=round(time.time()),
|
||||
pagination=pagination,
|
||||
processor_badge_texts=processors.get_processor_badge_texts(),
|
||||
processor_descriptions=processors.get_processor_descriptions(),
|
||||
queued_uuids=[q_uuid.item['uuid'] for q_uuid in update_q.queue],
|
||||
search_q=request.args.get('q', '').strip(),
|
||||
sort_attribute=request.args.get('sort') if request.args.get('sort') else request.cookies.get('sort'),
|
||||
|
||||
@@ -173,8 +173,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<div class="ldjson-price-track-offer">Switch to Restock & Price watch mode? <a href="{{url_for('price_data_follower.accept', uuid=watch.uuid)}}" class="pure-button button-xsmall">Yes</a> <a href="{{url_for('price_data_follower.reject', uuid=watch.uuid)}}" class="">No</a></div>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- if watch['processor'] == 'restock_diff' -%}
|
||||
<span class="tracking-ldjson-price-data" title="Automatically following embedded price information"><img src="{{url_for('static_content', group='images', filename='price-tag-icon.svg')}}" class="status-icon price-follow-tag-icon" > Price</span>
|
||||
{%- if watch['processor'] and watch['processor'] in processor_badge_texts -%}
|
||||
<span class="processor-badge processor-badge-{{ watch['processor'] }}" title="{{ processor_descriptions.get(watch['processor'], watch['processor']) }}">{{ processor_badge_texts[watch['processor']] }}</span>
|
||||
{%- endif -%}
|
||||
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
|
||||
<span class="watch-tag-list">{{ watch_tag.title }}</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ from abc import abstractmethod
|
||||
from changedetectionio.content_fetchers.base import Fetcher
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from copy import deepcopy
|
||||
from functools import lru_cache
|
||||
from loguru import logger
|
||||
import hashlib
|
||||
import importlib
|
||||
@@ -205,13 +206,14 @@ class difference_detection_processor():
|
||||
logger.warning(f"Failed to read extra watch config {filename}: {e}")
|
||||
return {}
|
||||
|
||||
def update_extra_watch_config(self, filename, data):
|
||||
def update_extra_watch_config(self, filename, data, merge=True):
|
||||
"""
|
||||
Write processor-specific JSON config file to watch data directory.
|
||||
|
||||
Args:
|
||||
filename: Name of JSON file (e.g., "visual_ssim_score.json")
|
||||
data: Dictionary to serialize as JSON
|
||||
merge: If True, merge with existing data; if False, overwrite completely
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -229,8 +231,25 @@ class difference_detection_processor():
|
||||
filepath = os.path.join(watch_data_dir, filename)
|
||||
|
||||
try:
|
||||
# If merge is enabled, read existing data first
|
||||
existing_data = {}
|
||||
if merge and os.path.isfile(filepath):
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
existing_data = json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to read existing config for merge: {e}")
|
||||
|
||||
# Merge new data with existing
|
||||
if merge:
|
||||
existing_data.update(data)
|
||||
data_to_save = existing_data
|
||||
else:
|
||||
data_to_save = data
|
||||
|
||||
# Write the data
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
json.dump(data_to_save, f, indent=2)
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to write extra watch config {filename}: {e}")
|
||||
|
||||
@@ -365,3 +384,58 @@ def available_processors():
|
||||
# Return as tuples without weight (for backwards compatibility)
|
||||
return [(name, desc) for name, desc, weight in available]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_processor_badge_texts():
|
||||
"""
|
||||
Get a dictionary mapping processor names to their list_badge_text values.
|
||||
Cached to avoid repeated lookups.
|
||||
|
||||
:return: A dict mapping processor name to badge text (e.g., {'text_json_diff': 'Text', 'restock_diff': 'Restock'})
|
||||
"""
|
||||
processor_classes = find_processors()
|
||||
badge_texts = {}
|
||||
|
||||
for module, sub_package_name in processor_classes:
|
||||
# Try to get the 'list_badge_text' attribute from the processor module
|
||||
if hasattr(module, 'list_badge_text'):
|
||||
badge_texts[sub_package_name] = module.list_badge_text
|
||||
else:
|
||||
# Fall back to parent module's __init__.py
|
||||
parent_module = get_parent_module(module)
|
||||
if parent_module and hasattr(parent_module, 'list_badge_text'):
|
||||
badge_texts[sub_package_name] = parent_module.list_badge_text
|
||||
|
||||
return badge_texts
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_processor_descriptions():
|
||||
"""
|
||||
Get a dictionary mapping processor names to their description/name values.
|
||||
Cached to avoid repeated lookups.
|
||||
|
||||
:return: A dict mapping processor name to description (e.g., {'text_json_diff': 'Webpage Text/HTML, JSON and PDF changes'})
|
||||
"""
|
||||
processor_classes = find_processors()
|
||||
descriptions = {}
|
||||
|
||||
for module, sub_package_name in processor_classes:
|
||||
# Try to get the 'name' or 'description' attribute from the processor module first
|
||||
if hasattr(module, 'name'):
|
||||
descriptions[sub_package_name] = module.name
|
||||
elif hasattr(module, 'description'):
|
||||
descriptions[sub_package_name] = module.description
|
||||
else:
|
||||
# Fall back to parent module's __init__.py
|
||||
parent_module = get_parent_module(module)
|
||||
if parent_module and hasattr(parent_module, 'processor_description'):
|
||||
descriptions[sub_package_name] = parent_module.processor_description
|
||||
elif parent_module and hasattr(parent_module, 'name'):
|
||||
descriptions[sub_package_name] = parent_module.name
|
||||
else:
|
||||
# Final fallback to a readable name
|
||||
descriptions[sub_package_name] = sub_package_name.replace('_', ' ').title()
|
||||
|
||||
return descriptions
|
||||
|
||||
@@ -2,8 +2,39 @@
|
||||
Configuration forms for fast screenshot comparison processor.
|
||||
"""
|
||||
|
||||
from wtforms import SelectField, validators
|
||||
from wtforms import SelectField, StringField, validators, ValidationError
|
||||
from changedetectionio.forms import processor_text_json_diff_form
|
||||
import re
|
||||
|
||||
|
||||
def validate_bounding_box(form, field):
|
||||
"""Validate bounding box format: x,y,width,height with integers."""
|
||||
if not field.data:
|
||||
return # Optional field
|
||||
|
||||
if len(field.data) > 100:
|
||||
raise ValidationError('Bounding box value is too long')
|
||||
|
||||
# Should be comma-separated integers
|
||||
if not re.match(r'^\d+,\d+,\d+,\d+$', field.data):
|
||||
raise ValidationError('Bounding box must be in format: x,y,width,height (integers only)')
|
||||
|
||||
# Validate values are reasonable (not negative, not ridiculously large)
|
||||
parts = [int(p) for p in field.data.split(',')]
|
||||
for part in parts:
|
||||
if part < 0:
|
||||
raise ValidationError('Bounding box values must be non-negative')
|
||||
if part > 10000: # Reasonable max screen dimension
|
||||
raise ValidationError('Bounding box values are too large')
|
||||
|
||||
|
||||
def validate_selection_mode(form, field):
|
||||
"""Validate selection mode value."""
|
||||
if not field.data:
|
||||
return # Optional field
|
||||
|
||||
if field.data not in ['element', 'draw']:
|
||||
raise ValidationError('Selection mode must be either "element" or "draw"')
|
||||
|
||||
|
||||
class processor_settings_form(processor_text_json_diff_form):
|
||||
@@ -23,6 +54,27 @@ class processor_settings_form(processor_text_json_diff_form):
|
||||
default=''
|
||||
)
|
||||
|
||||
# Processor-specific config fields (stored in separate JSON file)
|
||||
processor_config_bounding_box = StringField(
|
||||
'Bounding Box',
|
||||
validators=[
|
||||
validators.Optional(),
|
||||
validators.Length(max=100, message='Bounding box value is too long'),
|
||||
validate_bounding_box
|
||||
],
|
||||
render_kw={"style": "display: none;", "id": "bounding_box"}
|
||||
)
|
||||
|
||||
processor_config_selection_mode = StringField(
|
||||
'Selection Mode',
|
||||
validators=[
|
||||
validators.Optional(),
|
||||
validators.Length(max=20, message='Selection mode value is too long'),
|
||||
validate_selection_mode
|
||||
],
|
||||
render_kw={"style": "display: none;", "id": "selection_mode"}
|
||||
)
|
||||
|
||||
def extra_tab_content(self):
|
||||
"""Tab label for processor-specific settings."""
|
||||
return 'Screenshot Comparison'
|
||||
|
||||
@@ -14,10 +14,10 @@ from changedetectionio.processors import difference_detection_processor, SCREENS
|
||||
from changedetectionio.processors.exceptions import ProcessorException
|
||||
from . import DEFAULT_COMPARISON_METHOD, DEFAULT_COMPARISON_THRESHOLD_OPENCV, DEFAULT_COMPARISON_THRESHOLD_PIXELMATCH
|
||||
|
||||
name = 'Visual/Screenshot change detection (Fast)'
|
||||
name = 'Visual/Image screenshot change detection'
|
||||
description = 'Compares screenshots using fast algorithms (OpenCV or pixelmatch), 10-100x faster than SSIM'
|
||||
processor_weight = 2
|
||||
|
||||
list_badge_text = "Visual"
|
||||
|
||||
class perform_site_check(difference_detection_processor):
|
||||
"""Fast screenshot comparison processor."""
|
||||
@@ -84,43 +84,73 @@ class perform_site_check(difference_detection_processor):
|
||||
url=watch.get('url')
|
||||
)
|
||||
|
||||
# Check if visual selector (include_filters) is set for region-based comparison
|
||||
include_filters = watch.get('include_filters', [])
|
||||
# Check if bounding box is set (for drawn area mode)
|
||||
# Read from processor-specific config JSON file (named after processor)
|
||||
crop_region = None
|
||||
# Automatically use the processor name from watch config as filename
|
||||
processor_name = watch.get('processor', 'default')
|
||||
config_filename = f'{processor_name}.json'
|
||||
processor_config = self.get_extra_watch_config(config_filename)
|
||||
bounding_box = processor_config.get('bounding_box') if processor_config else None
|
||||
|
||||
if include_filters and len(include_filters) > 0:
|
||||
# Get the first filter to use for cropping
|
||||
first_filter = include_filters[0].strip()
|
||||
if bounding_box:
|
||||
try:
|
||||
# Parse bounding box: "x,y,width,height"
|
||||
parts = [int(p.strip()) for p in bounding_box.split(',')]
|
||||
if len(parts) == 4:
|
||||
x, y, width, height = parts
|
||||
|
||||
if first_filter and self.xpath_data:
|
||||
try:
|
||||
import json
|
||||
# xpath_data is JSON string from browser
|
||||
xpath_data_obj = json.loads(self.xpath_data) if isinstance(self.xpath_data, str) else self.xpath_data
|
||||
# PIL crop uses (left, top, right, bottom)
|
||||
crop_region = (
|
||||
max(0, x),
|
||||
max(0, y),
|
||||
min(current_img.width, x + width),
|
||||
min(current_img.height, y + height)
|
||||
)
|
||||
|
||||
# Find the bounding box for the first filter
|
||||
for element in xpath_data_obj.get('size_pos', []):
|
||||
# Match the filter with the element's xpath
|
||||
if element.get('xpath') == first_filter and element.get('highlight_as_custom_filter'):
|
||||
# Found the element - extract crop coordinates
|
||||
left = element.get('left', 0)
|
||||
top = element.get('top', 0)
|
||||
width = element.get('width', 0)
|
||||
height = element.get('height', 0)
|
||||
logger.info(f"Bounding box enabled: cropping to region {crop_region} (x={x}, y={y}, w={width}, h={height})")
|
||||
else:
|
||||
logger.warning(f"Invalid bounding box format: {bounding_box} (expected 4 values)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse bounding box '{bounding_box}': {e}")
|
||||
|
||||
# PIL crop uses (left, top, right, bottom)
|
||||
crop_region = (
|
||||
max(0, left),
|
||||
max(0, top),
|
||||
min(current_img.width, left + width),
|
||||
min(current_img.height, top + height)
|
||||
)
|
||||
# If no bounding box, check if visual selector (include_filters) is set for region-based comparison
|
||||
if not crop_region:
|
||||
include_filters = watch.get('include_filters', [])
|
||||
|
||||
logger.info(f"Visual selector enabled: cropping to region {crop_region} for filter: {first_filter}")
|
||||
break
|
||||
if include_filters and len(include_filters) > 0:
|
||||
# Get the first filter to use for cropping
|
||||
first_filter = include_filters[0].strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse xpath_data for visual selector: {e}")
|
||||
if first_filter and self.xpath_data:
|
||||
try:
|
||||
import json
|
||||
# xpath_data is JSON string from browser
|
||||
xpath_data_obj = json.loads(self.xpath_data) if isinstance(self.xpath_data, str) else self.xpath_data
|
||||
|
||||
# Find the bounding box for the first filter
|
||||
for element in xpath_data_obj.get('size_pos', []):
|
||||
# Match the filter with the element's xpath
|
||||
if element.get('xpath') == first_filter and element.get('highlight_as_custom_filter'):
|
||||
# Found the element - extract crop coordinates
|
||||
left = element.get('left', 0)
|
||||
top = element.get('top', 0)
|
||||
width = element.get('width', 0)
|
||||
height = element.get('height', 0)
|
||||
|
||||
# PIL crop uses (left, top, right, bottom)
|
||||
crop_region = (
|
||||
max(0, left),
|
||||
max(0, top),
|
||||
min(current_img.width, left + width),
|
||||
min(current_img.height, top + height)
|
||||
)
|
||||
|
||||
logger.info(f"Visual selector enabled: cropping to region {crop_region} for filter: {first_filter}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse xpath_data for visual selector: {e}")
|
||||
|
||||
# Crop the current image if region was found
|
||||
if crop_region:
|
||||
@@ -130,7 +160,7 @@ class perform_site_check(difference_detection_processor):
|
||||
# Update self.screenshot to the cropped version for history storage
|
||||
crop_buffer = io.BytesIO()
|
||||
current_img.save(crop_buffer, format='PNG')
|
||||
self.screenshot = crop_buffer.getvalue()
|
||||
self.screenshot = self.fetcher.screenshot = crop_buffer.getvalue()
|
||||
|
||||
logger.debug(f"Cropped screenshot to {current_img.size} (region: {crop_region})")
|
||||
except Exception as e:
|
||||
|
||||
@@ -10,6 +10,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
name = 'Re-stock & Price detection for pages with a SINGLE product'
|
||||
description = 'Detects if the product goes back to in-stock'
|
||||
processor_weight = 1
|
||||
list_badge_text = "Restock"
|
||||
|
||||
class UnableToExtractRestockData(Exception):
|
||||
def __init__(self, status_code):
|
||||
|
||||
@@ -20,6 +20,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
name = 'Webpage Text/HTML, JSON and PDF changes'
|
||||
description = 'Detects all text changes where possible'
|
||||
processor_weight = -100
|
||||
list_badge_text = "Text"
|
||||
|
||||
JSON_FILTER_PREFIXES = ['json:', 'jq:', 'jqraw:']
|
||||
|
||||
|
||||
@@ -11,6 +11,18 @@ $(document).ready(() => {
|
||||
let c, xctx, ctx;
|
||||
let xScale = 1, yScale = 1;
|
||||
let selectorImage, selectorImageRect, selectorData;
|
||||
let elementHandlers = {}; // Store references to element selection handlers (needed for draw mode toggling)
|
||||
|
||||
// Box drawing mode variables (for image_ssim_diff processor)
|
||||
let drawMode = false;
|
||||
let isDrawing = false;
|
||||
let isDragging = false;
|
||||
let drawStartX, drawStartY;
|
||||
let dragOffsetX, dragOffsetY;
|
||||
let drawnBox = null;
|
||||
let resizeHandle = null;
|
||||
const HANDLE_SIZE = 8;
|
||||
const isImageProcessor = $('input[value="image_ssim_diff"]').is(':checked');
|
||||
|
||||
|
||||
// Global jQuery selectors with "Elem" appended
|
||||
@@ -141,6 +153,10 @@ $(document).ready(() => {
|
||||
|
||||
setScale();
|
||||
reflowSelector();
|
||||
|
||||
// Initialize draw mode after everything is set up
|
||||
initializeDrawMode();
|
||||
|
||||
$fetchingUpdateNoticeElem.fadeOut();
|
||||
});
|
||||
}
|
||||
@@ -201,9 +217,14 @@ $(document).ready(() => {
|
||||
highlightCurrentSelected();
|
||||
updateFiltersText();
|
||||
|
||||
$selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5));
|
||||
$selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5));
|
||||
$selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5));
|
||||
// Store handler references for later use
|
||||
elementHandlers.handleMouseMove = handleMouseMove.debounce(5);
|
||||
elementHandlers.handleMouseDown = handleMouseDown.debounce(5);
|
||||
elementHandlers.handleMouseLeave = highlightCurrentSelected.debounce(5);
|
||||
|
||||
$selectorCanvasElem.bind('mousemove', elementHandlers.handleMouseMove);
|
||||
$selectorCanvasElem.bind('mousedown', elementHandlers.handleMouseDown);
|
||||
$selectorCanvasElem.bind('mouseleave', elementHandlers.handleMouseLeave);
|
||||
|
||||
function handleMouseMove(e) {
|
||||
if (!e.offsetX && !e.offsetY) {
|
||||
@@ -257,4 +278,372 @@ $(document).ready(() => {
|
||||
xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale);
|
||||
});
|
||||
}
|
||||
|
||||
// ============= BOX DRAWING MODE (for image_ssim_diff processor) =============
|
||||
|
||||
function initializeDrawMode() {
|
||||
if (!isImageProcessor || !c) return;
|
||||
|
||||
const $selectorModeRadios = $('input[name="selector-mode"]');
|
||||
const $boundingBoxField = $('#bounding_box');
|
||||
const $selectionModeField = $('#selection_mode');
|
||||
|
||||
// Load existing selection mode if present
|
||||
const savedMode = $selectionModeField.val();
|
||||
if (savedMode && (savedMode === 'element' || savedMode === 'draw')) {
|
||||
$selectorModeRadios.filter(`[value="${savedMode}"]`).prop('checked', true);
|
||||
console.log('Loaded saved mode:', savedMode);
|
||||
}
|
||||
|
||||
// Load existing bounding box if present
|
||||
const existingBox = $boundingBoxField.val();
|
||||
if (existingBox) {
|
||||
try {
|
||||
const parts = existingBox.split(',').map(p => parseFloat(p));
|
||||
if (parts.length === 4) {
|
||||
drawnBox = {
|
||||
x: parts[0] * xScale,
|
||||
y: parts[1] * yScale,
|
||||
width: parts[2] * xScale,
|
||||
height: parts[3] * yScale
|
||||
};
|
||||
console.log('Loaded saved bounding box:', existingBox);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse existing bounding box:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update mode when radio changes
|
||||
$selectorModeRadios.off('change').on('change', function() {
|
||||
const newMode = $(this).val();
|
||||
drawMode = newMode === 'draw';
|
||||
console.log('Mode changed to:', newMode);
|
||||
|
||||
// Save the mode to the hidden field
|
||||
$selectionModeField.val(newMode);
|
||||
|
||||
if (drawMode) {
|
||||
enableDrawMode();
|
||||
} else {
|
||||
disableDrawMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial mode based on which radio is checked
|
||||
drawMode = $selectorModeRadios.filter(':checked').val() === 'draw';
|
||||
console.log('Initial mode:', drawMode ? 'draw' : 'element');
|
||||
|
||||
// Save initial mode
|
||||
$selectionModeField.val(drawMode ? 'draw' : 'element');
|
||||
|
||||
if (drawMode) {
|
||||
enableDrawMode();
|
||||
}
|
||||
}
|
||||
|
||||
function enableDrawMode() {
|
||||
console.log('Enabling draw mode...');
|
||||
|
||||
// Unbind element selection handlers
|
||||
$selectorCanvasElem.unbind('mousemove mousedown mouseleave');
|
||||
|
||||
// Set cursor to crosshair
|
||||
$selectorCanvasElem.css('cursor', 'crosshair');
|
||||
|
||||
// Bind draw mode handlers
|
||||
$selectorCanvasElem.on('mousedown', handleDrawMouseDown);
|
||||
$selectorCanvasElem.on('mousemove', handleDrawMouseMove);
|
||||
$selectorCanvasElem.on('mouseup', handleDrawMouseUp);
|
||||
$selectorCanvasElem.on('mouseleave', handleDrawMouseUp);
|
||||
|
||||
// Clear element selections and xpath display
|
||||
currentSelections = [];
|
||||
$includeFiltersElem.val('');
|
||||
$selectorCurrentXpathElem.html('Draw mode - click and drag to select an area');
|
||||
|
||||
// Clear the canvas
|
||||
if (ctx && xctx) {
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
xctx.clearRect(0, 0, c.width, c.height);
|
||||
}
|
||||
|
||||
// Redraw if we have an existing box
|
||||
if (drawnBox) {
|
||||
drawBox();
|
||||
}
|
||||
}
|
||||
|
||||
function disableDrawMode() {
|
||||
console.log('Disabling draw mode, switching to element mode...');
|
||||
|
||||
// Unbind draw handlers
|
||||
$selectorCanvasElem.unbind('mousedown mousemove mouseup mouseleave');
|
||||
|
||||
// Reset cursor
|
||||
$selectorCanvasElem.css('cursor', 'default');
|
||||
|
||||
// Clear drawn box
|
||||
drawnBox = null;
|
||||
$('#bounding_box').val('');
|
||||
|
||||
// Clear the canvases
|
||||
if (ctx && xctx) {
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
xctx.clearRect(0, 0, c.width, c.height);
|
||||
}
|
||||
|
||||
// Restore element selections from include_filters
|
||||
currentSelections = [];
|
||||
if (selectorData && selectorData['size_pos']) {
|
||||
let existingFilters = splitToList($includeFiltersElem.val());
|
||||
|
||||
selectorData['size_pos'].forEach(sel => {
|
||||
if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) {
|
||||
console.log("Restoring selection: " + sel.xpath);
|
||||
currentSelections.push(sel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Re-enable element selection handlers using stored references
|
||||
if (elementHandlers.handleMouseMove) {
|
||||
$selectorCanvasElem.bind('mousemove', elementHandlers.handleMouseMove);
|
||||
$selectorCanvasElem.bind('mousedown', elementHandlers.handleMouseDown);
|
||||
$selectorCanvasElem.bind('mouseleave', elementHandlers.handleMouseLeave);
|
||||
}
|
||||
|
||||
// Restore the element selection display
|
||||
$selectorCurrentXpathElem.html('Hover over elements to select');
|
||||
|
||||
// Highlight the restored selections
|
||||
highlightCurrentSelected();
|
||||
}
|
||||
|
||||
function handleDrawMouseDown(e) {
|
||||
const rect = c.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Check if clicking on a resize handle
|
||||
if (drawnBox) {
|
||||
resizeHandle = getResizeHandle(x, y);
|
||||
if (resizeHandle) {
|
||||
isDrawing = true;
|
||||
drawStartX = x;
|
||||
drawStartY = y;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if clicking inside the box (for dragging)
|
||||
if (isInsideBox(x, y)) {
|
||||
isDragging = true;
|
||||
dragOffsetX = x - drawnBox.x;
|
||||
dragOffsetY = y - drawnBox.y;
|
||||
$selectorCanvasElem.css('cursor', 'move');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Start new box
|
||||
isDrawing = true;
|
||||
drawStartX = x;
|
||||
drawStartY = y;
|
||||
drawnBox = { x: x, y: y, width: 0, height: 0 };
|
||||
}
|
||||
|
||||
function handleDrawMouseMove(e) {
|
||||
const rect = c.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// Update cursor based on position
|
||||
if (!isDrawing && !isDragging && drawnBox) {
|
||||
const handle = getResizeHandle(x, y);
|
||||
if (handle) {
|
||||
$selectorCanvasElem.css('cursor', getHandleCursor(handle));
|
||||
} else if (isInsideBox(x, y)) {
|
||||
$selectorCanvasElem.css('cursor', 'move');
|
||||
} else {
|
||||
$selectorCanvasElem.css('cursor', 'crosshair');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dragging the box
|
||||
if (isDragging) {
|
||||
drawnBox.x = x - dragOffsetX;
|
||||
drawnBox.y = y - dragOffsetY;
|
||||
drawBox();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDrawing) return;
|
||||
|
||||
if (resizeHandle) {
|
||||
// Resize existing box
|
||||
resizeBox(x, y);
|
||||
} else {
|
||||
// Draw new box
|
||||
drawnBox.width = x - drawStartX;
|
||||
drawnBox.height = y - drawStartY;
|
||||
}
|
||||
|
||||
drawBox();
|
||||
}
|
||||
|
||||
function handleDrawMouseUp(e) {
|
||||
if (!isDrawing && !isDragging) return;
|
||||
|
||||
isDrawing = false;
|
||||
isDragging = false;
|
||||
resizeHandle = null;
|
||||
|
||||
if (drawnBox) {
|
||||
// Normalize box (handle negative dimensions)
|
||||
if (drawnBox.width < 0) {
|
||||
drawnBox.x += drawnBox.width;
|
||||
drawnBox.width = Math.abs(drawnBox.width);
|
||||
}
|
||||
if (drawnBox.height < 0) {
|
||||
drawnBox.y += drawnBox.height;
|
||||
drawnBox.height = Math.abs(drawnBox.height);
|
||||
}
|
||||
|
||||
// Constrain to canvas bounds
|
||||
drawnBox.x = Math.max(0, Math.min(drawnBox.x, c.width - drawnBox.width));
|
||||
drawnBox.y = Math.max(0, Math.min(drawnBox.y, c.height - drawnBox.height));
|
||||
|
||||
// Save to form field (convert from scaled to natural coordinates)
|
||||
const naturalX = Math.round(drawnBox.x / xScale);
|
||||
const naturalY = Math.round(drawnBox.y / yScale);
|
||||
const naturalWidth = Math.round(drawnBox.width / xScale);
|
||||
const naturalHeight = Math.round(drawnBox.height / yScale);
|
||||
|
||||
$('#bounding_box').val(`${naturalX},${naturalY},${naturalWidth},${naturalHeight}`);
|
||||
|
||||
drawBox();
|
||||
}
|
||||
}
|
||||
|
||||
function drawBox() {
|
||||
if (!drawnBox) return;
|
||||
|
||||
// Clear and redraw
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
xctx.clearRect(0, 0, c.width, c.height);
|
||||
|
||||
// Draw box
|
||||
ctx.strokeStyle = STROKE_STYLE_REDLINE;
|
||||
ctx.fillStyle = FILL_STYLE_REDLINE;
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;
|
||||
const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;
|
||||
const drawW = Math.abs(drawnBox.width);
|
||||
const drawH = Math.abs(drawnBox.height);
|
||||
|
||||
ctx.strokeRect(drawX, drawY, drawW, drawH);
|
||||
ctx.fillRect(drawX, drawY, drawW, drawH);
|
||||
|
||||
// Draw resize handles
|
||||
if (!isDrawing) {
|
||||
drawResizeHandles(drawX, drawY, drawW, drawH);
|
||||
}
|
||||
}
|
||||
|
||||
function drawResizeHandles(x, y, w, h) {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const handles = [
|
||||
{ x: x, y: y }, // top-left
|
||||
{ x: x + w, y: y }, // top-right
|
||||
{ x: x, y: y + h }, // bottom-left
|
||||
{ x: x + w, y: y + h } // bottom-right
|
||||
];
|
||||
|
||||
handles.forEach(handle => {
|
||||
ctx.fillRect(handle.x - HANDLE_SIZE/2, handle.y - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE);
|
||||
ctx.strokeRect(handle.x - HANDLE_SIZE/2, handle.y - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
function isInsideBox(x, y) {
|
||||
if (!drawnBox) return false;
|
||||
|
||||
const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;
|
||||
const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;
|
||||
const drawW = Math.abs(drawnBox.width);
|
||||
const drawH = Math.abs(drawnBox.height);
|
||||
|
||||
return x >= drawX && x <= drawX + drawW && y >= drawY && y <= drawY + drawH;
|
||||
}
|
||||
|
||||
function getResizeHandle(x, y) {
|
||||
if (!drawnBox) return null;
|
||||
|
||||
const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width;
|
||||
const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height;
|
||||
const drawW = Math.abs(drawnBox.width);
|
||||
const drawH = Math.abs(drawnBox.height);
|
||||
|
||||
const handles = {
|
||||
'tl': { x: drawX, y: drawY },
|
||||
'tr': { x: drawX + drawW, y: drawY },
|
||||
'bl': { x: drawX, y: drawY + drawH },
|
||||
'br': { x: drawX + drawW, y: drawY + drawH }
|
||||
};
|
||||
|
||||
for (const [key, handle] of Object.entries(handles)) {
|
||||
if (Math.abs(x - handle.x) <= HANDLE_SIZE && Math.abs(y - handle.y) <= HANDLE_SIZE) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getHandleCursor(handle) {
|
||||
const cursors = {
|
||||
'tl': 'nw-resize',
|
||||
'tr': 'ne-resize',
|
||||
'bl': 'sw-resize',
|
||||
'br': 'se-resize'
|
||||
};
|
||||
return cursors[handle] || 'crosshair';
|
||||
}
|
||||
|
||||
function resizeBox(x, y) {
|
||||
const dx = x - drawStartX;
|
||||
const dy = y - drawStartY;
|
||||
|
||||
const originalBox = { ...drawnBox };
|
||||
|
||||
switch (resizeHandle) {
|
||||
case 'tl':
|
||||
drawnBox.x = x;
|
||||
drawnBox.y = y;
|
||||
drawnBox.width = originalBox.x + originalBox.width - x;
|
||||
drawnBox.height = originalBox.y + originalBox.height - y;
|
||||
break;
|
||||
case 'tr':
|
||||
drawnBox.y = y;
|
||||
drawnBox.width = x - originalBox.x;
|
||||
drawnBox.height = originalBox.y + originalBox.height - y;
|
||||
break;
|
||||
case 'bl':
|
||||
drawnBox.x = x;
|
||||
drawnBox.width = originalBox.x + originalBox.width - x;
|
||||
drawnBox.height = y - originalBox.y;
|
||||
break;
|
||||
case 'br':
|
||||
drawnBox.width = x - originalBox.x;
|
||||
drawnBox.height = y - originalBox.y;
|
||||
break;
|
||||
}
|
||||
|
||||
drawStartX = x;
|
||||
drawStartY = y;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
body.image_ssim_diff {
|
||||
#edit-text-filter {
|
||||
.text-filtering {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
#conditions-tab {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
@use "parts/socket";
|
||||
@use "parts/visualselector";
|
||||
@use "parts/widgets";
|
||||
@use "parts/diff_image";
|
||||
|
||||
|
||||
body {
|
||||
color: var(--color-text);
|
||||
@@ -1023,6 +1025,46 @@ ul {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* processor type badges */
|
||||
.processor-badge {
|
||||
@extend .inline-tag;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.processor-badge-text_json_diff {
|
||||
background-color: rgba(100, 149, 237, 0.4);
|
||||
color: #3a5a8a;
|
||||
}
|
||||
|
||||
.processor-badge-restock_diff {
|
||||
background-color: rgba(45, 134, 89, 0.4);
|
||||
color: #1a5c3a;
|
||||
}
|
||||
|
||||
.processor-badge-image_ssim_diff {
|
||||
background-color: rgba(139, 92, 246, 0.4);
|
||||
color: #6b4bbd;
|
||||
}
|
||||
|
||||
// Dark mode: solid colors for better contrast
|
||||
html[data-darkmode="true"] {
|
||||
.processor-badge-text_json_diff {
|
||||
background-color: #4682b4;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.processor-badge-restock_diff {
|
||||
background-color: #2d8659;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.processor-badge-image_ssim_diff {
|
||||
background-color: #8b5cf6;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* automatic price following helpers */
|
||||
.tracking-ldjson-price-data {
|
||||
background-color: var(--color-background-button-green);
|
||||
|
||||
@@ -1 +1 @@
|
||||
:root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100);--highlight-trigger-text-bg-color: #1b98f8;--highlight-ignored-text-bg-color: var(--color-grey-700);--highlight-blocked-text-bg-color: rgb(202, 60, 60)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadoLine truncated
|
||||
:root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100);--highlight-trigger-text-bg-color: #1b98f8;--highlight-ignored-text-bg-color: var(--color-grey-700);--highlight-blocked-text-bg-color: rgb(202, 60, 60)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadoLine truncated
|
||||
Reference in new issue
Block a user