Extract reusable HudCanvas component
This commit is contained in:
parent
468ddf4e9a
commit
c849b8d2e6
|
@ -21,7 +21,7 @@ import { first, map } from 'rxjs/operators';
|
|||
const thumbnailWidth = 177;
|
||||
const thumbnailHeight = 100;
|
||||
|
||||
const initialViewportSeconds = 10;
|
||||
const initialViewportCanvasPixels = 100;
|
||||
|
||||
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
|
||||
|
||||
|
@ -147,7 +147,8 @@ function App(): JSX.Element {
|
|||
}
|
||||
|
||||
const numFrames = Math.min(
|
||||
mediaSet.audioSampleRate * initialViewportSeconds,
|
||||
Math.round(mediaSet.audioFrames / CanvasLogicalWidth) *
|
||||
initialViewportCanvasPixels,
|
||||
mediaSet.audioFrames
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,289 @@
|
|||
import { useState, useEffect, useRef, MouseEvent } from 'react';
|
||||
import { VideoPosition } from './App';
|
||||
|
||||
interface Props {
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
position: VideoPosition;
|
||||
selection: Selection;
|
||||
onSelectionChange: (selection: Selection) => void;
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Normal,
|
||||
Selecting,
|
||||
Dragging,
|
||||
ResizingStart,
|
||||
ResizingEnd,
|
||||
}
|
||||
|
||||
enum HoverState {
|
||||
Normal,
|
||||
OverSelectionStart,
|
||||
OverSelectionEnd,
|
||||
OverSelection,
|
||||
}
|
||||
|
||||
export interface Selection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const emptySelection: Selection = { start: 0, end: 0 };
|
||||
|
||||
export const HudCanvas: React.FC<Props> = ({
|
||||
width,
|
||||
height,
|
||||
zIndex,
|
||||
position,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
}: Props) => {
|
||||
// selection and newSelection are in canvas logical pixels:
|
||||
const [newSelection, setNewSelection] = useState({
|
||||
...emptySelection,
|
||||
});
|
||||
const [mode, setMode] = useState(Mode.Normal);
|
||||
const [hoverState, setHoverState] = useState(HoverState.Normal);
|
||||
const [cursor, setCursor] = useState('auto');
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const moveOffsetX = useRef(0);
|
||||
|
||||
// side effects
|
||||
|
||||
// handle global mouse up
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [mode, newSelection]);
|
||||
|
||||
// draw the overview HUD
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas == null) {
|
||||
console.error('no hud canvas ref available');
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx == null) {
|
||||
console.error('no hud 2d context available');
|
||||
return;
|
||||
}
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// draw selection
|
||||
|
||||
let currentSelection: Selection;
|
||||
if (
|
||||
mode == Mode.Selecting ||
|
||||
mode == Mode.Dragging ||
|
||||
mode == Mode.ResizingStart ||
|
||||
mode == Mode.ResizingEnd
|
||||
) {
|
||||
currentSelection = newSelection;
|
||||
} else {
|
||||
currentSelection = selection;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'red';
|
||||
ctx.lineWidth = 4;
|
||||
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13';
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
||||
ctx.rect(
|
||||
currentSelection.start,
|
||||
2,
|
||||
currentSelection.end - currentSelection.start,
|
||||
canvas.height - 10
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// draw position marker
|
||||
|
||||
const markerX = canvas.width * (position.percent / 100);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(markerX, 0);
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineTo(markerX, canvas.height - 4);
|
||||
ctx.stroke();
|
||||
});
|
||||
}, [selection, newSelection, position]);
|
||||
|
||||
// handlers
|
||||
|
||||
const hoverOffset = 10;
|
||||
|
||||
const isHoveringSelectionStart = (x: number): boolean => {
|
||||
return (
|
||||
x > selection.start - hoverOffset && x < selection.start + hoverOffset
|
||||
);
|
||||
};
|
||||
|
||||
const isHoveringSelectionEnd = (x: number): boolean => {
|
||||
return x > selection.end - hoverOffset && x < selection.end + hoverOffset;
|
||||
};
|
||||
|
||||
const isHoveringSelection = (x: number): boolean => {
|
||||
return x >= selection.start && x <= selection.end;
|
||||
};
|
||||
|
||||
const getCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const x = Math.round(((evt.clientX - rect.left) / rect.width) * width);
|
||||
return constrainXToCanvas(x);
|
||||
};
|
||||
|
||||
const constrainXToCanvas = (x: number): number => {
|
||||
if (x < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (x > width) {
|
||||
return width;
|
||||
}
|
||||
return x;
|
||||
};
|
||||
|
||||
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
if (mode != Mode.Normal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const x = getCanvasX(evt);
|
||||
|
||||
if (isHoveringSelectionStart(x)) {
|
||||
setMode(Mode.ResizingStart);
|
||||
moveOffsetX.current = x;
|
||||
} else if (isHoveringSelectionEnd(x)) {
|
||||
setMode(Mode.ResizingEnd);
|
||||
moveOffsetX.current = x;
|
||||
} else if (isHoveringSelection(x)) {
|
||||
setMode(Mode.Dragging);
|
||||
setCursor('pointer');
|
||||
moveOffsetX.current = x;
|
||||
} else {
|
||||
setMode(Mode.Selecting);
|
||||
setCursor('col-resize');
|
||||
moveOffsetX.current = x;
|
||||
setNewSelection({ start: x, end: x });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
const x = getCanvasX(evt);
|
||||
|
||||
switch (mode) {
|
||||
case Mode.Normal: {
|
||||
if (isHoveringSelectionStart(x)) {
|
||||
setHoverState(HoverState.OverSelectionStart);
|
||||
setCursor('col-resize');
|
||||
} else if (isHoveringSelectionEnd(x)) {
|
||||
setHoverState(HoverState.OverSelectionEnd);
|
||||
setCursor('col-resize');
|
||||
} else if (isHoveringSelection(x)) {
|
||||
setHoverState(HoverState.OverSelection);
|
||||
setCursor('pointer');
|
||||
} else {
|
||||
setCursor('auto');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Mode.ResizingStart: {
|
||||
const diff = x - moveOffsetX.current;
|
||||
const start = constrainXToCanvas(selection.start + diff);
|
||||
|
||||
if (start > selection.end) {
|
||||
setNewSelection({ start: selection.end, end: start });
|
||||
break;
|
||||
}
|
||||
|
||||
setNewSelection({ ...newSelection, start: start });
|
||||
break;
|
||||
}
|
||||
case Mode.ResizingEnd: {
|
||||
const diff = x - moveOffsetX.current;
|
||||
const start = constrainXToCanvas(selection.end + diff);
|
||||
|
||||
if (start < selection.start) {
|
||||
setNewSelection({ start: Math.max(0, start), end: selection.start });
|
||||
break;
|
||||
}
|
||||
|
||||
setNewSelection({ ...newSelection, end: start });
|
||||
break;
|
||||
}
|
||||
case Mode.Dragging: {
|
||||
const diff = x - moveOffsetX.current;
|
||||
const selectionWidth = selection.end - selection.start;
|
||||
let start = Math.max(0, selection.start + diff);
|
||||
let end = start + selectionWidth;
|
||||
if (end > width) {
|
||||
end = width;
|
||||
start = end - selectionWidth;
|
||||
}
|
||||
|
||||
setNewSelection({ start: start, end: end });
|
||||
break;
|
||||
}
|
||||
case Mode.Selecting: {
|
||||
if (x < moveOffsetX.current) {
|
||||
setNewSelection({
|
||||
start: x,
|
||||
end: moveOffsetX.current,
|
||||
});
|
||||
} else {
|
||||
setNewSelection({ start: moveOffsetX.current, end: x });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (mode == Mode.Normal) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(Mode.Normal);
|
||||
setCursor('auto');
|
||||
|
||||
if (newSelection.start == newSelection.end) {
|
||||
setNewSelection({ start: 0, end: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange({ ...newSelection });
|
||||
};
|
||||
|
||||
const handleMouseLeave = (_evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
setHoverState(HoverState.Normal);
|
||||
};
|
||||
|
||||
const canvasStyles = {
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: zIndex,
|
||||
cursor: cursor,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={canvasStyles}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
></canvas>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useEffect, useRef, MouseEvent } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { MediaSet } from './generated/media_set';
|
||||
import { Frames, VideoPosition } from './App';
|
||||
import { WaveformCanvas } from './WaveformCanvas';
|
||||
import { HudCanvas } from './HudCanvas';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface Selection {
|
||||
|
@ -16,29 +17,12 @@ interface Props {
|
|||
offsetPixels: number;
|
||||
position: VideoPosition;
|
||||
viewport: Frames;
|
||||
onSelectionChange: (selection: Frames) => void;
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Normal,
|
||||
Selecting,
|
||||
Dragging,
|
||||
ResizingStart,
|
||||
ResizingEnd,
|
||||
}
|
||||
|
||||
enum HoverState {
|
||||
Normal,
|
||||
OverSelectionStart,
|
||||
OverSelectionEnd,
|
||||
OverSelection,
|
||||
onSelectionChange: (selection: Selection) => void;
|
||||
}
|
||||
|
||||
export const CanvasLogicalWidth = 2_000;
|
||||
export const CanvasLogicalHeight = 500;
|
||||
|
||||
const emptySelection = { start: 0, end: 0 };
|
||||
|
||||
export const Overview: React.FC<Props> = ({
|
||||
peaks,
|
||||
mediaSet,
|
||||
|
@ -48,36 +32,13 @@ export const Overview: React.FC<Props> = ({
|
|||
viewport,
|
||||
onSelectionChange,
|
||||
}: Props) => {
|
||||
const hudCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [mode, setMode] = useState(Mode.Normal);
|
||||
const [hoverState, setHoverState] = useState(HoverState.Normal);
|
||||
const [cursor, setCursor] = useState('auto');
|
||||
|
||||
// selection and newSelection relate to canvas logical pixels:
|
||||
const [selection, setSelection] = useState({ ...emptySelection });
|
||||
const [newSelection, setNewSelection] = useState({
|
||||
...emptySelection,
|
||||
});
|
||||
|
||||
const moveOffsetX = useRef(0);
|
||||
const [selectedPixels, setSelectedPixels] = useState({ start: 0, end: 0 });
|
||||
|
||||
// side effects
|
||||
|
||||
// handle global mouse up.
|
||||
// convert viewport from frames to canvas pixels.
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [mode, newSelection]);
|
||||
|
||||
// set selection state on viewport change
|
||||
useEffect(() => {
|
||||
if (mediaSet == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelection({
|
||||
setSelectedPixels({
|
||||
start: Math.round(
|
||||
(viewport.start / mediaSet.audioFrames) * CanvasLogicalWidth
|
||||
),
|
||||
|
@ -85,235 +46,20 @@ export const Overview: React.FC<Props> = ({
|
|||
(viewport.end / mediaSet.audioFrames) * CanvasLogicalWidth
|
||||
),
|
||||
});
|
||||
}, [mediaSet, viewport]);
|
||||
|
||||
// load peaks on mediaset change
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
if (mediaSet == null) {
|
||||
return;
|
||||
}
|
||||
const canvas = hudCanvasRef.current;
|
||||
if (canvas == null) {
|
||||
console.error('no hud canvas ref available');
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx == null) {
|
||||
console.error('no hud 2d context available');
|
||||
return;
|
||||
}
|
||||
})();
|
||||
}, [mediaSet]);
|
||||
|
||||
// draw the overview HUD
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const canvas = hudCanvasRef.current;
|
||||
if (canvas == null) {
|
||||
console.error('no hud canvas ref available');
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx == null) {
|
||||
console.error('no hud 2d context available');
|
||||
return;
|
||||
}
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// draw selection
|
||||
|
||||
let currentSelection: Selection;
|
||||
if (
|
||||
mode == Mode.Selecting ||
|
||||
mode == Mode.Dragging ||
|
||||
mode == Mode.ResizingStart ||
|
||||
mode == Mode.ResizingEnd
|
||||
) {
|
||||
currentSelection = newSelection;
|
||||
} else {
|
||||
currentSelection = selection;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'red';
|
||||
ctx.lineWidth = 4;
|
||||
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13';
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
||||
ctx.rect(
|
||||
currentSelection.start,
|
||||
2,
|
||||
currentSelection.end - currentSelection.start,
|
||||
canvas.height - 10
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// draw position marker
|
||||
|
||||
const markerX = canvas.width * (position.percent / 100);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(markerX, 0);
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineTo(markerX, canvas.height - 4);
|
||||
ctx.stroke();
|
||||
});
|
||||
}, [mediaSet, selection, newSelection, position]);
|
||||
}, [viewport, mediaSet]);
|
||||
|
||||
// handlers
|
||||
|
||||
const hoverOffset = 10;
|
||||
|
||||
const isHoveringSelectionStart = (x: number): boolean => {
|
||||
return (
|
||||
x > selection.start - hoverOffset && x < selection.start + hoverOffset
|
||||
);
|
||||
};
|
||||
|
||||
const isHoveringSelectionEnd = (x: number): boolean => {
|
||||
return x > selection.end - hoverOffset && x < selection.end + hoverOffset;
|
||||
};
|
||||
|
||||
const isHoveringSelection = (x: number): boolean => {
|
||||
return x >= selection.start && x <= selection.end;
|
||||
};
|
||||
|
||||
const getCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const x = Math.round(
|
||||
((evt.clientX - rect.left) / rect.width) * CanvasLogicalWidth
|
||||
);
|
||||
return constrainXToCanvas(x);
|
||||
};
|
||||
|
||||
const constrainXToCanvas = (x: number): number => {
|
||||
if (x < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (x > CanvasLogicalWidth) {
|
||||
return CanvasLogicalWidth;
|
||||
}
|
||||
return x;
|
||||
};
|
||||
|
||||
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
if (mode != Mode.Normal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const x = getCanvasX(evt);
|
||||
|
||||
if (isHoveringSelectionStart(x)) {
|
||||
setMode(Mode.ResizingStart);
|
||||
moveOffsetX.current = x;
|
||||
return;
|
||||
} else if (isHoveringSelectionEnd(x)) {
|
||||
setMode(Mode.ResizingEnd);
|
||||
moveOffsetX.current = x;
|
||||
return;
|
||||
} else if (isHoveringSelection(x)) {
|
||||
setMode(Mode.Dragging);
|
||||
setCursor('pointer');
|
||||
moveOffsetX.current = x;
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(Mode.Selecting);
|
||||
setCursor('col-resize');
|
||||
moveOffsetX.current = x;
|
||||
setNewSelection({ start: x, end: x });
|
||||
};
|
||||
|
||||
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
const x = getCanvasX(evt);
|
||||
|
||||
switch (mode) {
|
||||
case Mode.Normal: {
|
||||
if (isHoveringSelectionStart(x)) {
|
||||
setHoverState(HoverState.OverSelectionStart);
|
||||
setCursor('col-resize');
|
||||
} else if (isHoveringSelectionEnd(x)) {
|
||||
setHoverState(HoverState.OverSelectionEnd);
|
||||
setCursor('col-resize');
|
||||
} else if (isHoveringSelection(x)) {
|
||||
setHoverState(HoverState.OverSelection);
|
||||
setCursor('pointer');
|
||||
} else {
|
||||
setCursor('auto');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Mode.ResizingStart: {
|
||||
const diff = x - moveOffsetX.current;
|
||||
const start = constrainXToCanvas(selection.start + diff);
|
||||
|
||||
if (start > selection.end) {
|
||||
setNewSelection({ start: selection.end, end: start });
|
||||
break;
|
||||
}
|
||||
|
||||
setNewSelection({ ...newSelection, start: start });
|
||||
break;
|
||||
}
|
||||
case Mode.ResizingEnd: {
|
||||
const diff = x - moveOffsetX.current;
|
||||
const start = constrainXToCanvas(selection.end + diff);
|
||||
|
||||
if (start < selection.start) {
|
||||
setNewSelection({ start: Math.max(0, start), end: selection.start });
|
||||
break;
|
||||
}
|
||||
|
||||
setNewSelection({ ...newSelection, end: start });
|
||||
break;
|
||||
}
|
||||
case Mode.Dragging: {
|
||||
const diff = x - moveOffsetX.current;
|
||||
const width = selection.end - selection.start;
|
||||
let start = Math.max(0, selection.start + diff);
|
||||
let end = start + width;
|
||||
if (end > CanvasLogicalWidth) {
|
||||
end = CanvasLogicalWidth;
|
||||
start = end - width;
|
||||
}
|
||||
|
||||
setNewSelection({ start: start, end: end });
|
||||
break;
|
||||
}
|
||||
case Mode.Selecting: {
|
||||
if (x < moveOffsetX.current) {
|
||||
setNewSelection({
|
||||
start: x,
|
||||
end: moveOffsetX.current,
|
||||
});
|
||||
} else {
|
||||
setNewSelection({ start: moveOffsetX.current, end: x });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (mode == Mode.Normal) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(Mode.Normal);
|
||||
setCursor('auto');
|
||||
|
||||
const start = Math.round(
|
||||
(newSelection.start / CanvasLogicalWidth) * mediaSet.audioFrames
|
||||
);
|
||||
const end = Math.round(
|
||||
(newSelection.end / CanvasLogicalWidth) * mediaSet.audioFrames
|
||||
);
|
||||
onSelectionChange({ start, end });
|
||||
};
|
||||
|
||||
const handleMouseLeave = (_evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
setHoverState(HoverState.Normal);
|
||||
};
|
||||
// convert selection change from canvas pixels to frames, and trigger callback.
|
||||
const handleSelectionChange = useCallback(
|
||||
({ start, end }: Selection) => {
|
||||
onSelectionChange({
|
||||
start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames),
|
||||
end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames),
|
||||
});
|
||||
},
|
||||
[mediaSet]
|
||||
);
|
||||
|
||||
// render component
|
||||
|
||||
|
@ -324,15 +70,6 @@ export const Overview: React.FC<Props> = ({
|
|||
height: `${height}px`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const hudCanvasStyles = {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
zIndex: 2,
|
||||
cursor: cursor,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={containerStyles}>
|
||||
|
@ -346,15 +83,14 @@ export const Overview: React.FC<Props> = ({
|
|||
zIndex={1}
|
||||
alpha={1}
|
||||
></WaveformCanvas>
|
||||
<canvas
|
||||
ref={hudCanvasRef}
|
||||
<HudCanvas
|
||||
width={CanvasLogicalWidth}
|
||||
height={CanvasLogicalHeight}
|
||||
style={hudCanvasStyles}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
></canvas>
|
||||
zIndex={1}
|
||||
position={position}
|
||||
selection={selectedPixels}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue