Web编辑器适配平板思路以及踩过的坑

104 阅读5分钟

Web适配平板思路以及踩过的坑

标签:Web 前端 / H5 编辑器 / 平板适配 / 触控交互

随着平板设备(尤其是 iPad)在生产力场景中的普及,Web 应用在平板端的交互体验变得越来越重要。许多 Web 编辑器、可视化搭建平台、在线设计工具仍然主要依赖鼠标事件(mousedown/mousemove/mouseup/click),导致平板端触控无法正常触发拖拽、框选、点击等操作。

本文分享我在项目中实现的网页端平板触控适配方案,包括:

  • 触控事件转鼠标事件的完整实现
  • 关键注意点与坑点分析
  • 平板端额外适配:虚拟键盘、视口变化
  • 最终 Hook 封装与优化经验

目录

  1. 背景与需求分析
  2. 实现方案概述
  3. 核心实现代码
  4. 平板适配中的坑与解决方案
  5. 总结与优化建议

背景与需求分析

我们项目是一个在线设计编辑器,支持图层、拖拽、框选、文本编辑等复杂交互。桌面端交互基于鼠标事件,逻辑如下:

  • 拖拽图层:mousedown → mousemove → mouseup
  • 框选区域:mousedown → 拖拽 → mouseup
  • 点击选中:mousedown → mouseup → click

但在平板端,使用 touchstart/touchmove/touchend。如果直接监听 touch 事件,就要重复实现一套逻辑,既不优雅,也容易与现有鼠标事件冲突。

因此,我们希望:

  1. 将触控事件统一映射为鼠标事件,最大程度复用现有逻辑。
  2. 局部生效:只在编辑画布内触发。
  3. 排除输入框、弹窗等非交互区域。
  4. 兼容平板端特有问题(:Safari下的虚拟键盘)。

实现方案概述

我们封装了一个 Hook:useTouchToMouse(containerId)

  • 入参为画布容器 ID
  • 监听全局 touchstart/touchmove/touchend
  • 根据事件目标与状态判断是否转发为 mousedown/mousemove/mouseup/click

主要流程:

  1. touchstart

    • 记录起始坐标
    • 判断是否为禁用区域
    • 转发为 mousedown
  2. touchmove

    • 计算移动距离,超过阈值才认为是拖动
    • 转发为 mousemove
  3. touchend

    • 判断是否需要失焦
    • 转发为 mouseupclick

核心实现代码

事件监听与容器绑定

useEffect(() => {
    const container = document.getElementById(containerId);
    if (!container) return;

    const options = { passive: false, capture: true } as const;
    document.addEventListener('touchstart', handleTouchStart, options);
    document.addEventListener('touchmove', handleTouchMove, options);
    document.addEventListener('touchend', handleTouchEnd, options);

    return () => {
        document.removeEventListener('touchstart', handleTouchStart);
        document.removeEventListener('touchmove', handleTouchMove);
        document.removeEventListener('touchend', handleTouchEnd);
    };
}, [containerId]);

触控转鼠标按下事件逻辑

const handleTouchStart = (event: TouchEvent) => {
    const touch = event.touches[0];
    if (!touch) return;
    
    const targetElement = document.elementFromPoint(touch.clientX, touch.clientY);

     // container 内部 — 转发 mousedown
     if (isInsideContainer(targetElement)) {
        event.preventDefault();
        const mouseDown = new MouseEvent('mousedown', {
            bubbles: true,
            cancelable: true,
            view: window,
            clientX: touch.clientX,
            clientY: touch.clientY,
            button: 0,
            buttons: 1,
           });
        targetElement?.dispatchEvent(mouseDown);
  }
};

触控鼠标移动逻辑

 const handleTouchMove = (event: TouchEvent) => {
    const touch = event.touches[0];
    if (!touch) return;

    const targetElement = document.elementFromPoint(touch.clientX, touch.clientY);

    /** .... 可以插入是否是鼠标移动的逻辑 */

    // container 内部 — 转发 mousemove
    if (isInsideContainer(targetElement)) {
        event.preventDefault();
        const mouseMove = new MouseEvent('mousemove', {
            bubbles: true,
            cancelable: true,
            view: window,
            clientX: touch.clientX,
            clientY: touch.clientY,
            button: 0,
            buttons: 1,
        });
        targetElement?.dispatchEvent(mouseMove);
    }
   };

触控鼠标弹起逻辑

这里需要注意的是,因为我们阻止了鼠标的原生事件,需要在mouseUp后重新触发click事件。


/**
 * 处理 touchend 事件
 * @param event 事件
 */
const handleTouchEnd = (event: TouchEvent) => {
    const touch = event.changedTouches[0];
    if (!touch) return;

    const targetElement = document.elementFromPoint(touch.clientX, touch.clientY);

    // 处理失焦逻辑
    handleBlur(targetElement);
    
     // container 内部 — 转发 mouseup
    if (isInsideContainer(targetElement)) {
        event.preventDefault();

        const mouseUp = new MouseEvent('mouseup', {
            bubbles: true,
            cancelable: true,
            view: window,
            clientX: touch.clientX,
            clientY: touch.clientY,
            button: 0,
            buttons: 0,
        });
        targetElement?.dispatchEvent(mouseUp);

        const clickEvent = new MouseEvent('click', {
            bubbles: true,
            cancelable: true,
            view: window,
            clientX: touch.clientX,
            clientY: touch.clientY,
        });

        setTimeout(() => targetElement?.dispatchEvent(clickEvent), 10);
    }
};
        

平板适配中的坑与解决方案

1. 轻微触控被误识别为拖动

可以在mouseDown的时候去记录鼠标位置(startX、startY),在mouseMove的时候通过以下逻辑去计算是否产生偏移,来判断是点击还是拖动行为。

//偏移区间
const MOVE_THRESHOLD = 10;
const dx = Math.abs(touch.clientX - startX);
const dy = Math.abs(touch.clientY - startY);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
    isMove = true;
}

2. 输入框失焦与虚拟键盘问题

比如我们当前聚焦在一个输入框内,如果点击输入框外的其他地方,这时候可能是不会触发原生的失焦事件,因为被touch事件劫持了。这时候可以通过以下代码解决。

const handleBlur = (targetElement: Element | null) => {
    const activeElement = document.activeElement;
    if (activeElement && activeElement !== document.body) {
        let isTargetInActive = false;
        let parent = targetElement;
        while (parent) {
            if (parent === activeElement) {
                isTargetInActive = true;
                break;
            }
            parent = (parent as HTMLElement).parentElement as HTMLElement;
        }
        if (!isTargetInActive) (activeElement as HTMLElement)?.blur();
    }
};

3. safari浏览器存在兼容性问题

1.键盘收起后页面留白

当键盘收起后,我们可以采用取巧的办法,比如手动触发屏幕滚动,去除虚拟键盘的留白。

ps:本人在实战中是这样解决问题的,如果有更好的,可以一起讨论下

const originalHeight = window.visualViewport?.height || 0;
let isKeyboardVisible = false;

const handleViewportResize = () => {
    const currentHeight = window.visualViewport?.height || 0;
    const heightDiff = originalHeight - currentHeight;

    if (heightDiff > 200) {
        isKeyboardVisible = true;
    } else if (isKeyboardVisible) {
        isKeyboardVisible = false;
        setTimeout(() => {
            window.scrollTo(0, 0);
        }, 100);
    }
};

window.visualViewport?.addEventListener('resize', handleViewportResize);

4.是否是平板(兼容大部分场景)

兼容了很多情况,最后加上了一个dpi的判断,因为用户的电脑存在触屏电脑,可能被我们忽略。

const isPad = () => {
    const ua = navigator.userAgent.toLowerCase();
    const maxTouch = navigator.maxTouchPoints || 0;
    const dpr = window.devicePixelRatio || 1;
    const width = Math.min(window.screen.width, window.screen.height);

    // 1. iPad / iPadOS(iPadOS 13+ 会伪装成 Mac)
    if (/ipad/.test(ua) || (/macintosh/.test(ua) && maxTouch > 1)) {
        return true;
    }

    // 2. Android 平板(非手机)
    if (/android/.test(ua) && !/mobile/.test(ua)) {
        return true;
    }

    // 3. Windows / Linux / X11 平板(触屏设备)
    if ((/windows/.test(ua) || /linux/.test(ua) || /x11/.test(ua)) && maxTouch > 1) {
        // 增加 DPI 判断:一般平板 >= 1.5,触屏电脑多为 1.0~1.25
        const isHighDPI = dpr >= 1.5;
        // 宽度小于 1280 且高 DPI 视作平板,否则视作触屏电脑
        return width < 1280 && isHighDPI;
    }
    return false;
};

总结与优化建议

  • 统一触控与鼠标事件,最大化复用逻辑
  • 增加拖动阈值,减少误触发
  • 兼顾虚拟键盘与布局优化

通过这套方案,平板端体验已接近桌面端且未破坏原有交互。