diff --git a/src/App.vue b/src/App.vue index 190afe1..83078ac 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,6 +18,7 @@ const autoFitOptions = { } let viewportWidth = document.documentElement.clientWidth +let autoFitResetTimer: ReturnType | undefined const initAutoFit = () => { autofit.init(autoFitOptions, false) @@ -29,29 +30,36 @@ const resetAutoFit = () => { viewportWidth = document.documentElement.clientWidth } +const scheduleAutoFitReset = () => { + clearTimeout(autoFitResetTimer) + // 等待旋转动画和视口尺寸稳定,避免 resize 期间反复重建 autofit。 + autoFitResetTimer = setTimeout(resetAutoFit, 50) +} + const handleResize = () => { const nextWidth = document.documentElement.clientWidth // 软键盘通常只改变可视高度;宽度不变时保持原缩放比例。 if (Math.abs(nextWidth - viewportWidth) <= 2) return - resetAutoFit() + scheduleAutoFitReset() } onMounted(() => { initAutoFit() window.addEventListener('resize', handleResize) - window.addEventListener('orientationchange', resetAutoFit) - document.addEventListener('fullscreenchange', resetAutoFit) - document.addEventListener('webkitfullscreenchange', resetAutoFit) + window.addEventListener('orientationchange', scheduleAutoFitReset) + document.addEventListener('fullscreenchange', scheduleAutoFitReset) + document.addEventListener('webkitfullscreenchange', scheduleAutoFitReset) }) onUnmounted(() => { autofit.off('#app') + clearTimeout(autoFitResetTimer) window.removeEventListener('resize', handleResize) - window.removeEventListener('orientationchange', resetAutoFit) - document.removeEventListener('fullscreenchange', resetAutoFit) - document.removeEventListener('webkitfullscreenchange', resetAutoFit) + window.removeEventListener('orientationchange', scheduleAutoFitReset) + document.removeEventListener('fullscreenchange', scheduleAutoFitReset) + document.removeEventListener('webkitfullscreenchange', scheduleAutoFitReset) }) diff --git a/src/components/LandscapeGuard.vue b/src/components/LandscapeGuard.vue index 780e398..d734752 100644 --- a/src/components/LandscapeGuard.vue +++ b/src/components/LandscapeGuard.vue @@ -25,6 +25,8 @@ import { onBeforeUnmount, onMounted, ref } from 'vue' const isPortraitTablet = ref(false) let portraitMediaQuery +let orientationFrame +const orientationTimers = new Set() const isTablet = () => { const userAgent = navigator.userAgent @@ -40,11 +42,32 @@ const isTablet = () => { } const updateOrientation = () => { - const isPortrait = portraitMediaQuery?.matches ?? window.innerHeight > window.innerWidth + const viewport = window.visualViewport + const viewportWidth = viewport?.width || window.innerWidth + const viewportHeight = viewport?.height || window.innerHeight + const isPortrait = viewportHeight > viewportWidth isPortraitTablet.value = isTablet() && isPortrait document.documentElement.classList.toggle('is-portrait-tablet', isPortraitTablet.value) } +const scheduleOrientationUpdate = () => { + updateOrientation() + + cancelAnimationFrame(orientationFrame) + orientationFrame = requestAnimationFrame(updateOrientation) + + orientationTimers.forEach(clearTimeout) + orientationTimers.clear() + // iPad 旋转动画期间视口尺寸会分阶段更新,短时间复检避免停留在旧方向。 + for (const delay of [80, 180, 350]) { + const timer = setTimeout(() => { + orientationTimers.delete(timer) + updateOrientation() + }, delay) + orientationTimers.add(timer) + } +} + const tryLockLandscape = async () => { if (!isTablet() || !screen.orientation?.lock) return @@ -60,17 +83,22 @@ onMounted(() => { updateOrientation() tryLockLandscape() - portraitMediaQuery.addEventListener?.('change', updateOrientation) - window.addEventListener('resize', updateOrientation) - window.addEventListener('orientationchange', updateOrientation) + portraitMediaQuery.addEventListener?.('change', scheduleOrientationUpdate) + window.addEventListener('resize', scheduleOrientationUpdate) + window.addEventListener('orientationchange', scheduleOrientationUpdate) + window.visualViewport?.addEventListener('resize', scheduleOrientationUpdate) document.addEventListener('fullscreenchange', tryLockLandscape) }) onBeforeUnmount(() => { document.documentElement.classList.remove('is-portrait-tablet') - portraitMediaQuery?.removeEventListener?.('change', updateOrientation) - window.removeEventListener('resize', updateOrientation) - window.removeEventListener('orientationchange', updateOrientation) + cancelAnimationFrame(orientationFrame) + orientationTimers.forEach(clearTimeout) + orientationTimers.clear() + portraitMediaQuery?.removeEventListener?.('change', scheduleOrientationUpdate) + window.removeEventListener('resize', scheduleOrientationUpdate) + window.removeEventListener('orientationchange', scheduleOrientationUpdate) + window.visualViewport?.removeEventListener('resize', scheduleOrientationUpdate) document.removeEventListener('fullscreenchange', tryLockLandscape) }) diff --git a/src/views/chassis/MapCanvas.vue b/src/views/chassis/MapCanvas.vue index 5fefa61..9b3b883 100644 --- a/src/views/chassis/MapCanvas.vue +++ b/src/views/chassis/MapCanvas.vue @@ -94,6 +94,9 @@ const containerRef = ref(null) const mapCanvasRef = ref(null) let resizeObserver let eventsBound = false +let orientationObserver +let animationFrameId = null +let isRendering = false const init = () => { mapCanvas = mapCanvasRef.value; @@ -214,6 +217,8 @@ async function loadMapIntoApplication(parsedMap) { * @param {number} timestamp - requestAnimationFrame 提供的时间戳(毫秒) */ function renderFrame(timestamp) { + if (!isRendering || !canvasCtx) return; + // 更新动画时间 applicationState.animationTimeSeconds = (timestamp || 0) / 1000; @@ -223,7 +228,7 @@ function renderFrame(timestamp) { // 如果地图未加载,跳过绘制 if (!applicationState.isMapLoaded) { - requestAnimationFrame(renderFrame); // 继续请求下一帧 + animationFrameId = requestAnimationFrame(renderFrame); // 继续请求下一帧 return; } @@ -273,7 +278,36 @@ function renderFrame(timestamp) { drawMapBoundary(canvasCtx, map, applicationState); // 请求下一帧渲染 - requestAnimationFrame(renderFrame); + animationFrameId = requestAnimationFrame(renderFrame); +} + +const stopRendering = () => { + isRendering = false + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } +} + +const startRendering = () => { + if (isRendering || !applicationState.isMapLoaded || document.documentElement.classList.contains('is-portrait-tablet')) return + isRendering = true + animationFrameId = requestAnimationFrame(renderFrame) +} + +const handleOrientationVisibility = () => { + if (document.documentElement.classList.contains('is-portrait-tablet')) { + stopRendering() + return + } + + // v-show 恢复后等待 Vue 和浏览器完成布局,再重设画布并恢复渲染。 + nextTick(() => { + requestAnimationFrame(() => { + resizeCanvasToContainer() + startRendering() + }) + }) } const drawAllRobots = (ctx) => { @@ -495,6 +529,11 @@ onMounted(() => { resizeObserver = new ResizeObserver(resizeCanvasToContainer) resizeObserver.observe(containerRef.value) window.addEventListener('resize', resizeCanvasToContainer) + orientationObserver = new MutationObserver(handleOrientationVisibility) + orientationObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }) }) watch(() => props.mapName, @@ -502,7 +541,7 @@ watch(() => props.mapName, if (newVal) { await loadSourceMap(newVal) addRobot(robotStore.ip, robotStore.ip, robotStore.position.x, robotStore.position.y, robotStore.position.theta, getThemeColor('--color-canvas-robot')) - requestAnimationFrame(renderFrame); + startRendering() } }, { immediate: true @@ -515,8 +554,10 @@ defineExpose({ }) onUnmounted(() => { + stopRendering() window.removeEventListener('resize', resizeCanvasToContainer) resizeObserver?.disconnect() + orientationObserver?.disconnect() canvasCtx = null })