first commit

This commit is contained in:
2025-10-19 13:31:11 +00:00
commit 8bfc183b66
1248 changed files with 195992 additions and 0 deletions

309
src/hooks/areaSelection.ts Executable file
View 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
View 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
View 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
View 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
View 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;
};