|
|
|
@ -11,6 +11,11 @@ export interface VideoEraseFrameRect { |
|
|
|
height: number; |
|
|
|
} |
|
|
|
|
|
|
|
export interface VideoEraseHistoryState { |
|
|
|
canUndo: boolean; |
|
|
|
canRedo: boolean; |
|
|
|
} |
|
|
|
|
|
|
|
interface DisplayRect { |
|
|
|
left: number; |
|
|
|
top: number; |
|
|
|
@ -27,6 +32,11 @@ interface EraseBox { |
|
|
|
height: number; |
|
|
|
} |
|
|
|
|
|
|
|
interface EraseHistorySnapshot { |
|
|
|
boxes: EraseBox[]; |
|
|
|
selectedId: number | null; |
|
|
|
} |
|
|
|
|
|
|
|
type DragMode = "move" | "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se" | "create"; |
|
|
|
|
|
|
|
const MIN_BOX_SIZE = 24; |
|
|
|
@ -36,10 +46,12 @@ interface MediaVideoEraseOverlayProps { |
|
|
|
naturalWidth?: number; |
|
|
|
naturalHeight?: number; |
|
|
|
busy?: boolean; |
|
|
|
clearSignal?: number; |
|
|
|
deleteSelectedSignal?: number; |
|
|
|
undoSignal?: number; |
|
|
|
redoSignal?: number; |
|
|
|
resetSignal?: number; |
|
|
|
onBoxesChange?: (boxes: VideoEraseRatioBox[]) => void; |
|
|
|
onFrameChange?: (rect: VideoEraseFrameRect | null) => void; |
|
|
|
onHistoryStateChange?: (state: VideoEraseHistoryState) => void; |
|
|
|
} |
|
|
|
|
|
|
|
function clamp(value: number, min: number, max: number) { |
|
|
|
@ -57,6 +69,22 @@ function readVideoNaturalDimensions(el: HTMLVideoElement | null): { width: numbe |
|
|
|
return validDimensions(el.videoWidth, el.videoHeight); |
|
|
|
} |
|
|
|
|
|
|
|
function cloneBoxes(boxes: EraseBox[]): EraseBox[] { |
|
|
|
return boxes.map((box) => ({ ...box })); |
|
|
|
} |
|
|
|
|
|
|
|
function snapshotsEqual(a: EraseHistorySnapshot, b: EraseHistorySnapshot) { |
|
|
|
if (a.selectedId !== b.selectedId || a.boxes.length !== b.boxes.length) return false; |
|
|
|
return a.boxes.every((box, index) => { |
|
|
|
const next = b.boxes[index]; |
|
|
|
return box.id === next.id && |
|
|
|
box.x === next.x && |
|
|
|
box.y === next.y && |
|
|
|
box.width === next.width && |
|
|
|
box.height === next.height; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
function fitBoxToBounds(box: EraseBox, bounds: DisplayRect): EraseBox { |
|
|
|
const width = Math.min(Math.max(box.width, MIN_BOX_SIZE), bounds.width); |
|
|
|
const height = Math.min(Math.max(box.height, MIN_BOX_SIZE), bounds.height); |
|
|
|
@ -95,10 +123,12 @@ export function MediaVideoEraseOverlay({ |
|
|
|
naturalWidth, |
|
|
|
naturalHeight, |
|
|
|
busy = false, |
|
|
|
clearSignal = 0, |
|
|
|
deleteSelectedSignal = 0, |
|
|
|
undoSignal = 0, |
|
|
|
redoSignal = 0, |
|
|
|
resetSignal = 0, |
|
|
|
onBoxesChange, |
|
|
|
onFrameChange, |
|
|
|
onHistoryStateChange, |
|
|
|
}: MediaVideoEraseOverlayProps) { |
|
|
|
const overlayRef = useRef<HTMLDivElement>(null); |
|
|
|
const idRef = useRef(0); |
|
|
|
@ -109,13 +139,60 @@ export function MediaVideoEraseOverlay({ |
|
|
|
startY: number; |
|
|
|
origin: EraseBox; |
|
|
|
} | null>(null); |
|
|
|
const boxesRef = useRef<EraseBox[]>([]); |
|
|
|
const selectedIdRef = useRef<number | null>(null); |
|
|
|
const historyRef = useRef<EraseHistorySnapshot[]>([{ boxes: [], selectedId: null }]); |
|
|
|
const historyIndexRef = useRef(0); |
|
|
|
const [bounds, setBounds] = useState<DisplayRect | null>(null); |
|
|
|
const [boxes, setBoxes] = useState<EraseBox[]>([]); |
|
|
|
const [selectedId, setSelectedId] = useState<number | null>(null); |
|
|
|
const [historyState, setHistoryState] = useState<VideoEraseHistoryState>({ canUndo: false, canRedo: false }); |
|
|
|
const [naturalDimensions, setNaturalDimensions] = useState<{ width: number; height: number } | null>(() => |
|
|
|
validDimensions(naturalWidth, naturalHeight) |
|
|
|
); |
|
|
|
|
|
|
|
const updateHistoryState = useCallback(() => { |
|
|
|
setHistoryState({ |
|
|
|
canUndo: historyIndexRef.current > 0, |
|
|
|
canRedo: historyIndexRef.current < historyRef.current.length - 1, |
|
|
|
}); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const applySnapshot = useCallback((snapshot: EraseHistorySnapshot) => { |
|
|
|
const nextBoxes = cloneBoxes(snapshot.boxes); |
|
|
|
boxesRef.current = nextBoxes; |
|
|
|
selectedIdRef.current = snapshot.selectedId; |
|
|
|
setBoxes(nextBoxes); |
|
|
|
setSelectedId(snapshot.selectedId); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const commitHistory = useCallback((snapshot: EraseHistorySnapshot) => { |
|
|
|
const normalized = { boxes: cloneBoxes(snapshot.boxes), selectedId: snapshot.selectedId }; |
|
|
|
const current = historyRef.current[historyIndexRef.current]; |
|
|
|
if (current && snapshotsEqual(current, normalized)) { |
|
|
|
updateHistoryState(); |
|
|
|
return; |
|
|
|
} |
|
|
|
const nextHistory = historyRef.current.slice(0, historyIndexRef.current + 1); |
|
|
|
nextHistory.push(normalized); |
|
|
|
historyRef.current = nextHistory; |
|
|
|
historyIndexRef.current = nextHistory.length - 1; |
|
|
|
updateHistoryState(); |
|
|
|
}, [updateHistoryState]); |
|
|
|
|
|
|
|
const setBoxesWithRefs = useCallback((updater: (current: EraseBox[]) => EraseBox[]) => { |
|
|
|
setBoxes((current) => { |
|
|
|
const next = updater(current); |
|
|
|
boxesRef.current = next; |
|
|
|
return next; |
|
|
|
}); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const setSelectedIdWithRef = useCallback((next: number | null) => { |
|
|
|
selectedIdRef.current = next; |
|
|
|
setSelectedId(next); |
|
|
|
}, []); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const explicit = validDimensions(naturalWidth, naturalHeight); |
|
|
|
if (explicit) { |
|
|
|
@ -167,16 +244,33 @@ export function MediaVideoEraseOverlay({ |
|
|
|
}, [measureBounds]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (clearSignal === 0) return; |
|
|
|
setBoxes([]); |
|
|
|
setSelectedId(null); |
|
|
|
}, [clearSignal]); |
|
|
|
onHistoryStateChange?.(historyState); |
|
|
|
}, [historyState, onHistoryStateChange]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (undoSignal === 0) return; |
|
|
|
if (historyIndexRef.current <= 0) return; |
|
|
|
historyIndexRef.current -= 1; |
|
|
|
applySnapshot(historyRef.current[historyIndexRef.current]); |
|
|
|
updateHistoryState(); |
|
|
|
}, [applySnapshot, undoSignal, updateHistoryState]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (deleteSelectedSignal === 0) return; |
|
|
|
setBoxes((current) => current.filter((box) => box.id !== selectedId)); |
|
|
|
if (redoSignal === 0) return; |
|
|
|
if (historyIndexRef.current >= historyRef.current.length - 1) return; |
|
|
|
historyIndexRef.current += 1; |
|
|
|
applySnapshot(historyRef.current[historyIndexRef.current]); |
|
|
|
updateHistoryState(); |
|
|
|
}, [applySnapshot, redoSignal, updateHistoryState]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (resetSignal === 0) return; |
|
|
|
boxesRef.current = []; |
|
|
|
selectedIdRef.current = null; |
|
|
|
setBoxes([]); |
|
|
|
setSelectedId(null); |
|
|
|
}, [deleteSelectedSignal]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
commitHistory({ boxes: [], selectedId: null }); |
|
|
|
}, [commitHistory, resetSignal]); |
|
|
|
|
|
|
|
const pointerToOverlay = useCallback((event: React.PointerEvent) => { |
|
|
|
const el = overlayRef.current; |
|
|
|
@ -196,11 +290,12 @@ export function MediaVideoEraseOverlay({ |
|
|
|
const startY = clamp(point.y, bounds.top, bounds.top + bounds.height); |
|
|
|
const id = ++idRef.current; |
|
|
|
const newBox: EraseBox = { id, x: startX, y: startY, width: MIN_BOX_SIZE, height: MIN_BOX_SIZE }; |
|
|
|
setBoxes((current) => [...current, newBox]); |
|
|
|
setSelectedId(id); |
|
|
|
setBoxesWithRefs((current) => [...current, newBox]); |
|
|
|
setSelectedIdWithRef(id); |
|
|
|
dragRef.current = { mode: "create", boxId: id, startX, startY, origin: newBox }; |
|
|
|
event.currentTarget.setPointerCapture(event.pointerId); |
|
|
|
}, [bounds, busy, pointerToOverlay]); |
|
|
|
overlayRef.current?.focus(); |
|
|
|
}, [bounds, busy, pointerToOverlay, setBoxesWithRefs, setSelectedIdWithRef]); |
|
|
|
|
|
|
|
const handleBoxPointerDown = useCallback((event: React.PointerEvent, boxId: number, mode: DragMode) => { |
|
|
|
if (busy || !bounds) return; |
|
|
|
@ -209,10 +304,11 @@ export function MediaVideoEraseOverlay({ |
|
|
|
const origin = boxes.find((box) => box.id === boxId); |
|
|
|
if (!origin) return; |
|
|
|
const point = pointerToOverlay(event); |
|
|
|
setSelectedId(boxId); |
|
|
|
setSelectedIdWithRef(boxId); |
|
|
|
dragRef.current = { mode, boxId, startX: point.x, startY: point.y, origin }; |
|
|
|
event.currentTarget.setPointerCapture(event.pointerId); |
|
|
|
}, [bounds, boxes, busy, pointerToOverlay]); |
|
|
|
overlayRef.current?.focus(); |
|
|
|
}, [bounds, boxes, busy, pointerToOverlay, setSelectedIdWithRef]); |
|
|
|
|
|
|
|
const handlePointerMove = useCallback((event: React.PointerEvent) => { |
|
|
|
const drag = dragRef.current; |
|
|
|
@ -220,7 +316,7 @@ export function MediaVideoEraseOverlay({ |
|
|
|
event.preventDefault(); |
|
|
|
const point = pointerToOverlay(event); |
|
|
|
|
|
|
|
setBoxes((current) => current.map((box) => { |
|
|
|
setBoxesWithRefs((current) => current.map((box) => { |
|
|
|
if (box.id !== drag.boxId) return box; |
|
|
|
if (drag.mode === "create") { |
|
|
|
const left = Math.min(drag.startX, point.x); |
|
|
|
@ -244,13 +340,29 @@ export function MediaVideoEraseOverlay({ |
|
|
|
} |
|
|
|
return fitBoxToBounds(resizeFree(drag.origin, bounds, drag.mode, point.x, point.y), bounds); |
|
|
|
})); |
|
|
|
}, [bounds, pointerToOverlay]); |
|
|
|
}, [bounds, pointerToOverlay, setBoxesWithRefs]); |
|
|
|
|
|
|
|
const handlePointerUp = useCallback((event: React.PointerEvent) => { |
|
|
|
if (!dragRef.current) return; |
|
|
|
event.preventDefault(); |
|
|
|
commitHistory({ boxes: boxesRef.current, selectedId: selectedIdRef.current }); |
|
|
|
dragRef.current = null; |
|
|
|
}, []); |
|
|
|
}, [commitHistory]); |
|
|
|
|
|
|
|
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => { |
|
|
|
if (busy) return; |
|
|
|
if (event.key !== "Delete" && event.key !== "Backspace") return; |
|
|
|
const currentSelectedId = selectedIdRef.current; |
|
|
|
if (currentSelectedId === null) return; |
|
|
|
event.preventDefault(); |
|
|
|
event.stopPropagation(); |
|
|
|
const nextBoxes = boxesRef.current.filter((box) => box.id !== currentSelectedId); |
|
|
|
boxesRef.current = nextBoxes; |
|
|
|
selectedIdRef.current = null; |
|
|
|
setBoxes(nextBoxes); |
|
|
|
setSelectedId(null); |
|
|
|
commitHistory({ boxes: nextBoxes, selectedId: null }); |
|
|
|
}, [busy, commitHistory]); |
|
|
|
|
|
|
|
// 归一化擦除框 + 选中框的显示矩形回调。
|
|
|
|
useEffect(() => { |
|
|
|
@ -287,6 +399,8 @@ export function MediaVideoEraseOverlay({ |
|
|
|
onPointerMove={handlePointerMove} |
|
|
|
onPointerUp={handlePointerUp} |
|
|
|
onPointerCancel={handlePointerUp} |
|
|
|
onKeyDown={handleKeyDown} |
|
|
|
tabIndex={0} |
|
|
|
data-media-video-erase-overlay |
|
|
|
> |
|
|
|
{bounds && ( |
|
|
|
|