|
|
|
@ -59,17 +59,6 @@ const DEFAULT_TEXT_BOX_WIDTH = 180; |
|
|
|
const DEFAULT_TEXT_BOX_HEIGHT = 32; |
|
|
|
const FIXED_TEXT_FONT_SIZE = 24; |
|
|
|
|
|
|
|
function validDimensions(width: unknown, height: unknown): { width: number; height: number } | null { |
|
|
|
return typeof width === "number" && typeof height === "number" && width > 0 && height > 0 |
|
|
|
? { width, height } |
|
|
|
: null; |
|
|
|
} |
|
|
|
|
|
|
|
function readImageNaturalDimensions(image: HTMLImageElement | null): { width: number; height: number } | null { |
|
|
|
if (!image) return null; |
|
|
|
return validDimensions(image.naturalWidth, image.naturalHeight); |
|
|
|
} |
|
|
|
|
|
|
|
/** 把某个 shape 从显示空间按比例映射到导出(natural)空间。 */ |
|
|
|
function scaleShape(shape: AnnotationShape, scaleX: number, scaleY: number): AnnotationShape { |
|
|
|
const base = { |
|
|
|
@ -126,10 +115,7 @@ function buildKonvaShape(shape: AnnotationShape): Konva.Shape | null { |
|
|
|
} |
|
|
|
|
|
|
|
export const MediaAnnotationOverlay = forwardRef<MediaAnnotationOverlayHandle, MediaAnnotationOverlayProps>(function MediaAnnotationOverlay({ |
|
|
|
mediaElementRef, |
|
|
|
imageSource, |
|
|
|
naturalWidth, |
|
|
|
naturalHeight, |
|
|
|
annotations, |
|
|
|
selectedShapeId, |
|
|
|
currentTool, |
|
|
|
@ -152,19 +138,14 @@ export const MediaAnnotationOverlay = forwardRef<MediaAnnotationOverlayHandle, M |
|
|
|
const [currentShape, setCurrentShape] = useState<AnnotationShape | null>(null); |
|
|
|
const [editingTextId, setEditingTextId] = useState<string | null>(null); |
|
|
|
const [pendingTextPosition, setPendingTextPosition] = useState<{ x: number; y: number } | null>(null); |
|
|
|
const [naturalDimensions, setNaturalDimensions] = useState<{ width: number; height: number } | null>(() => |
|
|
|
validDimensions(naturalWidth, naturalHeight) |
|
|
|
); |
|
|
|
|
|
|
|
// 加载用于回显的图片(走代理避免跨域污染),既作为 Stage 底图也用于导出。
|
|
|
|
// 加载底图(走代理避免跨域污染):仅用于导出时的干净合成。回显阶段画布下方
|
|
|
|
// 已有节点自身的 <img>(object-cover)透出,故底图加载失败也不影响绘制。
|
|
|
|
useEffect(() => { |
|
|
|
let cancelled = false; |
|
|
|
loadCropImage(imageSource) |
|
|
|
.then((image) => { |
|
|
|
if (cancelled) return; |
|
|
|
setDisplayImage(image); |
|
|
|
const dims = readImageNaturalDimensions(image); |
|
|
|
if (dims) setNaturalDimensions(dims); |
|
|
|
if (!cancelled) setDisplayImage(image); |
|
|
|
}) |
|
|
|
.catch(() => { |
|
|
|
if (!cancelled) setDisplayImage(null); |
|
|
|
@ -174,27 +155,18 @@ export const MediaAnnotationOverlay = forwardRef<MediaAnnotationOverlayHandle, M |
|
|
|
}; |
|
|
|
}, [imageSource]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const explicit = validDimensions(naturalWidth, naturalHeight); |
|
|
|
if (explicit) setNaturalDimensions(explicit); |
|
|
|
}, [naturalWidth, naturalHeight]); |
|
|
|
|
|
|
|
// 绘制面 = 媒体框本身。节点图片以 object-cover 铺满媒体框(框的尺寸由节点
|
|
|
|
// style 决定),故 overlay 自身的 client 矩形即绘制区,无需依赖图片自然尺寸;
|
|
|
|
// 自然尺寸仅在导出(exportComposite)时用于坐标换算。这样只要媒体框有布局
|
|
|
|
// 尺寸,canvas 就一定渲染,不会再卡在“自然尺寸未知”而空白。
|
|
|
|
const measureBounds = useCallback((): DisplayRect | null => { |
|
|
|
const overlay = overlayRef.current; |
|
|
|
if (!overlay || !naturalDimensions) return null; |
|
|
|
if (!overlay) return null; |
|
|
|
const width = overlay.clientWidth; |
|
|
|
const height = overlay.clientHeight; |
|
|
|
if (width <= 0 || height <= 0) return null; |
|
|
|
const scale = Math.min(width / naturalDimensions.width, height / naturalDimensions.height); |
|
|
|
const displayWidth = naturalDimensions.width * scale; |
|
|
|
const displayHeight = naturalDimensions.height * scale; |
|
|
|
return { |
|
|
|
left: (width - displayWidth) / 2, |
|
|
|
top: (height - displayHeight) / 2, |
|
|
|
width: displayWidth, |
|
|
|
height: displayHeight, |
|
|
|
}; |
|
|
|
}, [naturalDimensions]); |
|
|
|
return { left: 0, top: 0, width, height }; |
|
|
|
}, []); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const overlay = overlayRef.current; |
|
|
|
|