632 lines
20 KiB
TypeScript
632 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|