fcd9470dcf
增加桌面端全站客服反馈悬浮入口。 悬停入口时展示 QQ 和微信社区二维码浮窗。 补充入口拖拽边界、点击跳转和移动端隐藏测试。 同步全站客服悬浮入口接入文档。
337 lines
9.1 KiB
TypeScript
337 lines
9.1 KiB
TypeScript
import {
|
|
type CSSProperties,
|
|
type MouseEvent,
|
|
type PointerEvent,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
import { BRAND_ASSETS } from '../../uiAssets';
|
|
import {
|
|
clampFloatingFeedbackPosition,
|
|
FLOATING_FEEDBACK_DESKTOP_QUERY,
|
|
FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS,
|
|
FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD,
|
|
FLOATING_FEEDBACK_FORM_URL,
|
|
FLOATING_FEEDBACK_INITIAL_LEFT,
|
|
FLOATING_FEEDBACK_MARGIN,
|
|
type FloatingFeedbackPosition,
|
|
resolveFloatingFeedbackContactPanelOffset,
|
|
resolveInitialFloatingFeedbackPosition,
|
|
} from './floatingFeedbackEntryModel';
|
|
|
|
type FloatingFeedbackDragState = {
|
|
pointerId: number;
|
|
source: 'mouse' | 'pointer';
|
|
startClientX: number;
|
|
startClientY: number;
|
|
startLeft: number;
|
|
startTop: number;
|
|
hasMoved: boolean;
|
|
};
|
|
|
|
function isSameFloatingFeedbackPointer(
|
|
currentPointerId: number,
|
|
nextPointerId: number,
|
|
) {
|
|
if (!Number.isFinite(currentPointerId) || !Number.isFinite(nextPointerId)) {
|
|
return true;
|
|
}
|
|
|
|
return currentPointerId === nextPointerId;
|
|
}
|
|
|
|
function shouldUseMouseDragFallback() {
|
|
return (
|
|
typeof window !== 'undefined' &&
|
|
typeof window.PointerEvent !== 'function'
|
|
);
|
|
}
|
|
|
|
function getViewportSize() {
|
|
return {
|
|
width: window.innerWidth || document.documentElement.clientWidth,
|
|
height: window.innerHeight || document.documentElement.clientHeight,
|
|
};
|
|
}
|
|
|
|
function getInitialDesktopVisibility() {
|
|
if (
|
|
typeof window === 'undefined' ||
|
|
typeof window.matchMedia !== 'function'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return window.matchMedia(FLOATING_FEEDBACK_DESKTOP_QUERY).matches;
|
|
}
|
|
|
|
export function FloatingFeedbackEntry() {
|
|
const [isDesktopVisible, setIsDesktopVisible] = useState(
|
|
getInitialDesktopVisibility,
|
|
);
|
|
const [isPointerOverEntry, setIsPointerOverEntry] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [position, setPosition] = useState<FloatingFeedbackPosition>(() =>
|
|
typeof window === 'undefined'
|
|
? { left: FLOATING_FEEDBACK_INITIAL_LEFT, top: FLOATING_FEEDBACK_MARGIN }
|
|
: resolveInitialFloatingFeedbackPosition(getViewportSize()),
|
|
);
|
|
const dragStateRef = useRef<FloatingFeedbackDragState | null>(null);
|
|
const suppressClickUntilRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
typeof window === 'undefined' ||
|
|
typeof window.matchMedia !== 'function'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const mediaQuery = window.matchMedia(FLOATING_FEEDBACK_DESKTOP_QUERY);
|
|
const updateVisibility = (event?: MediaQueryListEvent) => {
|
|
setIsDesktopVisible(event?.matches ?? mediaQuery.matches);
|
|
};
|
|
|
|
updateVisibility();
|
|
|
|
if (typeof mediaQuery.addEventListener === 'function') {
|
|
mediaQuery.addEventListener('change', updateVisibility);
|
|
return () => mediaQuery.removeEventListener('change', updateVisibility);
|
|
}
|
|
|
|
mediaQuery.addListener(updateVisibility);
|
|
return () => mediaQuery.removeListener(updateVisibility);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const clampToViewport = () => {
|
|
setPosition((current) =>
|
|
clampFloatingFeedbackPosition(current, getViewportSize()),
|
|
);
|
|
};
|
|
|
|
window.addEventListener('resize', clampToViewport);
|
|
return () => window.removeEventListener('resize', clampToViewport);
|
|
}, []);
|
|
|
|
const updateDrag = (
|
|
pointerId: number,
|
|
source: FloatingFeedbackDragState['source'],
|
|
clientX: number,
|
|
clientY: number,
|
|
) => {
|
|
const dragState = dragStateRef.current;
|
|
if (
|
|
!dragState ||
|
|
dragState.source !== source ||
|
|
!isSameFloatingFeedbackPointer(dragState.pointerId, pointerId)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = clientX - dragState.startClientX;
|
|
const deltaY = clientY - dragState.startClientY;
|
|
if (
|
|
!dragState.hasMoved &&
|
|
Math.hypot(deltaX, deltaY) >= FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD
|
|
) {
|
|
dragState.hasMoved = true;
|
|
}
|
|
|
|
setPosition(
|
|
clampFloatingFeedbackPosition(
|
|
{
|
|
left: dragState.startLeft + deltaX,
|
|
top: dragState.startTop + deltaY,
|
|
},
|
|
getViewportSize(),
|
|
),
|
|
);
|
|
};
|
|
|
|
const stopDragById = (
|
|
pointerId: number,
|
|
source: FloatingFeedbackDragState['source'],
|
|
) => {
|
|
const dragState = dragStateRef.current;
|
|
if (
|
|
!dragState ||
|
|
dragState.source !== source ||
|
|
!isSameFloatingFeedbackPointer(dragState.pointerId, pointerId)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
suppressClickUntilRef.current = dragState.hasMoved
|
|
? Date.now() + FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS
|
|
: 0;
|
|
dragStateRef.current = null;
|
|
setIsDragging(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
|
const dragState = dragStateRef.current;
|
|
if (
|
|
!dragState ||
|
|
!isSameFloatingFeedbackPointer(dragState.pointerId, event.pointerId)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
updateDrag(event.pointerId, 'pointer', event.clientX, event.clientY);
|
|
};
|
|
|
|
const stopPointerDrag = (event: globalThis.PointerEvent) => {
|
|
stopDragById(event.pointerId, 'pointer');
|
|
};
|
|
|
|
window.addEventListener('pointermove', handlePointerMove);
|
|
window.addEventListener('pointerup', stopPointerDrag);
|
|
window.addEventListener('pointercancel', stopPointerDrag);
|
|
return () => {
|
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
window.removeEventListener('pointerup', stopPointerDrag);
|
|
window.removeEventListener('pointercancel', stopPointerDrag);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (event: globalThis.MouseEvent) => {
|
|
const dragState = dragStateRef.current;
|
|
if (!dragState || dragState.source !== 'mouse') {
|
|
return;
|
|
}
|
|
|
|
updateDrag(Number.NaN, 'mouse', event.clientX, event.clientY);
|
|
};
|
|
|
|
const stopMouseDrag = () => {
|
|
stopDragById(Number.NaN, 'mouse');
|
|
};
|
|
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', stopMouseDrag);
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', stopMouseDrag);
|
|
};
|
|
}, []);
|
|
|
|
const startPointerDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
|
if (event.button > 0) {
|
|
return;
|
|
}
|
|
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
setIsDragging(true);
|
|
dragStateRef.current = {
|
|
pointerId: event.pointerId,
|
|
source: 'pointer',
|
|
startClientX: event.clientX,
|
|
startClientY: event.clientY,
|
|
startLeft: position.left,
|
|
startTop: position.top,
|
|
hasMoved: false,
|
|
};
|
|
};
|
|
|
|
const startMouseDrag = (event: MouseEvent<HTMLButtonElement>) => {
|
|
if (!shouldUseMouseDragFallback() || event.button > 0) {
|
|
return;
|
|
}
|
|
|
|
setIsDragging(true);
|
|
dragStateRef.current = {
|
|
pointerId: Number.NaN,
|
|
source: 'mouse',
|
|
startClientX: event.clientX,
|
|
startClientY: event.clientY,
|
|
startLeft: position.left,
|
|
startTop: position.top,
|
|
hasMoved: false,
|
|
};
|
|
};
|
|
|
|
const openFeedbackForm = () => {
|
|
if (suppressClickUntilRef.current >= Date.now()) {
|
|
suppressClickUntilRef.current = 0;
|
|
dragStateRef.current = null;
|
|
return;
|
|
}
|
|
|
|
window.open(FLOATING_FEEDBACK_FORM_URL, '_blank', 'noopener,noreferrer');
|
|
};
|
|
|
|
if (!isDesktopVisible || typeof document === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const shouldShowContactPanel = isPointerOverEntry && !isDragging;
|
|
const contactPanelOffset = resolveFloatingFeedbackContactPanelOffset(
|
|
position,
|
|
getViewportSize(),
|
|
);
|
|
|
|
return createPortal(
|
|
<div
|
|
className="floating-feedback-entry-shell"
|
|
style={{
|
|
left: `${position.left}px`,
|
|
top: `${position.top}px`,
|
|
}}
|
|
>
|
|
{shouldShowContactPanel ? (
|
|
<div
|
|
className="floating-feedback-entry-contact-panel"
|
|
style={
|
|
{
|
|
'--floating-feedback-contact-offset': `${contactPanelOffset}px`,
|
|
} as CSSProperties
|
|
}
|
|
aria-hidden="true"
|
|
>
|
|
<img
|
|
src={BRAND_ASSETS.taonierFeedbackQq}
|
|
alt=""
|
|
draggable={false}
|
|
/>
|
|
<img
|
|
src={BRAND_ASSETS.taonierFeedbackWechat}
|
|
alt=""
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="floating-feedback-entry"
|
|
aria-label="反馈点我"
|
|
onPointerEnter={() => setIsPointerOverEntry(true)}
|
|
onPointerLeave={() => setIsPointerOverEntry(false)}
|
|
onPointerDown={startPointerDrag}
|
|
onMouseEnter={() => setIsPointerOverEntry(true)}
|
|
onMouseLeave={() => setIsPointerOverEntry(false)}
|
|
onMouseDown={startMouseDrag}
|
|
onPointerMove={(event) =>
|
|
updateDrag(event.pointerId, 'pointer', event.clientX, event.clientY)
|
|
}
|
|
onPointerUp={(event) => stopDragById(event.pointerId, 'pointer')}
|
|
onPointerCancel={(event) => stopDragById(event.pointerId, 'pointer')}
|
|
onClick={openFeedbackForm}
|
|
>
|
|
<img
|
|
src={BRAND_ASSETS.taonierFeedbackEntry}
|
|
alt=""
|
|
aria-hidden="true"
|
|
draggable={false}
|
|
/>
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|