mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-17 10:56:49 +00:00
Keep history diff title and controls visible while scrolling
The watch label, version selectors, diff options and tabs move into a sticky #diff-header, and the top menu above it (watch URL, EDIT, theme and GitHub links) becomes sticky too, so the whole header stays on screen while the diff scrolls. The minimap and anchor jumps offset by the measured height of both bars instead of a fixed value. Heights are measured on load and kept current with a ResizeObserver where it is available, falling back to the window resize and hashchange events as the restock graph and queue sparkline already do, plus one deferred measure at load - the tab switch hides #settings and so resizes the header without a window resize, and opening a link already at #screenshot does the same with no hashchange to follow. The app header is stored before the diff header is measured, since the latter's cap is derived from the former. A sticky bar must be opaque to the content scrolling beneath it, but the page's own backdrop is a fixed full-viewport gradient layer over the page colour, so a flat --color-background panel reads as a white band cutting across it. Instead each bar repaints that same backdrop via a shared page-surface-gradient mixin: the three gradient stops pre-composited against --color-background-page with color-mix() at the layer's 0.91 opacity, with background-attachment: fixed so they stay registered with body::after. Children of #diff-header are centred with a flex column, mirroring the align-items: center that section.content applies to the non-sticky siblings, so the controls panel and tabs keep their intrinsic width and standard colours. The top menu is made sticky only under body.difference-page. Upstream already wants this globally (see the @todo in parts/_top_menu.scss) but held off because the bar has no background of its own; scoping it here keeps that decision separate. Sticky makes .header a stacking context, so its mobile drawer is capped at the bar's z-index of 30 - high enough to cover #diff-header, low enough that the action rail's hover flyout and toast notifications still paint over the bar as before. The activity strip is fixed on body in the root stacking context, so it would have painted through the capped drawer; on this page it drops just below the bar rather than raising the bar past the rail. Dropping the 40px section.content padding and tightening the title's own padding closes the gap between the two bars, and #diff-header's half-viewport cap now excludes the top menu so the sticky stack stays within the same budget on short viewports. .app-main's 0.55rem gap goes too on this page: it sits between the two sticky bars, so the diff header would otherwise start 8.8px below the top menu and slide up to meet it over the first 8.8px of scroll. That gap cannot survive sticking - it is outside both bars, so the diff would scroll through it - and the bars are flush at every scroll position instead. Co-authored-by: Engineer <engineer@agents.matrixsi.com> Co-authored-by: Jeff Hedlund <jhedlund@gmail.com> Signed-off-by: Jeff Hedlund <jhedlund@gmail.com>
This commit is contained in:
committed by
Jeff Hedlund
co-authored by
Engineer
Jeff Hedlund
parent
e990d9909f
commit
43b07d5b5a
@@ -20,6 +20,8 @@
|
||||
<script src="{{url_for('static_content', group='js', filename='diff-overview.js')}}" defer></script>
|
||||
|
||||
|
||||
<div id="diff-header">
|
||||
<h1 id="diff-watch-title">{{ watch_a.label }}</h1>
|
||||
<div id="settings">
|
||||
<form class="pure-form " action="{{ url_for("ui.ui_diff.diff_history_page", uuid=uuid) }}" method="GET" id="diff-form">
|
||||
<fieldset class="diff-fieldset">
|
||||
@@ -110,6 +112,8 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>{# /diff-header #}
|
||||
|
||||
<div id="diff-ui">
|
||||
<div class="tab-pane-inner" id="error-text">
|
||||
<div class="snapshot-age error">{{watch_a.error_text_ctime|format_seconds_ago}} {{ _('seconds ago.') }}</div>
|
||||
|
||||
@@ -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 () {
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 <select> is as wide as its longest option). Without
|
||||
// this cap a narrow viewport would centre an over-wide child and spill it
|
||||
// out of both sides of the header.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#diff-watch-title {
|
||||
margin: 0;
|
||||
padding: 0.25rem 1rem;
|
||||
font-size: 1.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
color: #fff; // match #diff-form's white-on-gradient text
|
||||
}
|
||||
|
||||
#diff-form {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.diff-fieldset {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
> span {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
// Size to content so From/To stay side-by-side on desktop (as they were
|
||||
// before the header existed); shrink and wrap only when the row can't fit.
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// A <select> 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;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from flask import url_for
|
||||
from bs4 import BeautifulSoup
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize('title,page_title,expected', [
|
||||
('Release notes <script>alert(1)</script>', 'Fetched title', 'Release notes <script>alert(1)</script>'),
|
||||
('', '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')
|
||||
Reference in New Issue
Block a user