diff --git a/changedetectionio/blueprint/ui/templates/diff.html b/changedetectionio/blueprint/ui/templates/diff.html index 5ed484c5c..d97eecb4c 100644 --- a/changedetectionio/blueprint/ui/templates/diff.html +++ b/changedetectionio/blueprint/ui/templates/diff.html @@ -20,6 +20,8 @@ +
+

{{ watch_a.label }}

@@ -110,6 +112,8 @@
+
{# /diff-header #} +
{{watch_a.error_text_ctime|format_seconds_ago}} {{ _('seconds ago.') }}
diff --git a/changedetectionio/static/js/diff-render.js b/changedetectionio/static/js/diff-render.js index 27c632348..9bc91b4dd 100644 --- a/changedetectionio/static/js/diff-render.js +++ b/changedetectionio/static/js/diff-render.js @@ -11,6 +11,59 @@ $(document).ready(function () { var visualizerResolutionCells = $cells.length; var cellHeight; + var header = document.getElementById('diff-header'); + // The app top menu, sticky above #diff-header on the diff page. + var appHeader = document.querySelector('.app-main > .header'); + + // Controls can wrap or disappear when switching tabs, and the top menu + // rewraps on narrow viewports. Keep each sticky layer, the minimap and the + // anchor links below the actual measured heights instead of fixed offsets. + // Order matters: #diff-header's max-height is calc(50dvh - the app header), + // so store that first and measure the diff header against the new cap. The + // other way round reads it under the previous cap and one pass is not enough + // to settle. + function updateHeaderHeight() { + if (appHeader) { + document.body.style.setProperty('--app-header-height', appHeader.offsetHeight + 'px'); + } + if (header) { + document.body.style.setProperty('--diff-header-height', header.offsetHeight + 'px'); + } + } + if (header || appHeader) { + // Measure once regardless of observer support, so the offsets are never + // left at their 0 fallback. + updateHeaderHeight(); + if (typeof ResizeObserver !== 'undefined') { + var headerObserver = new ResizeObserver(updateHeaderHeight); + if (header) headerObserver.observe(header); + if (appHeader) headerObserver.observe(appHeader); + } else { + // Without it, catch the two things that actually resize the bars: + // the window rewrapping them, and the tab switch that shows or hides + // #settings. diff-overview.js toggles that from its own hashchange + // handler, so defer past it rather than depend on listener order. + $(window).on('resize.diffheader', updateHeaderHeight.debounce(100)); + $(window).on('hashchange.diffheader', function () { + setTimeout(updateHeaderHeight, 0); + }); + // Opening a link straight at #screenshot / #extract hides #settings + // from diff-overview.js's ready handler, with no hashchange to + // follow. Re-measure once the whole ready pass has run. + setTimeout(updateHeaderHeight, 0); + } + } + + // Centre of the region left visible below the sticky stack, not of the whole + // viewport - every sticky layer above the diff has to be counted or jumps + // land half its height too high. + function viewportCenterOffset() { + var appHeaderHeight = appHeader ? appHeader.offsetHeight : 0; + var headerHeight = header ? header.offsetHeight : 0; + var visualizerHeight = $visualizer.is(':visible') ? $visualizer.outerHeight() : 0; + return (appHeaderHeight + headerHeight + visualizerHeight + $(window).height()) / 2; + } + if ($difference.length && visualizerResolutionCells > 0) { var docHeight = $difference[0].scrollHeight; cellHeight = docHeight / visualizerResolutionCells; @@ -21,11 +74,10 @@ $(document).ready(function () { $(this).on('click', function() { var cellIndex = $(this).data('cellIndex'); var targetPositionInDifference = cellIndex * cellHeight; - var viewportHeight = $(window).height(); // Scroll so target is at viewport center (where eyes expect it) window.scrollTo({ - top: $difference.offset().top + targetPositionInDifference - (viewportHeight / 2), + top: $difference.offset().top + targetPositionInDifference - viewportCenterOffset(), behavior: "smooth" }); }); @@ -37,8 +89,7 @@ $(document).ready(function () { // Find the next change after current scroll position var currentScrollPos = $(window).scrollTop(); - var viewportHeight = $(window).height(); - var currentCenter = currentScrollPos + (viewportHeight / 2); + var currentCenter = currentScrollPos + viewportCenterOffset(); // Add small buffer (50px) to jump past changes already near center var searchFromPosition = currentCenter + 50; @@ -59,7 +110,7 @@ $(document).ready(function () { // Scroll to position the element at viewport center var elementTop = $(nextElement).offset().top; - var targetScrollPos = elementTop - (viewportHeight / 2); + var targetScrollPos = elementTop - viewportCenterOffset(); window.scrollTo({ top: targetScrollPos, @@ -73,7 +124,7 @@ $(document).ready(function () { var scrollTop = $(window).scrollTop(); var viewportHeight = $(window).height(); - var viewportCenter = scrollTop + (viewportHeight / 2); + var viewportCenter = scrollTop + viewportCenterOffset(); var differenceTop = $difference.offset().top; var differenceHeight = $difference[0].scrollHeight; var positionInDifference = viewportCenter - differenceTop; @@ -129,10 +180,9 @@ $(document).ready(function () { 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), + top: $difference.offset().top + estimatedTop - viewportCenterOffset(), behavior: "smooth" }); }, 100); // Small delay to ensure page is fully loaded @@ -149,4 +199,3 @@ $(document).ready(function () { } }); - diff --git a/changedetectionio/static/styles/diff.css b/changedetectionio/static/styles/diff.css index 0e629bd34..626922d62 100644 --- a/changedetectionio/static/styles/diff.css +++ b/changedetectionio/static/styles/diff.css @@ -1 +1 @@ -#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page section.content{padding-top:40px}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces;overflow-wrap:anywhere}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:0;z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #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}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #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)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem} +#diff-form{background:rgba(0,0,0,.05);padding:1em;border-radius:10px;margin-bottom:1em;color:#fff;font-size:.9rem;text-align:center}#diff-form label.from-to-label{width:4rem;text-decoration:none;padding:.5rem}#diff-form label.from-to-label#change-from{color:#b30000;background:#fadad7}#diff-form label.from-to-label#change-to{background:#eaf2c2;color:#406619}#diff-form #diff-style>span{display:inline-block;padding:.3em}#diff-form #diff-style>span label{font-weight:normal}#diff-form *{vertical-align:middle}body.difference-page .header{position:sticky;top:0;z-index:30;background:var(--color-background-gradient-second);background:linear-gradient(130deg, color-mix(in srgb, var(--color-background-gradient-first) 91%, var(--color-background-page)), color-mix(in srgb, var(--color-background-gradient-second) 91%, var(--color-background-page)) 41.07%, color-mix(in srgb, var(--color-background-gradient-third) 91%, var(--color-background-page)) 84.05%);background-attachment:fixed}body.difference-page .app-main{gap:0}body.difference-page .tab-pane-inner{scroll-margin-top:calc(var(--app-header-height, 0px) + var(--diff-header-height, 0px) + 1rem)}body.difference-page #pure-menu-horizontal-spinner{z-index:29}#diff-header{position:sticky;top:var(--app-header-height, 0px);z-index:20;width:100%;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;max-height:calc(50vh - var(--app-header-height, 0px));max-height:calc(50dvh - var(--app-header-height, 0px));overflow:auto;background:var(--color-background-gradient-second);background:linear-gradient(130deg, color-mix(in srgb, var(--color-background-gradient-first) 91%, var(--color-background-page)), color-mix(in srgb, var(--color-background-gradient-second) 91%, var(--color-background-page)) 41.07%, color-mix(in srgb, var(--color-background-gradient-third) 91%, var(--color-background-page)) 84.05%);background-attachment:fixed}#diff-header>*{max-width:100%}#diff-header #diff-watch-title{margin:0;padding:.25rem 1rem;font-size:1.25rem;overflow-wrap:anywhere;text-align:center;color:#fff}#diff-header #diff-form{margin-bottom:.5rem}#diff-header .diff-fieldset{min-width:0;display:flex;justify-content:center;flex-wrap:wrap;gap:.5rem}#diff-header .diff-fieldset>span{max-width:100%;min-width:0;flex:0 1 auto;display:flex;align-items:center;flex-wrap:wrap}#diff-header .diff-fieldset select{min-width:0;max-width:100%;flex:1}#diff-header .diff-fieldset label{flex-shrink:0}#diff-ui{background:var(--color-background);padding:1rem;border-radius:5px}@media(min-width: 767px){#diff-ui{min-width:50%}}#diff-ui #text{font-size:11px}#diff-ui pre{white-space:break-spaces;overflow-wrap:anywhere}#diff-ui h1{display:inline;font-size:100%}#diff-ui #result{white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word}#diff-ui .source{position:absolute;right:1%;top:.2em}@-moz-document url-prefix(){#diff-ui body{height:99%}}#diff-ui td#diff-col div{text-align:justify;white-space:pre-wrap}#diff-ui .ignored{background-color:#ccc;opacity:.7}#diff-ui .triggered{background-color:#1b98f8}#diff-ui .ignored.triggered{background-color:red}#diff-ui .tab-pane-inner#screenshot{text-align:center}#diff-ui .tab-pane-inner#screenshot img{max-width:99%}#diff-ui .pure-form button.reset-margin{margin:0px}#diff-ui .diff-fieldset{display:flex;align-items:center;gap:4px;flex-wrap:wrap}#diff-ui ul#highlightSnippetActions{list-style-type:none;display:flex;align-items:center;justify-content:center;gap:1.5rem;flex-wrap:wrap;padding:0;margin:0}#diff-ui ul#highlightSnippetActions li{display:flex;flex-direction:column;align-items:center;text-align:center;padding:.5rem;gap:.3rem}#diff-ui ul#highlightSnippetActions li button,#diff-ui ul#highlightSnippetActions li a{white-space:nowrap}#diff-ui ul#highlightSnippetActions span{font-size:.8rem;color:var(--color-text-input-description)}#diff-ui #cell-diff-jump-visualiser{display:flex;flex-direction:row;gap:1px;background:var(--color-background);border-radius:3px;overflow-x:hidden;position:sticky;top:calc(var(--app-header-height, 0px) + var(--diff-header-height, 0px));z-index:10;padding-top:1rem;padding-bottom:1rem;justify-content:center}#diff-ui #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}#diff-ui #cell-diff-jump-visualiser>div.deletion{background:#b30000;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.insertion{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.note{background:#406619;opacity:1}#diff-ui #cell-diff-jump-visualiser>div.mixed{background:linear-gradient(to right, #b30000 50%, #406619 50%);opacity:1}#diff-ui #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)}#diff-ui #cell-diff-jump-visualiser>div:hover{opacity:.8;cursor:pointer}#text-diff-heading-area .snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}#text-diff-heading-area .snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#text-diff-heading-area .snapshot-age>*{padding-right:1rem} diff --git a/changedetectionio/static/styles/scss/diff.scss b/changedetectionio/static/styles/scss/diff.scss index 41f8fc69b..0c50b2c50 100644 --- a/changedetectionio/static/styles/scss/diff.scss +++ b/changedetectionio/static/styles/scss/diff.scss @@ -1,3 +1,16 @@ +// Paint the page's own background (page colour + the fixed 130deg gradient at +// 0.91, pre-composited with color-mix) so a sticky bar stays opaque to content +// scrolling under it while looking identical to the page behind it. +// background-attachment: fixed keeps it aligned with body::after's fixed layer. +@mixin page-surface-gradient { + background: var(--color-background-gradient-second); // fallback, no color-mix + background: linear-gradient(130deg, + color-mix(in srgb, var(--color-background-gradient-first) 91%, var(--color-background-page)), + color-mix(in srgb, var(--color-background-gradient-second) 91%, var(--color-background-page)) 41.07%, + color-mix(in srgb, var(--color-background-gradient-third) 91%, var(--color-background-page)) 84.05%); + background-attachment: fixed; +} + #diff-form { background: rgba(0, 0, 0, .05); @@ -40,8 +53,121 @@ } body.difference-page { - section.content { - padding-top: 40px; + // Keep the top menu (watch URL, EDIT, theme/GitHub icons) on screen while the + // diff scrolls, stacked above #diff-header. Scoped to the diff page on + // purpose - upstream's @todo in parts/_top_menu.scss wants this globally, but + // that is a separate change. + // + // Sticky makes .header a stacking context, so its mobile drawer / overlay + // (z-index 10000 / 9999) are now capped by this value. 30 clears #diff-header + // below and still leaves toasts (10000, fixed) painting over the bar. + .header { + position: sticky; + top: 0; + z-index: 30; + @include page-surface-gradient; + } + + // .app-main's 0.55rem gap sits between the two sticky bars, so #diff-header + // starts 8.8px below the top menu and then slides up to meet it over the + // first 8.8px of scroll - a visible twitch right as scrolling begins. The gap + // can't be preserved once stuck (it is outside both bars, so the diff would + // scroll through it), so close it here and let them sit flush at every scroll + // position. Only the .header/section.content pair is affected; .app-main has + // no other children. + .app-main { + gap: 0; + } + + .tab-pane-inner { + scroll-margin-top: calc(var(--app-header-height, 0px) + var(--diff-header-height, 0px) + 1rem); + } + + // The 2px activity strip is fixed on body, so it lives in the root stacking + // context and would paint over the mobile drawer now that the drawer is capped + // inside .header's context above. Drop it below .header rather than raising + // .header past the action rail (60), which would hide the rail's hover flyout. + // Still above #diff-header (20) and the minimap (10); on this page it now also + // passes under the rail's flyout, which is 2px of imperceptible overlap. + #pure-menu-horizontal-spinner { + z-index: 29; + } +} + +#diff-header { + position: sticky; + // Sits directly under the now-sticky top menu. The 0 fallback (JS off) just + // slides this under the bar - the same degradation the minimap already takes. + top: var(--app-header-height, 0px); + z-index: 20; + width: 100%; + box-sizing: border-box; + // Center children at their intrinsic width, the same way section.content does + // for the non-sticky siblings it still lays out (align-items: center). + display: flex; + flex-direction: column; + align-items: center; + // Keep the diff readable even with wrapped controls, zoom, or a short viewport. + // The budget is for the whole sticky stack, so the top menu above comes out of + // it - otherwise the two bars together take 64% of a 390px-tall landscape phone. + max-height: calc(50vh - var(--app-header-height, 0px)); + max-height: calc(50dvh - var(--app-header-height, 0px)); + overflow: auto; + + @include page-surface-gradient; + + > * { + // A centered flex item is sized to fit-content, which has a min-content + // floor (the version keeps the intrinsic min-content width of its longest option + // (min-width: 0 does not lower it), so label + select cannot fit a phone + // viewport side by side. Let the label drop above it instead of forcing + // the whole header wider than the screen. + flex-wrap: wrap; + } + + select { + min-width: 0; + max-width: 100%; + flex: 1; + } + + label { + flex-shrink: 0; + } } } @@ -170,7 +296,7 @@ body.difference-page { border-radius: 3px; overflow-x: hidden; position: sticky; - top: 0; + top: calc(var(--app-header-height, 0px) + var(--diff-header-height, 0px)); z-index: 10; padding-top: 1rem; padding-bottom: 1rem; diff --git a/changedetectionio/tests/test_diff_header.py b/changedetectionio/tests/test_diff_header.py new file mode 100644 index 000000000..83fb6b411 --- /dev/null +++ b/changedetectionio/tests/test_diff_header.py @@ -0,0 +1,47 @@ +from flask import url_for +from bs4 import BeautifulSoup +import pytest + + +@pytest.mark.parametrize('title,page_title,expected', [ + ('Release notes ', 'Fetched title', 'Release notes '), + ('', 'Fetched title', 'Fetched title'), + ('', '', 'https://example.com/releases'), +]) +def test_diff_header_watch_label(client, title, page_title, expected): + datastore = client.application.config['DATASTORE'] + uuid = datastore.add_watch(url='https://example.com/releases', extras={ + 'title': title, 'page_title': page_title, 'paused': True, + }) + watch = datastore.data['watching'][uuid] + watch.save_history_blob('First release', 1700000000, 'first') + watch.save_history_blob('Second release', 1700000060, 'second') + + response = client.get(url_for('ui.ui_diff.diff_history_page', uuid=uuid)) + assert response.status_code == 200 + page = BeautifulSoup(response.data, 'html.parser') + heading = page.select_one('#diff-header #diff-watch-title') + assert heading.get_text() == expected + assert heading.find('script') is None + assert page.select_one('#diff-header #diff-form') is not None + assert page.select_one('#diff-header .tabs') is not None + assert page.select_one('#diff-header #difference') is None + assert 'Second release' in page.select_one('#difference').get_text() + + +def test_difference_page_class_scopes_sticky_header(client): + """The sticky top menu is styled off body.difference-page, so only the diff + page may carry that class - the extract page shares the same blueprint.""" + datastore = client.application.config['DATASTORE'] + uuid = datastore.add_watch(url='https://example.com/releases', extras={'paused': True}) + watch = datastore.data['watching'][uuid] + watch.save_history_blob('First release', 1700000000, 'first') + watch.save_history_blob('Second release', 1700000060, 'second') + + diff = BeautifulSoup(client.get(url_for('ui.ui_diff.diff_history_page', uuid=uuid)).data, + 'html.parser') + assert 'difference-page' in diff.select_one('body').get('class') + + extract = client.get(url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid)) + assert extract.status_code == 200 + assert 'difference-page' not in BeautifulSoup(extract.data, 'html.parser').select_one('body').get('class')