364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import { Spin } from 'antd';
|
|
import { previewHomeMaterialTextWatermark } from '../../api';
|
|
import { apiUrl } from '../../utils/resourceUrl';
|
|
import type { HomeMaterialMediaType, HomeMaterialTextWatermarkPayload, HomeMaterialWatermarkConfig } from '../../types';
|
|
|
|
interface WatermarkPreviewProps {
|
|
mediaUrl?: string | null;
|
|
mediaType?: HomeMaterialMediaType;
|
|
watermarkUrl?: string | null;
|
|
config: HomeMaterialWatermarkConfig;
|
|
onChange?: (patch: Partial<HomeMaterialWatermarkConfig>) => void;
|
|
}
|
|
|
|
interface Size {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const PREVIEW_MAX_HEIGHT = 360;
|
|
const PREVIEW_MIN_HEIGHT = 280;
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
if (!Number.isFinite(value)) return min;
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function calcStageSize(containerWidth: number, naturalSize: Size | null): Size {
|
|
const maxWidth = Math.max(0, containerWidth);
|
|
if (!maxWidth) return { width: 0, height: PREVIEW_MIN_HEIGHT };
|
|
|
|
if (!naturalSize?.width || !naturalSize?.height) {
|
|
return { width: maxWidth, height: PREVIEW_MIN_HEIGHT };
|
|
}
|
|
|
|
const scale = Math.min(maxWidth / naturalSize.width, PREVIEW_MAX_HEIGHT / naturalSize.height);
|
|
return {
|
|
width: Math.max(1, Math.round(naturalSize.width * scale)),
|
|
height: Math.max(1, Math.round(naturalSize.height * scale)),
|
|
};
|
|
}
|
|
|
|
function calcWatermarkWidth(config: HomeMaterialWatermarkConfig, stageWidth: number): number {
|
|
if (config.sizeMode === 'px' && config.widthPx) {
|
|
return clamp(config.widthPx, 1, Math.max(1, stageWidth));
|
|
}
|
|
const ratio = clamp(config.widthRatio ?? 0.18, 0.01, 1);
|
|
return Math.max(1, stageWidth * ratio);
|
|
}
|
|
|
|
function calcPresetPosition(config: HomeMaterialWatermarkConfig): React.CSSProperties {
|
|
const marginX = config.marginX ?? 24;
|
|
const marginY = config.marginY ?? 24;
|
|
|
|
switch (config.position) {
|
|
case 'top_left':
|
|
return { left: marginX, top: marginY };
|
|
case 'top_center':
|
|
return { left: '50%', top: marginY, transform: 'translateX(-50%)' };
|
|
case 'top_right':
|
|
return { right: marginX, top: marginY };
|
|
case 'middle_left':
|
|
return { left: marginX, top: '50%', transform: 'translateY(-50%)' };
|
|
case 'center':
|
|
return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
|
|
case 'middle_right':
|
|
return { right: marginX, top: '50%', transform: 'translateY(-50%)' };
|
|
case 'bottom_left':
|
|
return { left: marginX, bottom: marginY };
|
|
case 'bottom_center':
|
|
return { left: '50%', bottom: marginY, transform: 'translateX(-50%)' };
|
|
case 'bottom_right':
|
|
default:
|
|
return { right: marginX, bottom: marginY };
|
|
}
|
|
}
|
|
|
|
function toTextPayload(config: HomeMaterialWatermarkConfig): HomeMaterialTextWatermarkPayload | null {
|
|
const text = config.textWatermark;
|
|
if (!text || !text.text?.trim()) return null;
|
|
return {
|
|
text: text.text.trim(),
|
|
opacity_level: text.opacityLevel,
|
|
font_size_px: text.fontSizePx,
|
|
color: text.color,
|
|
rotate_deg: text.rotateDeg,
|
|
gap_x: text.gapX,
|
|
gap_y: text.gapY,
|
|
staggered: text.staggered,
|
|
};
|
|
}
|
|
|
|
const WatermarkPreview: React.FC<WatermarkPreviewProps> = ({
|
|
mediaUrl,
|
|
mediaType = 'image',
|
|
watermarkUrl,
|
|
config,
|
|
onChange,
|
|
}) => {
|
|
const outerRef = useRef<HTMLDivElement | null>(null);
|
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
const watermarkRef = useRef<HTMLImageElement | null>(null);
|
|
const dragOffsetRef = useRef({ x: 0, y: 0 });
|
|
const previewSeqRef = useRef(0);
|
|
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
const [naturalSize, setNaturalSize] = useState<Size | null>(null);
|
|
const [watermarkSize, setWatermarkSize] = useState<Size>({ width: 0, height: 0 });
|
|
const [dragging, setDragging] = useState(false);
|
|
const [textPreviewLayer, setTextPreviewLayer] = useState<string>('');
|
|
const [textPreviewLoading, setTextPreviewLoading] = useState(false);
|
|
const [textPreviewError, setTextPreviewError] = useState<string>('');
|
|
|
|
const mediaSrc = useMemo(() => apiUrl(mediaUrl), [mediaUrl]);
|
|
const watermarkSrc = useMemo(() => apiUrl(watermarkUrl), [watermarkUrl]);
|
|
const stageSize = useMemo(() => calcStageSize(containerWidth, naturalSize), [containerWidth, naturalSize]);
|
|
const watermarkWidth = useMemo(() => calcWatermarkWidth(config, stageSize.width), [config, stageSize.width]);
|
|
const isRepeatedText = config.watermarkType === 'repeated_text';
|
|
|
|
useLayoutEffect(() => {
|
|
if (!outerRef.current) return;
|
|
const update = () => setContainerWidth(outerRef.current?.clientWidth || 0);
|
|
update();
|
|
const observer = new ResizeObserver(update);
|
|
observer.observe(outerRef.current);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!watermarkRef.current) return;
|
|
const update = () => {
|
|
const rect = watermarkRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
setWatermarkSize({ width: rect.width, height: rect.height });
|
|
};
|
|
update();
|
|
const observer = new ResizeObserver(update);
|
|
observer.observe(watermarkRef.current);
|
|
return () => observer.disconnect();
|
|
}, [watermarkSrc, watermarkWidth]);
|
|
|
|
useEffect(() => {
|
|
setNaturalSize(null);
|
|
setTextPreviewLayer('');
|
|
setTextPreviewError('');
|
|
}, [mediaSrc, mediaType]);
|
|
|
|
useEffect(() => {
|
|
if (!isRepeatedText) {
|
|
setTextPreviewLayer('');
|
|
setTextPreviewLoading(false);
|
|
setTextPreviewError('');
|
|
return;
|
|
}
|
|
const textPayload = toTextPayload(config);
|
|
if (!naturalSize?.width || !naturalSize.height || !textPayload) {
|
|
setTextPreviewLayer('');
|
|
return;
|
|
}
|
|
|
|
const seq = ++previewSeqRef.current;
|
|
setTextPreviewLoading(true);
|
|
setTextPreviewError('');
|
|
const timer = window.setTimeout(async () => {
|
|
try {
|
|
const res = await previewHomeMaterialTextWatermark({
|
|
width: naturalSize.width,
|
|
height: naturalSize.height,
|
|
text_watermark: textPayload,
|
|
});
|
|
if (previewSeqRef.current === seq) {
|
|
setTextPreviewLayer(res.previewLayerDataUrl);
|
|
}
|
|
} catch (e) {
|
|
if (previewSeqRef.current === seq) {
|
|
setTextPreviewLayer('');
|
|
setTextPreviewError(e instanceof Error ? e.message : '文字水印预览生成失败');
|
|
}
|
|
} finally {
|
|
if (previewSeqRef.current === seq) setTextPreviewLoading(false);
|
|
}
|
|
}, 300);
|
|
return () => window.clearTimeout(timer);
|
|
}, [config, isRepeatedText, naturalSize]);
|
|
|
|
useEffect(() => {
|
|
const onMove = (e: PointerEvent) => {
|
|
if (!dragging || !stageRef.current || !onChange || isRepeatedText) return;
|
|
|
|
const rect = stageRef.current.getBoundingClientRect();
|
|
const wmWidth = watermarkSize.width || watermarkWidth;
|
|
const wmHeight = watermarkSize.height || 1;
|
|
const availableX = Math.max(1, rect.width - wmWidth);
|
|
const availableY = Math.max(1, rect.height - wmHeight);
|
|
const left = clamp(e.clientX - rect.left - dragOffsetRef.current.x, 0, availableX);
|
|
const top = clamp(e.clientY - rect.top - dragOffsetRef.current.y, 0, availableY);
|
|
|
|
onChange({
|
|
position: 'custom',
|
|
customXRatio: Number((left / availableX).toFixed(4)),
|
|
customYRatio: Number((top / availableY).toFixed(4)),
|
|
});
|
|
};
|
|
const onUp = () => setDragging(false);
|
|
window.addEventListener('pointermove', onMove);
|
|
window.addEventListener('pointerup', onUp);
|
|
window.addEventListener('pointercancel', onUp);
|
|
return () => {
|
|
window.removeEventListener('pointermove', onMove);
|
|
window.removeEventListener('pointerup', onUp);
|
|
window.removeEventListener('pointercancel', onUp);
|
|
};
|
|
}, [dragging, isRepeatedText, onChange, watermarkSize.height, watermarkSize.width, watermarkWidth]);
|
|
|
|
const watermarkPositionStyle = useMemo<React.CSSProperties>(() => {
|
|
if (config.position !== 'custom') return calcPresetPosition(config);
|
|
|
|
const wmWidth = watermarkSize.width || watermarkWidth;
|
|
const wmHeight = watermarkSize.height || 1;
|
|
const availableX = Math.max(0, stageSize.width - wmWidth);
|
|
const availableY = Math.max(0, stageSize.height - wmHeight);
|
|
const xRatio = clamp(config.customXRatio ?? 0.5, 0, 1);
|
|
const yRatio = clamp(config.customYRatio ?? 0.5, 0, 1);
|
|
|
|
return {
|
|
left: availableX * xRatio,
|
|
top: availableY * yRatio,
|
|
transform: 'none',
|
|
};
|
|
}, [config, stageSize.height, stageSize.width, watermarkSize.height, watermarkSize.width, watermarkWidth]);
|
|
|
|
return (
|
|
<div
|
|
ref={outerRef}
|
|
style={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
minHeight: Math.max(PREVIEW_MIN_HEIGHT, stageSize.height),
|
|
background: '#0f172a',
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: '#94a3b8',
|
|
}}
|
|
>
|
|
{mediaSrc ? (
|
|
<div
|
|
ref={stageRef}
|
|
style={{
|
|
position: 'relative',
|
|
width: stageSize.width || '100%',
|
|
height: stageSize.height || PREVIEW_MIN_HEIGHT,
|
|
maxWidth: '100%',
|
|
overflow: 'hidden',
|
|
background: '#000',
|
|
}}
|
|
>
|
|
{mediaType === 'video' ? (
|
|
<video
|
|
src={mediaSrc}
|
|
controls
|
|
onLoadedMetadata={(e) => {
|
|
const target = e.currentTarget;
|
|
if (target.videoWidth && target.videoHeight) {
|
|
setNaturalSize({ width: target.videoWidth, height: target.videoHeight });
|
|
}
|
|
}}
|
|
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={mediaSrc}
|
|
alt="素材预览"
|
|
onLoad={(e) => {
|
|
const target = e.currentTarget;
|
|
if (target.naturalWidth && target.naturalHeight) {
|
|
setNaturalSize({ width: target.naturalWidth, height: target.naturalHeight });
|
|
}
|
|
}}
|
|
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
|
|
/>
|
|
)}
|
|
|
|
{isRepeatedText && textPreviewLayer && (
|
|
<img
|
|
src={textPreviewLayer}
|
|
alt="重复文字水印预览"
|
|
draggable={false}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'fill',
|
|
zIndex: 2,
|
|
pointerEvents: 'none',
|
|
userSelect: 'none',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isRepeatedText && (textPreviewLoading || textPreviewError) && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
left: 12,
|
|
bottom: 12,
|
|
zIndex: 3,
|
|
padding: '6px 10px',
|
|
borderRadius: 8,
|
|
background: 'rgba(15, 23, 42, 0.82)',
|
|
color: '#e2e8f0',
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
{textPreviewLoading ? <><Spin size="small" /> 正在生成精准预览...</> : textPreviewError}
|
|
</div>
|
|
)}
|
|
|
|
{!isRepeatedText && watermarkSrc && (
|
|
<img
|
|
ref={watermarkRef}
|
|
src={watermarkSrc}
|
|
alt="水印预览"
|
|
draggable={false}
|
|
onLoad={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setWatermarkSize({ width: rect.width, height: rect.height });
|
|
}}
|
|
onPointerDown={(e) => {
|
|
if (!onChange) return;
|
|
e.preventDefault();
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
dragOffsetRef.current = {
|
|
x: e.clientX - rect.left,
|
|
y: e.clientY - rect.top,
|
|
};
|
|
setDragging(true);
|
|
}}
|
|
style={{
|
|
position: 'absolute',
|
|
zIndex: 2,
|
|
cursor: onChange ? 'move' : 'default',
|
|
width: watermarkWidth,
|
|
opacity: (config.opacityLevel ?? 6) / 10,
|
|
userSelect: 'none',
|
|
touchAction: 'none',
|
|
pointerEvents: onChange ? 'auto' : 'none',
|
|
...watermarkPositionStyle,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span>上传素材后可预览水印效果</span>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default WatermarkPreview;
|