586 lines
18 KiB
TypeScript
586 lines
18 KiB
TypeScript
|
import extensions from "components/system/Files/FileEntry/extensions";
|
||
|
import {
|
||
|
getModifiedTime,
|
||
|
getTextWrapData,
|
||
|
} from "components/system/Files/FileEntry/functions";
|
||
|
import StyledFigure from "components/system/Files/FileEntry/StyledFigure";
|
||
|
import SubIcons from "components/system/Files/FileEntry/SubIcons";
|
||
|
import useFile from "components/system/Files/FileEntry/useFile";
|
||
|
import useFileContextMenu from "components/system/Files/FileEntry/useFileContextMenu";
|
||
|
import useFileInfo from "components/system/Files/FileEntry/useFileInfo";
|
||
|
import FileManager from "components/system/Files/FileManager";
|
||
|
import type { FileStat } from "components/system/Files/FileManager/functions";
|
||
|
import { isSelectionIntersecting } from "components/system/Files/FileManager/Selection/functions";
|
||
|
import type { SelectionRect } from "components/system/Files/FileManager/Selection/useSelection";
|
||
|
import useFileDrop from "components/system/Files/FileManager/useFileDrop";
|
||
|
import type { FocusEntryFunctions } from "components/system/Files/FileManager/useFocusableEntries";
|
||
|
import type { FileActions } from "components/system/Files/FileManager/useFolder";
|
||
|
import type { FileManagerViewNames } from "components/system/Files/Views";
|
||
|
import { FileEntryIconSize } from "components/system/Files/Views";
|
||
|
import { useFileSystem } from "contexts/fileSystem";
|
||
|
import { useProcesses } from "contexts/process";
|
||
|
import { m as motion } from "framer-motion";
|
||
|
import useDoubleClick from "hooks/useDoubleClick";
|
||
|
import dynamic from "next/dynamic";
|
||
|
import { basename, dirname, extname, join } from "path";
|
||
|
import {
|
||
|
useCallback,
|
||
|
useEffect,
|
||
|
useLayoutEffect,
|
||
|
useMemo,
|
||
|
useRef,
|
||
|
useState,
|
||
|
} from "react";
|
||
|
import { useTheme } from "styled-components";
|
||
|
import Button from "styles/common/Button";
|
||
|
import Icon from "styles/common/Icon";
|
||
|
import {
|
||
|
DEFAULT_LOCALE,
|
||
|
ICON_CACHE,
|
||
|
ICON_CACHE_EXTENSION,
|
||
|
ICON_PATH,
|
||
|
IMAGE_FILE_EXTENSIONS,
|
||
|
LIST_VIEW_ANIMATION,
|
||
|
MOUNTABLE_EXTENSIONS,
|
||
|
NON_BREAKING_HYPHEN,
|
||
|
ONE_TIME_PASSIVE_EVENT,
|
||
|
PREVENT_SCROLL,
|
||
|
SHORTCUT_EXTENSION,
|
||
|
SMALLEST_PNG_SIZE,
|
||
|
TRANSITIONS_IN_MILLISECONDS,
|
||
|
USER_ICON_PATH,
|
||
|
VIDEO_FILE_EXTENSIONS,
|
||
|
} from "utils/constants";
|
||
|
import {
|
||
|
bufferToUrl,
|
||
|
getFormattedSize,
|
||
|
getHtmlToImage,
|
||
|
isCanvasEmpty,
|
||
|
isYouTubeUrl,
|
||
|
} from "utils/functions";
|
||
|
import { spotlightEffect } from "utils/spotlightEffect";
|
||
|
|
||
|
const Down = dynamic(() =>
|
||
|
import("components/apps/FileExplorer/NavigationIcons").then((mod) => mod.Down)
|
||
|
);
|
||
|
|
||
|
const RenameBox = dynamic(
|
||
|
() => import("components/system/Files/FileEntry/RenameBox")
|
||
|
);
|
||
|
|
||
|
type FileEntryProps = {
|
||
|
fileActions: FileActions;
|
||
|
fileManagerId?: string;
|
||
|
fileManagerRef: React.MutableRefObject<HTMLOListElement | null>;
|
||
|
focusFunctions: FocusEntryFunctions;
|
||
|
focusedEntries: string[];
|
||
|
hideShortcutIcon?: boolean;
|
||
|
isLoadingFileManager: boolean;
|
||
|
loadIconImmediately?: boolean;
|
||
|
name: string;
|
||
|
path: string;
|
||
|
readOnly?: boolean;
|
||
|
renaming: boolean;
|
||
|
selectionRect?: SelectionRect;
|
||
|
setRenaming: React.Dispatch<React.SetStateAction<string>>;
|
||
|
stats: FileStat;
|
||
|
useNewFolderIcon?: boolean;
|
||
|
view: FileManagerViewNames;
|
||
|
};
|
||
|
|
||
|
const truncateName = (
|
||
|
name: string,
|
||
|
fontSize: string,
|
||
|
fontFamily: string,
|
||
|
maxWidth: number
|
||
|
): string => {
|
||
|
const nonBreakingName = name.replace(/-/g, NON_BREAKING_HYPHEN);
|
||
|
const { lines } = getTextWrapData(
|
||
|
nonBreakingName,
|
||
|
fontSize,
|
||
|
fontFamily,
|
||
|
maxWidth
|
||
|
);
|
||
|
|
||
|
if (lines.length > 2) {
|
||
|
const text = name.includes(" ") ? lines.slice(0, 2).join("") : lines[0];
|
||
|
|
||
|
return `${text.slice(0, -3)}...`;
|
||
|
}
|
||
|
|
||
|
return nonBreakingName;
|
||
|
};
|
||
|
|
||
|
const focusing: string[] = [];
|
||
|
|
||
|
const cacheQueue: (() => Promise<void>)[] = [];
|
||
|
|
||
|
const FileEntry: FC<FileEntryProps> = ({
|
||
|
fileActions,
|
||
|
fileManagerId,
|
||
|
fileManagerRef,
|
||
|
focusedEntries,
|
||
|
focusFunctions,
|
||
|
hideShortcutIcon,
|
||
|
isLoadingFileManager,
|
||
|
loadIconImmediately,
|
||
|
name,
|
||
|
path,
|
||
|
readOnly,
|
||
|
renaming,
|
||
|
selectionRect,
|
||
|
setRenaming,
|
||
|
stats,
|
||
|
useNewFolderIcon,
|
||
|
view,
|
||
|
}) => {
|
||
|
const { blurEntry, focusEntry } = focusFunctions;
|
||
|
const { url: changeUrl } = useProcesses();
|
||
|
const [{ comment, getIcon, icon, pid, subIcons, url }, setInfo] = useFileInfo(
|
||
|
path,
|
||
|
stats.isDirectory(),
|
||
|
useNewFolderIcon
|
||
|
);
|
||
|
const openFile = useFile(url);
|
||
|
const {
|
||
|
createPath,
|
||
|
exists,
|
||
|
mkdirRecursive,
|
||
|
pasteList,
|
||
|
readFile,
|
||
|
stat,
|
||
|
unlink,
|
||
|
updateFolder,
|
||
|
writeFile,
|
||
|
} = useFileSystem();
|
||
|
const [showInFileManager, setShowInFileManager] = useState(false);
|
||
|
const { formats, sizes } = useTheme();
|
||
|
const listView = view === "list";
|
||
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||
|
const figureRef = useRef<HTMLElement | null>(null);
|
||
|
const fileName = basename(path);
|
||
|
const urlExt = extname(url).toLowerCase();
|
||
|
const isDynamicIcon = useMemo(
|
||
|
() =>
|
||
|
IMAGE_FILE_EXTENSIONS.has(urlExt) ||
|
||
|
VIDEO_FILE_EXTENSIONS.has(urlExt) ||
|
||
|
isYouTubeUrl(url),
|
||
|
[url, urlExt]
|
||
|
);
|
||
|
const isOnlyFocusedEntry =
|
||
|
focusedEntries.length === 1 && focusedEntries[0] === fileName;
|
||
|
const extension = extname(path).toLowerCase();
|
||
|
const isShortcut = extension === SHORTCUT_EXTENSION;
|
||
|
const directory = isShortcut ? url : path;
|
||
|
const fileDrop = useFileDrop({
|
||
|
callback: async (fileDropName, data) => {
|
||
|
if (!focusedEntries.includes(fileName)) {
|
||
|
const uniqueName = await createPath(fileDropName, directory, data);
|
||
|
|
||
|
if (uniqueName) updateFolder(directory, uniqueName);
|
||
|
}
|
||
|
},
|
||
|
directory,
|
||
|
onDragLeave: () =>
|
||
|
buttonRef.current?.parentElement?.classList.remove("focus-within"),
|
||
|
onDragOver: () =>
|
||
|
buttonRef.current?.parentElement?.classList.add("focus-within"),
|
||
|
});
|
||
|
const openInFileExplorer = pid === "FileExplorer";
|
||
|
const truncatedName = useMemo(
|
||
|
() =>
|
||
|
truncateName(
|
||
|
name,
|
||
|
sizes.fileEntry.fontSize,
|
||
|
formats.systemFont,
|
||
|
sizes.fileEntry[
|
||
|
listView ? "maxListTextDisplayWidth" : "maxIconTextDisplayWidth"
|
||
|
]
|
||
|
),
|
||
|
[formats.systemFont, listView, name, sizes.fileEntry]
|
||
|
);
|
||
|
const iconRef = useRef<HTMLImageElement | null>(null);
|
||
|
const isIconCached = useRef(false);
|
||
|
const isDynamicIconLoaded = useRef(false);
|
||
|
const getIconAbortController = useRef<AbortController>();
|
||
|
const createTooltip = useCallback(async (): Promise<string> => {
|
||
|
if (stats.isDirectory()) return "";
|
||
|
|
||
|
if (isShortcut) {
|
||
|
if (comment) return comment;
|
||
|
if (url) {
|
||
|
if (url.startsWith("http:") || url.startsWith("https:")) return url;
|
||
|
return `Location: ${basename(url, extname(url))} (${dirname(url)})`;
|
||
|
}
|
||
|
return "";
|
||
|
}
|
||
|
|
||
|
const type =
|
||
|
extensions[extension]?.type ||
|
||
|
`${extension.toUpperCase().replace(".", "")} File`;
|
||
|
const fullStats = stats.size < 0 ? await stat(path) : stats;
|
||
|
const { size: sizeInBytes } = fullStats;
|
||
|
const modifiedTime = getModifiedTime(path, fullStats);
|
||
|
const size = getFormattedSize(sizeInBytes);
|
||
|
const toolTip = `Type: ${type}${size === "-1" ? "" : `\nSize: ${size}`}`;
|
||
|
const date = new Date(modifiedTime).toISOString().slice(0, 10);
|
||
|
const time = new Intl.DateTimeFormat(
|
||
|
DEFAULT_LOCALE,
|
||
|
formats.dateModified
|
||
|
).format(modifiedTime);
|
||
|
const dateModified = `${date} ${time}`;
|
||
|
|
||
|
return `${toolTip}\nDate modified: ${dateModified}`;
|
||
|
}, [
|
||
|
comment,
|
||
|
extension,
|
||
|
formats.dateModified,
|
||
|
isShortcut,
|
||
|
path,
|
||
|
stat,
|
||
|
stats,
|
||
|
url,
|
||
|
]);
|
||
|
const [tooltip, setTooltip] = useState<string>();
|
||
|
const doubleClickHandler = useCallback(() => {
|
||
|
if (
|
||
|
openInFileExplorer &&
|
||
|
fileManagerId &&
|
||
|
!MOUNTABLE_EXTENSIONS.has(urlExt)
|
||
|
) {
|
||
|
changeUrl(fileManagerId, url);
|
||
|
blurEntry();
|
||
|
} else if (openInFileExplorer && listView) {
|
||
|
setShowInFileManager((currentState) => !currentState);
|
||
|
} else {
|
||
|
openFile(pid, isDynamicIcon ? undefined : icon);
|
||
|
}
|
||
|
}, [
|
||
|
blurEntry,
|
||
|
changeUrl,
|
||
|
fileManagerId,
|
||
|
icon,
|
||
|
isDynamicIcon,
|
||
|
listView,
|
||
|
openFile,
|
||
|
openInFileExplorer,
|
||
|
pid,
|
||
|
url,
|
||
|
urlExt,
|
||
|
]);
|
||
|
|
||
|
useEffect(() => {
|
||
|
if (!isLoadingFileManager && !isIconCached.current) {
|
||
|
const updateIcon = async (): Promise<void> => {
|
||
|
if (icon.startsWith("blob:") || icon.startsWith("data:")) {
|
||
|
if (icon.startsWith("data:image/jpeg;base64,")) return;
|
||
|
|
||
|
isIconCached.current = true;
|
||
|
|
||
|
const cachedIconPath = join(
|
||
|
ICON_CACHE,
|
||
|
`${path}${ICON_CACHE_EXTENSION}`
|
||
|
);
|
||
|
|
||
|
if (
|
||
|
urlExt !== ".ico" &&
|
||
|
!url.startsWith(ICON_PATH) &&
|
||
|
!url.startsWith(USER_ICON_PATH) &&
|
||
|
!(await exists(cachedIconPath)) &&
|
||
|
iconRef.current instanceof HTMLImageElement
|
||
|
) {
|
||
|
const cacheIcon = async (): Promise<void> => {
|
||
|
if (iconRef.current instanceof HTMLImageElement) {
|
||
|
const nextQueueItem = (): Promise<void> => {
|
||
|
cacheQueue.shift();
|
||
|
return cacheQueue[0]?.();
|
||
|
};
|
||
|
let generatedIcon = "";
|
||
|
|
||
|
if (
|
||
|
iconRef.current.currentSrc.startsWith(
|
||
|
"data:image/gif;base64,"
|
||
|
)
|
||
|
) {
|
||
|
generatedIcon = iconRef.current.currentSrc;
|
||
|
} else {
|
||
|
const { clientHeight, clientWidth } = iconRef.current;
|
||
|
const { naturalHeight, naturalWidth } = iconRef.current;
|
||
|
const naturalAspectRatio = naturalWidth / naturalHeight;
|
||
|
const clientAspectRatio = clientWidth / clientHeight;
|
||
|
let height: number | undefined;
|
||
|
let width: number | undefined;
|
||
|
|
||
|
if (naturalAspectRatio !== clientAspectRatio) {
|
||
|
if (naturalWidth > naturalHeight) {
|
||
|
height = clientHeight / naturalAspectRatio;
|
||
|
} else {
|
||
|
width = clientWidth * naturalAspectRatio;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const htmlToImage = await getHtmlToImage();
|
||
|
let iconCanvas: HTMLCanvasElement | undefined;
|
||
|
|
||
|
try {
|
||
|
iconCanvas = await htmlToImage?.toCanvas(iconRef.current, {
|
||
|
height,
|
||
|
skipAutoScale: true,
|
||
|
style: {
|
||
|
objectPosition: height
|
||
|
? "top"
|
||
|
: width
|
||
|
? "left"
|
||
|
: undefined,
|
||
|
},
|
||
|
width,
|
||
|
});
|
||
|
} catch {
|
||
|
// Ignore failure to capture
|
||
|
}
|
||
|
|
||
|
if (iconCanvas && !isCanvasEmpty(iconCanvas)) {
|
||
|
generatedIcon = iconCanvas.toDataURL("image/png");
|
||
|
} else {
|
||
|
setTimeout(cacheIcon, TRANSITIONS_IN_MILLISECONDS.WINDOW);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (generatedIcon) {
|
||
|
cacheQueue.push(async () => {
|
||
|
const baseCachedPath = dirname(cachedIconPath);
|
||
|
|
||
|
await mkdirRecursive(baseCachedPath);
|
||
|
|
||
|
const cachedIcon = Buffer.from(
|
||
|
generatedIcon.replace(/data:(.*);base64,/, ""),
|
||
|
"base64"
|
||
|
);
|
||
|
|
||
|
await writeFile(cachedIconPath, cachedIcon, true);
|
||
|
setInfo((info) => ({
|
||
|
...info,
|
||
|
icon: bufferToUrl(cachedIcon),
|
||
|
}));
|
||
|
updateFolder(baseCachedPath, basename(cachedIconPath));
|
||
|
|
||
|
return nextQueueItem();
|
||
|
});
|
||
|
}
|
||
|
|
||
|
if (cacheQueue.length === 1) await cacheQueue[0]();
|
||
|
}
|
||
|
};
|
||
|
|
||
|
if (iconRef.current.complete) cacheIcon();
|
||
|
else {
|
||
|
iconRef.current.addEventListener(
|
||
|
"load",
|
||
|
cacheIcon,
|
||
|
ONE_TIME_PASSIVE_EVENT
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
if (isIconCached.current) return;
|
||
|
|
||
|
const cachedIconPath = join(
|
||
|
ICON_CACHE,
|
||
|
`${path}${ICON_CACHE_EXTENSION}`
|
||
|
);
|
||
|
|
||
|
if (await exists(cachedIconPath)) {
|
||
|
const cachedIconData = await readFile(cachedIconPath);
|
||
|
|
||
|
if (cachedIconData.length >= SMALLEST_PNG_SIZE) {
|
||
|
if (!isIconCached.current) {
|
||
|
isIconCached.current = true;
|
||
|
|
||
|
setInfo((info) => ({
|
||
|
...info,
|
||
|
icon: bufferToUrl(cachedIconData),
|
||
|
}));
|
||
|
}
|
||
|
} else {
|
||
|
try {
|
||
|
await unlink(cachedIconPath);
|
||
|
updateFolder(dirname(path));
|
||
|
} catch {
|
||
|
// Ignore issues deleting bad cached icon
|
||
|
}
|
||
|
}
|
||
|
} else if (
|
||
|
!isDynamicIconLoaded.current &&
|
||
|
buttonRef.current &&
|
||
|
typeof getIcon === "function"
|
||
|
) {
|
||
|
isDynamicIconLoaded.current = true;
|
||
|
new IntersectionObserver(
|
||
|
([{ intersectionRatio }], observer) => {
|
||
|
if (intersectionRatio > 0) {
|
||
|
observer.disconnect();
|
||
|
getIconAbortController.current = new AbortController();
|
||
|
getIcon(getIconAbortController.current.signal);
|
||
|
}
|
||
|
},
|
||
|
{ root: fileManagerRef.current, rootMargin: "5px" }
|
||
|
).observe(buttonRef.current);
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
updateIcon();
|
||
|
}
|
||
|
}, [
|
||
|
exists,
|
||
|
fileManagerRef,
|
||
|
getIcon,
|
||
|
icon,
|
||
|
isLoadingFileManager,
|
||
|
mkdirRecursive,
|
||
|
path,
|
||
|
readFile,
|
||
|
setInfo,
|
||
|
unlink,
|
||
|
updateFolder,
|
||
|
url,
|
||
|
urlExt,
|
||
|
writeFile,
|
||
|
]);
|
||
|
|
||
|
useEffect(
|
||
|
() => () => {
|
||
|
try {
|
||
|
getIconAbortController?.current?.abort?.();
|
||
|
} catch {
|
||
|
// Failed to abort getIcon
|
||
|
}
|
||
|
},
|
||
|
[]
|
||
|
);
|
||
|
|
||
|
useLayoutEffect(() => {
|
||
|
if (buttonRef.current && fileManagerRef.current) {
|
||
|
const inFocusedEntries = focusedEntries.includes(fileName);
|
||
|
const inFocusing = focusing.includes(fileName);
|
||
|
const isFocused = inFocusedEntries || inFocusing;
|
||
|
|
||
|
if (inFocusedEntries && inFocusing) {
|
||
|
focusing.splice(focusing.indexOf(fileName), 1);
|
||
|
}
|
||
|
|
||
|
if (selectionRect) {
|
||
|
const selected = isSelectionIntersecting(
|
||
|
buttonRef.current.getBoundingClientRect(),
|
||
|
fileManagerRef.current.getBoundingClientRect(),
|
||
|
selectionRect,
|
||
|
fileManagerRef.current.scrollTop
|
||
|
);
|
||
|
|
||
|
if (selected && !isFocused) {
|
||
|
focusing.push(fileName);
|
||
|
focusEntry(fileName);
|
||
|
buttonRef.current.focus(PREVENT_SCROLL);
|
||
|
} else if (!selected && isFocused) {
|
||
|
blurEntry(fileName);
|
||
|
}
|
||
|
} else if (
|
||
|
isFocused &&
|
||
|
focusedEntries.length === 1 &&
|
||
|
!buttonRef.current.contains(document.activeElement)
|
||
|
) {
|
||
|
blurEntry();
|
||
|
focusEntry(fileName);
|
||
|
buttonRef.current.focus(PREVENT_SCROLL);
|
||
|
}
|
||
|
}
|
||
|
}, [
|
||
|
blurEntry,
|
||
|
fileManagerRef,
|
||
|
fileName,
|
||
|
focusEntry,
|
||
|
focusedEntries,
|
||
|
selectionRect,
|
||
|
]);
|
||
|
|
||
|
return (
|
||
|
<>
|
||
|
<Button
|
||
|
ref={buttonRef}
|
||
|
aria-label={name}
|
||
|
onMouseOver={() => createTooltip().then(setTooltip)}
|
||
|
title={tooltip}
|
||
|
{...(listView && { ...LIST_VIEW_ANIMATION, as: motion.button })}
|
||
|
{...useDoubleClick(doubleClickHandler, listView)}
|
||
|
{...(openInFileExplorer && fileDrop)}
|
||
|
{...useFileContextMenu(
|
||
|
url,
|
||
|
pid,
|
||
|
path,
|
||
|
setRenaming,
|
||
|
fileActions,
|
||
|
focusFunctions,
|
||
|
focusedEntries,
|
||
|
fileManagerId,
|
||
|
readOnly
|
||
|
)}
|
||
|
>
|
||
|
<StyledFigure
|
||
|
ref={figureRef}
|
||
|
$renaming={renaming}
|
||
|
{...(listView && spotlightEffect(figureRef.current))}
|
||
|
>
|
||
|
<Icon
|
||
|
ref={iconRef}
|
||
|
$eager={loadIconImmediately}
|
||
|
$moving={pasteList[path] === "move"}
|
||
|
alt={name}
|
||
|
src={icon}
|
||
|
{...FileEntryIconSize[view]}
|
||
|
/>
|
||
|
<SubIcons
|
||
|
icon={icon}
|
||
|
name={name}
|
||
|
showShortcutIcon={Boolean(hideShortcutIcon || stats.systemShortcut)}
|
||
|
subIcons={subIcons}
|
||
|
view={view}
|
||
|
/>
|
||
|
{renaming ? (
|
||
|
<RenameBox
|
||
|
name={name}
|
||
|
path={path}
|
||
|
renameFile={(origPath, newName) => {
|
||
|
fileActions.renameFile(origPath, newName);
|
||
|
setRenaming("");
|
||
|
}}
|
||
|
/>
|
||
|
) : (
|
||
|
<figcaption>
|
||
|
{!isOnlyFocusedEntry || name.length === truncatedName.length
|
||
|
? truncatedName
|
||
|
: name}
|
||
|
</figcaption>
|
||
|
)}
|
||
|
{listView && openInFileExplorer && <Down flip={showInFileManager} />}
|
||
|
</StyledFigure>
|
||
|
</Button>
|
||
|
{showInFileManager && (
|
||
|
<FileManager
|
||
|
url={url}
|
||
|
view="list"
|
||
|
hideFolders
|
||
|
hideLoading
|
||
|
hideShortcutIcons
|
||
|
loadIconsImmediately
|
||
|
preloadShortcuts
|
||
|
readOnly
|
||
|
skipFsWatcher
|
||
|
skipSorting
|
||
|
/>
|
||
|
)}
|
||
|
</>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
export default FileEntry;
|