/** * 扫码组件的两种形态: * 1. 页面采集:监听全局键盘事件:addEventListenerKeyDown2Scan * 2. 输入框采集:监听onKeyDown事件 */ /* eslint-disable @typescript-eslint/ban-ts-comment */ import React, { useState, useEffect, useCallback, useRef, ReactNode, useMemo, } from 'react'; import classnames from 'classnames'; import _ from 'lodash'; import event from '../event'; import './index.scss'; type noop = (value: string) => void; type AnonymousFunction = (...args: any[]) => any; // 扫码枪输入最大间隔时长(ms) const MAX_KEEP_TIME = 100; // 浏览器热键黑名单 const WHITE_CTRL_KEYS = ['a', 'c', 'v', 'x']; const WHITE_SPECIAL_KEYS = ['ENTER', 'BACKSPACE']; export interface ScanProps { /** 默认值 */ defaultValue?: string | number; /** 默认展示输入框 */ defaultShowInput?: boolean; /** 组件文字 */ placeholder?: string; /** 扫码框大小 */ size?: 'large' | 'medium' | 'small'; /** 是否允许手输/复制粘贴条码 */ manual?: boolean; /** 是否可以通过空格键切换扫码框形态 */ hasToggle?: boolean; /** 是否处于禁用态,禁用态时Enter事件会失效 */ disabled?: boolean; /** 扫码/回车时触发函数 */ onEnter?: (value: string, isScan: boolean) => void; /** 输入框状态聚焦时触发事件 */ onFocus?: AnonymousFunction; /** 输入框状态失焦时触发事件 */ onBlur?: AnonymousFunction; /** 输入框状态值更改时触发事件 */ onChange?: AnonymousFunction; /** 组件状态变更时触发事件 */ onToggle?: (showInput: boolean) => void; className?: string; /** 自定义内联样式 */ style?: React.CSSProperties; prefix?: boolean | ReactNode; // 显示 Input 时 自动聚焦 autoFocus?: boolean; value?: string | number; } const globalEventCallbacks: noop[] = []; Scan.addGlobalEnterEvent = function (callback: (value: string) => void) { if ( callback instanceof Function && !globalEventCallbacks.includes(callback) ) { globalEventCallbacks.push(callback); } }; Scan.removeGlobalEnterEvent = function (callback: (value: string) => void) { const findIndex = globalEventCallbacks.findIndex( (callItem) => callItem === callback, ); if (callback instanceof Function && findIndex > -1) { globalEventCallbacks.splice(findIndex, 1); } }; let scanning = false; // 特殊标记:是否正在扫码,用来处理扫码时识别到特殊字符‘space’从而触发toggle事件的问题 export default function Scan({ defaultValue = '', defaultShowInput, placeholder = '', size = 'large', manual = true, hasToggle = true, disabled, onEnter, onFocus, onBlur, onChange, onToggle, className = '', prefix = true, autoFocus, value, ...props }: ScanProps): JSX.Element { const [showInput, setShowInput] = useState(defaultShowInput); const inputEl = useRef(null); const focusRecord = useHasInputFocus(); const defaultInputValue = useRef(defaultValue); // 缓存,不参与渲染 const cache = useMemo<{ lastPressKeyTime?: number }>(() => ({}), []); const toggle = useCallback( (e?: KeyboardEvent) => { (e as KeyboardEvent).preventDefault(); // 如果扫码枪在扫码过程中识别到了空格键, 禁用toggle if (scanning) { return; } manual && setShowInput((v) => !v); if (inputEl.current) { inputEl.current.value = ''; } showInput && focusRecord.unFocus(); onToggle instanceof Function && onToggle(!showInput); }, [manual, showInput, focusRecord, onToggle], ); const onEnterRef = useRef(null); // 支持 显示输入框时自动聚焦 useEffect(() => { if (showInput && autoFocus) inputEl.current?.focus(); }, [showInput, autoFocus]); const enterHub = useCallback( (val: string, isScan: boolean) => { if (disabled) { return; } const onEnterCur = onEnterRef.current as AnonymousFunction; onEnterCur instanceof Function && onEnterCur(val.trim().replace(/Enter/g, ''), isScan); globalEventCallbacks.forEach((callback) => { callback(val); }); }, [disabled], ); useEffect(() => { onEnterRef.current = onEnter ?? null; }, [onEnter]); // 监听空格键 useEffect(() => { if (hasToggle) { // 延时处理toggle,避免扫码状态还未设置成功(扫码状态需要在读取到第二个按键时才能设置成功)就触发toggle,导致状态混乱 const toggleDebounce = _.debounce(toggle, 1); event.bind('space', toggleDebounce); return () => { event.unbind('space', toggleDebounce); }; } }, [toggle, hasToggle]); // 处理扫码枪事件 useEffect(() => { return addEventListenerKeyDown2Scan( function (value: string) { !focusRecord.hasFocus() && enterHub(value, true); }, focusRecord, (inputval) => { defaultInputValue.current = inputval; }, ); }, [enterHub]); // 处理输入框事件 const onKeyDown = useCallback( function (e: React.KeyboardEvent) { const { key = '' } = e; const upperKey = key.toLocaleUpperCase(); const isScan = !!cache.lastPressKeyTime && Date.now() - cache.lastPressKeyTime < MAX_KEEP_TIME; if (!scanning && isScan) { scanning = true; } if (isScan && upperKey === 'TAB') { e.preventDefault(); return; } if (upperKey === 'ENTER') { enterHub((inputEl.current?.value || '').trim(), isScan); if (inputEl.current) { inputEl.current.value = ''; inputEl.current.focus(); } // 延长scanning状态,避免与toggle冲突 setTimeout(() => { scanning = false; }, 2); e.stopPropagation(); } cache.lastPressKeyTime = Date.now(); }, [enterHub], ); const handleFocus = useCallback( (event) => { focusRecord.doFocus(); onFocus instanceof Function && onFocus(event); }, [focusRecord, onFocus], ); const handleBlur = useCallback( (event) => { hasInputFocus = false; focusRecord.unFocus(); onBlur instanceof Function && onBlur(event); }, [focusRecord, onBlur], ); const handleChange = useCallback( (event) => { onChange instanceof Function && onChange(event.target.value); }, [onChange], ); const inputOtherProps = useMemo(() => { return value ? { value } : {}; }, [value]); return (
manual && setShowInput(true)} {...props} > {showInput ? ( ) : (
{prefix ? ( typeof prefix === 'boolean' ? (
) : ( prefix ) ) : null} {placeholder}
)}
); } // 通过监听keydown模拟扫码事件 function addEventListenerKeyDown2Scan( cb: (value: string) => void, focusRecord: IFocusOp, setDefaultInputValue: (value: string) => void, ) { let prevInputTime = Date.now(); let inputValue = ''; function onDocumentKeyDown(event?: KeyboardEvent) { const key = (event as KeyboardEvent).key; // 如果是在页面的文本框输入,则不处理 if (event && ['INPUT'].includes((event.target as HTMLElement)?.tagName)) { return; } if (focusRecord.hasFocus()) { if ( [ 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', ].includes(key?.toLowerCase?.()) ) { // input focus 的时候,需要阻止默认快捷键,避免受到默认快捷的影响 (event as KeyboardEvent)?.preventDefault?.(); } return; } const upperKey = key.toUpperCase(); const timestamp = Date.now(); const timeDiff = timestamp - prevInputTime; prevInputTime = timestamp; const isScan = timeDiff < MAX_KEEP_TIME; // 是否扫码枪扫描 if (!scanning && isScan) { scanning = true; } // 如下场景需屏蔽: // 1. ctrl+a/c/v/x,alt+a/c/v/x const cond1 = ((event as KeyboardEvent).ctrlKey || (event as KeyboardEvent).altKey) && !WHITE_CTRL_KEYS.includes(key); // 2. 不是输入一个字符,比如shift up, down 等 const cond2 = String(key).length !== 1 && !WHITE_SPECIAL_KEYS.includes(upperKey); // 3. 扫码状态屏蔽tab及浏览器默认行为 const cond3 = isScan && upperKey === 'TAB'; if (cond1 || cond2 || cond3) { event?.preventDefault(); return; } if (isScan && upperKey === 'ENTER' && String(inputValue).length) { cb(inputValue); inputValue = ''; // 延长scanning状态,避免与toggle冲突 setTimeout(() => { scanning = false; }, 2); return; } if (isScan) { inputValue += key; } else { inputValue = key; } setDefaultInputValue(inputValue); } event.bind('all', onDocumentKeyDown); return () => { event.unbind('all', onDocumentKeyDown); }; } let hasInputFocus = false; interface IFocusOp { hasFocus: () => boolean; doFocus: () => void; unFocus: () => void; } function useHasInputFocus(): IFocusOp { useEffect(() => { return () => { // 实例销毁,需要做个判断 if (hasInputFocus) { hasInputFocus = false; } }; }, []); return { hasFocus() { return hasInputFocus; }, doFocus() { hasInputFocus = true; }, unFocus() { hasInputFocus = false; }, }; }