在 React 中优雅的使用长按事件

3,414 阅读4分钟

在 React 中优雅的使用长按事件

背景

之前有个需求是需要支持表单内文本的复制,我一想这还不简单?浏览器有自带的 API。可实际上大家都知道 Clipboard API 的兼容性不是很好。再者又出于对项目本身的考量(因为本身是几乎以表单为主的),不能需求说要加一个可复制的字段就再开发一次。

需求:

  • 长按可复制文本
  • 可复制 input、text、textarea、picker、div
  • 支持复制 dform 的 input、text、textarea、picker、div,和普通 input、textarea
  • 禁用或灰显时,也需支持复制

在移动端上复制似乎是个问题,咱也不能弄的满屏都是按钮,而且这种单独配置某个字段来实现的方式,是挺麻烦的,还丑。

于是乎就跟项目经理扯说那我搞个长按复制吧。也就有了这篇文章。

长按事件

说到长按事件,就需要扯出移动端的几个事件了:

  • 按下时触发 ontouchstart
  • 移动时触发 ontouchmove
  • 手指离开时触发 ontouchend

那么思路就是:在 ontouchstart 时加上一个 timeout 去执行回调,ontouchmove 或 ontouchend 清除 timeout 即可。也就是说,超过指定的时间,长按回调执行一次。如果没有超过指定的时间,手指离开屏幕或手指移动,长按的回调不会执行。

interface ILongPressEventsProps {
  onStartCallback: (
    event: React.TouchEvent<
      HTMLDivElement | HTMLTextAreaElement | HTMLInputElement
    >['nativeEvent']['target'],
  ) => void;
  onEndCallback?: (
    event: React.TouchEvent<
      HTMLDivElement | HTMLTextAreaElement | HTMLInputElement
    >['nativeEvent']['target'],
  ) => void;
  ms?: number;
}

type ILongPressStartMethod = (event: React.TouchEvent) => NodeJS.Timeout;
type ILongPressEndMethod = (event: React.TouchEvent) => void;

interface RLongPressEventsReturnTypes {
  onTouchStart: ILongPressStartMethod;
  onTouchMove: ILongPressEndMethod;
  onTouchEnd: ILongPressEndMethod;
}

const longPressEvents = function ({
  onStartCallback,
  onEndCallback,
  ms = 2000,
}: ILongPressEventsProps): RLongPressEventsReturnTypes {
  let timeout: NodeJS.Timeout;
  let target: EventTarget | null;

  const start: ILongPressStartMethod = (event) => {
    if (event.nativeEvent instanceof TouchEvent) target = event.nativeEvent.target;
    return (timeout = setTimeout(() => onStartCallback(target), ms));
  };
  const stop: ILongPressEndMethod = (event) => {
    timeout && window.clearTimeout(timeout); // 合成事件,要先 clear,否则报 warning
    // 下边的其实可以不用,如果不需要结束回调的话
    if (event.nativeEvent instanceof TouchEvent) target = event.nativeEvent.target;
    onEndCallback?.(target);
  };

  return {
    onTouchStart: start,
    onTouchMove: stop,
    onTouchEnd: stop,
  };
};

只需要将这三个事件挂载到 div 上就好(如果是 pc 也可以替换为鼠标事件)。

<div {...longPressMethods}>hello world</div>

实现复制功能

考虑到兼容性,这边引入了 clipboard.js。

// data-clipboard-text 文本源,可参考 clipboard.js,使其隐藏,不手动点击这个按钮
<button id="copy_btn" data-clipboard-text="" style={{ display: 'none' }}></button>
function copyToClipboard(value: string | undefined) {
  if (!value) return;
  const clipboard = new ClipboardJS('#copy_btn');

  clipboard.on('success', (e) => {
    Toast.success('复制成功!', 1);
    e.clearSelection();
  });

  clipboard.on('error', (e) => {
    Toast.fail('复制失败!', 1);
  });

  const btn = document.querySelector('#copy_btn') as HTMLButtonElement;
  btn?.setAttribute('data-clipboard-text', value);
  btn?.click();
}

问题

直接调用复制方法 copyToClipboard 会复制失败,需要通过按钮手动触发。

解决方案

新增一个 prompt,长按 setTimeout 回调弹出,点击确认按钮触发复制。

使用 prompt 有个优点,可以将取得的文本回填到 prompt 的输入框,可以做更改,再复制。

function showCopyPrompt(defaultValue: string | undefined) {
  Modal.prompt(
    '点击以复制文本',
    '',
    [{ text: '取消' }, { text: '确认', onPress: copyToClipboard }],
    'default',
    defaultValue,
  );
}

HOC

抽离出一个 HOC,接收 children,让 children 内部支持复制。

import React from 'react';
import ClipboardJS from 'clipboard';
import { Modal, Toast } from 'antd-mobile';

import { longPressEvents } from '@/utils';

interface CopyToClipboardWrapperProps {}

function copyToClipboard(value: string | undefined) {
  if (!value) return;
  const clipboard = new ClipboardJS('#copy_btn');

  clipboard.on('success', (e) => {
    Toast.success('复制成功!', 1);
    e.clearSelection();
  });

  clipboard.on('error', (e) => {
    Toast.fail('复制失败!', 1);
  });

  // 通过手动触发 prompt 的 positive 回调去触发 copy_btn 的自动点击,很呆,但有用
  const btn = document.querySelector('#copy_btn') as HTMLButtonElement;
  btn?.setAttribute('data-clipboard-text', value);
  btn?.click();
}

function showCopyPrompt(defaultValue: string | undefined) {
  Modal.prompt(
    '点击以复制文本',
    '',
    [{ text: '取消' }, { text: '确认', onPress: copyToClipboard }],
    'default',
    defaultValue,
  );
}

const CopyToClipboardWrapper: React.FC<CopyToClipboardWrapperProps> = ({ children }) => {
  const startCallback = (ele: EventTarget | null) => {
    let text = '';
    const isDFormDiv = (ele as HTMLDivElement)?.className?.includes?.('alitajs-dform');
    const type = (ele as HTMLTextAreaElement | HTMLInputElement)?.tagName;
    if (isDFormDiv) {
      text =
        (ele as HTMLDivElement)?.innerText ||
        (ele as HTMLTextAreaElement | HTMLInputElement)?.value;
    } else {
      if (type === 'INPUT' || type === 'TEXTAREA') {
        text = (ele as HTMLTextAreaElement | HTMLInputElement)?.value;
      } else {
        // 普通标签,比如 div、p、span 等
        // @ts-ignore
        if (ele?.children?.length) return;
        text = (ele as HTMLDivElement)?.innerText;
      }
    }
    showCopyPrompt(text);
  };
  const longPressMethods = longPressEvents(startCallback);

  return (
    <>
      <button id="copy_btn" data-clipboard-text="" style={{ display: 'none' }}></button>
      <div {...longPressMethods}>{children}</div>
    </>
  );
};

export default CopyToClipboardWrapper;

使用

<CopyToClipboardWrapper>
  <DynamicForm {...formProps} />
</CopyToClipboardWrapper>
<CopyToClipboardWrapper>
  <Form {...formProps}>
    <List>
      <NomarInput fieldProps="username" required placeholder="请输入" title="用户名" />
      <NomarRadio fieldProps="gender" title="性别" data={genderList} />
      <NomarDatePicker fieldProps="date" placeholder="请选择" title="出生年月" />
      <NomarPicker
        fieldProps="weather"
        placeholder="请选择"
        title="天气"
        data={weatherList}
      />
      <MultiplePicker
        fieldProps="motion"
        placeholder="请选择"
        title="特长"
        data={motionList}
      />
      {/* ...... */}
    </List>
  </Form>
  <Button onClick={() => form.submit()}>提交</Button>
</CopyToClipboardWrapper>

让它更加实用

既然我们可以使用长按事件来做复制了,还将长按复制给封装成了一个 HOC,那么长按选择或是拖拽呢?自然也就可以实现了。

按照类似的思路,我做了一个长按选择的 layout,支持多选和单选。有兴趣的小伙伴可以看看。

<MultiSelectLayout
  mode="multi"
  namespace="demo"
  selectedListKey="selectData"
  hidden={!data?.length}
  longPress
>
  <Card>
    {data?.map((item: any, index: number) => {
      return (
        <MultiSelectLayout.MultiSelectRow key={item?.id} rowItem={item}>
          <div>{item?.name}</div>
        </MultiSelectLayout.MultiSelectRow>
      );
    })}
  </Card>
</MultiSelectLayout>

关键代码,有删减

MultiSelectLayout

const dispatch = useDispatch();
const childs = React.Children.toArray(children);

const [selectedRows, setSelectedRows] = useState<Array<unknown>>([]);

useEffect(() => {
  dispatch!({
    type: `${namespace}/save`,
    payload: {
      [selectedListKey]: selectedRows,
    },
  });
}, [selectedRows.length]);
<MultiSelectContext.Provider value={{ selectedRows, setSelectedRows, disabled, mode }}>
  <div className={prefixCls} {...reset}>
    {childs.map((child) => {
      if (!React.isValidElement(child)) return;
      const props = {
        ...child.props,
        longPress,
      };
      return React.cloneElement(child, props);
    })}
  </div>
</MultiSelectContext.Provider>

MultiSelectLayout.MultiSelectRow

const [selected, toggle] = useToggle(false);

const fn = {
  toggleSelect() {
    if (disabled) return;
    toggle();
    if (mode === 'multi') {
      fn.mutiSelect(rowItem);
    } else {
      fn.singleSelect(rowItem);
    }
    afterSelect?.(rowItem, [...selectedRows, rowItem]);
  },
  singleSelect(rowItem: MultiSelectRowProps['rowItem']) {
    if (fn.isSelected()) {
      setSelectedRows([]);
    } else {
      setSelectedRows([rowItem]);
    }
  },
  mutiSelect(rowItem: MultiSelectRowProps['rowItem']) {
    const foundResult = fn.isSelected();
    if (foundResult) {
      setSelectedRows!((prev) => prev.filter((item) => item.id !== rowItem.id));
    } else {
      setSelectedRows([...selectedRows, rowItem]);
    }
  },
  isSelected() {
    return selectedRows!.find((item) => item.id === rowItem.id);
  },
  onPress() {
    const execResult = beforeSelect?.();
    if (beforeSelect) {
      if (execResult) {
        fn.toggleSelect();
      }
    } else {
      fn.toggleSelect();
    }
  },
};

useEffect(() => {
  if (selected) {
    onSelect?.(rowItem);
  } else {
    onUnselect?.(rowItem);
  }
}, [selected]);
<div
  className={`${prefixCls}-content`}
  {...(props?.longPress
    ? window.longPressEvents(
        (ele: any) => {
          fn.onPress();
        },
        () => {},
        500,
      )
    : {})}
>
  {children}
</div>

大力支持

dform

clipboardjs

最后

感谢各位耐心看到这里 ❤️