Merge pull request 'Added page scroll based progress indicator' (#6176) from progress_indicator into development

Reviewed-on: https://codeberg.org/bookstack/bookstack/pulls/6176
This commit is contained in:
Dan Brown
2026-08-25 14:09:10 +02:00
5 changed files with 177 additions and 69 deletions
-58
View File
@@ -1,58 +0,0 @@
import {Component} from './component';
export class BackToTop extends Component {
setup() {
this.button = this.$el;
this.targetElem = document.getElementById('header');
this.showing = false;
this.breakPoint = 1200;
if (document.body.classList.contains('flexbox')) {
this.button.style.display = 'none';
return;
}
this.button.addEventListener('click', this.scrollToTop.bind(this));
window.addEventListener('scroll', this.onPageScroll.bind(this));
}
onPageScroll() {
const scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
if (!this.showing && scrollTopPos > this.breakPoint) {
this.button.style.display = 'block';
this.showing = true;
setTimeout(() => {
this.button.style.opacity = 0.4;
}, 1);
} else if (this.showing && scrollTopPos < this.breakPoint) {
this.button.style.opacity = 0;
this.showing = false;
setTimeout(() => {
this.button.style.display = 'none';
}, 500);
}
}
scrollToTop() {
const targetTop = this.targetElem.getBoundingClientRect().top;
const scrollElem = document.documentElement.scrollTop ? document.documentElement : document.body;
const duration = 300;
const start = Date.now();
const scrollStart = this.targetElem.getBoundingClientRect().top;
function setPos() {
const percentComplete = (1 - ((Date.now() - start) / duration));
const target = Math.abs(percentComplete * scrollStart);
if (percentComplete > 0) {
scrollElem.scrollTop = target;
requestAnimationFrame(setPos.bind(this));
} else {
scrollElem.scrollTop = targetTop;
}
}
requestAnimationFrame(setPos.bind(this));
}
}
+132
View File
@@ -0,0 +1,132 @@
import {Component} from './component';
export class BackToTop extends Component {
private container!: HTMLElement;
private button!: HTMLElement;
private progress!: HTMLElement;
private progressPath!: SVGPathElement;
private targetElem!: HTMLElement;
private showing: boolean = false;
private breakPoint: number = 1200;
private isAnimating: boolean = false;
setup(): void {
this.container = this.$el;
this.button = this.$refs.button;
this.progress = this.$refs.progress;
this.progressPath = this.$refs.progressPath as unknown as SVGPathElement;
this.targetElem = document.getElementById('header') as HTMLElement;
if (document.body.classList.contains('flexbox')) {
this.container.style.display = 'none';
return;
}
this.button.addEventListener('click', this.scrollToTop.bind(this));
window.addEventListener('scroll', this.onPageScroll.bind(this));
this.setupProgressBar();
}
private setupProgressBar(): void {
this.button.addEventListener('transitionstart', event => {
if (event.target !== this.button) return;
this.isAnimating = true;
this.renderProgressPath();
});
const stopAnimating = (event: TransitionEvent) => {
if (event.target !== this.button) return;
this.isAnimating = false;
};
this.button.addEventListener('transitionend', stopAnimating);
this.button.addEventListener('transitioncancel', stopAnimating);
this.renderProgressPath();
}
private onPageScroll(): void {
const scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
if (!this.showing && scrollTopPos > this.breakPoint) {
this.container.style.display = 'block';
this.showing = true;
setTimeout(() => {
this.container.style.opacity = '0.4';
}, 1);
} else if (this.showing && scrollTopPos < this.breakPoint) {
this.container.style.opacity = '0';
this.showing = false;
setTimeout(() => {
this.container.style.display = 'none';
}, 500);
}
if (this.showing) {
const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight;
const scrollTopPercent = (scrollTopPos / maxScrollTop) * 100;
this.updateProgress(scrollTopPercent);
}
}
private scrollToTop(): void {
const targetTop = this.targetElem.getBoundingClientRect().top;
const scrollElem = document.documentElement.scrollTop ? document.documentElement : document.body;
const duration = 300;
const start = Date.now();
const scrollStart = this.targetElem.getBoundingClientRect().top;
const setPos = () => {
const percentComplete = (1 - ((Date.now() - start) / duration));
const target = Math.abs(percentComplete * scrollStart);
if (percentComplete > 0) {
scrollElem.scrollTop = target;
requestAnimationFrame(setPos);
} else {
scrollElem.scrollTop = targetTop;
}
};
requestAnimationFrame(setPos);
}
private renderProgressPath(): void {
const bounds = this.button.getBoundingClientRect();
const progressInset = window.getComputedStyle(this.progress).insetInlineStart;
const offset = Math.abs(Number(progressInset.replace('px', '') || 0));
const path = this.roundedRectPath(bounds.width, bounds.height, bounds.height / 2, offset);
this.progressPath.setAttribute('d', path);
if (this.isAnimating) {
window.requestAnimationFrame(this.renderProgressPath.bind(this));
}
}
private updateProgress(percentComplete: number): void {
if (percentComplete < 5) {
percentComplete = 5;
}
this.progressPath.setAttribute('stroke-dasharray', `${Math.ceil(percentComplete)} 100`);
}
private roundedRectPath(w: number, h: number, r: number, offset: number = 1.5): string {
// Expand dimensions outward by the offset
const W = w + 2 * offset;
const H = h + 2 * offset;
// The corner radius also grows by the offset
const R = Math.min(r + offset, W / 2, H / 2);
return [
`M ${W / 2} 0`, // start at top center
`H ${W - R}`, // line to top-right (before arc)
`A ${R} ${R} 0 0 1 ${W} ${R}`, // arc: top-right corner
`V ${H - R}`, // line down right edge
`A ${R} ${R} 0 0 1 ${W - R} ${H}`, // arc: bottom-right corner
`H ${R}`, // line across bottom edge
`A ${R} ${R} 0 0 1 ${0} ${H - R}`, // arc: bottom-left corner
`V ${R}`, // line up left edge
`A ${R} ${R} 0 0 1 ${R} ${0}`, // arc: top-left corner
`Z` // close path back to start
].join(' ');
}
}
+29 -5
View File
@@ -1098,16 +1098,24 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
// Back to top link
$btt-size: 40px;
.back-to-top {
background-color: var(--color-primary);
.back-to-top-container {
position: fixed;
bottom: vars.$m;
right: vars.$l;
opacity: 0;
z-index: 999;
transition: opacity ease-in-out 180ms;
&:hover {
opacity: 1 !important;
}
}
.back-to-top {
background-color: var(--color-primary);
padding: 5px 7px;
cursor: pointer;
color: #FFF;
fill: #FFF;
svg {
.inner svg {
width: math.div($btt-size, 1.5);
height: math.div($btt-size, 1.5);
margin-inline-end: 4px;
@@ -1116,8 +1124,6 @@ $btt-size: 40px;
height: $btt-size;
border-radius: $btt-size;
transition: all ease-in-out 180ms;
opacity: 0;
z-index: 999;
overflow: hidden;
&:hover {
width: $btt-size*3.4;
@@ -1125,6 +1131,7 @@ $btt-size: 40px;
}
.inner {
width: $btt-size*3.4;
text-align: start;
}
span {
position: relative;
@@ -1133,6 +1140,23 @@ $btt-size: 40px;
}
}
.back-to-top-progress {
position: absolute;
top: -3px;
inset-inline-start: -3px;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
path {
fill: none;
stroke: var(--color-primary);
stroke-width: 2px;
stroke-linecap: butt;
transition: stroke-dasharray ease-in-out 120ms;
}
}
// Sortable scroll boxes
.scroll-box {
list-style: none;
+1 -6
View File
@@ -61,12 +61,7 @@
</div>
@include('layouts.parts.footer')
<div component="back-to-top" class="back-to-top print-hidden">
<div class="inner">
@icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span>
</div>
</div>
@include('layouts.parts.back-to-top')
@if($cspNonce ?? false)
<script src="{{ versioned_asset('dist/app.js') }}" type="module" nonce="{{ $cspNonce }}"></script>
@@ -0,0 +1,15 @@
<div component="back-to-top" class="back-to-top-container">
<svg refs="back-to-top@progress" class="back-to-top-progress" width="100%" height="100%">
<path
refs="back-to-top@progress-path"
d=""
pathLength="100"
stroke-dasharray="0 100"
/>
</svg>
<button refs="back-to-top@button" class="back-to-top print-hidden">
<div class="inner">
@icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span>
</div>
</button>
</div>