Files
Genarrative/src/components/common/FloatingFeedbackEntry.tsx
T
wuxiangwanzi fcd9470dcf 加入客服反馈和社区二维码
增加桌面端全站客服反馈悬浮入口。

悬停入口时展示 QQ 和微信社区二维码浮窗。

补充入口拖拽边界、点击跳转和移动端隐藏测试。

同步全站客服悬浮入口接入文档。
2026-06-23 21:02:13 +08:00

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,
);
}