Merge pull request #2877 from timongh/feat_slider

重构 VSlider,修复 bug,添加新的功能与行为
This commit is contained in:
Grant Howard 2022-01-15 14:05:54 +08:00 committed by GitHub
commit e8cdd14b25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,12 +1,17 @@
<template>
<div
ref="slider"
class="be-slider"
role="slider"
:tabindex="focusable ? 0 : -1"
@keydown.left.prevent.stop="moveBy(-1)"
@keydown.right.prevent.stop="moveBy(1)"
@keydown.left.prevent.stop="offsetByStep(-1)"
@keydown.right.prevent.stop="offsetByStep(1)"
>
<div ref="barContainer" class="bar-container">
<div
ref="barContainer"
class="bar-container"
@click="setByCoord($event.offsetX)"
>
<slot name="bar">
<div class="default-bar"></div>
</slot>
@ -16,20 +21,81 @@
class="thumb-container"
placement="top"
:arrow="false"
:style="{ left: thumbLeft }"
>
<slot name="thumb">
<div class="default-thumb"></div>
</slot>
<template #toast>
{{ value }}
{{ displayFun(realValue) }}
</template>
</MiniToast>
</div>
</template>
<script lang="ts">
/**
* 提供一种数值输入方式
*
* 组件的值受 step 严格影响且以 center 对齐例如 `center == 0.8``step == 1`
* `max == 3`则组件的真实最大值为 `2.8` step 的约束后最终值还会接受 fix-num 的修正
*
* min, max 仅代表组件值的上下限不等于真实最大最小可取值
* 真实最大最小值是 min, max 在对齐 step接受 fix-num 修正之后得到的值
*
* # Props
*
* - focusable {boolean} {default: true} 是否可由 Tab 键获取到焦点
* - min {number} {default: 0} 最小值下限真实最小值会结合 step center 共同决定
* - max {number} {default: 100} 最大值上限真实最大值会结合 step center 共同决定
* - value {number} {default: 0} 当前组件的值
* - center {number} {default: 0} 对齐的中心
* 当该值被改变时组件值也会被改变到新 center 下其最接近的刻度
* - step {number} {default: 1} 单步的跨度
* - fix-num {(v: number) => number} {default: v => v}
* 修正组件最终得到的值包括真实最大最小值
* - display-fun {(v: number) => string} {default: v => String(v)}
* 仅修改提示中显示内容的函数不修改真实组件值接受当前的组件值返回用于显示的字符串
*
* # Emits
*
* - change 当值被改变时触发
*
* params
* - value {number} 改变时的值
* - start 开始滑动时触发
*
* params
* - value {number} 触发时的值
* - end 结束滑动时触发
*
* params
* - value {number} 触发时的值
*/
/*
* 给定一个 value其变为组件允许的值 this.realValue需经历以下几步
* round -> fix -> limit
* round: 将值限制到 step center 共同决定的刻度上
* fix: 使用用户传入的 fixNum 进行修正
* limit: 将值限制到 this.realMin this.realMax 的范围内
*
* 受限制的 value 用以下名称称呼
* rounded center 为中心偏移整数个 step value
* this.realMin this.realMax 取整方式使用 Math.round
* fixed rounded 基础上被 fixNum 修正的值
* limited: fixed 基础上被 this.realMinthis.realMax 约束的值
*
* 长度相关
* lengthslider bar 上的一段长度单位为像素
* coordslider bar 上某一点到左端点的像素距离
*/
import MiniToast from '@/core/toast/MiniToast.vue'
// Math.roundMath.ceil
type IntoIntCallback = (value: number) => number;
export default Vue.extend({
name: 'VSlider',
components: { MiniToast },
@ -52,123 +118,231 @@ export default Vue.extend({
},
value: {
type: Number,
required: true,
default: 0,
},
center: {
type: Number,
default: 0,
},
step: {
type: Number,
default: 1,
},
fixNum: {
type: Function,
default: (v: number) => v,
},
displayFun: {
type: Function,
default: (v: number) => String(v),
},
},
data() {
return {
//
// change
realValue: 0,
}
},
computed: {
realMax() {
return this.valueToFixed(this.max, Math.floor)
},
realMin() {
return this.valueToFixed(this.min, Math.ceil)
},
thumbLeft() {
const totalValueLength = this.realMax - this.realMin
if (totalValueLength === 0) {
return 0
}
const percent = 100 * ((this.realValue - this.realMin) / totalValueLength)
return `${percent}%`
},
// center coord
centerCoord() {
return this.valueToLength(this.center - this.realMin)
},
},
watch: {
value(value: number) {
this.updateThumbPosition(value)
if (value !== this.realValue) {
this.setByValue(value)
}
},
center() {
this.setByValue(this.realValue)
},
min() {
this.setByFixed(this.realValue)
},
max() {
this.setByFixed(this.realValue)
},
},
created() {
this.setByValue(this.value)
},
mounted() {
this.normalizeValue()
this.setupEvents()
this.updateThumbPosition(this.value)
this.setupDrag()
},
methods: {
/** value , value normalize
* 否则对 this.value 进行
*/
normalizeValue(value: number | undefined) {
if (value !== undefined) {
if (value < this.min) {
return this.min
} if (value > this.max) {
return this.max
}
return value
}
if (this.value < this.min) {
this.$emit('change', this.min)
} else if (this.value > this.max) {
this.$emit('change', this.max)
}
return undefined
// 0 value step intoIntCallback
valueToStep(
value: number,
intoIntCallback: IntoIntCallback = Math.round,
): number {
return intoIntCallback(value / this.step)
},
updateThumbPosition(value: number) {
const thumbContainer = this.$refs.thumbContainer.$el as HTMLElement
thumbContainer.style.left = `${((100 * (value - this.min)) / (this.max - this.min)).toString()}%`
// 0 value step intoIntCallback
valueToStepped(
value: number,
intoIntCallback: IntoIntCallback = Math.round,
): number {
return this.valueToStep(value, intoIntCallback) * this.step
},
setupEvents() {
const barContainer = this.$refs.barContainer as HTMLElement
const thumbContainer = this.$refs.thumbContainer.$el as HTMLElement
const updateValue = (value: number) => {
this.$emit('change', value)
// slider bar length value
lengthToValue(length: number): number {
const bar = this.$refs.barContainer as HTMLElement
const totalLength = bar.getBoundingClientRect().width
const totalValueLength = this.realMax - this.realMin
return totalValueLength * (length / totalLength)
},
// slider bar length step
lengthToStep(length: number): number {
return this.valueToStep(this.lengthToValue(length))
},
// slider bar length step value
lengthToStepped(length: number): number {
return this.lengthToStep(length) * this.step
},
// slider bar value length
valueToLength(value: number): number {
const bar = this.$refs.barContainer as HTMLElement
const totalLength = bar.getBoundingClientRect().width
const totalValueLength = this.realMax - this.realMin
if (totalValueLength === 0) {
return 0
}
barContainer.addEventListener('click', e => {
const x = e.offsetX
const totalWidth = barContainer.getBoundingClientRect().width
const value = this.max * (x / totalWidth)
updateValue(Math.trunc(value / this.step) * this.step)
})
thumbContainer.addEventListener('mousedown', () => this.$el.focus())
thumbContainer.addEventListener('touchstart', () => this.$el.focus())
let dragging = false
let lastValue = 0
let startPoint: [number, number] = [0, 0]
const startDrag = (e: { screenX: number; screenY: number }) => {
dragging = true
lastValue = this.value
startPoint = [e.screenX, e.screenY]
const endDrag = () => (dragging = false)
document.body.addEventListener('mouseup', endDrag, { once: true })
document.body.addEventListener('touchend', endDrag, { once: true })
}
thumbContainer.addEventListener('mousedown', e => {
e.preventDefault()
startDrag(e)
})
thumbContainer.addEventListener('touchstart', e => {
if (e.touches.length === 1) {
e.preventDefault()
startDrag(e.touches[0])
}
})
const doDrag = (e: { screenX: number; screenY: number }) => {
const [startX] = startPoint
const deltaX = e.screenX - startX
const totalWidth = barContainer.getBoundingClientRect().width
const valueChange: number = (this.max - this.min) * (deltaX / totalWidth)
const value: number = this.normalizeValue(
lastValue + Math.trunc(valueChange / this.step) * this.step,
)
updateValue(value)
}
document.body.addEventListener('mousemove', e => {
if (dragging) {
e.preventDefault()
doDrag(e)
}
})
document.body.addEventListener(
'touchmove',
e => {
if (dragging && e.touches.length === 1) {
e.preventDefault()
doDrag(e.touches[0])
}
},
{ passive: false },
return totalLength * (value / totalValueLength)
},
// value rounded
valueToRounded(
value: number,
intoIntCallback: IntoIntCallback = Math.round,
): number {
return (
this.center + this.valueToStepped(value - this.center, intoIntCallback)
)
},
moveBy(count) {
const valueChange = count * this.step
this.$emit('change', this.normalizeValue(this.value + valueChange))
// value fixed
valueToFixed(
value: number,
intoIntCallback: IntoIntCallback = Math.round,
): number {
return this.fixNum(this.valueToRounded(value, intoIntCallback))
},
// limit
limitValue(value: number): number {
if (value < this.realMin) {
value = this.realMin
} else if (value > this.realMax) {
value = this.realMax
}
return value
},
// offset step
offsetByStep(offset: number) {
this.setByRounded(this.realValue + offset * this.step)
},
// limited
setByLimited(limited: number) {
if (limited !== this.realValue) {
this.realValue = limited
this.$emit('change', this.realValue)
}
},
// fixed
setByFixed(fixed: number) {
this.setByLimited(this.limitValue(fixed))
},
// rounded
setByRounded(rounded: number) {
this.setByFixed(this.fixNum(rounded))
},
// value
setByValue(value: number) {
this.setByRounded(this.valueToRounded(value))
},
// coord
setByCoord(coord: number) {
this.setByRounded(
this.center + this.lengthToStepped(coord - this.centerCoord),
)
},
//
setupDrag() {
type Listener = (pageX: number) => void;
type Stopper = () => void;
// addEventListener
//
//
function startListen(
target: EventTarget,
type: string,
listener: Listener,
once = false,
): Stopper {
const listener0 = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (e instanceof MouseEvent) {
listener(e.pageX)
} else if (e.touches.length === 1) {
listener(e.touches[0].pageX)
}
}
target.addEventListener(type, listener0, { once, passive: false })
return () => target.removeEventListener(type, listener0)
}
//
const thumb = this.$refs.thumbContainer.$el
const types = [
{ start: 'mousedown', move: 'mousemove', end: 'mouseup' },
{ start: 'touchstart', move: 'touchmove', end: 'touchend' },
]
for (const type of types) {
let startPageX = 0
let startRealValue = 0
startListen(thumb, type.start, pageX => {
this.$emit('start', this.realValue)
this.$refs.slider.focus()
startPageX = pageX
startRealValue = this.realValue
const stopListenMove = startListen(window, type.move, pageX0 => {
this.setByValue(
startRealValue + this.lengthToValue(pageX0 - startPageX),
)
})
startListen(
window,
type.end,
() => {
this.$emit('end', this.realValue)
stopListenMove()
},
true,
)
})
}
},
},
})
</script>
<style lang="scss" scoped>
@import './common';
@import "./common";
.be-slider {
min-width: 50px;
position: relative;
@ -178,6 +352,7 @@ export default Vue.extend({
}
.default-bar {
height: 4px;
cursor: pointer;
@include round-corner(2px);
background-color: #8882;
}
@ -185,12 +360,12 @@ export default Vue.extend({
position: absolute;
top: 50%;
transform: translateX(-50%) translateY(-50%);
cursor: pointer;
transition: none;
}
.default-thumb {
width: 16px;
height: 16px;
cursor: pointer;
@include round-corner(50%);
background-color: var(--theme-color);
box-shadow: 0 0 0 2px var(--theme-color-20);