mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-10-12 02:01:37 +00:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user