468 lines
11 KiB
Plaintext
468 lines
11 KiB
Plaintext
<template>
|
|
<div class="container">
|
|
<div class="wrapper">
|
|
<div class="content"><slot /></div>
|
|
</div>
|
|
</div>
|
|
<div class="is-horizontal"><span class="thumb"></span></div>
|
|
<div class="is-vertical"><span class="thumb"></span></div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
:host {
|
|
position: relative;
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
.container {
|
|
overflow: hidden;
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.wrapper {
|
|
overflow: auto;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
}
|
|
|
|
/* 横向 */
|
|
|
|
.is-horizontal,
|
|
.is-vertical {
|
|
display: none;
|
|
visibility: hidden;
|
|
position: absolute;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
z-index: 10240;
|
|
opacity: 0;
|
|
user-select: none;
|
|
transition: opacity 0.3s linear, visibility 0.3s linear;
|
|
|
|
.thumb {
|
|
display: block;
|
|
border-radius: 5px;
|
|
background: rgba(44, 47, 53, 0.25);
|
|
cursor: default;
|
|
transition: width 0.1s linear, height 0.1s linear;
|
|
|
|
&:hover {
|
|
background: rgba(44, 47, 53, 0.5);
|
|
}
|
|
}
|
|
}
|
|
|
|
.is-horizontal {
|
|
flex-direction: column;
|
|
left: 0;
|
|
bottom: 0;
|
|
width: 100%;
|
|
height: 10px;
|
|
|
|
.thumb {
|
|
width: 0;
|
|
height: 6px;
|
|
|
|
&:hover {
|
|
height: 10px;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* 纵向 */
|
|
.is-vertical {
|
|
top: 0;
|
|
right: 0;
|
|
width: 10px;
|
|
height: 100%;
|
|
|
|
.thumb {
|
|
width: 6px;
|
|
height: 0;
|
|
|
|
&:hover {
|
|
width: 10px;
|
|
}
|
|
}
|
|
}
|
|
|
|
:host(:hover) {
|
|
.is-horizontal,
|
|
.is-vertical {
|
|
visibility: visible;
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
:host([axis='x']) {
|
|
.wrapper {
|
|
overflow-y: hidden;
|
|
}
|
|
.is-vertical {
|
|
display: none;
|
|
}
|
|
}
|
|
:host([axis='y']) {
|
|
.wrapper {
|
|
overflow-x: hidden;
|
|
}
|
|
.is-horizontal {
|
|
display: none;
|
|
}
|
|
}
|
|
:host([disabled]) {
|
|
.wrapper {
|
|
overflow: hidden;
|
|
}
|
|
.is-vertical,
|
|
.is-horizontal {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import $ from '../utils'
|
|
|
|
const AXIS_LIST = ['x', 'y', 'xy']
|
|
|
|
/* */
|
|
export default class Scroll {
|
|
props = {
|
|
axis: 'xy', // 滚动方向, 默认x轴和y轴都可以滚动
|
|
delay: 1000, // 节流防抖延迟
|
|
distance: 1 // 触发距离阀值, 单位像素
|
|
}
|
|
|
|
state = {
|
|
width: 0, // 滚动组件的真实宽度(可视宽高)
|
|
height: 0, // 滚动组件的真实高度(可视宽高)
|
|
widthFixed: 0, // 滚动组件的宽度修正值
|
|
heightFixed: 0, // 滚动组件的高度修正值
|
|
scrollWidth: 0, // 滚动组件的滚动宽度
|
|
scrollHeight: 0, // 滚动组件的滚动高度
|
|
xBar: 0, // 横轴长度
|
|
yBar: 0, // 纵轴长度
|
|
thumbX: 0, //横向条滚动距离
|
|
thumbY: 0 // 纵向条滚动距离
|
|
}
|
|
|
|
__init__() {
|
|
/* render */
|
|
|
|
this.__WRAPPER__ = this.root.children[1].children[0]
|
|
this.__CONTENT__ = this.__WRAPPER__.firstElementChild
|
|
this.__X__ = this.root.children[2].children[0]
|
|
this.__Y__ = this.root.children[3].children[0]
|
|
this.__last__ = 0
|
|
}
|
|
|
|
get scrollTop() {
|
|
return this.__WRAPPER__.scrollTop
|
|
}
|
|
|
|
set scrollTop(n) {
|
|
n = +n
|
|
if (n === n) {
|
|
this.__WRAPPER__.scrollTop = n
|
|
}
|
|
}
|
|
|
|
get scrollLeft() {
|
|
return this.__WRAPPER__.scrollLeft
|
|
}
|
|
|
|
set scrollLeft(n) {
|
|
n = +n
|
|
if (n === n) {
|
|
this.__WRAPPER__.scrollLeft = n
|
|
}
|
|
}
|
|
|
|
get scrollHeight() {
|
|
return this.__WRAPPER__.scrollHeight
|
|
}
|
|
|
|
get scrollWidth() {
|
|
return this.__WRAPPER__.scrollWidth
|
|
}
|
|
|
|
_fetchScrollX(moveX) {
|
|
var { scrollWidth, width, xBar } = this.state
|
|
|
|
if (moveX < 0) {
|
|
moveX = 0
|
|
} else if (moveX > width - xBar) {
|
|
moveX = width - xBar
|
|
}
|
|
this.__WRAPPER__.scrollLeft = (scrollWidth - width) * (moveX / (width - xBar))
|
|
this.__X__.style.transform = `translateX(${moveX}px)`
|
|
|
|
return moveX
|
|
}
|
|
|
|
_fetchScrollY(moveY) {
|
|
var { scrollHeight, height, yBar } = this.state
|
|
|
|
if (moveY < 0) {
|
|
moveY = 0
|
|
} else if (moveY > height - yBar) {
|
|
moveY = height - yBar
|
|
}
|
|
|
|
this.__WRAPPER__.scrollTop = (scrollHeight - height) * (moveY / (height - yBar))
|
|
this.__Y__.style.transform = `translateY(${moveY}px)`
|
|
return moveY
|
|
}
|
|
|
|
_fireReachEnd(action = 'reach-bottom') {
|
|
var { delay } = this.props
|
|
var { scrollHeight, height } = this.state
|
|
var top = this.__WRAPPER__.scrollTop
|
|
var now = Date.now()
|
|
|
|
if (now - this.__last__ > delay) {
|
|
if (action === 'reach-bottom') {
|
|
if (height + top < scrollHeight) {
|
|
return
|
|
}
|
|
} else {
|
|
if (top > 0) {
|
|
return
|
|
}
|
|
}
|
|
|
|
this.__last__ = now
|
|
this.dispatchEvent(new CustomEvent(action))
|
|
}
|
|
}
|
|
|
|
mounted() {
|
|
// 初始化滚动条的位置和长度
|
|
this._initFn = ev => {
|
|
// 需要减去因为隐藏原生滚动条修正的18像素
|
|
let width = this.offsetWidth
|
|
let height = this.offsetHeight
|
|
let clientWidth = this.__WRAPPER__.clientWidth
|
|
let clientHeight = this.__WRAPPER__.clientHeight
|
|
let scrollWidth = this.__WRAPPER__.scrollWidth
|
|
let scrollHeight = this.__WRAPPER__.scrollHeight
|
|
let yBar = 50 // 滚动条的高度
|
|
let xBar = 50 // 滚动条的宽度
|
|
let { widthFixed, heightFixed } = this.state
|
|
let needFixed = false
|
|
let style = ''
|
|
|
|
// 已经修正过宽度
|
|
if (widthFixed > 0) {
|
|
needFixed = scrollHeight === clientHeight
|
|
widthFixed = needFixed ? 0 : widthFixed
|
|
} else {
|
|
// 高度超出, 说明出现横向滚动条
|
|
if (scrollHeight > clientHeight) {
|
|
// width - clientWidth 等于滚动条的宽度, 下同
|
|
needFixed = true
|
|
widthFixed = width + (width - clientWidth)
|
|
}
|
|
}
|
|
|
|
if (heightFixed > 0) {
|
|
needFixed = scrollWidth === clientWidth
|
|
heightFixed = needFixed ? 0 : heightFixed
|
|
} else {
|
|
if (scrollWidth > clientWidth) {
|
|
needFixed = true
|
|
heightFixed = height + (height - clientHeight)
|
|
}
|
|
}
|
|
|
|
this.state.widthFixed = widthFixed
|
|
this.state.heightFixed = heightFixed
|
|
|
|
if (needFixed) {
|
|
if (widthFixed > 0) {
|
|
style += `width:${widthFixed}px;`
|
|
}
|
|
|
|
if (heightFixed > 0) {
|
|
style += `height:${heightFixed}px;`
|
|
}
|
|
this.__WRAPPER__.style.cssText = style
|
|
|
|
// 需要修正视图容器, 修正完之后, 需要重新获取滚动宽高
|
|
scrollWidth = this.__WRAPPER__.scrollWidth
|
|
scrollHeight = this.__WRAPPER__.scrollHeight
|
|
}
|
|
// 修正由于未知原因,导致父容器产生滚动距离
|
|
// 导致的内容被遮挡的bug
|
|
this.__WRAPPER__.parentNode.scrollTop = 0
|
|
|
|
yBar = (height * (height / scrollHeight)) >> 0
|
|
xBar = (width * (width / scrollWidth)) >> 0
|
|
|
|
if (yBar < 50) {
|
|
yBar = 50
|
|
}
|
|
if (xBar < 50) {
|
|
xBar = 50
|
|
}
|
|
|
|
// 100%或主体高度比滚动条还短时不显示
|
|
if (xBar >= width) {
|
|
xBar = 0
|
|
}
|
|
if (yBar >= height) {
|
|
yBar = 0
|
|
}
|
|
|
|
this.state.height = height
|
|
this.state.width = width
|
|
this.state.scrollHeight = scrollHeight
|
|
this.state.scrollWidth = scrollWidth
|
|
this.state.yBar = yBar
|
|
this.state.xBar = xBar
|
|
|
|
this.__X__.style.width = xBar + 'px'
|
|
this.__Y__.style.height = yBar + 'px'
|
|
}
|
|
|
|
// 鼠标滚动事件
|
|
this._scrollFn = $.catch(this.__WRAPPER__, 'scroll', ev => {
|
|
// 拖拽时忽略滚动事件
|
|
if (this._active) {
|
|
return
|
|
}
|
|
var { axis } = this.props
|
|
var { xBar, yBar, thumbX, thumbY, scrollHeight, scrollWidth, width, height } = this.state
|
|
var currTop = this.__WRAPPER__.scrollTop
|
|
var currLeft = this.__WRAPPER__.scrollLeft
|
|
|
|
// x轴 y轴 都为0时, 不作任何处理
|
|
if (xBar === 0 && yBar === 0) {
|
|
return
|
|
}
|
|
|
|
//
|
|
if (axis === 'y' || axis === 'xy') {
|
|
if (yBar) {
|
|
// 修正滚动条的位置
|
|
// 滚动比例 y 滚动条的可移动距离
|
|
let fixedY = (currTop / (scrollHeight - height)) * (height - yBar)
|
|
|
|
fixedY = fixedY >> 0
|
|
|
|
if (fixedY !== thumbY) {
|
|
this.state.thumbY = fixedY
|
|
this.__Y__.style.transform = `translateY(${fixedY}px)`
|
|
|
|
if (Math.abs(fixedY - thumbY) > this.props.distance) {
|
|
this._fireReachEnd(fixedY > thumbY ? 'reach-bottom' : 'reach-top')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (axis === 'x' || axis === 'xy') {
|
|
if (xBar) {
|
|
// 修正滚动条的位置
|
|
// 滚动比例 x 滚动条的可移动距离
|
|
let fixedX = (currLeft / (scrollWidth - width)) * (width - xBar)
|
|
|
|
fixedX = fixedX >> 0
|
|
|
|
if (fixedX !== thumbX) {
|
|
this.state.thumbX = fixedX
|
|
this.__X__.style.transform = `translateX(${fixedX}px)`
|
|
}
|
|
}
|
|
}
|
|
|
|
this.dispatchEvent(new CustomEvent('scroll'))
|
|
})
|
|
|
|
let startX,
|
|
startY,
|
|
moveX,
|
|
moveY,
|
|
mousemoveFn = ev => {
|
|
let { thumbY, thumbX } = this.state
|
|
if (startX !== undefined) {
|
|
moveX = this._fetchScrollX(thumbX + ev.pageX - startX)
|
|
}
|
|
|
|
if (startY !== undefined) {
|
|
moveY = this._fetchScrollY(thumbY + ev.pageY - startY)
|
|
}
|
|
},
|
|
mouseupFn = ev => {
|
|
if (Math.abs(ev.pageY - startY) > this.props.distance) {
|
|
this._fireReachEnd(ev.pageY > startY ? 'reach-bottom' : 'reach-top')
|
|
}
|
|
startX = undefined
|
|
startY = undefined
|
|
this.state.thumbX = moveX || 0
|
|
this.state.thumbY = moveY || 0
|
|
delete this._active
|
|
$.unbind(document, 'mousemove', mousemoveFn)
|
|
$.unbind(document, 'mouseup', mouseupFn)
|
|
}
|
|
|
|
this._yBarFn = $.bind(this.__Y__, 'mousedown', ev => {
|
|
startY = ev.pageY
|
|
|
|
this._active = true
|
|
|
|
$.bind(document, 'mousemove', mousemoveFn)
|
|
$.bind(document, 'mouseup', mouseupFn)
|
|
})
|
|
|
|
this._xBarFn = $.bind(this.__X__, 'mousedown', ev => {
|
|
startX = ev.pageX
|
|
this._active = true
|
|
|
|
$.bind(document, 'mousemove', mousemoveFn)
|
|
$.bind(document, 'mouseup', mouseupFn)
|
|
})
|
|
|
|
this.__observer = new ResizeObserver(this._initFn)
|
|
this.__observer.observe(this.__CONTENT__)
|
|
}
|
|
|
|
unmounted() {
|
|
this.__observer.disconnect()
|
|
|
|
$.unbind(this.__X__, 'mousedown', this._xBarFn)
|
|
$.unbind(this.__Y__, 'mousedown', this._yBarFn)
|
|
$.unbind(this.__WRAPPER__, 'scroll', this._scrollFn)
|
|
}
|
|
|
|
watch() {
|
|
switch (name) {
|
|
case 'axis':
|
|
if (val) {
|
|
if (AXIS_LIST.includes(val)) {
|
|
this.props.axis = val
|
|
} else {
|
|
this.removeAttribute(name)
|
|
}
|
|
} else {
|
|
this.props.axis = 'xy'
|
|
}
|
|
break
|
|
|
|
case 'delay':
|
|
this.props.delay = +val || 1000
|
|
break
|
|
|
|
case 'distance':
|
|
this.props.distance = +val || 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
</script>
|
JavaScript
95.2%
CSS
4.8%