first commit
This commit is contained in:
309
src/hooks/areaSelection.ts
Executable file
309
src/hooks/areaSelection.ts
Executable file
@@ -0,0 +1,309 @@
|
||||
import { alpha, lighten, useTheme } from "@mui/material";
|
||||
import * as React from "react";
|
||||
import { useCallback, useContext, useEffect, useRef } from "react";
|
||||
import { FileManagerIndex } from "../component/FileManager/FileManager.tsx";
|
||||
import { FmIndexContext } from "../component/FileManager/FmIndexContext.tsx";
|
||||
import { clearSelected } from "../redux/fileManagerSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../redux/hooks.ts";
|
||||
import { selectionFromDragBox } from "../redux/thunks/file.ts";
|
||||
|
||||
const dataRectId = "data-rect-id";
|
||||
|
||||
interface Coordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Candidates extends Coordinates {
|
||||
index: string;
|
||||
bottom: number;
|
||||
right: number;
|
||||
}
|
||||
interface DrawnArea {
|
||||
start: undefined | Coordinates;
|
||||
end: undefined | Coordinates;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
interface UseAreaSelectionProps {
|
||||
container: React.RefObject<HTMLElement> | undefined;
|
||||
}
|
||||
|
||||
// Smallest value >= target
|
||||
function binarySearchTop(list: Candidates[][], target: number) {
|
||||
let start = 0;
|
||||
let end = list.length - 1;
|
||||
while (start <= end) {
|
||||
let mid = Math.floor((start + end) / 2);
|
||||
if (list[mid][0].y < target) start = mid + 1;
|
||||
else end = mid - 1;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
// Largest value <= target
|
||||
function binarySearchBottom(list: Candidates[][], target: number) {
|
||||
let start = 0;
|
||||
let end = list.length - 1;
|
||||
while (start <= end) {
|
||||
let mid = Math.floor((start + end) / 2);
|
||||
if (list[mid][0].y <= target) start = mid + 1;
|
||||
else end = mid - 1;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
const boxNode = document.createElement("div");
|
||||
boxNode.style.position = "fixed";
|
||||
boxNode.style.borderRadius = "2px";
|
||||
boxNode.style.pointerEvents = "none";
|
||||
|
||||
export function useAreaSelection(container: React.RefObject<HTMLElement>, explorerIndex: number, enabled: boolean) {
|
||||
const theme = useTheme();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const dispatch = useAppDispatch();
|
||||
const boxRef = React.useRef<HTMLDivElement>(boxNode);
|
||||
const fileList = useAppSelector((state) => state.fileManager[explorerIndex]?.list?.files);
|
||||
const boxElement = boxRef;
|
||||
const [mouseDown, setMouseDown] = React.useState<boolean>(false);
|
||||
const mouseMoving = useRef(false);
|
||||
const selectCandidates = useRef<Candidates[][]>([]);
|
||||
const elementsCache = useRef<string[] | null>(null);
|
||||
const [drawArea, setDrawArea] = React.useState<DrawnArea>({
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
});
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!mouseMoving.current || fmIndex == FileManagerIndex.selector || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
const containerElement = container.current;
|
||||
if (containerElement) {
|
||||
const pos = getPosition(containerElement, e);
|
||||
setDrawArea((prev) => ({
|
||||
...prev,
|
||||
end: {
|
||||
...pos,
|
||||
},
|
||||
ctrlKey: e.ctrlKey,
|
||||
metaKey: e.metaKey,
|
||||
}));
|
||||
|
||||
const containerBox = containerElement.getBoundingClientRect();
|
||||
const containerHeight = containerBox.bottom - containerBox.top;
|
||||
const scrollMargin = containerHeight * 0.1;
|
||||
if (containerHeight - e.clientY + containerBox.top < scrollMargin) {
|
||||
containerElement.scrollTop += 10;
|
||||
} else if (e.clientY - containerBox.top < scrollMargin) {
|
||||
containerElement.scrollTop -= 10;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getPosition = useCallback((containerElement: HTMLElement, e: React.MouseEvent<HTMLElement>): Coordinates => {
|
||||
const containerBox = containerElement.getBoundingClientRect();
|
||||
const y = containerElement.scrollTop + e.clientY - containerBox.top;
|
||||
const x = containerElement.scrollLeft + e.clientX - containerBox.left;
|
||||
return { x, y };
|
||||
}, []);
|
||||
|
||||
const getDrawPosition = useCallback(
|
||||
(containerElement: HTMLElement, containerBox: DOMRect, cord: Coordinates): Coordinates => {
|
||||
const y = Math.min(
|
||||
Math.max(cord.y - containerElement.scrollTop + containerBox.top, containerBox.top),
|
||||
containerBox.bottom,
|
||||
);
|
||||
const x = Math.min(
|
||||
Math.max(cord.x - containerElement.scrollLeft + containerBox.left, containerBox.left),
|
||||
containerBox.right,
|
||||
);
|
||||
return { x, y };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateCandidate = useCallback((containerElement: HTMLElement) => {
|
||||
// query all child with data-rect-id attr
|
||||
selectCandidates.current = [];
|
||||
const containerBox = containerElement.getBoundingClientRect();
|
||||
let currentY = 0;
|
||||
let currentRow: Candidates[] = [];
|
||||
containerElement.querySelectorAll("[data-rect-id]").forEach((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
const rectId = el.getAttribute(dataRectId);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rectId) {
|
||||
const candidate = {
|
||||
index: rectId,
|
||||
x: rect.x - containerBox.x,
|
||||
y: containerElement.scrollTop + rect.y - containerBox.y,
|
||||
bottom: containerElement.scrollTop + rect.bottom - containerBox.y,
|
||||
right: rect.right - containerBox.x,
|
||||
};
|
||||
if (candidate.y > currentY) {
|
||||
currentY = candidate.y;
|
||||
if (currentRow.length > 0) {
|
||||
selectCandidates.current.push(currentRow);
|
||||
}
|
||||
currentRow = [];
|
||||
}
|
||||
currentRow.push(candidate);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (currentRow.length > 0) {
|
||||
selectCandidates.current.push(currentRow);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
if (e.button != 0 || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fmIndex == FileManagerIndex.selector) {
|
||||
return dispatch(clearSelected({ index: fmIndex, value: undefined }));
|
||||
}
|
||||
|
||||
const containerElement = container.current;
|
||||
setMouseDown(true);
|
||||
mouseMoving.current = true;
|
||||
elementsCache.current = null;
|
||||
|
||||
if (containerElement && containerElement.contains(e.target as HTMLElement)) {
|
||||
const pos = getPosition(containerElement, e);
|
||||
updateCandidate(containerElement);
|
||||
setDrawArea({
|
||||
start: {
|
||||
...pos,
|
||||
},
|
||||
end: {
|
||||
...pos,
|
||||
},
|
||||
ctrlKey: e.ctrlKey,
|
||||
metaKey: e.metaKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mouseMoving.current) {
|
||||
const containerElement = container.current;
|
||||
if (containerElement) {
|
||||
updateCandidate(containerElement);
|
||||
}
|
||||
}
|
||||
}, [fileList]);
|
||||
|
||||
const handleMouseUp = (_e: React.MouseEvent<HTMLElement>) => {
|
||||
document.body.style.userSelect = "initial";
|
||||
setMouseDown(false);
|
||||
mouseMoving.current = false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const { start, end } = drawArea;
|
||||
const containerElement = container.current;
|
||||
if (start && end && boxElement.current && containerElement) {
|
||||
const containerBox = containerElement.getBoundingClientRect();
|
||||
drawSelectionBox(
|
||||
boxElement.current,
|
||||
getDrawPosition(containerElement, containerBox, start),
|
||||
getDrawPosition(containerElement, containerBox, end),
|
||||
);
|
||||
|
||||
const startX = Math.min(start.x, end.x);
|
||||
const startY = Math.min(start.y, end.y);
|
||||
const endX = Math.max(start.x, end.x);
|
||||
const endY = Math.max(start.y, end.y);
|
||||
|
||||
// use binary search to find interest area in candidates
|
||||
const top = Math.max(0, binarySearchTop(selectCandidates.current, startY));
|
||||
const bottom = binarySearchBottom(selectCandidates.current, endY);
|
||||
|
||||
const interestCandidates = selectCandidates.current.slice(top, bottom + 1);
|
||||
// find all candidates that are within the selection box
|
||||
const elements = interestCandidates.flat().filter((el) => {
|
||||
return !(el.x > endX || el.right < startX || el.y > endY || el.bottom < startY);
|
||||
});
|
||||
|
||||
const activeElements = elements.map((el) => el.index);
|
||||
if (elementsCache.current != null) {
|
||||
// Compare if selection is changed
|
||||
if (
|
||||
elementsCache.current.length === activeElements.length &&
|
||||
elementsCache.current.every((el, index) => (activeElements[index] = el))
|
||||
) {
|
||||
// No change
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
elementsCache.current = activeElements;
|
||||
|
||||
dispatch(
|
||||
selectionFromDragBox(
|
||||
0,
|
||||
elements.map((el) => el.index),
|
||||
drawArea.ctrlKey,
|
||||
drawArea.metaKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [drawArea, boxElement, dispatch, container]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const containerElement = container.current;
|
||||
const selectionBoxElement = boxElement.current;
|
||||
if (containerElement && selectionBoxElement) {
|
||||
if (mouseDown) {
|
||||
if (!document.body.contains(selectionBoxElement)) {
|
||||
containerElement.appendChild(selectionBoxElement);
|
||||
}
|
||||
} else {
|
||||
if (containerElement.contains(selectionBoxElement)) {
|
||||
containerElement.removeChild(selectionBoxElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [mouseDown, container, boxElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectionBoxElement = boxElement.current;
|
||||
if (selectionBoxElement) {
|
||||
if (theme.palette.mode === "dark") {
|
||||
selectionBoxElement.style.background = alpha(lighten(theme.palette.primary.main, 0.3), 0.2);
|
||||
} else {
|
||||
selectionBoxElement.style.background = alpha(theme.palette.primary.main, 0.3);
|
||||
}
|
||||
selectionBoxElement.style.boxShadow = `inset 0 0 0 2px ${theme.palette.primary.light}`;
|
||||
}
|
||||
}, [theme, boxElement]);
|
||||
|
||||
return [handleMouseDown, handleMouseUp, handleMouseMove] as const;
|
||||
}
|
||||
|
||||
function drawSelectionBox(boxElement: HTMLElement, start: Coordinates, end: Coordinates): void {
|
||||
const b = boxElement;
|
||||
if (end.x > start.x) {
|
||||
b.style.left = start.x + "px";
|
||||
b.style.width = end.x - start.x + "px";
|
||||
} else {
|
||||
b.style.left = end.x + "px";
|
||||
b.style.width = start.x - end.x + "px";
|
||||
}
|
||||
|
||||
if (end.y > start.y) {
|
||||
b.style.top = start.y + "px";
|
||||
b.style.height = end.y - start.y + "px";
|
||||
} else {
|
||||
b.style.top = end.y + "px";
|
||||
b.style.height = start.y - end.y + "px";
|
||||
}
|
||||
}
|
||||
40
src/hooks/delayedHover.tsx
Executable file
40
src/hooks/delayedHover.tsx
Executable file
@@ -0,0 +1,40 @@
|
||||
import { bindHover } from "material-ui-popup-state";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { PopupState } from "material-ui-popup-state/hooks";
|
||||
|
||||
export function bindDelayedHover(popupState: PopupState, delayMs = 200) {
|
||||
const { onTouchStart, onMouseOver, onMouseLeave, ...hoverAriaProps } = bindHover(popupState);
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const delayedMouseOver = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (timeout.current) clearTimeout(timeout.current);
|
||||
|
||||
// material-ui-popup-state's event handler uses currentTarget to set the anchorEl, but
|
||||
// currentTarget is only defined while the initial event is firing. Save the original
|
||||
// and set it again before calling the delayed event handler
|
||||
const { currentTarget } = e;
|
||||
timeout.current = setTimeout(() => {
|
||||
e.currentTarget = currentTarget;
|
||||
onMouseOver(e);
|
||||
}, delayMs);
|
||||
},
|
||||
[onMouseOver],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (timeout.current) clearTimeout(timeout.current);
|
||||
onMouseLeave(e);
|
||||
},
|
||||
[onMouseLeave],
|
||||
);
|
||||
|
||||
return {
|
||||
onTouchStart,
|
||||
onMouseOver: delayedMouseOver,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
...hoverAriaProps,
|
||||
};
|
||||
}
|
||||
203
src/hooks/useMediaSession.ts
Executable file
203
src/hooks/useMediaSession.ts
Executable file
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { FileResponse, Metadata } from "../api/explorer";
|
||||
|
||||
interface MediaSessionConfig {
|
||||
file?: FileResponse;
|
||||
playing: boolean;
|
||||
duration: number;
|
||||
current: number;
|
||||
thumbSrc?: string | null;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
export const useMediaSession = ({
|
||||
file,
|
||||
playing,
|
||||
duration,
|
||||
current,
|
||||
thumbSrc,
|
||||
onPlay,
|
||||
onPause,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSeek,
|
||||
}: MediaSessionConfig) => {
|
||||
// Update media session metadata
|
||||
const updateMetadata = useCallback(() => {
|
||||
if (!("mediaSession" in navigator) || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = file.metadata?.[Metadata.music_title] ?? file.name;
|
||||
const artist = file.metadata?.[Metadata.music_artist] ?? "";
|
||||
const album = file.metadata?.[Metadata.music_album] ?? "";
|
||||
|
||||
// Prepare artwork array
|
||||
const artwork: MediaImage[] = [];
|
||||
if (thumbSrc) {
|
||||
// Add multiple sizes for better compatibility
|
||||
artwork.push(
|
||||
{ src: thumbSrc, sizes: "96x96", type: "image/jpeg" },
|
||||
{ src: thumbSrc, sizes: "128x128", type: "image/jpeg" },
|
||||
{ src: thumbSrc, sizes: "192x192", type: "image/jpeg" },
|
||||
{ src: thumbSrc, sizes: "256x256", type: "image/jpeg" },
|
||||
{ src: thumbSrc, sizes: "384x384", type: "image/jpeg" },
|
||||
{ src: thumbSrc, sizes: "512x512", type: "image/jpeg" },
|
||||
);
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
artwork,
|
||||
});
|
||||
}, [file, thumbSrc]);
|
||||
|
||||
// Update playback state
|
||||
const updatePlaybackState = useCallback(() => {
|
||||
if (!("mediaSession" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.mediaSession.playbackState = playing ? "playing" : "paused";
|
||||
}, [playing]);
|
||||
|
||||
// Update position state
|
||||
const updatePositionState = useCallback(() => {
|
||||
if (!("mediaSession" in navigator) || !duration || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration,
|
||||
playbackRate: 1,
|
||||
position: current,
|
||||
});
|
||||
} catch (error) {
|
||||
// Some browsers may not support position state
|
||||
console.debug("Media Session position state not supported:", error);
|
||||
}
|
||||
}, [duration, current]);
|
||||
|
||||
// Set up action handlers
|
||||
const setupActionHandlers = useCallback(() => {
|
||||
if (!("mediaSession" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Play action
|
||||
navigator.mediaSession.setActionHandler("play", () => {
|
||||
onPlay();
|
||||
});
|
||||
|
||||
// Pause action
|
||||
navigator.mediaSession.setActionHandler("pause", () => {
|
||||
onPause();
|
||||
});
|
||||
|
||||
// Previous track action
|
||||
navigator.mediaSession.setActionHandler("previoustrack", () => {
|
||||
onPrevious();
|
||||
});
|
||||
|
||||
// Next track action
|
||||
navigator.mediaSession.setActionHandler("nexttrack", () => {
|
||||
onNext();
|
||||
});
|
||||
|
||||
// Seek backward action
|
||||
navigator.mediaSession.setActionHandler("seekbackward", (details) => {
|
||||
if (onSeek) {
|
||||
const seekTime = Math.max(0, current - (details.seekOffset || 10));
|
||||
onSeek(seekTime);
|
||||
}
|
||||
});
|
||||
|
||||
// Seek forward action
|
||||
navigator.mediaSession.setActionHandler("seekforward", (details) => {
|
||||
if (onSeek) {
|
||||
const seekTime = Math.min(duration, current + (details.seekOffset || 10));
|
||||
onSeek(seekTime);
|
||||
}
|
||||
});
|
||||
|
||||
// Seek to action (for scrubbing)
|
||||
navigator.mediaSession.setActionHandler("seekto", (details) => {
|
||||
if (onSeek && details.seekTime !== undefined) {
|
||||
onSeek(details.seekTime);
|
||||
}
|
||||
});
|
||||
|
||||
// Stop action
|
||||
navigator.mediaSession.setActionHandler("stop", () => {
|
||||
onPause();
|
||||
});
|
||||
}, [onPlay, onPause, onPrevious, onNext, onSeek, current, duration]);
|
||||
|
||||
// Clean up action handlers
|
||||
const cleanupActionHandlers = useCallback(() => {
|
||||
if (!("mediaSession" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions: MediaSessionAction[] = [
|
||||
"play",
|
||||
"pause",
|
||||
"previoustrack",
|
||||
"nexttrack",
|
||||
"seekbackward",
|
||||
"seekforward",
|
||||
"seekto",
|
||||
"stop",
|
||||
];
|
||||
|
||||
actions.forEach((action) => {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, null);
|
||||
} catch (error) {
|
||||
// Some browsers may not support all actions
|
||||
console.debug(`Media Session action ${action} not supported:`, error);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initialize media session when component mounts
|
||||
useEffect(() => {
|
||||
setupActionHandlers();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
cleanupActionHandlers();
|
||||
};
|
||||
}, [setupActionHandlers, cleanupActionHandlers]);
|
||||
|
||||
// Update metadata when file or thumbnail changes
|
||||
useEffect(() => {
|
||||
updateMetadata();
|
||||
}, [updateMetadata]);
|
||||
|
||||
// Update playback state when playing status changes
|
||||
useEffect(() => {
|
||||
updatePlaybackState();
|
||||
}, [updatePlaybackState]);
|
||||
|
||||
// Update position state when duration or current position changes
|
||||
useEffect(() => {
|
||||
updatePositionState();
|
||||
}, [updatePositionState]);
|
||||
|
||||
// Return utility functions for manual control if needed
|
||||
return {
|
||||
updateMetadata,
|
||||
updatePlaybackState,
|
||||
updatePositionState,
|
||||
setupActionHandlers,
|
||||
cleanupActionHandlers,
|
||||
};
|
||||
};
|
||||
48
src/hooks/useNavigation.tsx
Executable file
48
src/hooks/useNavigation.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from "react";
|
||||
import { FileManagerIndex } from "../component/FileManager/FileManager.tsx";
|
||||
import { useAppDispatch, useAppSelector } from "../redux/hooks.ts";
|
||||
import {
|
||||
beforePathChange,
|
||||
checkOpenViewerQuery,
|
||||
checkReadMeEnabled,
|
||||
navigateReconcile,
|
||||
setTargetPath,
|
||||
} from "../redux/thunks/filemanager.ts";
|
||||
import { useQuery } from "../util";
|
||||
import { Filesystem } from "../util/uri.ts";
|
||||
|
||||
const pathQueryKey = "path";
|
||||
export const defaultPath = "cloudreve://my";
|
||||
export const defaultTrashPath = "cloudreve://trash";
|
||||
export const defaultSharedWithMePath = "cloudreve://" + Filesystem.shared_with_me;
|
||||
|
||||
const useNavigation = (index: number, initialPath?: string) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const query = useQuery();
|
||||
const path = useAppSelector((s) => s.fileManager[index].path);
|
||||
|
||||
// Update path in redux when path in query changes
|
||||
if (index === FileManagerIndex.main) {
|
||||
useEffect(() => {
|
||||
const path = query.get(pathQueryKey) ? (query.get(pathQueryKey) as string) : defaultPath;
|
||||
dispatch(setTargetPath(index, path));
|
||||
}, [query]);
|
||||
} else {
|
||||
useEffect(() => {
|
||||
dispatch(setTargetPath(index, initialPath ?? defaultPath));
|
||||
}, []);
|
||||
}
|
||||
|
||||
// When path state changed, dispatch to load file list
|
||||
useEffect(() => {
|
||||
if (path) {
|
||||
dispatch(navigateReconcile(index)).then(() => {
|
||||
dispatch(checkReadMeEnabled(index));
|
||||
dispatch(checkOpenViewerQuery(index));
|
||||
});
|
||||
dispatch(beforePathChange(index));
|
||||
}
|
||||
}, [path]);
|
||||
};
|
||||
|
||||
export default useNavigation;
|
||||
30
src/hooks/useOverflow.tsx
Executable file
30
src/hooks/useOverflow.tsx
Executable file
@@ -0,0 +1,30 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
export const useIsOverflow = (ref: React.RefObject<HTMLElement>, callback?: (o: boolean) => void) => {
|
||||
const [isOverflow, setIsOverflow] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const { current } = ref;
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = () => {
|
||||
const hasOverflow = current.scrollWidth > current.clientWidth;
|
||||
|
||||
setIsOverflow(hasOverflow);
|
||||
|
||||
if (callback) callback(hasOverflow);
|
||||
};
|
||||
|
||||
if (current) {
|
||||
if ("ResizeObserver" in window) {
|
||||
const observer = new ResizeObserver(trigger);
|
||||
observer.observe(current);
|
||||
return () => observer.unobserve(current);
|
||||
}
|
||||
}
|
||||
}, [callback, ref]);
|
||||
|
||||
return isOverflow;
|
||||
};
|
||||
Reference in New Issue
Block a user