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;