Files
hfb_jysh/distribution-admin/src/components/BannerVisualEditor.tsx
T

632 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import {
Button,
Input,
InputNumber,
Select,
Space,
Switch,
Typography,
Upload,
message,
} from 'antd'
import { DeleteOutlined, PictureOutlined, UploadOutlined } from '@ant-design/icons'
import {
BANNER_HOTSPOT_DEFAULT_HOVER_SCALE,
bannerActionHints,
bannerActionOptions,
createBannerHotspot,
type BannerAction,
type BannerActionType,
type BannerHotspot,
type BannerVisual,
} from '../homeBanner'
import { uploadSiteImage } from '../api'
import { probeImageTransparency } from '../utils/imageTransparency'
interface Props {
value: BannerVisual
onChange: (value: BannerVisual) => void
ratio: { width: number; height: number }
label: string
standard: string
}
type DragMode = 'move' | 'resize'
interface DragState {
id: string
mode: DragMode
clientX: number
clientY: number
origin: BannerHotspot
}
function clamp(value: number, min: number, max: number) {
return Math.round(Math.min(Math.max(value, min), Math.max(max, min)) * 100) / 100
}
export default function BannerVisualEditor({ value, onChange, ratio, label, standard }: Props) {
const [uploading, setUploading] = useState(false)
const [uploadingHotspot, setUploadingHotspot] = useState(false)
const [bannerWarning, setBannerWarning] = useState('')
const [hotspotWarning, setHotspotWarning] = useState('')
const [selectedId, setSelectedId] = useState('')
const canvasRef = useRef<HTMLDivElement | null>(null)
const valueRef = useRef(value)
const onChangeRef = useRef(onChange)
const dragRef = useRef<DragState | null>(null)
const draggingRef = useRef(false)
valueRef.current = value
onChangeRef.current = onChange
const selectedHotspot = value.hotspots.find((item) => item.id === selectedId) ?? null
function emit(patch: Partial<BannerVisual>) {
onChangeRef.current({ ...valueRef.current, ...patch })
}
function updateHotspot(id: string, patch: Partial<BannerHotspot>) {
emit({
hotspots: valueRef.current.hotspots.map((item) =>
item.id === id ? { ...item, ...patch } : item,
),
})
}
function patchSelected(patch: Partial<BannerHotspot>) {
const target = valueRef.current.hotspots.find((item) => item.id === selectedId)
if (!target) return
updateHotspot(target.id, patch)
}
async function handleBannerUpload(file: File) {
setUploading(true)
try {
const url = await uploadSiteImage(file, 'home-banner')
emit({ image_url: url })
message.success('图片已上传')
await warnAboutBannerImage(file)
} catch {
message.error('图片上传失败')
} finally {
setUploading(false)
}
}
async function warnAboutBannerImage(file: File) {
setBannerWarning('')
const probe = await probeImageTransparency(file)
if (!probe?.suspectedBakedBackdrop) return
setBannerWarning(
'这张图几乎只有白/浅灰两种颜色且没有透明区域,很像把「透明棋盘格」一起导出了,前台会照实显示。',
)
message.warning('图片疑似带棋盘格底纹,请检查后重新导出')
}
async function handleHotspotUpload(file: File) {
const target = valueRef.current.hotspots.find((item) => item.id === selectedId)
if (!target) return
setUploadingHotspot(true)
try {
const url = await uploadSiteImage(file, 'home-banner')
updateHotspot(target.id, { image_url: url })
message.success('热区图片已上传')
await warnAboutHotspotImage(file)
} catch {
message.error('热区图片上传失败')
} finally {
setUploadingHotspot(false)
}
}
async function warnAboutHotspotImage(file: File) {
setHotspotWarning('')
const probe = await probeImageTransparency(file)
if (!probe || probe.hasTransparentPixels) return
setHotspotWarning(
probe.suspectedBakedBackdrop
? '这张图没有透明区域,且只有白/浅灰底色:很可能是把「透明棋盘格」一起导出了。请重新导出带透明通道的 PNG。'
: '这张图没有透明区域,会整块盖住海报背景;透明按钮图请用带 alpha 通道的 PNG。',
)
message.warning('热区图片不带透明区域,请确认导出方式')
}
function addHotspotAt(event: React.MouseEvent<HTMLDivElement>) {
if (draggingRef.current) return
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
const width = 24
const height = 24
const x = ((event.clientX - rect.left) / rect.width) * 100
const y = ((event.clientY - rect.top) / rect.height) * 100
const hotspot = createBannerHotspot({
x: clamp(x - width / 2, 0, 100 - width),
y: clamp(y - height / 2, 0, 100 - height),
width,
height,
})
emit({ hotspots: [...valueRef.current.hotspots, hotspot] })
setSelectedId(hotspot.id)
setHotspotWarning('')
}
function startDrag(
event: React.MouseEvent<HTMLElement>,
hotspot: BannerHotspot,
mode: DragMode,
) {
event.preventDefault()
event.stopPropagation()
setSelectedId(hotspot.id)
dragRef.current = {
id: hotspot.id,
mode,
clientX: event.clientX,
clientY: event.clientY,
origin: { ...hotspot },
}
window.addEventListener('mousemove', handleDragMove)
window.addEventListener('mouseup', stopDrag)
}
function handleDragMove(event: MouseEvent) {
const state = dragRef.current
const canvas = canvasRef.current
if (!state || !canvas) return
const rect = canvas.getBoundingClientRect()
if (!rect.width || !rect.height) return
const deltaX = ((event.clientX - state.clientX) / rect.width) * 100
const deltaY = ((event.clientY - state.clientY) / rect.height) * 100
const target = valueRef.current.hotspots.find((item) => item.id === state.id)
if (!target) return
if (state.mode === 'move') {
updateHotspot(state.id, {
x: clamp(state.origin.x + deltaX, 0, 100 - target.width),
y: clamp(state.origin.y + deltaY, 0, 100 - target.height),
})
return
}
updateHotspot(state.id, {
width: clamp(state.origin.width + deltaX, 4, 100 - target.x),
height: clamp(state.origin.height + deltaY, 4, 100 - target.y),
})
}
function stopDrag() {
if (!dragRef.current) return
draggingRef.current = true
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', stopDrag)
dragRef.current = null
window.setTimeout(() => {
draggingRef.current = false
}, 0)
}
useEffect(() => {
return () => {
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', stopDrag)
}
}, [])
function removeHotspot() {
if (!selectedHotspot) return
emit({ hotspots: value.hotspots.filter((item) => item.id !== selectedHotspot.id) })
setSelectedId('')
}
function updateAction(action: BannerAction) {
emit({ action })
}
function renderActionEditor(action: BannerAction, onActionChange: (next: BannerAction) => void) {
const needTarget = action.type === 'route' || action.type === 'link'
return (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
<Space wrap>
<Typography.Text strong>跳转类型</Typography.Text>
<Select<BannerActionType>
value={action.type}
style={{ width: 130 }}
options={bannerActionOptions}
onChange={(type) => onActionChange({ ...action, type })}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{bannerActionHints[action.type]}
</Typography.Text>
</Space>
{needTarget && (
<>
<Input
value={action.target}
placeholder={action.type === 'link' ? 'https://example.com/act' : '/m/announcements'}
onChange={(event) => onActionChange({ ...action, target: event.target.value })}
/>
<Space>
<Switch
checked={action.open_new_tab}
onChange={(checked) => onActionChange({ ...action, open_new_tab: checked })}
/>
<Typography.Text type="secondary">在新窗口打开</Typography.Text>
</Space>
</>
)}
</Space>
)
}
return (
<div style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<Typography.Text strong>{label}</Typography.Text>
<Typography.Text style={{ color: '#ff6a00', fontSize: 12, fontWeight: 700 }}>
建议出图 {standard}
</Typography.Text>
</div>
<Space.Compact style={{ width: '100%' }}>
<Input
value={value.image_url}
placeholder="图片 URL,留空则该端退回文字卡片"
onChange={(event) => {
setBannerWarning('')
emit({ image_url: event.target.value })
}}
/>
<Upload
accept="image/jpeg,image/png,image/webp"
showUploadList={false}
beforeUpload={(file) => {
void handleBannerUpload(file)
return false
}}
>
<Button icon={<UploadOutlined />} loading={uploading}>
上传图片
</Button>
</Upload>
<Button
disabled={!value.image_url}
onClick={() => {
setBannerWarning('')
setSelectedId('')
emit({ image_url: '' })
}}
>
清除图片
</Button>
</Space.Compact>
{bannerWarning && (
<Typography.Text style={{ color: '#d97706', fontSize: 12 }}>{bannerWarning}</Typography.Text>
)}
<div
ref={canvasRef}
onClick={addHotspotAt}
style={{
position: 'relative',
width: '100%',
aspectRatio: `${ratio.width} / ${ratio.height}`,
borderRadius: 10,
overflow: 'hidden',
cursor: 'crosshair',
userSelect: 'none',
background: 'repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 50% / 18px 18px',
}}
>
{value.image_url ? (
<img
src={value.image_url}
alt=""
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
pointerEvents: 'none',
}}
/>
) : (
<div
style={{
position: 'absolute',
inset: 0,
display: 'grid',
placeItems: 'center',
color: '#94a3b8',
fontSize: 13,
}}
>
先上传图片,再在图上单击新增热区
</div>
)}
{value.hotspots.map((hotspot, index) => (
<div
key={hotspot.id}
onClick={(event) => {
event.stopPropagation()
setSelectedId(hotspot.id)
setHotspotWarning('')
}}
onMouseDown={(event) => startDrag(event, hotspot, 'move')}
style={{
position: 'absolute',
left: `${hotspot.x}%`,
top: `${hotspot.y}%`,
width: `${hotspot.width}%`,
height: `${hotspot.height}%`,
display: 'flex',
alignItems: 'flex-start',
gap: 4,
padding: '2px 4px',
border: `1px ${hotspot.id === selectedId ? 'solid' : 'dashed'} rgba(255, 106, 0, 0.85)`,
borderRadius: 6,
background:
hotspot.id === selectedId ? 'rgba(255, 106, 0, 0.32)' : 'rgba(255, 106, 0, 0.16)',
color: '#ffffff',
fontSize: 11,
lineHeight: 1.4,
cursor: 'move',
overflow: 'hidden',
}}
>
{hotspot.image_url && (
<img
src={hotspot.image_url}
alt=""
draggable={false}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
opacity: 0.82,
pointerEvents: 'none',
}}
/>
)}
<span
style={{
position: 'relative',
zIndex: 1,
flex: '0 0 auto',
padding: '0 4px',
borderRadius: 4,
background: '#ff6a00',
fontWeight: 700,
}}
>
{index + 1}
</span>
<span
style={{
position: 'relative',
zIndex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textShadow: '0 1px 2px rgba(15, 23, 42, 0.6)',
}}
>
{hotspot.label || '未命名热区'}
</span>
<i
onMouseDown={(event) => startDrag(event, hotspot, 'resize')}
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: 12,
height: 12,
borderRight: '2px solid #ff6a00',
borderBottom: '2px solid #ff6a00',
background: 'rgba(255, 255, 255, 0.9)',
cursor: 'nwse-resize',
}}
/>
</div>
))}
</div>
<Typography.Text type="secondary" style={{ fontSize: 12, lineHeight: 1.6 }}>
在预览图上单击可新增热区,按住拖动可移动,拖右下角小方块可缩放;坐标按百分比保存,换分辨率也不跑位。
热区按钮图必须是带透明通道的 PNG。
</Typography.Text>
<div
style={{
display: 'grid',
gap: 10,
padding: '10px 12px',
border: '1px solid #eef1f5',
borderRadius: 10,
background: '#fafbfc',
}}
>
{renderActionEditor(value.action, updateAction)}
<Input
value={value.alt}
placeholder="整图说明(无障碍描述,可选)"
onChange={(event) => emit({ alt: event.target.value })}
/>
</div>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography.Text strong>热区({value.hotspots.length})</Typography.Text>
<Button
size="small"
disabled={!value.hotspots.length}
onClick={() => {
emit({ hotspots: [] })
setSelectedId('')
setHotspotWarning('')
}}
>
清空热区
</Button>
</div>
{!value.hotspots.length ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
未配置热区时,整张图按上面的「整图点击」行为响应。
</Typography.Text>
) : (
<>
<Space wrap size={6}>
{value.hotspots.map((hotspot, index) => (
<Button
key={hotspot.id}
size="small"
type={hotspot.id === selectedId ? 'primary' : 'default'}
onClick={() => {
setSelectedId(hotspot.id)
setHotspotWarning('')
}}
>
{index + 1}. {hotspot.label || '未命名热区'}
</Button>
))}
</Space>
{selectedHotspot && (
<div
style={{
display: 'grid',
gap: 10,
padding: '10px 12px',
border: '1px solid #eef1f5',
borderRadius: 10,
background: '#fafbfc',
}}
>
<Input
value={selectedHotspot.label}
placeholder="热区名称,如 租币教程"
onChange={(event) => patchSelected({ label: event.target.value })}
/>
<Space.Compact style={{ width: '100%' }}>
<Input
value={selectedHotspot.image_url}
placeholder="透明 PNG 按钮图 URL,留空则用高亮框"
onChange={(event) => {
setHotspotWarning('')
patchSelected({ image_url: event.target.value })
}}
/>
<Upload
accept="image/png,image/webp"
showUploadList={false}
beforeUpload={(file) => {
void handleHotspotUpload(file)
return false
}}
>
<Button icon={<PictureOutlined />} loading={uploadingHotspot}>
上传按钮图
</Button>
</Upload>
<Button
disabled={!selectedHotspot.image_url}
onClick={() => {
setHotspotWarning('')
patchSelected({ image_url: '' })
}}
>
清除
</Button>
</Space.Compact>
{hotspotWarning && (
<Typography.Text style={{ color: '#d97706', fontSize: 12 }}>
{hotspotWarning}
</Typography.Text>
)}
<Space wrap>
<Typography.Text>悬浮放大</Typography.Text>
<InputNumber
min={1}
max={1.5}
step={0.02}
precision={2}
value={selectedHotspot.hover_scale}
onChange={(value) =>
patchSelected({
hover_scale: value ?? BANNER_HOTSPOT_DEFAULT_HOVER_SCALE,
})
}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
1 = 不放大,建议 1.04-1.08
</Typography.Text>
</Space>
<Space wrap size={8}>
<Typography.Text>位置尺寸</Typography.Text>
<InputNumber
min={0}
max={100}
step={0.5}
precision={1}
addonBefore="X"
value={selectedHotspot.x}
onChange={(value) => patchSelected({ x: value ?? 0 })}
/>
<InputNumber
min={0}
max={100}
step={0.5}
precision={1}
addonBefore="Y"
value={selectedHotspot.y}
onChange={(value) => patchSelected({ y: value ?? 0 })}
/>
<InputNumber
min={1}
max={100}
step={0.5}
precision={1}
addonBefore="宽"
value={selectedHotspot.width}
onChange={(value) => patchSelected({ width: value ?? 1 })}
/>
<InputNumber
min={1}
max={100}
step={0.5}
precision={1}
addonBefore="高"
value={selectedHotspot.height}
onChange={(value) => patchSelected({ height: value ?? 1 })}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
百分比(0-100)
</Typography.Text>
</Space>
{renderActionEditor(selectedHotspot.action, (action) =>
patchSelected({ action }),
)}
<div>
<Button danger size="small" icon={<DeleteOutlined />} onClick={removeHotspot}>
删除该热区
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}