mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-07-11 09:47:30 +00:00
a2d77752c0
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
752 lines
27 KiB
JavaScript
752 lines
27 KiB
JavaScript
// 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!
|
|
//
|
|
// Refactored into a reusable factory: window.initVisualSelector(opts)
|
|
// - edit.html drives it from stored screenshot + xpath-data URLs (auto-init at bottom of file)
|
|
// - the Add Watch UI drives it from inline snapshot data fetched on demand
|
|
// Both share the same hover/highlight/select core.
|
|
|
|
window.initVisualSelector = function (opts) {
|
|
// ---- DOM references (passed in, no globals) ----
|
|
const $selectorCanvasElem = opts.$canvas;
|
|
const $includeFiltersElem = opts.$includeFilters;
|
|
const $selectorBackgroundElem = opts.$background;
|
|
const $selectorCurrentXpathElem = opts.$xpathDisplay; // the inner <span>
|
|
const $fetchingUpdateNoticeElem = opts.$fetchingNotice || $();
|
|
const $selectorWrapperElem = opts.$wrapper;
|
|
const $clearSelectorElem = opts.$clearButton || $();
|
|
|
|
// edit.html supplies an AJAX url for the element data; the add-watch UI supplies data inline
|
|
const xpathDataUrl = opts.xpathDataUrl;
|
|
const isImageProcessor = !!opts.processorIsImage;
|
|
// Stored watch screenshots are DPR-1, so X scales by the screenshot's natural width.
|
|
// The live browser-steps capture (used by Add Watch) may render at device-scale-factor != 1,
|
|
// so its X must scale by the page's CSS width (browser_width) instead - exactly like
|
|
// browser-steps.js does. Y always scales by the screenshot's natural height.
|
|
const scaleByBrowserWidth = !!opts.scaleByBrowserWidth;
|
|
|
|
// ---- state ----
|
|
let runInClearMode = false;
|
|
let selectionEnabled = opts.enableSelection !== false;
|
|
let currentSelections = [];
|
|
let currentSelection = null;
|
|
let appendToList = false;
|
|
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;
|
|
|
|
// 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() {
|
|
if (ctx) {
|
|
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';
|
|
}
|
|
|
|
if (event.type === 'keydown') {
|
|
if ($selectorBackgroundElem.is(":visible") && event.key === "Escape") {
|
|
clearReset();
|
|
}
|
|
}
|
|
});
|
|
|
|
$clearSelectorElem.on('click', () => {
|
|
clearReset();
|
|
});
|
|
|
|
// ---- public: (re)load a screenshot + element data into the selector ----
|
|
// source = { screenshotSrc: <url|dataURI>, xpathData: <object|undefined> }
|
|
function load(source) {
|
|
currentSelections = [];
|
|
runInClearMode = false;
|
|
|
|
$selectorBackgroundElem
|
|
.off("load error")
|
|
.on("error", () => {
|
|
$fetchingUpdateNoticeElem.html("<strong>Ooops!</strong> 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 = $selectorCanvasElem[0];
|
|
xctx = c.getContext("2d");
|
|
ctx = c.getContext("2d");
|
|
if (source.xpathData) {
|
|
// Inline data (add-watch snapshot) - no extra round trip needed
|
|
applyElementData(source.xpathData);
|
|
} else {
|
|
fetchData();
|
|
}
|
|
$selectorCanvasElem.off("mousemove mousedown");
|
|
});
|
|
|
|
// data: URIs must be used verbatim; real URLs get a cache-buster
|
|
let src = source.screenshotSrc;
|
|
if (src && src.indexOf('data:') !== 0) {
|
|
src = `${src}?${new Date().getTime()}`;
|
|
}
|
|
$selectorBackgroundElem.attr("src", src);
|
|
}
|
|
|
|
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: xpathDataUrl,
|
|
context: document.body
|
|
}).done((data) => {
|
|
applyElementData(data);
|
|
});
|
|
}
|
|
|
|
function applyElementData(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();
|
|
|
|
// Initialize draw mode after everything is set up
|
|
initializeDrawMode();
|
|
|
|
$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")
|
|
|
|
if (scaleByBrowserWidth && selectorData && selectorData['browser_width']) {
|
|
// xpath box coords are CSS pixels (getBoundingClientRect) and the scraper reports
|
|
// browser_width = window.innerWidth (also CSS px). The screenshot is displayed with a
|
|
// preserved aspect ratio, so a single uniform factor (displayed width / page CSS width)
|
|
// maps both axes in sync, regardless of the capture's device-pixel-ratio.
|
|
xScale = yScale = selectorImageRect.width / selectorData['browser_width'];
|
|
} else {
|
|
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 bindElementHandlers() {
|
|
// 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) {
|
|
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() {
|
|
// If we are in 'appendToList' mode, grow the list, if not, just 1
|
|
currentSelections = appendToList ? [...currentSelections, currentSelection] : [currentSelection];
|
|
highlightCurrentSelected();
|
|
updateFiltersText();
|
|
}
|
|
|
|
function reflowSelector() {
|
|
$(window).off('resize.visualselector').on('resize.visualselector', () => {
|
|
setScale();
|
|
highlightCurrentSelected();
|
|
});
|
|
|
|
setScale();
|
|
|
|
console.log(selectorData['size_pos'].length + " selectors found");
|
|
|
|
let existingFilters = splitToList($includeFiltersElem.val());
|
|
|
|
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();
|
|
|
|
if (selectionEnabled) {
|
|
bindElementHandlers();
|
|
}
|
|
}
|
|
|
|
function highlightCurrentSelected() {
|
|
if (!xctx) return;
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Toggle hover/click element selection on the fly (Add Watch "by element" mode)
|
|
function setSelectionEnabled(enabled) {
|
|
selectionEnabled = enabled;
|
|
if (!c) return; // not loaded yet, will respect the flag in reflowSelector
|
|
|
|
if (enabled) {
|
|
if (!drawMode) {
|
|
bindElementHandlers();
|
|
if ($selectorCurrentXpathElem.length) {
|
|
$selectorCurrentXpathElem[0].innerHTML = 'Hover over elements to select';
|
|
}
|
|
}
|
|
} else {
|
|
$selectorCanvasElem.unbind('mousemove mousedown mouseleave');
|
|
currentSelections = [];
|
|
$includeFiltersElem.val('');
|
|
highlightCurrentSelected();
|
|
if ($selectorCurrentXpathElem.length) {
|
|
$selectorCurrentXpathElem[0].innerHTML = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============= 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;
|
|
}
|
|
|
|
// ---- public API ----
|
|
return {
|
|
load: load,
|
|
clear: clearReset,
|
|
setSelectionEnabled: setSelectionEnabled,
|
|
markClearMode: function () { runInClearMode = true; }
|
|
};
|
|
};
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-init for the watch Edit page (edit.html), preserving previous behaviour.
|
|
// Detected by the presence of the `screenshot_url` global that edit.html sets.
|
|
// ---------------------------------------------------------------------------
|
|
$(document).ready(() => {
|
|
if (typeof screenshot_url === 'undefined') {
|
|
return; // Not the edit page - the Add Watch UI inits the selector itself
|
|
}
|
|
|
|
const isImageProcessor = $('input[value="image_ssim_diff"]').is(':checked');
|
|
|
|
const vs = window.initVisualSelector({
|
|
$canvas: $('#selector-canvas'),
|
|
$includeFilters: $("#include_filters"),
|
|
$background: $("img#selector-background"),
|
|
$xpathDisplay: $("#selector-current-xpath span"),
|
|
$fetchingNotice: $('.fetching-update-notice'),
|
|
$wrapper: $("#selector-wrapper"),
|
|
$clearButton: $('#clear-selector'),
|
|
xpathDataUrl: watch_visual_selector_data_url,
|
|
enableSelection: true,
|
|
processorIsImage: isImageProcessor,
|
|
});
|
|
|
|
$('#visualselector-tab').click(() => {
|
|
vs.load({screenshotSrc: screenshot_url});
|
|
});
|
|
|
|
// So if they start switching between visualSelector and manual filters, stop it from rendering old filters
|
|
$('li.tab a').on('click', () => {
|
|
vs.markClearMode();
|
|
});
|
|
|
|
if (window.location.hash === '#visualselector') {
|
|
vs.load({screenshotSrc: screenshot_url});
|
|
} else {
|
|
$("img#selector-background").attr('src', '');
|
|
}
|
|
});
|