diff --git a/changedetectionio/blueprint/ui/templates/diff.html b/changedetectionio/blueprint/ui/templates/diff.html
index c1bb8f38d..890a32d2b 100644
--- a/changedetectionio/blueprint/ui/templates/diff.html
+++ b/changedetectionio/blueprint/ui/templates/diff.html
@@ -9,6 +9,9 @@
const highlight_submit_ignore_url="{{url_for('ui.ui_edit.highlight_submit_ignore_url', uuid=uuid)}}";
const watch_url= {{watch_a.link|tojson}};
+
+ // Initial scroll position: if set, scroll to this line number in #difference on page load
+ const initialScrollToLineNumber = {{ initial_scroll_line_number|default('null') }};
@@ -100,7 +103,11 @@
Pro-tip: You can enable "share access when password is enabled" from settings.
{% endif %}
-
+
+ {%- for cell in diff_cell_grid -%}
+
+ {%- endfor -%}
+
{{ watch_a.snapshot_text_ctime|format_timestamp_timeago }}
{{ content| diff_unescape_difference_spans }}
diff --git a/changedetectionio/blueprint/ui/views.py b/changedetectionio/blueprint/ui/views.py
index 819427443..b7818a970 100644
--- a/changedetectionio/blueprint/ui/views.py
+++ b/changedetectionio/blueprint/ui/views.py
@@ -5,7 +5,13 @@ import re
from loguru import logger
from markupsafe import Markup
-from changedetectionio.diff import REMOVED_STYLE, ADDED_STYLE, REMOVED_INNER_STYLE, ADDED_INNER_STYLE
+from changedetectionio.diff import (
+ REMOVED_STYLE, ADDED_STYLE, REMOVED_INNER_STYLE, ADDED_INNER_STYLE,
+ REMOVED_PLACEMARKER_OPEN, REMOVED_PLACEMARKER_CLOSED,
+ ADDED_PLACEMARKER_OPEN, ADDED_PLACEMARKER_CLOSED,
+ CHANGED_PLACEMARKER_OPEN, CHANGED_PLACEMARKER_CLOSED,
+ CHANGED_INTO_PLACEMARKER_OPEN, CHANGED_INTO_PLACEMARKER_CLOSED
+)
from changedetectionio.notification.handler import apply_html_color_to_body
from changedetectionio.notification_service import CUSTOM_LINEBREAK_PLACEHOLDER
from changedetectionio.store import ChangeDetectionStore
@@ -14,6 +20,77 @@ from changedetectionio import html_tools, diff
from changedetectionio import worker_handler
from changedetectionio.blueprint.cookie_preferences import PreferenceManager
+
+def build_diff_cell_visualizer(content, resolution=100):
+ """
+ Build a visual cell grid for the diff visualizer.
+
+ Analyzes the content for placemarkers indicating changes and creates a
+ grid of cells representing the document, with each cell marked as:
+ - 'deletion' for removed content
+ - 'insertion' for added content
+ - 'mixed' for cells containing both deletions and insertions
+ - empty string for cells with no changes
+
+ Args:
+ content: The diff content with placemarkers
+ resolution: Number of cells to create (default 100)
+
+ Returns:
+ List of dicts with 'class' key for each cell's CSS class
+ """
+ if not content:
+ return [{'class': ''} for _ in range(resolution)]
+ now = time.time()
+ # Work with character positions for better accuracy
+ content_length = len(content)
+
+ if content_length == 0:
+ return [{'class': ''} for _ in range(resolution)]
+
+ chars_per_cell = max(1, content_length / resolution)
+
+ # Track change type for each cell
+ cell_data = {}
+
+ # Placemarkers to detect
+ change_markers = {
+ REMOVED_PLACEMARKER_OPEN: 'deletion',
+ ADDED_PLACEMARKER_OPEN: 'insertion',
+ CHANGED_PLACEMARKER_OPEN: 'deletion',
+ CHANGED_INTO_PLACEMARKER_OPEN: 'insertion',
+ }
+
+ # Find all occurrences of each marker
+ for marker, change_type in change_markers.items():
+ pos = 0
+ while True:
+ pos = content.find(marker, pos)
+ if pos == -1:
+ break
+
+ # Calculate which cell this marker falls into
+ cell_index = min(int(pos / chars_per_cell), resolution - 1)
+
+ if cell_index not in cell_data:
+ cell_data[cell_index] = change_type
+ elif cell_data[cell_index] != change_type:
+ # Mixed changes in this cell
+ cell_data[cell_index] = 'mixed'
+
+ pos += len(marker)
+
+ # Build the cell list
+ cells = []
+ for i in range(resolution):
+ change_type = cell_data.get(i, '')
+ cells.append({'class': change_type})
+
+ logger.debug(f"Built diff cell visualizer: {len([c for c in cells if c['class']])} cells with changes out of {resolution} in {time.time() - now:.2f}s")
+
+ return cells
+
+
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData, watch_check_update):
views_blueprint = Blueprint('ui_views', __name__, template_folder="../ui/templates")
@@ -266,6 +343,10 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
include_equal=not diff_prefs.get('diff_changesOnly'),
word_diff=diff_prefs.get('diff_type') == 'diffWords',
)
+
+ # Build cell grid visualizer before applying HTML color (so we can detect placemarkers)
+ diff_cell_grid = build_diff_cell_visualizer(content)
+
content = apply_html_color_to_body(n_body=content)
content = content.replace(CUSTOM_LINEBREAK_PLACEHOLDER, "\n")
offscreen_content = render_template("diff-offscreen-options.html")
@@ -274,11 +355,13 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
bottom_horizontal_offscreen_contents=offscreen_content,
content=content,
current_diff_url=watch['url'],
+ diff_cell_grid=diff_cell_grid,
diff_prefs=diff_prefs,
extra_stylesheets=extra_stylesheets,
extra_title=f" - {watch.label} - History",
extract_form=extract_form,
from_version=str(from_version),
+ #initial_scroll_line_number=100,
is_html_webdriver=is_html_webdriver,
last_error=watch['last_error'],
last_error_screenshot=watch.get_error_snapshot(),
diff --git a/changedetectionio/static/js/diff-render.js b/changedetectionio/static/js/diff-render.js
index 35b6a7959..27c632348 100644
--- a/changedetectionio/static/js/diff-render.js
+++ b/changedetectionio/static/js/diff-render.js
@@ -4,91 +4,67 @@ $(document).ready(function () {
var inputs = $('#difference span').toArray();
inputs.current = 0;
- // Build visual minimap of difference locations
+ // Setup visual minimap of difference locations (cells are pre-built in Python)
var $visualizer = $('#cell-diff-jump-visualiser');
var $difference = $('#difference');
- var visualizerResolutionCells = 100; // Fixed resolution to prevent high CPU usage with many changes
+ var $cells = $visualizer.find('> div');
+ var visualizerResolutionCells = $cells.length;
var cellHeight;
- var cellData = {}; // Map cell index to change type
- if ($difference.length && inputs.length) {
+ if ($difference.length && visualizerResolutionCells > 0) {
var docHeight = $difference[0].scrollHeight;
cellHeight = docHeight / visualizerResolutionCells;
- // Map each span to a cell with its type based on color
- var differenceTop = $difference.offset().top;
- $(inputs).each(function() {
- var spanTop = $(this).offset().top - differenceTop;
- var cellIndex = Math.min(Math.floor(spanTop / cellHeight), visualizerResolutionCells - 1);
- var bgColor = $(this).css('background-color');
- var changeType;
-
- // Determine type by background color
- if (bgColor === 'rgb(250, 218, 215)' || bgColor === '#fadad7') {
- changeType = 'deletion'; // Red background
- } else if (bgColor === 'rgb(234, 242, 194)' || bgColor === '#eaf2c2') {
- changeType = 'insertion'; // Green background
- } else {
- changeType = $(this).attr('role'); // Fallback to role attribute
- }
-
- // Track the type of change in each cell (prioritize mixed changes)
- if (!cellData[cellIndex]) {
- cellData[cellIndex] = changeType;
- } else if (cellData[cellIndex] !== changeType) {
- cellData[cellIndex] = 'mixed'; // Both deletion and insertion in same cell
- }
- });
-
- // Create cell strip
- for (var i = 0; i < visualizerResolutionCells; i++) {
- var $cell = $('');
- var changeType = cellData[i];
-
- if (changeType === 'deletion') {
- $cell.addClass('deletion');
- } else if (changeType === 'insertion') {
- $cell.addClass('insertion');
- } else if (changeType === 'note') {
- $cell.addClass('note');
- } else if (changeType === 'mixed') {
- $cell.addClass('mixed');
- }
-
- // Add click handler to scroll to that position in the document
- $cell.data('cellIndex', i);
- $cell.on('click', function() {
+ // Add click handlers to pre-built cells
+ $cells.each(function(i) {
+ $(this).data('cellIndex', i);
+ $(this).on('click', function() {
var cellIndex = $(this).data('cellIndex');
var targetPositionInDifference = cellIndex * cellHeight;
- var viewportOffset = 150; // Keep change visible with 150px offset
+ var viewportHeight = $(window).height();
+ // Scroll so target is at viewport center (where eyes expect it)
window.scrollTo({
- top: $difference.offset().top + targetPositionInDifference - viewportOffset,
+ top: $difference.offset().top + targetPositionInDifference - (viewportHeight / 2),
behavior: "smooth"
});
});
-
- $visualizer.append($cell);
- }
+ });
}
$('#jump-next-diff').click(function () {
if (!inputs || inputs.length === 0) return;
- var element = inputs[inputs.current];
- var headerOffset = 80;
- var elementPosition = element.getBoundingClientRect().top;
- var offsetPosition = elementPosition - headerOffset + window.scrollY;
+ // Find the next change after current scroll position
+ var currentScrollPos = $(window).scrollTop();
+ var viewportHeight = $(window).height();
+ var currentCenter = currentScrollPos + (viewportHeight / 2);
+
+ // Add small buffer (50px) to jump past changes already near center
+ var searchFromPosition = currentCenter + 50;
+
+ var nextElement = null;
+ for (var i = 0; i < inputs.length; i++) {
+ var elementTop = $(inputs[i]).offset().top;
+ if (elementTop > searchFromPosition) {
+ nextElement = inputs[i];
+ break;
+ }
+ }
+
+ // If no element found ahead, wrap to first element
+ if (!nextElement) {
+ nextElement = inputs[0];
+ }
+
+ // Scroll to position the element at viewport center
+ var elementTop = $(nextElement).offset().top;
+ var targetScrollPos = elementTop - (viewportHeight / 2);
window.scrollTo({
- top: offsetPosition,
+ top: targetScrollPos,
behavior: "smooth",
});
-
- inputs.current++;
- if (inputs.current >= inputs.length) {
- inputs.current = 0;
- }
});
// Track current scroll position in visualizer
@@ -96,20 +72,72 @@ $(document).ready(function () {
if (!$difference.length || visualizerResolutionCells === 0) return;
var scrollTop = $(window).scrollTop();
+ var viewportHeight = $(window).height();
+ var viewportCenter = scrollTop + (viewportHeight / 2);
var differenceTop = $difference.offset().top;
- var positionInDifference = scrollTop - differenceTop;
+ var differenceHeight = $difference[0].scrollHeight;
+ var positionInDifference = viewportCenter - differenceTop;
+
+ // Handle edge case: if we're at max scroll, show last cell
+ // This prevents shorter documents from never reaching 100%
+ var maxScrollTop = $(document).height() - viewportHeight;
+ var isAtBottom = scrollTop >= maxScrollTop - 10; // 10px tolerance
// Calculate which cell we're currently viewing
- var currentCell = Math.floor(positionInDifference / cellHeight);
- currentCell = Math.max(0, Math.min(currentCell, visualizerResolutionCells - 1));
+ var currentCell;
+ if (isAtBottom) {
+ currentCell = visualizerResolutionCells - 1;
+ } else {
+ currentCell = Math.floor(positionInDifference / cellHeight);
+ currentCell = Math.max(0, Math.min(currentCell, visualizerResolutionCells - 1));
+ }
// Remove previous active marker and add to current cell
$visualizer.find('> div').removeClass('current-position');
$visualizer.find('> div').eq(currentCell).addClass('current-position');
}
- // Debounce scroll event to reduce CPU usage
+ // Recalculate cellHeight on window resize
+ function handleResize() {
+ if ($difference.length) {
+ var docHeight = $difference[0].scrollHeight;
+ cellHeight = docHeight / visualizerResolutionCells;
+ updateVisualizerPosition();
+ }
+ }
+
+ // Debounce scroll and resize events to reduce CPU usage
$(window).on('scroll', updateVisualizerPosition.debounce(5));
+ $(window).on('resize', handleResize.debounce(100));
+
+ // Initial scroll to specific line if requested
+ if (typeof initialScrollToLineNumber !== 'undefined' && initialScrollToLineNumber !== null && $difference.length) {
+ // Convert line number to text position and scroll to it
+ var diffText = $difference.text();
+ var lines = diffText.split('\n');
+
+ if (initialScrollToLineNumber > 0 && initialScrollToLineNumber <= lines.length) {
+ // Calculate character position of the target line
+ var charPosition = 0;
+ for (var i = 0; i < initialScrollToLineNumber - 1; i++) {
+ charPosition += lines[i].length + 1; // +1 for newline
+ }
+
+ // Estimate vertical position based on average line height
+ var totalChars = diffText.length;
+ var totalHeight = $difference[0].scrollHeight;
+ var estimatedTop = (charPosition / totalChars) * totalHeight;
+
+ // Scroll to position with line at viewport center
+ var viewportHeight = $(window).height();
+ setTimeout(function() {
+ window.scrollTo({
+ top: $difference.offset().top + estimatedTop - (viewportHeight / 2),
+ behavior: "smooth"
+ });
+ }, 100); // Small delay to ensure page is fully loaded
+ }
+ }
// Initial position update
if ($difference.length && cellHeight) {
diff --git a/changedetectionio/static/styles/diff.css b/changedetectionio/static/styles/diff.css
index caecb875c..45c83af82 100644
--- a/changedetectionio/static/styles/diff.css
+++ b/changedetectionio/static/styles/diff.css
@@ -1 +1 @@
-#diff-ui{background:var(--color-background);padding:2em;margin-left:1em;margin-right:1em;border-radius:5px}#diff-ui #text{font-size:11px}#diff-ui table{table-layout:fixed;width:100%}#diff-ui td{padding:3px 4px;border:1px solid rgba(0,0,0,0);vertical-align:top;font:1em monospace;text-align:left;overflow:clip}#diff-ui pre{white-space:break-spaces}h1{display:inline;font-size:100%}del{text-decoration:none;color:#b30000;background:#fadad7}ins{background:#eaf2c2;color:#406619;text-decoration:none}#result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#settings{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem}#settings label{margin-left:1em;display:inline-block;font-weight:normal}#settings del{padding:.5em}#settings ins{padding:.5em}#settings option:checked{font-weight:bold}#settings [type=radio],#settings [type=checkbox]{vertical-align:middle}.source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){body{height:99%}}td#diff-col div{text-align:justify;white-space:pre-wrap}.ignored{background-color:#ccc;opacity:.7}.triggered{background-color:#1b98f8}.ignored.triggered{background-color:red}.tab-pane-inner#screenshot{text-align:center}.tab-pane-inner#screenshot img{max-width:99%}.pure-form button.reset-margin{margin:0px}.diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}ul#highlightSnippetActions li button,ul#highlightSnippetActions li a{white-space:nowrap}ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:auto;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem}#cell-diff-jump-visualiser>div{flex:1;min-width:1px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}
+#diff-ui{background:var(--color-background);padding:2em;margin-left:1em;margin-right:1em;border-radius:5px}#diff-ui #text{font-size:11px}#diff-ui table{table-layout:fixed;width:100%}#diff-ui td{padding:3px 4px;border:1px solid rgba(0,0,0,0);vertical-align:top;font:1em monospace;text-align:left;overflow:clip}#diff-ui pre{white-space:break-spaces}h1{display:inline;font-size:100%}del{text-decoration:none;color:#b30000;background:#fadad7}ins{background:#eaf2c2;color:#406619;text-decoration:none}#result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#settings{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem}#settings label{margin-left:1em;display:inline-block;font-weight:normal}#settings del{padding:.5em}#settings ins{padding:.5em}#settings option:checked{font-weight:bold}#settings [type=radio],#settings [type=checkbox]{vertical-align:middle}.source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){body{height:99%}}td#diff-col div{text-align:justify;white-space:pre-wrap}.ignored{background-color:#ccc;opacity:.7}.triggered{background-color:#1b98f8}.ignored.triggered{background-color:red}.tab-pane-inner#screenshot{text-align:center}.tab-pane-inner#screenshot img{max-width:99%}.pure-form button.reset-margin{margin:0px}.diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}ul#highlightSnippetActions li button,ul#highlightSnippetActions li a{white-space:nowrap}ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:auto;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#cell-diff-jump-visualiser>div{flex:1;min-width:1px;max-width:10px;height:10px;background:var(--color-background-button-cancel);opacity:.3;border-radius:1px;transition:opacity .2s;position:relative}#cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#cell-diff-jump-visualiser>div.current-position::after{content:"";position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:4px solid rgba(0,0,0,0);border-right:4px solid rgba(0,0,0,0);border-bottom:4px solid var(--color-text)}#cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}
diff --git a/changedetectionio/static/styles/scss/diff.scss b/changedetectionio/static/styles/scss/diff.scss
index 808da92de..d1e14cdb5 100644
--- a/changedetectionio/static/styles/scss/diff.scss
+++ b/changedetectionio/static/styles/scss/diff.scss
@@ -184,9 +184,11 @@ ul#highlightSnippetActions {
z-index: 10;
padding-top: 1rem;
padding-bottom: 1rem;
+ justify-content: center;
> div {
flex: 1;
min-width: 1px;
+ max-width: 10px;
height: 10px;
background: var(--color-background-button-cancel);
opacity: 0.3;