<template>
	<view class="text-marquee" :style="rootStyle">
		<!-- 测量节点须留在组件布局树内；fixed + 移出视区在部分小程序上宽度为 0，导致永不跑马灯 -->
		<view class="text-marquee__measure">
			<text :id="measureId" class="text-marquee__measure-text" :style="textStyle">{{ text || '' }}</text>
		</view>

		<view :id="boxId" class="text-marquee__box">
			<view v-if="measured && !isOverflow" class="text-marquee__static">
				<text :style="textStyle">{{ text || '' }}</text>
			</view>
			<view v-else-if="measured && isOverflow" class="text-marquee__scroll">
				<view class="text-marquee__track" :style="marqueeTransformStyle">
					<text class="text-marquee__seg" :style="textStyle">{{ text }}</text>
					<view class="text-marquee__gap" :style="{ width: gapRpx + 'rpx' }" />
					<text class="text-marquee__seg" :style="textStyle">{{ text }}</text>
				</view>
			</view>
			<view v-else class="text-marquee__static">
				<text :style="textStyle">{{ text || '' }}</text>
			</view>
		</view>
	</view>
</template>

<script>
function raf(fn) {
	if (typeof requestAnimationFrame === 'function') {
		return requestAnimationFrame(fn)
	}
	return setTimeout(fn, 16)
}

function caf(id) {
	if (id == null) {
		return
	}
	clearTimeout(id)
	if (typeof cancelAnimationFrame === 'function') {
		cancelAnimationFrame(id)
	}
}

export default {
	name: 'text-marquee',
	props: {
		text: {
			type: String,
			default: ''
		},
		width: {
			type: String,
			default: '100%'
		},
		speed: {
			type: Number,
			default: 40
		},
		gap: {
			type: Number,
			default: 32
		},
		fontSize: {
			type: [Number, String],
			default: 28
		},
		color: {
			type: String,
			default: '#333333'
		},
		fontWeight: {
			type: [Number, String],
			default: 'normal'
		},
		/** 测量防抖（ms），减轻父页面频繁 setData 导致的反复测量 */
		measureDebounce: {
			type: Number,
			default: 200
		},
		/** 溢出滞回：进入需 tw > bw + margin，退出需 tw < bw - margin，避免临界抖动 */
		overflowMargin: {
			type: Number,
			default: 4
		}
	},
	data() {
		return {
			uid: `m_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
			measured: false,
			isOverflow: false,
			textWidthPx: 0,
			marqueeOffsetPx: 0,
			_periodPx: 0,
			_rAF: null,
			_lastTs: 0,
			_measureTimer: null,
			_resizeTimer: null,
			/** 每次发起测量自增，exec 回调只对最后一次测量生效，避免多次点击/切换时异步结果乱序覆盖 */
			_measureSeq: 0,
			/** 每次停止跑马灯自增，旧 rAF 回调读到后不继续调度，避免多循环抢 _lastTs 导致 dt 为负、左右狂抖 */
			_marqueeGen: 0
		}
	},
	computed: {
		gapPx() {
			if (typeof uni !== 'undefined' && typeof uni.upx2px === 'function') {
				return uni.upx2px(this.gapRpx)
			}
			return Number(this.gapRpx) || 0
		},
		measureId() {
			return `mm_${this.uid}`
		},
		boxId() {
			return `mb_${this.uid}`
		},
		gapRpx() {
			return this.gap
		},
		rootStyle() {
			return {
				width: this.width
			}
		},
		fontSizeWithUnit() {
			const fs = this.fontSize
			return typeof fs === 'number' ? `${fs}rpx` : fs
		},
		textStyle() {
			return {
				fontSize: this.fontSizeWithUnit,
				color: this.color,
				fontWeight: this.fontWeight
			}
		},
		marqueeTransformStyle() {
			if (!this.isOverflow) {
				return {}
			}
			const x = Math.round(this.marqueeOffsetPx * 100) / 100
			return {
				transform: `translate3d(${x}px, 0, 0)`,
				willChange: 'transform'
			}
		}
	},
	watch: {
		text() {
			this.scheduleRemeasure()
		},
		width() {
			this.scheduleRemeasure()
		},
		fontSize() {
			this.scheduleRemeasure()
		},
		gap() {
			this.scheduleRemeasure()
		}
	},
	mounted() {
		this.runRemeasure()
		// 父级 flex/百分比宽在首帧可能未就绪，延迟再测（尤其微信小程序）
		this.$nextTick(() => {
			setTimeout(() => this.runRemeasure(), 50)
			setTimeout(() => this.runRemeasure(), 280)
		})
		if (typeof uni !== 'undefined' && uni.onWindowResize) {
			this._resizeHandler = () => {
				clearTimeout(this._resizeTimer)
				this._resizeTimer = setTimeout(() => {
					this._resizeTimer = null
					this.runRemeasure()
				}, 280)
			}
			uni.onWindowResize(this._resizeHandler)
		}
	},
	beforeDestroy() {
		this.stopMarquee()
		clearTimeout(this._measureTimer)
		clearTimeout(this._resizeTimer)
		if (this._resizeHandler && typeof uni !== 'undefined' && uni.offWindowResize) {
			uni.offWindowResize(this._resizeHandler)
		}
	},
	methods: {
		scheduleRemeasure() {
			const ms = Math.max(0, Number(this.measureDebounce) || 0)
			clearTimeout(this._measureTimer)
			if (ms <= 0) {
				this.runRemeasure()
				return
			}
			this._measureTimer = setTimeout(() => {
				this._measureTimer = null
				this.runRemeasure()
			}, ms)
		},
		runRemeasure() {
			const seq = ++this._measureSeq
			this.$nextTick(() => {
				if (seq !== this._measureSeq) {
					return
				}
				const q = uni.createSelectorQuery().in(this)
				q.select(`#${this.measureId}`).boundingClientRect()
				q.select(`#${this.boxId}`).boundingClientRect()
				q.exec((res) => {
					if (seq !== this._measureSeq) {
						return
					}
					const tr = res && res[0]
					const br = res && res[1]
					if (!tr || !br || typeof tr.width !== 'number' || typeof br.width !== 'number') {
						this.applyOverflowDecision(false, 0)
						return
					}
					const tw = tr.width
					const bw = br.width
					this.textWidthPx = tw
					const m = Math.max(0, Number(this.overflowMargin) || 0)
					let nextOverflow
					if (!this.measured) {
						nextOverflow = tw > bw + m
					} else if (this.isOverflow) {
						nextOverflow = tw > bw - m
					} else {
						nextOverflow = tw > bw + m
					}
					const period = tw + this.gapPx
					this.applyOverflowDecision(nextOverflow, period)
				})
			})
		},
		applyOverflowDecision(nextOverflow, period) {
			const wasOverflow = this.isOverflow
			this.isOverflow = nextOverflow
			this.measured = true

			if (!nextOverflow) {
				this.stopMarquee()
				this.marqueeOffsetPx = 0
				this._periodPx = 0
				return
			}

			const periodChanged =
				this._periodPx <= 0 || Math.abs(this._periodPx - period) > 0.75
			this._periodPx = period

			if (!wasOverflow && nextOverflow) {
				this.marqueeOffsetPx = 0
				this.$nextTick(() => this.startMarquee(period))
				return
			}

			if (wasOverflow && nextOverflow && periodChanged) {
				this.marqueeOffsetPx = 0
				this.stopMarquee()
				this.$nextTick(() => this.startMarquee(period))
				return
			}

			if (wasOverflow && nextOverflow && !this._rAF) {
				this.$nextTick(() => this.startMarquee(period))
			}
		},
		stopMarquee() {
			this._marqueeGen++
			if (this._rAF != null) {
				caf(this._rAF)
				this._rAF = null
			}
			this._lastTs = 0
		},
		startMarquee(periodPx) {
			this.stopMarquee()
			if (!this.isOverflow || periodPx <= 0) {
				return
			}
			const myGen = this._marqueeGen
			const speed = Math.max(1, Number(this.speed) || 40)
			const loop = (ts) => {
				if (myGen !== this._marqueeGen) {
					return
				}
				if (!this.isOverflow || this._periodPx <= 0) {
					this._rAF = null
					return
				}
				if (!this._lastTs) {
					this._lastTs = ts
				}
				let dt = ts - this._lastTs
				this._lastTs = ts
				if (dt < 0) {
					dt = 0
				}
				if (dt > 80) {
					dt = 80
				}
				let next = this.marqueeOffsetPx - (speed * dt) / 1000
				const p = this._periodPx
				while (next <= -p) {
					next += p
				}
				this.marqueeOffsetPx = next
				if (myGen !== this._marqueeGen) {
					return
				}
				this._rAF = raf(loop)
			}
			this._rAF = raf(loop)
		}
	}
}
</script>

<style lang="scss" scoped>
.text-marquee {
	position: relative;
	box-sizing: border-box;
	min-width: 0;
	max-width: 100%;
}

.text-marquee__measure {
	position: absolute;
	left: 0;
	top: 0;
	z-index: -1;
	opacity: 0;
	pointer-events: none;
	white-space: nowrap;
	display: inline-block;
	width: max-content;
	max-width: none;
	height: 0;
	overflow: visible;
}

.text-marquee__measure-text {
	white-space: nowrap;
	display: inline-block;
}

.text-marquee__box {
	width: 100%;
	overflow: hidden;
	box-sizing: border-box;
}

.text-marquee__static {
	width: 100%;
	overflow: hidden;
	white-space: nowrap;
}

.text-marquee__scroll {
	width: 100%;
	overflow: hidden;
}

.text-marquee__track {
	display: inline-flex;
	flex-direction: row;
	flex-wrap: nowrap;
	align-items: center;
	white-space: nowrap;
	backface-visibility: hidden;
}

.text-marquee__seg {
	flex-shrink: 0;
	white-space: nowrap;
}

.text-marquee__gap {
	flex-shrink: 0;
	height: 1px;
}
</style>
