Commit 8fb5eb9 h
Pat Ambrosio · 26-09-01 10:29
Centre a linked passage instead of pinning it to the top
Changes: 3 files
+101
-16
Changes (Side-by-Side)
share/jenny/layout/page.sh +41 -8
cat << _EOF_ |
| 1238 |
}); |
1238 |
}); |
| 1239 |
|
1239 |
|
| 1240 |
// Mark whatever the reader arrived at, so a permalink lands on |
1240 |
// Mark whatever the reader arrived at, so a permalink lands on |
| 1241 |
// something visibly identified rather than just scrolled to. |
| |
| 1242 |
function markTarget() { |
| |
| |
1241 |
// something visibly identified rather than just scrolled to, and |
| |
1242 |
// put it in the middle of the screen rather than against the top |
| |
1243 |
// edge. A passage flush with the top has no text above it, so |
| |
1244 |
// there is nothing to show what it follows from; centred, it |
| |
1245 |
// arrives with its own context either side. |
| |
1246 |
function showTarget(t, smooth) { |
| 1243 |
document.querySelectorAll('.target-flash').forEach(function(e) { |
1247 |
document.querySelectorAll('.target-flash').forEach(function(e) { |
| 1244 |
e.classList.remove('target-flash'); |
1248 |
e.classList.remove('target-flash'); |
| 1245 |
}); |
1249 |
}); |
| 1246 |
if (!location.hash) return; |
| |
| 1247 |
var t = null; |
| |
| 1248 |
try { t = document.querySelector(location.hash); } catch (err) { return; } |
| |
| 1249 |
if (t) t.classList.add('target-flash'); |
| |
| |
1250 |
if (!t) return; |
| |
1251 |
t.classList.add('target-flash'); |
| |
1252 |
t.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' }); |
| |
1253 |
} |
| |
1254 |
function resolveHash() { |
| |
1255 |
if (!location.hash) return null; |
| |
1256 |
try { return document.querySelector(location.hash); } catch (err) { return null; } |
| |
1257 |
} |
| |
1258 |
|
| |
1259 |
// A click within the page: the browser would jump the target to the |
| |
1260 |
// top, so take the scroll over. |
| |
1261 |
document.addEventListener('click', function(e) { |
| |
1262 |
var link = e.target.closest('a[href^="#"]'); |
| |
1263 |
if (!link) return; |
| |
1264 |
var id = link.getAttribute('href').slice(1); |
| |
1265 |
if (!id) return; |
| |
1266 |
var t = document.getElementById(id); |
| |
1267 |
if (!t) return; |
| |
1268 |
e.preventDefault(); |
| |
1269 |
history.replaceState(null, '', '#' + id); |
| |
1270 |
showTarget(t, true); |
| |
1271 |
}); |
| |
1272 |
|
| |
1273 |
window.addEventListener('hashchange', function() { |
| |
1274 |
showTarget(resolveHash(), true); |
| |
1275 |
}); |
| |
1276 |
|
| |
1277 |
// Arriving from elsewhere at .../page#id: the browser has already |
| |
1278 |
// scrolled the target to the top by the time this runs, so it has |
| |
1279 |
// to be moved rather than merely marked. A frame later, once |
| |
1280 |
// layout has settled -- fonts and diagrams both change the height |
| |
1281 |
// above it and would otherwise leave the centring stale. |
| |
1282 |
var initial = resolveHash(); |
| |
1283 |
if (initial) { |
| |
1284 |
requestAnimationFrame(function() { showTarget(initial, false); }); |
| 1250 |
} |
1285 |
} |
| 1251 |
window.addEventListener('hashchange', markTarget); |
| |
| 1252 |
markTarget(); |
| |
| 1253 |
} |
1286 |
} |
| 1254 |
// The script tag sits in the body, so at parse time the content it |
1287 |
// The script tag sits in the body, so at parse time the content it |
| 1255 |
// needs may not exist yet -- the first version ran before any of it |
1288 |
// needs may not exist yet -- the first version ran before any of it |
share/jenny/layout/post.sh +41 -8
cat << _EOF_ |
| 1010 |
}); |
1010 |
}); |
| 1011 |
|
1011 |
|
| 1012 |
// Mark whatever the reader arrived at, so a permalink lands on |
1012 |
// Mark whatever the reader arrived at, so a permalink lands on |
| 1013 |
// something visibly identified rather than just scrolled to. |
| |
| 1014 |
function markTarget() { |
| |
| |
1013 |
// something visibly identified rather than just scrolled to, and |
| |
1014 |
// put it in the middle of the screen rather than against the top |
| |
1015 |
// edge. A passage flush with the top has no text above it, so |
| |
1016 |
// there is nothing to show what it follows from; centred, it |
| |
1017 |
// arrives with its own context either side. |
| |
1018 |
function showTarget(t, smooth) { |
| 1015 |
document.querySelectorAll('.target-flash').forEach(function(e) { |
1019 |
document.querySelectorAll('.target-flash').forEach(function(e) { |
| 1016 |
e.classList.remove('target-flash'); |
1020 |
e.classList.remove('target-flash'); |
| 1017 |
}); |
1021 |
}); |
| 1018 |
if (!location.hash) return; |
| |
| 1019 |
var t = null; |
| |
| 1020 |
try { t = document.querySelector(location.hash); } catch (err) { return; } |
| |
| 1021 |
if (t) t.classList.add('target-flash'); |
| |
| |
1022 |
if (!t) return; |
| |
1023 |
t.classList.add('target-flash'); |
| |
1024 |
t.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' }); |
| |
1025 |
} |
| |
1026 |
function resolveHash() { |
| |
1027 |
if (!location.hash) return null; |
| |
1028 |
try { return document.querySelector(location.hash); } catch (err) { return null; } |
| |
1029 |
} |
| |
1030 |
|
| |
1031 |
// A click within the page: the browser would jump the target to the |
| |
1032 |
// top, so take the scroll over. |
| |
1033 |
document.addEventListener('click', function(e) { |
| |
1034 |
var link = e.target.closest('a[href^="#"]'); |
| |
1035 |
if (!link) return; |
| |
1036 |
var id = link.getAttribute('href').slice(1); |
| |
1037 |
if (!id) return; |
| |
1038 |
var t = document.getElementById(id); |
| |
1039 |
if (!t) return; |
| |
1040 |
e.preventDefault(); |
| |
1041 |
history.replaceState(null, '', '#' + id); |
| |
1042 |
showTarget(t, true); |
| |
1043 |
}); |
| |
1044 |
|
| |
1045 |
window.addEventListener('hashchange', function() { |
| |
1046 |
showTarget(resolveHash(), true); |
| |
1047 |
}); |
| |
1048 |
|
| |
1049 |
// Arriving from elsewhere at .../page#id: the browser has already |
| |
1050 |
// scrolled the target to the top by the time this runs, so it has |
| |
1051 |
// to be moved rather than merely marked. A frame later, once |
| |
1052 |
// layout has settled -- fonts and diagrams both change the height |
| |
1053 |
// above it and would otherwise leave the centring stale. |
| |
1054 |
var initial = resolveHash(); |
| |
1055 |
if (initial) { |
| |
1056 |
requestAnimationFrame(function() { showTarget(initial, false); }); |
| 1022 |
} |
1057 |
} |
| 1023 |
window.addEventListener('hashchange', markTarget); |
| |
| 1024 |
markTarget(); |
| |
| 1025 |
} |
1058 |
} |
| 1026 |
// The script tag sits in the body, so at parse time the content it |
1059 |
// The script tag sits in the body, so at parse time the content it |
| 1027 |
// needs may not exist yet -- the first version ran before any of it |
1060 |
// needs may not exist yet -- the first version ran before any of it |
test/regressions.sh +19 -0
it "the mark is hidden until the passage is hovered or the mark focused" |
| 1450 |
assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }' |
1450 |
assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }' |
| 1451 |
assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }' |
1451 |
assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }' |
| 1452 |
|
1452 |
|
| |
1453 |
it "a linked passage is centred, not pinned to the top" |
| |
1454 |
# A passage flush with the top edge has no text above it, so nothing shows |
| |
1455 |
# what it follows from. Centred, it arrives with its own context either side. |
| |
1456 |
# Measured in Chrome once the scroll settles: dead centre for an in-page click, |
| |
1457 |
# for an inbound permalink, and for a prose cross-reference. |
| |
1458 |
assert_file_contains "$dg/.dist/chart.html" "block: 'center'" |
| |
1459 |
assert_file_contains "$dg/.dist/post/charted.html" "block: 'center'" |
| |
1460 |
|
| |
1461 |
it "an inbound permalink is re-centred after the browser's own jump" |
| |
1462 |
# Arriving at .../page#id, the browser has already put the target at the top |
| |
1463 |
# by the time this runs, so it has to be moved rather than merely marked -- |
| |
1464 |
# and a frame later, once fonts and diagrams have settled the height above it. |
| |
1465 |
assert_file_contains "$dg/.dist/chart.html" "requestAnimationFrame(function() { showTarget(initial, false); });" |
| |
1466 |
|
| |
1467 |
it "every in-page link is centred, not just the marks" |
| |
1468 |
# The handler is delegated on the document, so a cross-reference written in |
| |
1469 |
# the prose behaves the same way as a deep-link mark. |
| |
1470 |
assert_file_contains "$dg/.dist/chart.html" "a[href^=\"#\"]" |
| |
1471 |
|
| 1453 |
it "following a permalink marks what was linked" |
1472 |
it "following a permalink marks what was linked" |
| 1454 |
# A reader landing mid-document needs to see which passage was meant. |
1473 |
# A reader landing mid-document needs to see which passage was meant. |
| 1455 |
assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');" |
1474 |
assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');" |
Raw
commit 8fb5eb95dc6c90ba42d93f43ff56645582d0c5a9
Author: Pat Ambrosio <cp.ambrosio@gmail.com>
Date: Tue Sep 1 10:29:27 2026 +0800
Centre a linked passage instead of pinning it to the top
A passage scrolled flush with the top edge has nothing above it, so the
reader sees the sentence they followed and none of what it follows from.
Centred, it arrives with its own context either side.
Two paths need it, and they are not the same problem. A click can simply
take the scroll over before the browser jumps. An inbound permalink
cannot: by the time this runs the browser has already put the target at
the top, so it has to be moved rather than merely marked -- and a frame
later, because fonts and diagrams both change the height above it and
would otherwise leave the centring stale. Measured on a page whose
diagrams render after load: still dead centre.
The click handler is delegated on the document rather than bound to the
marks, so a cross-reference written in the prose gets the same treatment.
The footnote links do too: the jump to a note lands off centre only
because the notes sit at the end of the document and there is nothing
below them to scroll, which is the browser doing the right thing.
Measured in Chrome, polling until the scroll settles rather than after a
fixed delay -- read mid-animation the target is 530px out, which looks
like a defect and is not.
diff --git a/share/jenny/layout/page.sh b/share/jenny/layout/page.sh
index a8ad0d9..53449df 100755
--- a/share/jenny/layout/page.sh
+++ b/share/jenny/layout/page.sh
@@ -1238,18 +1238,51 @@ cat << _EOF_
});
// Mark whatever the reader arrived at, so a permalink lands on
- // something visibly identified rather than just scrolled to.
- function markTarget() {
+ // something visibly identified rather than just scrolled to, and
+ // put it in the middle of the screen rather than against the top
+ // edge. A passage flush with the top has no text above it, so
+ // there is nothing to show what it follows from; centred, it
+ // arrives with its own context either side.
+ function showTarget(t, smooth) {
document.querySelectorAll('.target-flash').forEach(function(e) {
e.classList.remove('target-flash');
});
- if (!location.hash) return;
- var t = null;
- try { t = document.querySelector(location.hash); } catch (err) { return; }
- if (t) t.classList.add('target-flash');
+ if (!t) return;
+ t.classList.add('target-flash');
+ t.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' });
+ }
+ function resolveHash() {
+ if (!location.hash) return null;
+ try { return document.querySelector(location.hash); } catch (err) { return null; }
+ }
+
+ // A click within the page: the browser would jump the target to the
+ // top, so take the scroll over.
+ document.addEventListener('click', function(e) {
+ var link = e.target.closest('a[href^="#"]');
+ if (!link) return;
+ var id = link.getAttribute('href').slice(1);
+ if (!id) return;
+ var t = document.getElementById(id);
+ if (!t) return;
+ e.preventDefault();
+ history.replaceState(null, '', '#' + id);
+ showTarget(t, true);
+ });
+
+ window.addEventListener('hashchange', function() {
+ showTarget(resolveHash(), true);
+ });
+
+ // Arriving from elsewhere at .../page#id: the browser has already
+ // scrolled the target to the top by the time this runs, so it has
+ // to be moved rather than merely marked. A frame later, once
+ // layout has settled -- fonts and diagrams both change the height
+ // above it and would otherwise leave the centring stale.
+ var initial = resolveHash();
+ if (initial) {
+ requestAnimationFrame(function() { showTarget(initial, false); });
}
- window.addEventListener('hashchange', markTarget);
- markTarget();
}
// The script tag sits in the body, so at parse time the content it
// needs may not exist yet -- the first version ran before any of it
diff --git a/share/jenny/layout/post.sh b/share/jenny/layout/post.sh
index 0785ce5..59e6308 100755
--- a/share/jenny/layout/post.sh
+++ b/share/jenny/layout/post.sh
@@ -1010,18 +1010,51 @@ cat << _EOF_
});
// Mark whatever the reader arrived at, so a permalink lands on
- // something visibly identified rather than just scrolled to.
- function markTarget() {
+ // something visibly identified rather than just scrolled to, and
+ // put it in the middle of the screen rather than against the top
+ // edge. A passage flush with the top has no text above it, so
+ // there is nothing to show what it follows from; centred, it
+ // arrives with its own context either side.
+ function showTarget(t, smooth) {
document.querySelectorAll('.target-flash').forEach(function(e) {
e.classList.remove('target-flash');
});
- if (!location.hash) return;
- var t = null;
- try { t = document.querySelector(location.hash); } catch (err) { return; }
- if (t) t.classList.add('target-flash');
+ if (!t) return;
+ t.classList.add('target-flash');
+ t.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'center' });
+ }
+ function resolveHash() {
+ if (!location.hash) return null;
+ try { return document.querySelector(location.hash); } catch (err) { return null; }
+ }
+
+ // A click within the page: the browser would jump the target to the
+ // top, so take the scroll over.
+ document.addEventListener('click', function(e) {
+ var link = e.target.closest('a[href^="#"]');
+ if (!link) return;
+ var id = link.getAttribute('href').slice(1);
+ if (!id) return;
+ var t = document.getElementById(id);
+ if (!t) return;
+ e.preventDefault();
+ history.replaceState(null, '', '#' + id);
+ showTarget(t, true);
+ });
+
+ window.addEventListener('hashchange', function() {
+ showTarget(resolveHash(), true);
+ });
+
+ // Arriving from elsewhere at .../page#id: the browser has already
+ // scrolled the target to the top by the time this runs, so it has
+ // to be moved rather than merely marked. A frame later, once
+ // layout has settled -- fonts and diagrams both change the height
+ // above it and would otherwise leave the centring stale.
+ var initial = resolveHash();
+ if (initial) {
+ requestAnimationFrame(function() { showTarget(initial, false); });
}
- window.addEventListener('hashchange', markTarget);
- markTarget();
}
// The script tag sits in the body, so at parse time the content it
// needs may not exist yet -- the first version ran before any of it
diff --git a/test/regressions.sh b/test/regressions.sh
index 416e45e..131759f 100755
--- a/test/regressions.sh
+++ b/test/regressions.sh
@@ -1450,6 +1450,25 @@ it "the mark is hidden until the passage is hovered or the mark focused"
assert_file_contains "$dg/.dist/chart.html" '.anchor-link:focus { opacity: 1; }'
assert_file_contains "$dg/.dist/chart.html" '@media (hover: none) { .anchor-link { display: none; } }'
+it "a linked passage is centred, not pinned to the top"
+# A passage flush with the top edge has no text above it, so nothing shows
+# what it follows from. Centred, it arrives with its own context either side.
+# Measured in Chrome once the scroll settles: dead centre for an in-page click,
+# for an inbound permalink, and for a prose cross-reference.
+assert_file_contains "$dg/.dist/chart.html" "block: 'center'"
+assert_file_contains "$dg/.dist/post/charted.html" "block: 'center'"
+
+it "an inbound permalink is re-centred after the browser's own jump"
+# Arriving at .../page#id, the browser has already put the target at the top
+# by the time this runs, so it has to be moved rather than merely marked --
+# and a frame later, once fonts and diagrams have settled the height above it.
+assert_file_contains "$dg/.dist/chart.html" "requestAnimationFrame(function() { showTarget(initial, false); });"
+
+it "every in-page link is centred, not just the marks"
+# The handler is delegated on the document, so a cross-reference written in
+# the prose behaves the same way as a deep-link mark.
+assert_file_contains "$dg/.dist/chart.html" "a[href^=\"#\"]"
+
it "following a permalink marks what was linked"
# A reader landing mid-document needs to see which passage was meant.
assert_file_contains "$dg/.dist/chart.html" "t.classList.add('target-flash');"