first commit
This commit is contained in:
466
src/component/Viewers/ArchivePreview/ArchivePreview.tsx
Executable file
466
src/component/Viewers/ArchivePreview/ArchivePreview.tsx
Executable file
@@ -0,0 +1,466 @@
|
||||
import { Box, Breadcrumbs, Button, Link, Table, TableCell, TableContainer, Typography, useTheme } from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TableVirtuoso } from "react-virtuoso";
|
||||
import { getArchiveListFiles } from "../../../api/api.ts";
|
||||
import { ArchivedFile, FileType } from "../../../api/explorer.ts";
|
||||
import { closeArchiveViewer, setExtractArchiveDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { fileBase, fileExtension, getFileLinkedUri, sizeToString } from "../../../util";
|
||||
import AutoHeight from "../../Common/AutoHeight.tsx";
|
||||
import EncodingSelector, { defaultEncodingValue } from "../../Common/Form/EncodingSelector.tsx";
|
||||
import { SecondaryButton, StyledCheckbox, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx";
|
||||
import TimeBadge from "../../Common/TimeBadge.tsx";
|
||||
import FileIcon from "../../FileManager/Explorer/FileIcon.tsx";
|
||||
import ChevronRight from "../../Icons/ChevronRight.tsx";
|
||||
import Folder from "../../Icons/Folder.tsx";
|
||||
import Home from "../../Icons/Home.tsx";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
|
||||
const ArchivePreview = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const viewerState = useAppSelector((state) => state.globalState.archiveViewer);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [files, setFiles] = useState<ArchivedFile[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const [height, setHeight] = useState(33);
|
||||
const [encoding, setEncoding] = useState(defaultEncodingValue);
|
||||
|
||||
const isZip = useMemo(() => {
|
||||
return fileExtension(viewerState?.file?.name ?? "") === "zip";
|
||||
}, [viewerState?.file?.name]);
|
||||
|
||||
const currentFiles = useMemo(() => {
|
||||
if (!files) return [];
|
||||
|
||||
if (!currentPath) {
|
||||
return files.filter((file) => !file.name.includes("/"));
|
||||
}
|
||||
|
||||
// 如果在子目录,显示该目录下的文件和文件夹
|
||||
const pathPrefix = currentPath.endsWith("/") ? currentPath : currentPath + "/";
|
||||
const pathFiles = files.filter((file) => file.name.startsWith(pathPrefix) && file.name !== currentPath);
|
||||
|
||||
// 去重并转换为相对路径
|
||||
const relativePaths = new Set<string>();
|
||||
const result: ArchivedFile[] = [];
|
||||
|
||||
pathFiles.forEach((file) => {
|
||||
const relativePath = file.name.substring(pathPrefix.length);
|
||||
const firstSlash = relativePath.indexOf("/");
|
||||
|
||||
if (firstSlash === -1) {
|
||||
if (!relativePaths.has(relativePath)) {
|
||||
relativePaths.add(relativePath);
|
||||
result.push({
|
||||
...file,
|
||||
name: relativePath,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const dirName = relativePath.substring(0, firstSlash);
|
||||
if (!relativePaths.has(dirName)) {
|
||||
relativePaths.add(dirName);
|
||||
result.push({
|
||||
name: dirName,
|
||||
size: 0,
|
||||
updated_at: file.updated_at,
|
||||
is_directory: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [files, currentPath]);
|
||||
|
||||
// 过滤文件
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!filterText) return currentFiles;
|
||||
return currentFiles.filter((file) => file.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||
}, [currentFiles, filterText]);
|
||||
|
||||
// 面包屑路径
|
||||
const breadcrumbPaths = useMemo(() => {
|
||||
if (!currentPath) return [];
|
||||
return currentPath.split("/").filter(Boolean);
|
||||
}, [currentPath]);
|
||||
|
||||
// 规范化路径,去除开头可能存在的 `/`
|
||||
const normalizeName = (name: string) => {
|
||||
if (name && typeof name === "string" && name.startsWith("/")) {
|
||||
return name.slice(1);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
setEncoding(defaultEncodingValue);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setFiles([]);
|
||||
setCurrentPath("");
|
||||
setSelectedFiles([]);
|
||||
setFilterText("");
|
||||
|
||||
dispatch(
|
||||
getArchiveListFiles({
|
||||
uri: getFileLinkedUri(viewerState.file),
|
||||
entity: viewerState.version,
|
||||
text_encoding: encoding !== defaultEncodingValue ? encoding : undefined,
|
||||
}),
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.files) {
|
||||
// 补齐目录
|
||||
const allItems: ArchivedFile[] = [];
|
||||
const allDirs = new Set<string>();
|
||||
|
||||
// 目录项
|
||||
res.files
|
||||
.filter((item) => item.is_directory)
|
||||
.forEach((item) => {
|
||||
const normalizedName = normalizeName(item.name);
|
||||
allItems.push({
|
||||
...item,
|
||||
name: normalizedName,
|
||||
});
|
||||
allDirs.add(normalizedName);
|
||||
});
|
||||
|
||||
// 文件项,并补齐缺失目录
|
||||
res.files
|
||||
.filter((item) => !item.is_directory)
|
||||
.forEach((item) => {
|
||||
const normalizedName = normalizeName(item.name);
|
||||
allItems.push({
|
||||
...item,
|
||||
name: normalizedName,
|
||||
});
|
||||
|
||||
const dirElements = normalizedName.split("/");
|
||||
for (let i = 1; i < dirElements.length; i++) {
|
||||
const dirName = dirElements.slice(0, i).join("/");
|
||||
if (!allDirs.has(dirName)) {
|
||||
allDirs.add(dirName);
|
||||
allItems.push({
|
||||
name: dirName,
|
||||
size: 0,
|
||||
updated_at: "1970-01-01T00:00:00Z",
|
||||
is_directory: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 排序文件
|
||||
// 先目录,后文件,分别按名称排序
|
||||
allItems.sort((a, b) => {
|
||||
if (a.is_directory && !b.is_directory) return -1;
|
||||
if (!a.is_directory && b.is_directory) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
setFiles(allItems);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
onClose();
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [viewerState, encoding]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeArchiveViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const navigateToDirectory = useCallback(
|
||||
(dirName: string) => {
|
||||
if (!currentPath) {
|
||||
setCurrentPath(dirName);
|
||||
} else {
|
||||
setCurrentPath(currentPath + "/" + dirName);
|
||||
}
|
||||
setSelectedFiles([]);
|
||||
},
|
||||
[currentPath],
|
||||
);
|
||||
|
||||
const navigateToBreadcrumb = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setCurrentPath("");
|
||||
} else {
|
||||
const newPath = breadcrumbPaths.slice(0, index + 1).join("/");
|
||||
setCurrentPath(newPath);
|
||||
}
|
||||
setSelectedFiles([]);
|
||||
},
|
||||
[breadcrumbPaths],
|
||||
);
|
||||
|
||||
const toggleFileSelection = useCallback(
|
||||
(fileName: string) => {
|
||||
const fullPath = currentPath ? currentPath + "/" + fileName : fileName;
|
||||
setSelectedFiles((prev) => {
|
||||
if (prev.includes(fullPath)) {
|
||||
return prev.filter((f) => f !== fullPath);
|
||||
} else {
|
||||
return [...prev, fullPath];
|
||||
}
|
||||
});
|
||||
},
|
||||
[currentPath],
|
||||
);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
const allFiles = filteredFiles.map((file) => (currentPath ? currentPath + "/" + file.name : file.name));
|
||||
|
||||
const allSelected = allFiles.every((file) => selectedFiles.includes(file));
|
||||
|
||||
if (allSelected) {
|
||||
setSelectedFiles((prev) => prev.filter((file) => !allFiles.includes(file)));
|
||||
} else {
|
||||
setSelectedFiles((prev) => [...new Set([...prev, ...allFiles])]);
|
||||
}
|
||||
}, [filteredFiles, selectedFiles, currentPath]);
|
||||
|
||||
// 解压选中的文件
|
||||
const extractSelectedFiles = useCallback(() => {
|
||||
if (selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setExtractArchiveDialog({ open: true, file: viewerState?.file, mask: selectedFiles, encoding }));
|
||||
}, [selectedFiles, t, enqueueSnackbar, encoding]);
|
||||
|
||||
const extractArchive = useCallback(() => {
|
||||
if (!viewerState?.file) {
|
||||
return;
|
||||
}
|
||||
dispatch(setExtractArchiveDialog({ open: true, file: viewerState?.file, encoding }));
|
||||
}, [viewerState?.file, encoding]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
<AutoHeight>
|
||||
<div>
|
||||
{loading && <ViewerLoading />}
|
||||
{!loading && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Breadcrumbs separator={<ChevronRight fontSize="small" />}>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
color="inherit"
|
||||
onClick={() => navigateToBreadcrumb(-1)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
<Home fontSize="small" sx={{ mr: 0.5 }} />
|
||||
{t("fileManager.rootFolder")}
|
||||
</Link>
|
||||
{breadcrumbPaths.map((path, index) => {
|
||||
const isLast = index === breadcrumbPaths.length - 1;
|
||||
return isLast ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
key={index}
|
||||
color="text.primary"
|
||||
sx={{ display: "flex", alignItems: "center" }}
|
||||
>
|
||||
<Folder fontSize="small" sx={{ mr: 0.5 }} />
|
||||
{path}
|
||||
</Typography>
|
||||
) : (
|
||||
<Link
|
||||
key={index}
|
||||
component="button"
|
||||
variant="body2"
|
||||
color="inherit"
|
||||
onClick={() => navigateToBreadcrumb(index)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
<Folder fontSize="small" sx={{ mr: 0.5 }} />
|
||||
{path}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</Box>
|
||||
|
||||
{filteredFiles.length > 0 ? (
|
||||
<TableContainer component={StyledTableContainerPaper}>
|
||||
<TableVirtuoso
|
||||
style={{
|
||||
height: Math.min(height, 400),
|
||||
overflow: "auto",
|
||||
}}
|
||||
totalListHeightChanged={(h) => {
|
||||
setHeight(h + 0.5);
|
||||
}}
|
||||
components={{
|
||||
// eslint-disable-next-line react/display-name
|
||||
Table: (props) => <Table {...props} size="small" />,
|
||||
}}
|
||||
data={filteredFiles}
|
||||
itemContent={(_index, file) => {
|
||||
const fullPath = currentPath ? currentPath + "/" + file.name : file.name;
|
||||
const isSelected = selectedFiles.includes(fullPath);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableCell sx={{ width: 50, padding: "4px 8px" }}>
|
||||
<StyledCheckbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelection(file.name)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
minWidth: 300,
|
||||
width: "100%",
|
||||
padding: "4px 8px",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ display: "flex", alignItems: "center" }}>
|
||||
<FileIcon
|
||||
sx={{ px: 0, py: 0, mr: 1, height: "20px" }}
|
||||
variant="small"
|
||||
iconProps={{ fontSize: "small" }}
|
||||
file={{
|
||||
type: file.is_directory ? FileType.folder : FileType.file,
|
||||
name: file.name,
|
||||
}}
|
||||
/>
|
||||
{file.is_directory ? (
|
||||
<Typography
|
||||
component="button"
|
||||
variant="inherit"
|
||||
onClick={() => navigateToDirectory(file.name)}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{fileBase(file.name)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="inherit"
|
||||
sx={{
|
||||
color: "inherit",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{fileBase(file.name)}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ minWidth: 100, padding: "4px 8px" }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{file.is_directory ? "-" : sizeToString(file.size)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ minWidth: 120, padding: "4px 8px" }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{file.updated_at ? <TimeBadge variant="inherit" datetime={file.updated_at} /> : "-"}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" align="center" sx={{ py: 4 }}>
|
||||
{t("fileManager.nothingFound")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!viewerState?.version && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
<Button variant="contained" onClick={extractArchive} color="primary">
|
||||
{t("fileManager.extractArchive")}
|
||||
</Button>
|
||||
{selectedFiles.length > 0 && (
|
||||
<SecondaryButton variant={"contained"} onClick={extractSelectedFiles}>
|
||||
{t("fileManager.extractSelected")}
|
||||
</SecondaryButton>
|
||||
)}
|
||||
</Box>
|
||||
{isZip && (
|
||||
<Box>
|
||||
<EncodingSelector
|
||||
value={encoding}
|
||||
onChange={setEncoding}
|
||||
size="small"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
showIcon
|
||||
label={t("modals.selectEncoding")}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</AutoHeight>
|
||||
</ViewerDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchivePreview;
|
||||
321
src/component/Viewers/CodeViewer/CodeViewer.tsx
Executable file
321
src/component/Viewers/CodeViewer/CodeViewer.tsx
Executable file
@@ -0,0 +1,321 @@
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import { Box, Button, ButtonGroup, IconButton, ListItemIcon, ListItemText, Menu, useTheme } from "@mui/material";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closeCodeViewer } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { getEntityContent } from "../../../redux/thunks/file.ts";
|
||||
import { saveCode } from "../../../redux/thunks/viewer.ts";
|
||||
import { fileExtension } from "../../../util";
|
||||
import { CascadingSubmenu } from "../../FileManager/ContextMenu/CascadingMenu.tsx";
|
||||
import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import useActionDisplayOpt, { canUpdate } from "../../FileManager/ContextMenu/useActionDisplayOpt.ts";
|
||||
import CaretDown from "../../Icons/CaretDown.tsx";
|
||||
import Checkmark from "../../Icons/Checkmark.tsx";
|
||||
import Setting from "../../Icons/Setting.tsx";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
|
||||
const MonacoEditor = lazy(() => import("./MonacoEditor.tsx"));
|
||||
|
||||
export const codePreviewSuffix: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
md: "markdown",
|
||||
json: "json",
|
||||
php: "php",
|
||||
py: "python",
|
||||
bat: "bat",
|
||||
cpp: "cpp",
|
||||
c: "cpp",
|
||||
h: "cpp",
|
||||
cs: "csharp",
|
||||
css: "css",
|
||||
dockerfile: "dockerfile",
|
||||
go: "go",
|
||||
html: "html",
|
||||
ini: "ini",
|
||||
java: "java",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
less: "less",
|
||||
lua: "lua",
|
||||
sh: "shell",
|
||||
sql: "sql",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
};
|
||||
|
||||
const allCharsets = [
|
||||
"utf-8",
|
||||
"ibm866",
|
||||
"iso-8859-2",
|
||||
"iso-8859-3",
|
||||
"iso-8859-4",
|
||||
"iso-8859-5",
|
||||
"iso-8859-6",
|
||||
"iso-8859-7",
|
||||
"iso-8859-8",
|
||||
"iso-8859-8i",
|
||||
"iso-8859-10",
|
||||
"iso-8859-13",
|
||||
"iso-8859-14",
|
||||
"iso-8859-15",
|
||||
"iso-8859-16",
|
||||
"koi8-r",
|
||||
"koi8-u",
|
||||
"macintosh",
|
||||
"windows-874",
|
||||
"windows-1250",
|
||||
"windows-1251",
|
||||
"windows-1252",
|
||||
"windows-1253",
|
||||
"windows-1254",
|
||||
"windows-1255",
|
||||
"windows-1256",
|
||||
"windows-1257",
|
||||
"windows-1258",
|
||||
"x-mac-cyrillic",
|
||||
"gbk",
|
||||
"gb18030",
|
||||
"hz-gb-2312",
|
||||
"big5",
|
||||
"euc-jp",
|
||||
"iso-2022-jp",
|
||||
"shift-jis",
|
||||
"euc-kr",
|
||||
"iso-2022-kr",
|
||||
"utf-16be",
|
||||
"utf-16le",
|
||||
];
|
||||
|
||||
const allLng = Array.from(new Set(Object.values(codePreviewSuffix))).sort();
|
||||
|
||||
const CodeViewer = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewerState = useAppSelector((state) => state.globalState.codeViewer);
|
||||
|
||||
const displayOpt = useActionDisplayOpt(viewerState?.file ? [viewerState?.file] : []);
|
||||
const supportUpdate = canUpdate(displayOpt);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [optionAnchorEl, setOptionAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [language, setLng] = useState<string | null>(null);
|
||||
const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off");
|
||||
const saveFunction = useRef<() => void>(() => {});
|
||||
|
||||
const loadContent = useCallback(
|
||||
(charset?: string) => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoaded(false);
|
||||
setOptionAnchorEl(null);
|
||||
dispatch(getEntityContent(viewerState.file, viewerState.version))
|
||||
.then((res) => {
|
||||
setValue(new TextDecoder(charset).decode(res));
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
onClose();
|
||||
});
|
||||
},
|
||||
[viewerState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLng(null);
|
||||
setSaved(true);
|
||||
setLng(codePreviewSuffix[fileExtension(viewerState.file.name) ?? ""] ?? "");
|
||||
loadContent();
|
||||
}, [viewerState?.open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeCodeViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const openMore = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const openOption = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
setOptionAnchorEl(e.currentTarget);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const toggleWordWrap = useCallback(() => {
|
||||
setOptionAnchorEl(null);
|
||||
setWordWrap((prev) => (prev == "off" ? "on" : "off"));
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(
|
||||
(saveAs?: boolean) => {
|
||||
if (!viewerState?.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(saveCode(value, viewerState.file, viewerState.version, saveAs))
|
||||
.then(() => {
|
||||
setSaved(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[value, viewerState],
|
||||
);
|
||||
|
||||
const onChange = useCallback((v: string) => {
|
||||
setValue(v);
|
||||
setSaved(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
saveFunction.current = () => {
|
||||
if (!saved && supportUpdate) {
|
||||
onSave(false);
|
||||
}
|
||||
};
|
||||
}, [saved, supportUpdate, onSave]);
|
||||
|
||||
useHotkeys(
|
||||
["Control+s", "Meta+s"],
|
||||
() => {
|
||||
saveFunction.current();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
readOnly={!supportUpdate}
|
||||
actions={
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
{supportUpdate && (
|
||||
<ButtonGroup disabled={loading || !loaded || saved} disableElevation variant="contained">
|
||||
<LoadingButton loading={loading} variant={"contained"} onClick={() => onSave(false)}>
|
||||
<span>{t("fileManager.save")}</span>
|
||||
</LoadingButton>
|
||||
<Button size="small" onClick={openMore}>
|
||||
<CaretDown sx={{ fontSize: "12px!important" }} />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
<IconButton onClick={openOption}>
|
||||
<Setting fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
fullScreenToggle
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem onClick={() => onSave(true)} dense>
|
||||
<ListItemText>{t("modals.saveAs")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
<Menu
|
||||
anchorEl={optionAnchorEl}
|
||||
open={Boolean(optionAnchorEl)}
|
||||
onClose={() => setOptionAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 200,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CascadingSubmenu popupId={"lng"} title={t("application:fileManager.charset")}>
|
||||
{allCharsets.map((charset) => (
|
||||
<SquareMenuItem key={charset} onClick={() => loadContent(charset)}>
|
||||
<ListItemText>{charset}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</CascadingSubmenu>
|
||||
<CascadingSubmenu popupId={"lng"} title={t("application:fileManager.textType")}>
|
||||
{allLng.map((l) => (
|
||||
<SquareMenuItem key={l} onClick={() => setLng(l)}>
|
||||
<ListItemText>{l}</ListItemText>
|
||||
{l == language && (
|
||||
<ListItemIcon>
|
||||
<Checkmark />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</CascadingSubmenu>
|
||||
<DenseDivider />
|
||||
<SquareMenuItem onClick={toggleWordWrap} dense>
|
||||
<ListItemText>{t("fileManager.wordWrap")}</ListItemText>
|
||||
{wordWrap === "on" && (
|
||||
<ListItemIcon sx={{ minWidth: "0!important" }}>
|
||||
<Checkmark />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
{!loaded && <ViewerLoading />}
|
||||
{loaded && (
|
||||
<Suspense fallback={<ViewerLoading />}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "calc(100vh - 200px)",
|
||||
}}
|
||||
>
|
||||
<MonacoEditor
|
||||
onSave={saveFunction}
|
||||
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
|
||||
options={{
|
||||
readOnly: !supportUpdate,
|
||||
wordWrap: wordWrap,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
value={value}
|
||||
language={language ?? ""}
|
||||
onChange={(v) => onChange(v as string)}
|
||||
/>
|
||||
</Box>
|
||||
</Suspense>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeViewer;
|
||||
396
src/component/Viewers/CodeViewer/MonacoEditor.tsx
Executable file
396
src/component/Viewers/CodeViewer/MonacoEditor.tsx
Executable file
@@ -0,0 +1,396 @@
|
||||
import { Box } from "@mui/material";
|
||||
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
|
||||
import * as monacoEditor from "monaco-editor/esm/vs/editor/editor.api";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import "./useWorker.ts";
|
||||
|
||||
/**
|
||||
* @remarks
|
||||
* This will be `IStandaloneEditorConstructionOptions` in newer versions of monaco-editor, or
|
||||
* `IEditorConstructionOptions` in versions before that was introduced.
|
||||
*/
|
||||
export type EditorConstructionOptions = NonNullable<Parameters<typeof monacoEditor.editor.create>[1]>;
|
||||
|
||||
export type EditorWillMount = (monaco: typeof monacoEditor) => void | EditorConstructionOptions;
|
||||
|
||||
export type EditorDidMount = (editor: monacoEditor.editor.IStandaloneCodeEditor, monaco: typeof monacoEditor) => void;
|
||||
|
||||
export type EditorWillUnmount = (
|
||||
editor: monacoEditor.editor.IStandaloneCodeEditor,
|
||||
monaco: typeof monacoEditor,
|
||||
) => void | EditorConstructionOptions;
|
||||
|
||||
export type ChangeHandler = (value: string, event: monacoEditor.editor.IModelContentChangedEvent) => void;
|
||||
|
||||
export interface MonacoEditorBaseProps {
|
||||
/**
|
||||
* Width of editor. Defaults to 100%.
|
||||
*/
|
||||
width?: string | number;
|
||||
|
||||
/**
|
||||
* Height of editor. Defaults to 100%.
|
||||
*/
|
||||
height?: string | number;
|
||||
|
||||
/**
|
||||
* The initial value of the auto created model in the editor.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
|
||||
/**
|
||||
* The initial language of the auto created model in the editor. Defaults to 'javascript'.
|
||||
*/
|
||||
language?: string;
|
||||
|
||||
/**
|
||||
* Theme to be used for rendering.
|
||||
* The current out-of-the-box available themes are: 'vs' (default), 'vs-dark', 'hc-black'.
|
||||
* You can create custom themes via `monaco.editor.defineTheme`.
|
||||
*/
|
||||
theme?: string | null;
|
||||
|
||||
/**
|
||||
* Optional string classname to append to the editor.
|
||||
*/
|
||||
className?: string | null;
|
||||
}
|
||||
|
||||
export interface MonacoEditorProps extends MonacoEditorBaseProps {
|
||||
/**
|
||||
* Value of the auto created model in the editor.
|
||||
* If you specify `null` or `undefined` for this property, the component behaves in uncontrolled mode.
|
||||
* Otherwise, it behaves in controlled mode.
|
||||
*/
|
||||
value?: string | null;
|
||||
|
||||
/**
|
||||
* Refer to Monaco interface {monaco.editor.IStandaloneEditorConstructionOptions}.
|
||||
*/
|
||||
options?: monacoEditor.editor.IStandaloneEditorConstructionOptions;
|
||||
|
||||
/**
|
||||
* Refer to Monaco interface {monaco.editor.IEditorOverrideServices}.
|
||||
*/
|
||||
overrideServices?: monacoEditor.editor.IEditorOverrideServices;
|
||||
|
||||
/**
|
||||
* An event emitted before the editor mounted (similar to componentWillMount of React).
|
||||
*/
|
||||
editorWillMount?: EditorWillMount;
|
||||
|
||||
/**
|
||||
* An event emitted when the editor has been mounted (similar to componentDidMount of React).
|
||||
*/
|
||||
editorDidMount?: EditorDidMount;
|
||||
|
||||
/**
|
||||
* An event emitted before the editor unmount (similar to componentWillUnmount of React).
|
||||
*/
|
||||
editorWillUnmount?: EditorWillUnmount;
|
||||
|
||||
/**
|
||||
* An event emitted when the content of the current model has changed.
|
||||
*/
|
||||
onChange?: ChangeHandler;
|
||||
|
||||
/**
|
||||
* An event emitted when the editor is blurred.
|
||||
*/
|
||||
onBlur?: (value: string) => void;
|
||||
|
||||
/**
|
||||
* Let the language be inferred from the uri
|
||||
*/
|
||||
uri?: (monaco: typeof monacoEditor) => monacoEditor.Uri;
|
||||
|
||||
minHeight?: string | number;
|
||||
|
||||
onSave?: React.MutableRefObject<() => void>;
|
||||
}
|
||||
|
||||
// ============ Diff Editor ============
|
||||
|
||||
export type DiffEditorWillMount = (
|
||||
monaco: typeof monacoEditor,
|
||||
) => void | monacoEditor.editor.IStandaloneEditorConstructionOptions;
|
||||
|
||||
export type DiffEditorDidMount = (
|
||||
editor: monacoEditor.editor.IStandaloneDiffEditor,
|
||||
monaco: typeof monacoEditor,
|
||||
) => void;
|
||||
|
||||
export type DiffEditorWillUnmount = (
|
||||
editor: monacoEditor.editor.IStandaloneDiffEditor,
|
||||
monaco: typeof monacoEditor,
|
||||
) => void;
|
||||
|
||||
export type DiffChangeHandler = ChangeHandler;
|
||||
|
||||
export interface MonacoDiffEditorProps extends MonacoEditorBaseProps {
|
||||
/**
|
||||
* The original value to compare against.
|
||||
*/
|
||||
original?: string;
|
||||
|
||||
/**
|
||||
* Value of the auto created model in the editor.
|
||||
* If you specify value property, the component behaves in controlled mode. Otherwise, it behaves in uncontrolled mode.
|
||||
*/
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* Refer to Monaco interface {monaco.editor.IDiffEditorConstructionOptions}.
|
||||
*/
|
||||
options?: monacoEditor.editor.IDiffEditorConstructionOptions;
|
||||
|
||||
/**
|
||||
* Refer to Monaco interface {monaco.editor.IEditorOverrideServices}.
|
||||
*/
|
||||
overrideServices?: monacoEditor.editor.IEditorOverrideServices;
|
||||
|
||||
/**
|
||||
* An event emitted before the editor mounted (similar to componentWillMount of React).
|
||||
*/
|
||||
editorWillMount?: DiffEditorWillMount;
|
||||
|
||||
/**
|
||||
* An event emitted when the editor has been mounted (similar to componentDidMount of React).
|
||||
*/
|
||||
editorDidMount?: DiffEditorDidMount;
|
||||
|
||||
/**
|
||||
* An event emitted before the editor unmount (similar to componentWillUnmount of React).
|
||||
*/
|
||||
editorWillUnmount?: DiffEditorWillUnmount;
|
||||
|
||||
/**
|
||||
* An event emitted when the content of the current model has changed.
|
||||
*/
|
||||
onChange?: DiffChangeHandler;
|
||||
|
||||
/**
|
||||
* Let the language be inferred from the uri
|
||||
*/
|
||||
originalUri?: (monaco: typeof monacoEditor) => monacoEditor.Uri;
|
||||
|
||||
/**
|
||||
* Let the language be inferred from the uri
|
||||
*/
|
||||
modifiedUri?: (monaco: typeof monacoEditor) => monacoEditor.Uri;
|
||||
|
||||
onBlur?: (value: string) => void;
|
||||
}
|
||||
|
||||
function processSize(size: number | string) {
|
||||
return !/^\d+$/.test(size as string) ? size : `${size}px`;
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
function MonacoEditor({
|
||||
width,
|
||||
height,
|
||||
minHeight,
|
||||
value,
|
||||
defaultValue,
|
||||
language,
|
||||
theme,
|
||||
options,
|
||||
overrideServices,
|
||||
editorWillMount,
|
||||
editorDidMount,
|
||||
editorWillUnmount,
|
||||
onChange,
|
||||
onBlur,
|
||||
className,
|
||||
uri,
|
||||
onSave,
|
||||
}: MonacoEditorProps) {
|
||||
const containerElement = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
const _subscription = useRef<monaco.IDisposable | null>(null);
|
||||
const _subscriptionBlur = useRef<monaco.IDisposable | null>(null);
|
||||
|
||||
const __prevent_trigger_change_event = useRef<boolean | null>(null);
|
||||
|
||||
const fixedWidth = processSize(width);
|
||||
|
||||
const fixedHeight = processSize(height);
|
||||
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
width: fixedWidth,
|
||||
height: fixedHeight,
|
||||
}),
|
||||
[fixedWidth, fixedHeight],
|
||||
);
|
||||
|
||||
const handleEditorWillMount = () => {
|
||||
const finalOptions = editorWillMount(monaco);
|
||||
return finalOptions || {};
|
||||
};
|
||||
|
||||
const handleEditorDidMount = () => {
|
||||
editorDidMount(editor.current, monaco);
|
||||
|
||||
_subscription.current = editor.current.onDidChangeModelContent((event) => {
|
||||
if (!__prevent_trigger_change_event.current) {
|
||||
onChange?.(editor.current.getValue(), event);
|
||||
}
|
||||
});
|
||||
|
||||
_subscriptionBlur.current = editor.current.onDidBlurEditorText((event) => {
|
||||
onBlur?.(editor.current.getValue());
|
||||
});
|
||||
|
||||
// Add key binding for Ctrl+S or Meta+S (Cmd+S on Mac)
|
||||
editor.current.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
onSave?.current();
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditorWillUnmount = () => {
|
||||
editorWillUnmount(editor.current, monaco);
|
||||
};
|
||||
|
||||
const initMonaco = () => {
|
||||
const finalValue = value !== null ? value : defaultValue;
|
||||
|
||||
if (containerElement.current) {
|
||||
// Before initializing monaco editor
|
||||
const finalOptions = { ...options, ...handleEditorWillMount() };
|
||||
const modelUri = uri?.(monaco);
|
||||
let model = modelUri && monaco.editor.getModel(modelUri);
|
||||
if (model) {
|
||||
// Cannot create two models with the same URI,
|
||||
// if model with the given URI is already created, just update it.
|
||||
model.setValue(finalValue);
|
||||
monaco.editor.setModelLanguage(model, language);
|
||||
} else {
|
||||
model = monaco.editor.createModel(finalValue, language, modelUri);
|
||||
}
|
||||
editor.current = monaco.editor.create(
|
||||
containerElement.current,
|
||||
{
|
||||
model,
|
||||
...(className ? { extraEditorClassName: className } : {}),
|
||||
...finalOptions,
|
||||
...(theme ? { theme } : {}),
|
||||
},
|
||||
overrideServices,
|
||||
);
|
||||
// After initializing monaco editor
|
||||
handleEditorDidMount();
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(initMonaco, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
if (value === editor.current.getValue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = editor.current.getModel();
|
||||
__prevent_trigger_change_event.current = true;
|
||||
editor.current.pushUndoStop();
|
||||
// pushEditOperations says it expects a cursorComputer, but doesn't seem to need one.
|
||||
model.pushEditOperations(
|
||||
[],
|
||||
[
|
||||
{
|
||||
range: model.getFullModelRange(),
|
||||
text: value,
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
editor.current.pushUndoStop();
|
||||
__prevent_trigger_change_event.current = false;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
const model = editor.current.getModel();
|
||||
monaco.editor.setModelLanguage(model, language);
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
// Don't pass in the model on update because monaco crashes if we pass the model
|
||||
// a second time. See https://github.com/microsoft/monaco-editor/issues/2027
|
||||
const { model: _model, ...optionsWithoutModel } = options;
|
||||
editor.current.updateOptions({
|
||||
...(className ? { extraEditorClassName: className } : {}),
|
||||
...optionsWithoutModel,
|
||||
});
|
||||
}
|
||||
}, [className, options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
editor.current.layout();
|
||||
}
|
||||
}, [width, height]);
|
||||
|
||||
useEffect(() => {
|
||||
monaco.editor.setTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (editor.current) {
|
||||
handleEditorWillUnmount();
|
||||
editor.current.dispose();
|
||||
}
|
||||
if (_subscription.current) {
|
||||
_subscription.current.dispose();
|
||||
}
|
||||
if (_subscriptionBlur.current) {
|
||||
_subscriptionBlur.current.dispose();
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerElement}
|
||||
style={style}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: height ? height : "100%",
|
||||
minHeight: minHeight ? minHeight : "calc(100vh - 200px)",
|
||||
}}
|
||||
className="react-monaco-editor-container"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
MonacoEditor.defaultProps = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
value: null,
|
||||
defaultValue: "",
|
||||
language: "javascript",
|
||||
theme: null,
|
||||
options: {},
|
||||
overrideServices: {},
|
||||
editorWillMount: noop,
|
||||
editorDidMount: noop,
|
||||
editorWillUnmount: noop,
|
||||
onChange: noop,
|
||||
className: null,
|
||||
};
|
||||
|
||||
MonacoEditor.displayName = "MonacoEditor";
|
||||
|
||||
export default MonacoEditor;
|
||||
27
src/component/Viewers/CodeViewer/useWorker.ts
Executable file
27
src/component/Viewers/CodeViewer/useWorker.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import * as monaco from "monaco-editor";
|
||||
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||
|
||||
// @ts-ignore
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_: any, label: string) {
|
||||
if (label === "json") {
|
||||
return new jsonWorker();
|
||||
}
|
||||
if (label === "css" || label === "scss" || label === "less") {
|
||||
return new cssWorker();
|
||||
}
|
||||
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||
return new htmlWorker();
|
||||
}
|
||||
if (label === "typescript" || label === "javascript") {
|
||||
return new tsWorker();
|
||||
}
|
||||
return new editorWorker();
|
||||
},
|
||||
};
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true);
|
||||
59
src/component/Viewers/CustomViewer.tsx
Executable file
59
src/component/Viewers/CustomViewer.tsx
Executable file
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch, useAppSelector } from "../../redux/hooks.ts";
|
||||
import ViewerDialog, { ViewerLoading } from "./ViewerDialog.tsx";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { closeCustomViewer } from "../../redux/globalStateSlice.ts";
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
|
||||
const CustomViewer = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewerState = useAppSelector((state) => state.globalState.customViewer);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
}, [viewerState]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeCustomViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
fullScreenToggle
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
{loading && <ViewerLoading />}
|
||||
{viewerState && (
|
||||
<Box
|
||||
onLoad={() => setLoading(false)}
|
||||
src={viewerState.url}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: loading ? 0 : "100%",
|
||||
border: "none",
|
||||
minHeight: loading ? 0 : "calc(100vh - 200px)",
|
||||
}}
|
||||
component={"iframe"}
|
||||
/>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomViewer;
|
||||
199
src/component/Viewers/DrawIO/DrawIOViewer.tsx
Executable file
199
src/component/Viewers/DrawIO/DrawIOViewer.tsx
Executable file
@@ -0,0 +1,199 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { closeDrawIOViewer } from "../../../redux/globalStateSlice.ts";
|
||||
import { Box, ListItemText, Menu, useTheme } from "@mui/material";
|
||||
import useActionDisplayOpt, { canUpdate } from "../../FileManager/ContextMenu/useActionDisplayOpt.ts";
|
||||
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import { generateIframeSrc, handleRemoteInvoke } from "./drawio.ts";
|
||||
import { getEntityContent } from "../../../redux/thunks/file.ts";
|
||||
import { saveDrawIO } from "../../../redux/thunks/viewer.ts";
|
||||
import dayjs from "dayjs";
|
||||
import { formatLocalTime } from "../../../util/datetime.ts";
|
||||
|
||||
const DrawIOViewer = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewerState = useAppSelector((state) => state.globalState.drawIOViewer);
|
||||
const instanceID = useAppSelector((state) => state.siteConfig.basic.config.instance_id);
|
||||
|
||||
const displayOpt = useActionDisplayOpt(viewerState?.file ? [viewerState?.file] : []);
|
||||
const supportUpdate = useRef(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [src, setSrc] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const pp = useRef<HTMLIFrameElement | undefined>();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!viewerState.open) {
|
||||
setSrc("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoaded(false);
|
||||
supportUpdate.current = canUpdate(displayOpt) && !viewerState?.version;
|
||||
pp.current = undefined;
|
||||
const src = generateIframeSrc(
|
||||
viewerState.host,
|
||||
viewerState.file,
|
||||
!supportUpdate.current,
|
||||
theme.palette.mode == "dark",
|
||||
);
|
||||
setSrc(src);
|
||||
window.addEventListener("message", handlePostMessage, false);
|
||||
return () => {
|
||||
window.removeEventListener("message", handlePostMessage, false);
|
||||
};
|
||||
}, [viewerState?.open]);
|
||||
|
||||
const handlePostMessage = async (e: MessageEvent) => {
|
||||
console.log("Received PostMessage from " + e.origin, e.data);
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(e.data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!viewerState?.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.event) {
|
||||
case "exit":
|
||||
onClose();
|
||||
break;
|
||||
case "configure":
|
||||
pp.current?.contentWindow?.postMessage(
|
||||
// TODO: use userv config
|
||||
JSON.stringify({ action: "configure", config: {} }),
|
||||
"*",
|
||||
);
|
||||
break;
|
||||
case "remoteInvoke":
|
||||
pp.current?.contentWindow &&
|
||||
handleRemoteInvoke(pp.current?.contentWindow, msg, dispatch, viewerState, supportUpdate.current, instanceID);
|
||||
break;
|
||||
case "save":
|
||||
try {
|
||||
dispatch(saveDrawIO(msg.xml, viewerState.file, false));
|
||||
pp.current?.contentWindow?.postMessage(
|
||||
JSON.stringify({
|
||||
action: "status",
|
||||
message: t("fileManager.saveSuccess", {
|
||||
time: formatLocalTime(dayjs()),
|
||||
}),
|
||||
modified: false,
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
} catch (e) {}
|
||||
break;
|
||||
|
||||
case "init":
|
||||
try {
|
||||
const content = await dispatch(getEntityContent(viewerState.file, viewerState.version));
|
||||
const contentStr = new TextDecoder().decode(content);
|
||||
pp.current?.contentWindow?.postMessage(
|
||||
JSON.stringify({
|
||||
action: "load",
|
||||
autosave: supportUpdate.current,
|
||||
title: viewerState.file.name,
|
||||
xml: contentStr,
|
||||
desc: {
|
||||
xml: contentStr,
|
||||
id: viewerState.file.id,
|
||||
size: content.byteLength,
|
||||
etag: viewerState.file.primary_entity,
|
||||
writeable: supportUpdate.current,
|
||||
name: viewerState.file.name,
|
||||
versionEnabled: true,
|
||||
ver: 2,
|
||||
instanceId: instanceID ?? "",
|
||||
},
|
||||
disableAutoSave: !supportUpdate.current,
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
if (supportUpdate.current) {
|
||||
pp.current?.contentWindow?.postMessage(JSON.stringify({ action: "remoteInvokeReady" }), "*");
|
||||
}
|
||||
} catch (e) {
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeDrawIOViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const openMore = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleIframeOnload = useCallback((e: React.SyntheticEvent<HTMLIFrameElement>) => {
|
||||
setLoaded(true);
|
||||
pp.current = e.currentTarget;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
readOnly={!supportUpdate.current}
|
||||
fullScreenToggle
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem dense>
|
||||
<ListItemText>{t("modals.saveAs")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
{(!loaded || !src) && <ViewerLoading />}
|
||||
{src && (
|
||||
<Box
|
||||
onLoad={handleIframeOnload}
|
||||
src={src}
|
||||
component={"iframe"}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: loaded ? "100%" : 0,
|
||||
border: "none",
|
||||
minHeight: loaded ? "calc(100vh - 200px)" : 0,
|
||||
visibility: loaded ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawIOViewer;
|
||||
117
src/component/Viewers/DrawIO/drawio.ts
Executable file
117
src/component/Viewers/DrawIO/drawio.ts
Executable file
@@ -0,0 +1,117 @@
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { fileExtension } from "../../../util";
|
||||
import i18next from "i18next";
|
||||
import SessionManager from "../../../session";
|
||||
import { saveDrawIO } from "../../../redux/thunks/viewer.ts";
|
||||
import { AppDispatch } from "../../../redux/store.ts";
|
||||
import { DrawIOViewerState } from "../../../redux/globalStateSlice.ts";
|
||||
import { getFileInfo } from "../../../api/api.ts";
|
||||
|
||||
const defaultHost = "https://embed.diagrams.net";
|
||||
|
||||
export const generateIframeSrc = (
|
||||
host: string | undefined,
|
||||
file: FileResponse,
|
||||
readOnly: boolean,
|
||||
darkMode: boolean,
|
||||
) => {
|
||||
const ext = fileExtension(file.name);
|
||||
const query = new URLSearchParams({
|
||||
embed: "1",
|
||||
embedRT: "1",
|
||||
configure: "1",
|
||||
libraries: "1",
|
||||
spin: "1",
|
||||
proto: "json",
|
||||
keepmodified: "1",
|
||||
p: "nxtcld",
|
||||
lang: i18next.t("fileManager.drawioLng"),
|
||||
dark: darkMode ? "1" : "0",
|
||||
});
|
||||
|
||||
if (ext == "dwb") {
|
||||
query.set("ui", "sketch");
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
query.set("chrome", "0");
|
||||
}
|
||||
|
||||
return (host ?? defaultHost) + "?" + query.toString();
|
||||
};
|
||||
|
||||
export const handleRemoteInvoke = async (
|
||||
w: Window,
|
||||
msg: any,
|
||||
dispatch: AppDispatch,
|
||||
viewerState: DrawIOViewerState,
|
||||
writeable?: boolean,
|
||||
instanceId?: string,
|
||||
) => {
|
||||
switch (msg.funtionName) {
|
||||
case "getCurrentUser":
|
||||
const currentUser = SessionManager.currentUser();
|
||||
sendResponse(w, msg, [
|
||||
currentUser
|
||||
? {
|
||||
displayName: currentUser?.user.nickname,
|
||||
uid: currentUser?.user.id,
|
||||
}
|
||||
: null,
|
||||
]);
|
||||
break;
|
||||
case "saveFile":
|
||||
try {
|
||||
const res = await dispatch(saveDrawIO(msg.functionArgs[2], viewerState.file, false));
|
||||
if (res) {
|
||||
sendResponse(w, msg, [
|
||||
{
|
||||
etag: res.primary_entity,
|
||||
size: res.size,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
sendResponse(w, msg, null, `${e}`);
|
||||
}
|
||||
break;
|
||||
case "getFileInfo":
|
||||
try {
|
||||
const res = await dispatch(getFileInfo({ uri: viewerState.file.path }));
|
||||
if (res) {
|
||||
sendResponse(w, msg, [
|
||||
{
|
||||
id: res.id,
|
||||
size: res.size,
|
||||
writeable,
|
||||
name: res.name,
|
||||
etag: res.primary_entity,
|
||||
versionEnabled: true,
|
||||
ver: 2,
|
||||
instanceId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
sendResponse(w, msg, null, `${e}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const sendResponse = (w: Window, msg: any, respose: any, error?: string) => {
|
||||
var respMsg: {
|
||||
action: string;
|
||||
msgMarkers: any;
|
||||
error?: { errResp?: string };
|
||||
resp?: any;
|
||||
} = { action: "remoteInvokeResponse", msgMarkers: msg.msgMarkers };
|
||||
if (error) {
|
||||
respMsg.error = { errResp: error };
|
||||
} else if (respose != null) {
|
||||
respMsg.resp = respose;
|
||||
}
|
||||
|
||||
console.log("Send remote invoke response PostMessage", respMsg);
|
||||
w.postMessage(JSON.stringify(respMsg), "*");
|
||||
};
|
||||
89
src/component/Viewers/EpubViewer/Epub.tsx
Executable file
89
src/component/Viewers/EpubViewer/Epub.tsx
Executable file
@@ -0,0 +1,89 @@
|
||||
import { IReactReaderProps, IReactReaderStyle, ReactReader, ReactReaderStyle } from "react-reader";
|
||||
|
||||
import { type Rendition } from "epubjs";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
function updateTheme(rendition: Rendition, theme: string) {
|
||||
const themes = rendition.themes;
|
||||
switch (theme) {
|
||||
case "dark": {
|
||||
themes.override("color", "#fff");
|
||||
themes.override("background", "#000");
|
||||
break;
|
||||
}
|
||||
case "light": {
|
||||
themes.override("color", "#000");
|
||||
themes.override("background", "#fff");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lightReaderTheme: IReactReaderStyle = {
|
||||
...ReactReaderStyle,
|
||||
readerArea: {
|
||||
...ReactReaderStyle.readerArea,
|
||||
transition: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const darkReaderTheme: IReactReaderStyle = {
|
||||
...ReactReaderStyle,
|
||||
arrow: {
|
||||
...ReactReaderStyle.arrow,
|
||||
color: "white",
|
||||
},
|
||||
arrowHover: {
|
||||
...ReactReaderStyle.arrowHover,
|
||||
color: "#ccc",
|
||||
},
|
||||
readerArea: {
|
||||
...ReactReaderStyle.readerArea,
|
||||
backgroundColor: "#000",
|
||||
transition: undefined,
|
||||
},
|
||||
titleArea: {
|
||||
...ReactReaderStyle.titleArea,
|
||||
color: "#ccc",
|
||||
},
|
||||
tocArea: {
|
||||
...ReactReaderStyle.tocArea,
|
||||
background: "#111",
|
||||
},
|
||||
tocButtonExpanded: {
|
||||
...ReactReaderStyle.tocButtonExpanded,
|
||||
background: "#222",
|
||||
},
|
||||
tocButtonBar: {
|
||||
...ReactReaderStyle.tocButtonBar,
|
||||
background: "#fff",
|
||||
},
|
||||
tocButton: {
|
||||
...ReactReaderStyle.tocButton,
|
||||
color: "white",
|
||||
},
|
||||
};
|
||||
|
||||
const Epub = (props: IReactReaderProps) => {
|
||||
const rendition = useRef<Rendition | undefined>(undefined);
|
||||
const theme = useTheme();
|
||||
useEffect(() => {
|
||||
if (rendition.current) {
|
||||
updateTheme(rendition.current, theme.palette.mode);
|
||||
}
|
||||
}, [theme.palette.mode]);
|
||||
|
||||
return (
|
||||
<ReactReader
|
||||
{...props}
|
||||
readerStyles={theme.palette.mode === "dark" ? darkReaderTheme : lightReaderTheme}
|
||||
getRendition={(_rendition) => {
|
||||
updateTheme(_rendition, theme.palette.mode);
|
||||
rendition.current = _rendition;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Epub;
|
||||
102
src/component/Viewers/EpubViewer/EpubViewer.tsx
Executable file
102
src/component/Viewers/EpubViewer/EpubViewer.tsx
Executable file
@@ -0,0 +1,102 @@
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import React, { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileEntityUrl } from "../../../api/api.ts";
|
||||
import { closeEpubViewer } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { getFileLinkedUri } from "../../../util";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
|
||||
const Epub = React.lazy(() => import("./Epub.tsx"));
|
||||
|
||||
const EpubViewer = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewerState = useAppSelector((state) => state.globalState.epubViewer);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [src, setSrc] = useState("");
|
||||
const [currentLocation, setLocation] = useState<string | null>(null);
|
||||
const locationChanged = useCallback(
|
||||
(epubcifi: string) => {
|
||||
setLocation(epubcifi);
|
||||
if (viewerState?.file) {
|
||||
SessionManager.set(`${UserSettings.BookLocationPrefix}_${viewerState.file.id}`, epubcifi);
|
||||
}
|
||||
},
|
||||
[viewerState?.file],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
setLocation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSrc("");
|
||||
dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(viewerState.file)],
|
||||
entity: viewerState.version,
|
||||
}),
|
||||
)
|
||||
.then((res) => {
|
||||
setSrc(res.urls[0].url);
|
||||
const location = SessionManager.get(`${UserSettings.BookLocationPrefix}_${viewerState.file.id}`);
|
||||
if (location) {
|
||||
setLocation(location);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
onClose();
|
||||
});
|
||||
}, [viewerState]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeEpubViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
fullScreen: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
{!src && <ViewerLoading />}
|
||||
{src && (
|
||||
<Suspense fallback={<ViewerLoading />}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "calc(100vh - 200px)",
|
||||
}}
|
||||
>
|
||||
<Epub
|
||||
loadingView={<ViewerLoading />}
|
||||
location={currentLocation}
|
||||
locationChanged={locationChanged}
|
||||
epubInitOptions={{
|
||||
openAs: "epub",
|
||||
}}
|
||||
showToc={true}
|
||||
url={src}
|
||||
/>
|
||||
</Box>
|
||||
</Suspense>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpubViewer;
|
||||
78
src/component/Viewers/Excalidraw/Excalidraw.tsx
Executable file
78
src/component/Viewers/Excalidraw/Excalidraw.tsx
Executable file
@@ -0,0 +1,78 @@
|
||||
import { Excalidraw as ExcalidrawComponent } from "@excalidraw/excalidraw";
|
||||
import { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import { AppState, BinaryFiles } from "@excalidraw/excalidraw/types";
|
||||
import { Box } from "@mui/material";
|
||||
import { useMemo } from "react";
|
||||
import "./excalidraw.css";
|
||||
|
||||
export interface ExcalidrawProps {
|
||||
value: string;
|
||||
initialValue: string;
|
||||
darkMode?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
language?: string;
|
||||
onSaveShortcut?: () => void;
|
||||
}
|
||||
|
||||
interface ExcalidrawState {
|
||||
elements: OrderedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
type: string;
|
||||
version: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const serializeExcalidrawState = (elements: readonly OrderedExcalidrawElement[], appState: any, file: BinaryFiles) => {
|
||||
if (!Array.isArray(appState.collaborators)) {
|
||||
appState.collaborators = [];
|
||||
}
|
||||
return JSON.stringify({
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
source: window.location.origin,
|
||||
elements,
|
||||
appState,
|
||||
files: file,
|
||||
});
|
||||
};
|
||||
|
||||
const Excalidraw = (props: ExcalidrawProps) => {
|
||||
const initialValue = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(props.initialValue) as ExcalidrawState;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}, [props.initialValue]);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "calc(100vh - 200px)",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
props.onSaveShortcut?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExcalidrawComponent
|
||||
isCollaborating={false}
|
||||
viewModeEnabled={props.readOnly}
|
||||
onChange={(elements, state, file) => {
|
||||
props.onChange(serializeExcalidrawState(elements, state, file));
|
||||
}}
|
||||
initialData={initialValue}
|
||||
langCode={props.language}
|
||||
theme={props.darkMode ? "dark" : "light"}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Excalidraw;
|
||||
169
src/component/Viewers/Excalidraw/ExcalidrawViewer.tsx
Executable file
169
src/component/Viewers/Excalidraw/ExcalidrawViewer.tsx
Executable file
@@ -0,0 +1,169 @@
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import { Box, Button, ButtonGroup, ListItemText, Menu, useTheme } from "@mui/material";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18next from "../../../i18n.ts";
|
||||
import { closeExcalidrawViewer } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { getEntityContent } from "../../../redux/thunks/file.ts";
|
||||
import { saveExcalidraw } from "../../../redux/thunks/viewer.ts";
|
||||
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import useActionDisplayOpt, { canUpdate } from "../../FileManager/ContextMenu/useActionDisplayOpt.ts";
|
||||
import CaretDown from "../../Icons/CaretDown.tsx";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
|
||||
const Excalidraw = lazy(() => import("./Excalidraw.tsx"));
|
||||
|
||||
const ExcalidrawViewer = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewerState = useAppSelector((state) => state.globalState.excalidrawViewer);
|
||||
|
||||
const displayOpt = useActionDisplayOpt(viewerState?.file ? [viewerState?.file] : []);
|
||||
const supportUpdate = canUpdate(displayOpt);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [changedValue, setChangedValue] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const saveFunction = useRef(() => {});
|
||||
|
||||
const loadContent = useCallback(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoaded(false);
|
||||
dispatch(getEntityContent(viewerState.file, viewerState.version))
|
||||
.then((res) => {
|
||||
const content = new TextDecoder().decode(res);
|
||||
setValue(content);
|
||||
setChangedValue(content);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
onClose();
|
||||
});
|
||||
}, [viewerState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
loadContent();
|
||||
}, [viewerState?.open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeExcalidrawViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const openMore = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
(saveAs?: boolean) => {
|
||||
if (!viewerState?.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(saveExcalidraw(changedValue, viewerState.file, viewerState.version, saveAs))
|
||||
.then(() => {
|
||||
setSaved(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[changedValue, viewerState],
|
||||
);
|
||||
|
||||
const onChange = useCallback((v: string) => {
|
||||
setChangedValue(v);
|
||||
setSaved(false);
|
||||
}, []);
|
||||
|
||||
const onSaveShortcut = useCallback(() => {
|
||||
if (!saved && supportUpdate) {
|
||||
onSave(false);
|
||||
}
|
||||
}, [saved, supportUpdate, onSave]);
|
||||
|
||||
useEffect(() => {
|
||||
saveFunction.current = () => {
|
||||
if (!saved && supportUpdate) {
|
||||
onSave(false);
|
||||
}
|
||||
};
|
||||
}, [saved, supportUpdate, onSave]);
|
||||
|
||||
return (
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
readOnly={!supportUpdate}
|
||||
actions={
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
{supportUpdate && (
|
||||
<ButtonGroup disabled={loading || !loaded || saved} disableElevation variant="contained">
|
||||
<LoadingButton loading={loading} variant={"contained"} onClick={() => onSave(false)}>
|
||||
<span>{t("fileManager.save")}</span>
|
||||
</LoadingButton>
|
||||
<Button size="small" onClick={openMore}>
|
||||
<CaretDown sx={{ fontSize: "12px!important" }} />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
fullScreenToggle
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem onClick={() => onSave(true)} dense>
|
||||
<ListItemText>{t("modals.saveAs")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
{!loaded && <ViewerLoading />}
|
||||
{loaded && (
|
||||
<Suspense fallback={<ViewerLoading />}>
|
||||
<Excalidraw
|
||||
language={i18next.language}
|
||||
value={changedValue}
|
||||
initialValue={value}
|
||||
readOnly={!supportUpdate}
|
||||
darkMode={theme.palette.mode === "dark"}
|
||||
onChange={(v) => onChange(v as string)}
|
||||
onSaveShortcut={onSaveShortcut}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExcalidrawViewer;
|
||||
4
src/component/Viewers/Excalidraw/excalidraw.css
Executable file
4
src/component/Viewers/Excalidraw/excalidraw.css
Executable file
@@ -0,0 +1,4 @@
|
||||
.excalidraw {
|
||||
min-height: calc(100vh - 200px);
|
||||
--zIndex-modal: 1400;
|
||||
}
|
||||
95
src/component/Viewers/ImageViewer/ImageEditor.tsx
Executable file
95
src/component/Viewers/ImageViewer/ImageEditor.tsx
Executable file
@@ -0,0 +1,95 @@
|
||||
import { Backdrop, Box, useTheme } from "@mui/material";
|
||||
import i18next from "i18next";
|
||||
import { useSnackbar } from "notistack";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import FilerobotImageEditor from "react-filerobot-image-editor";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileEntityUrl } from "../../../api/api.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { saveImage, switchToImageViewer } from "../../../redux/thunks/viewer.ts";
|
||||
import { fileExtension, getFileLinkedUri } from "../../../util";
|
||||
import "./editor.css";
|
||||
|
||||
export const editorSupportedExt = ["jpg", "jpeg", "png", "webp"];
|
||||
|
||||
const ImageEditor = () => {
|
||||
const theme = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [nsLoaded, setNsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
i18next.loadNamespaces(["image_editor"]).then(() => {
|
||||
setNsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const editorState = useAppSelector((state) => state.globalState.imageEditor);
|
||||
const [imageSrc, setImageSrc] = React.useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!editorState?.open) {
|
||||
setImageSrc(undefined);
|
||||
} else {
|
||||
dispatch(
|
||||
getFileEntityUrl({
|
||||
no_cache: true,
|
||||
uris: [getFileLinkedUri(editorState.file)],
|
||||
entity: editorState.version,
|
||||
}),
|
||||
)
|
||||
.then((res) => {
|
||||
setImageSrc(res.urls[0].url);
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(switchToImageViewer());
|
||||
});
|
||||
}
|
||||
}, [editorState]);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const save = useCallback(
|
||||
async (name: string, data?: string) => {
|
||||
if (!data || !editorState?.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatch(saveImage(name, data, editorState.file, editorState.version));
|
||||
dispatch(switchToImageViewer());
|
||||
},
|
||||
[dispatch, editorState],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop
|
||||
sx={{
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
}}
|
||||
open={true}
|
||||
>
|
||||
<Box sx={{ width: "100%", height: "100%" }}>
|
||||
{editorState && imageSrc && nsLoaded && (
|
||||
<FilerobotImageEditor
|
||||
translations={i18n.getResourceBundle(i18n.language, "image_editor")}
|
||||
useBackendTranslations={false}
|
||||
source={imageSrc}
|
||||
onSave={async (editedImageObject, _designState) => {
|
||||
await save(editedImageObject.name, editedImageObject.imageBase64);
|
||||
}}
|
||||
defaultSavedImageName={editorState.file.name}
|
||||
// @ts-ignore
|
||||
defaultSavedImageType={fileExtension(editorState.file.name) ?? "png"}
|
||||
onClose={() => dispatch(switchToImageViewer())}
|
||||
disableSaveIfNoChanges={true}
|
||||
previewPixelRatio={window.devicePixelRatio}
|
||||
savingPixelRatio={4}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Backdrop>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageEditor;
|
||||
39
src/component/Viewers/ImageViewer/ImageViewer.tsx
Executable file
39
src/component/Viewers/ImageViewer/ImageViewer.tsx
Executable file
@@ -0,0 +1,39 @@
|
||||
import { Backdrop, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { closeImageViewer } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import FacebookCircularProgress from "../../Common/CircularProgress.tsx";
|
||||
|
||||
const Lightbox = lazy(() => import("./Lightbox.tsx"));
|
||||
const ImageEditor = lazy(() => import("./ImageEditor.tsx"));
|
||||
|
||||
const Loading = (
|
||||
<Backdrop sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.drawer + 1 }} open={true}>
|
||||
<FacebookCircularProgress fgColor={"#fff"} bgColor={grey[800]} />
|
||||
</Backdrop>
|
||||
);
|
||||
|
||||
const ImageViewer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewer = useAppSelector((state) => state.globalState.imageViewer);
|
||||
const editorState = useAppSelector((state) => state.globalState.imageEditor);
|
||||
const sideBarOpen = useAppSelector((state) => state.globalState.sidebarOpen);
|
||||
const isTablet = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const sidebarOpenOnTablet = isTablet && sideBarOpen;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={Loading}>
|
||||
{viewer && viewer.open && !sidebarOpenOnTablet && (
|
||||
<Lightbox onClose={() => dispatch(closeImageViewer())} viewer={viewer} />
|
||||
)}
|
||||
</Suspense>
|
||||
<Suspense fallback={Loading}>{editorState && editorState.open && <ImageEditor />}</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageViewer;
|
||||
167
src/component/Viewers/ImageViewer/Lightbox.tsx
Executable file
167
src/component/Viewers/ImageViewer/Lightbox.tsx
Executable file
@@ -0,0 +1,167 @@
|
||||
import { Backdrop, Box, ThemeProvider } from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGeneratedTheme } from "../../../App.tsx";
|
||||
import { setSelected } from "../../../redux/fileManagerSlice.ts";
|
||||
import { ImageViewerState } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { onImageViewerIndexChange } from "../../../redux/thunks/viewer.ts";
|
||||
import { fileExtension } from "../../../util";
|
||||
import { FileManagerIndex } from "../../FileManager/FileManager.tsx";
|
||||
import { usePaginationState } from "../../FileManager/Pagination/PaginationFooter.tsx";
|
||||
import Sidebar from "../../FileManager/Sidebar/Sidebar.tsx";
|
||||
import ImageOffOutlined from "../../Icons/ImageOffOutlined.tsx";
|
||||
import { PhotoSlider } from "./react-photo-view";
|
||||
import type { DataType } from "./react-photo-view/types.ts";
|
||||
|
||||
export interface LightboxProps {
|
||||
viewer?: ImageViewerState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Lightbox = ({ viewer, onClose }: LightboxProps) => {
|
||||
const theme = useGeneratedTheme(true, true);
|
||||
const container = useRef<HTMLElement | undefined>(undefined);
|
||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [current, setCurrent] = useState(viewer?.index ?? 0);
|
||||
const indexChanged = useRef(false);
|
||||
const files = useAppSelector((state) => state.fileManager[FileManagerIndex.main].list?.files ?? []);
|
||||
const paginationState = usePaginationState(FileManagerIndex.main);
|
||||
|
||||
const viewerFiles = useMemo(() => {
|
||||
if (!viewer) return [];
|
||||
let imagesCount = 0;
|
||||
const res: DataType[] = [];
|
||||
let updatedCurrent: number | undefined = undefined;
|
||||
if (viewer.version) {
|
||||
setCurrent(imagesCount);
|
||||
res.push({
|
||||
key: viewer.file.id + viewer.version,
|
||||
file: viewer.file,
|
||||
version: viewer.version,
|
||||
});
|
||||
imagesCount++;
|
||||
} else if (viewer.index == -1) {
|
||||
setCurrent(imagesCount);
|
||||
res.push({
|
||||
key: viewer.file.id,
|
||||
file: viewer.file,
|
||||
});
|
||||
imagesCount++;
|
||||
} else {
|
||||
files.forEach((f) => {
|
||||
if (!indexChanged.current && f.path == viewer.file.path) {
|
||||
updatedCurrent = imagesCount;
|
||||
setCurrent(imagesCount);
|
||||
res.push({
|
||||
key: f.id,
|
||||
file: f,
|
||||
});
|
||||
imagesCount++;
|
||||
} else if (viewer.exts.includes(fileExtension(f.name) ?? "")) {
|
||||
imagesCount++;
|
||||
res.push({
|
||||
key: f.id,
|
||||
file: f,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (paginationState.moreItems) {
|
||||
res.push({
|
||||
loadMorePlaceholder: true,
|
||||
key: `${paginationState.currentPage} - ${paginationState.nextToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (paginationState.useEndlessLoading && (updatedCurrent ?? current) >= res.length) {
|
||||
setCurrent(res.length - 1);
|
||||
}
|
||||
|
||||
if (paginationState.usePagination && indexChanged.current) {
|
||||
setCurrent(0);
|
||||
if (res.length == 0 || res[0].loadMorePlaceholder) {
|
||||
setOpen(false);
|
||||
enqueueSnackbar(t("application:fileManager.noMoreImages"), {
|
||||
variant: "warning",
|
||||
preventDuplicate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
indexChanged.current = true;
|
||||
return res;
|
||||
}, [viewer?.file, viewer?.index, viewer?.version, viewer?.exts, files, paginationState.moreItems]);
|
||||
|
||||
const onIndexChange = useCallback(
|
||||
(index: number) => {
|
||||
setCurrent(index);
|
||||
},
|
||||
[setCurrent, dispatch, paginationState, viewerFiles],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const file = viewerFiles[current]?.file;
|
||||
if (file) {
|
||||
dispatch(onImageViewerIndexChange(file));
|
||||
}
|
||||
}, [viewerFiles[current]?.file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewerFiles.length == 0) {
|
||||
beforeClose();
|
||||
}
|
||||
}, [viewerFiles.length]);
|
||||
|
||||
const beforeClose = useCallback(() => {
|
||||
const file = viewerFiles[current]?.file;
|
||||
if (viewer?.index != -1 && file) {
|
||||
dispatch(setSelected({ index: FileManagerIndex.main, value: [file] }));
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, viewer?.index, viewerFiles, current, dispatch]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<PhotoSlider
|
||||
moreFiles={paginationState.moreItems}
|
||||
images={viewerFiles}
|
||||
onClose={() => setOpen(false)}
|
||||
afterClose={beforeClose}
|
||||
visible={open}
|
||||
index={current}
|
||||
onIndexChange={onIndexChange}
|
||||
brokenElement={
|
||||
<Box
|
||||
sx={{
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<ImageOffOutlined fontSize={"large"} />
|
||||
</Box>
|
||||
}
|
||||
portalContainer={container.current}
|
||||
/>
|
||||
<Backdrop
|
||||
ref={container}
|
||||
sx={{
|
||||
bgcolor: "rgb(0 0 0 / 0%)",
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<Box>
|
||||
<Sidebar inPhotoViewer />
|
||||
</Box>
|
||||
</Backdrop>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Lightbox;
|
||||
9
src/component/Viewers/ImageViewer/editor.css
Executable file
9
src/component/Viewers/ImageViewer/editor.css
Executable file
@@ -0,0 +1,9 @@
|
||||
.SfxModal-Wrapper {
|
||||
z-index: 20001 !important;
|
||||
}
|
||||
.SfxPopper-wrapper {
|
||||
z-index: 20002 !important;
|
||||
}
|
||||
.FIE_root {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
201
src/component/Viewers/ImageViewer/react-photo-view/LICENSE.txt
Executable file
201
src/component/Viewers/ImageViewer/react-photo-view/LICENSE.txt
Executable file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
23
src/component/Viewers/ImageViewer/react-photo-view/Photo.less
Executable file
23
src/component/Viewers/ImageViewer/react-photo-view/Photo.less
Executable file
@@ -0,0 +1,23 @@
|
||||
.PhotoView__Photo {
|
||||
max-width: initial;
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.PhotoView__icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-block;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.lpk-badge {
|
||||
right: 10px !important;
|
||||
bottom: 10px !important;
|
||||
top: initial !important;
|
||||
left: initial !important;
|
||||
}
|
||||
267
src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx
Executable file
267
src/component/Viewers/ImageViewer/react-photo-view/Photo.tsx
Executable file
@@ -0,0 +1,267 @@
|
||||
import { grey } from "@mui/material/colors";
|
||||
import { heicTo, isHeic } from "heic-to";
|
||||
import * as LivePhotosKit from "livephotoskit";
|
||||
import React, { useEffect } from "react";
|
||||
import { getFileEntityUrl, getFileInfo } from "../../../../api/api.ts";
|
||||
import { EntityType, FileResponse, Metadata } from "../../../../api/explorer.ts";
|
||||
import { useAppDispatch } from "../../../../redux/hooks.ts";
|
||||
import { getFileLinkedUri } from "../../../../util";
|
||||
import { LRUCache } from "../../../../util/lru.ts";
|
||||
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
|
||||
import useMountedRef from "./hooks/useMountedRef";
|
||||
import "./Photo.less";
|
||||
import type { BrokenElementParams } from "./types";
|
||||
|
||||
export interface IPhotoLoadedParams {
|
||||
loaded?: boolean;
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
broken?: boolean;
|
||||
}
|
||||
|
||||
export interface IPhotoProps extends React.HTMLAttributes<HTMLElement> {
|
||||
file: FileResponse;
|
||||
version?: string;
|
||||
loaded: boolean;
|
||||
broken: boolean;
|
||||
onPhotoLoad: (params: IPhotoLoadedParams) => void;
|
||||
loadingElement?: JSX.Element;
|
||||
brokenElement?: JSX.Element | ((photoProps: BrokenElementParams) => JSX.Element);
|
||||
}
|
||||
|
||||
// Global LRU cache for HEIC conversions (capacity: 50 images)
|
||||
const heicConversionCache = new LRUCache<string, string>(50);
|
||||
|
||||
export default function Photo({
|
||||
file,
|
||||
version,
|
||||
loaded,
|
||||
broken,
|
||||
className,
|
||||
onPhotoLoad,
|
||||
loadingElement,
|
||||
brokenElement,
|
||||
...restProps
|
||||
}: IPhotoProps) {
|
||||
const mountedRef = useMountedRef();
|
||||
const dispatch = useAppDispatch();
|
||||
const [imageSrc, setImageSrc] = React.useState<string | undefined>(undefined);
|
||||
const playerRef = React.useRef<LivePhotosKit.Player | null>(null);
|
||||
|
||||
// Helper function to check if file is HEIC/HEIF based on extension
|
||||
const isHeicFile = (fileName: string): boolean => {
|
||||
const extension = fileName.toLowerCase().split(".").pop();
|
||||
return extension === "heic" || extension === "heif";
|
||||
};
|
||||
|
||||
// Helper function to convert HEIC to JPG with caching
|
||||
const convertHeicToJpg = async (imageUrl: string, cacheKey: string): Promise<string> => {
|
||||
// Check cache first
|
||||
const cachedUrl = heicConversionCache.get(cacheKey);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the image as blob
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
// Convert blob to File object for isHeic check
|
||||
const file = new File([blob], "image", { type: blob.type });
|
||||
|
||||
// Check if it's actually a HEIC file
|
||||
const isHeicBlob = await isHeic(file);
|
||||
|
||||
if (isHeicBlob) {
|
||||
// Convert HEIC to JPG
|
||||
const jpgBlob = await heicTo({
|
||||
blob: blob,
|
||||
type: "image/jpeg",
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
// Create object URL for the converted image
|
||||
const convertedUrl = URL.createObjectURL(jpgBlob);
|
||||
|
||||
// Cache the converted URL
|
||||
heicConversionCache.set(cacheKey, convertedUrl);
|
||||
|
||||
return convertedUrl;
|
||||
} else {
|
||||
// If not HEIC, cache and return original URL
|
||||
heicConversionCache.set(cacheKey, imageUrl);
|
||||
return imageUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error converting HEIC to JPG:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(file)],
|
||||
entity: version,
|
||||
}),
|
||||
)
|
||||
.then(async (res) => {
|
||||
const originalUrl = res.urls[0].url;
|
||||
const cacheKey = `${file.id}-${version || "default"}`;
|
||||
|
||||
// Check if the file is HEIC/HEIF and convert if needed
|
||||
if (isHeicFile(file.name)) {
|
||||
try {
|
||||
const convertedUrl = await convertHeicToJpg(originalUrl, cacheKey);
|
||||
setImageSrc(convertedUrl);
|
||||
if (file.metadata?.[Metadata.live_photo]) {
|
||||
loadLivePhoto(file, convertedUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to convert HEIC image:", error);
|
||||
if (mountedRef.current) {
|
||||
onPhotoLoad({
|
||||
broken: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setImageSrc(originalUrl);
|
||||
loadLivePhoto(file, originalUrl);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (mountedRef.current) {
|
||||
onPhotoLoad({
|
||||
broken: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadLivePhoto = async (file: FileResponse, imgUrl: string) => {
|
||||
if (!file.metadata?.[Metadata.live_photo]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileExtended = await dispatch(
|
||||
getFileInfo(
|
||||
{
|
||||
uri: getFileLinkedUri(file),
|
||||
extended: true,
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
// find live photo entity
|
||||
const livePhotoEntity = fileExtended?.extended_info?.entities?.find(
|
||||
(entity) => entity.type === EntityType.live_photo,
|
||||
);
|
||||
|
||||
// get live photo entity url
|
||||
const livePhotoEntityUrl = await dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(file)],
|
||||
entity: livePhotoEntity?.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const imgElement = document.getElementById(file.id);
|
||||
if (imgElement) {
|
||||
const player = LivePhotosKit.Player(imgElement as HTMLElement);
|
||||
playerRef.current = player;
|
||||
player.photoSrc = imgUrl;
|
||||
player.videoSrc = livePhotoEntityUrl.urls[0].url;
|
||||
player.proactivelyLoadsVideo = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load live photo:", e);
|
||||
}
|
||||
};
|
||||
|
||||
function handleImageLoaded(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const { naturalWidth, naturalHeight } = e.target as HTMLImageElement;
|
||||
if (mountedRef.current) {
|
||||
onPhotoLoad({
|
||||
loaded: true,
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageBroken(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
console.log("handleImageBroken", e);
|
||||
if (mountedRef.current) {
|
||||
onPhotoLoad({
|
||||
broken: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up object URL when component unmounts or imageSrc changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageSrc && imageSrc.startsWith("blob:")) {
|
||||
// Don't revoke cached URLs, let the cache handle cleanup
|
||||
// URL.revokeObjectURL(imageSrc);
|
||||
}
|
||||
};
|
||||
}, [imageSrc]);
|
||||
|
||||
const { onMouseDown, onTouchStart, style, ...rest } = restProps;
|
||||
|
||||
// Extract width and height from style if available
|
||||
const { width, height, ...restStyle } = style || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (playerRef.current) {
|
||||
// Convert width and height to numbers, defaulting to 0 if not valid
|
||||
const numWidth = typeof width === "number" ? width : 0;
|
||||
const numHeight = typeof height === "number" ? height : 0;
|
||||
playerRef.current.updateSize(numWidth, numHeight);
|
||||
}
|
||||
}, [width, height]);
|
||||
|
||||
if (file && !broken) {
|
||||
return (
|
||||
<>
|
||||
{imageSrc && (
|
||||
<div onMouseDown={onMouseDown} onTouchStart={onTouchStart} style={{ width, height }}>
|
||||
<img
|
||||
id={file?.id ?? "photobox-img"}
|
||||
className={`PhotoView__Photo${className ? ` ${className}` : ""}`}
|
||||
src={imageSrc}
|
||||
draggable={false}
|
||||
onLoad={handleImageLoaded}
|
||||
onError={handleImageBroken}
|
||||
alt=""
|
||||
style={{ width, height, ...restStyle }}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!loaded && (
|
||||
<FacebookCircularProgress
|
||||
sx={{ position: "relative", top: "-20px", left: "-20px" }}
|
||||
fgColor={"#fff"}
|
||||
bgColor={grey[800]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (brokenElement) {
|
||||
return (
|
||||
<span className="PhotoView__icon">
|
||||
{typeof brokenElement === "function" ? brokenElement({ src: imageSrc ?? "" }) : brokenElement}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
22
src/component/Viewers/ImageViewer/react-photo-view/PhotoBox.less
Executable file
22
src/component/Viewers/ImageViewer/react-photo-view/PhotoBox.less
Executable file
@@ -0,0 +1,22 @@
|
||||
.PhotoView {
|
||||
&__PhotoWrap,
|
||||
&__PhotoBox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
touch-action: none;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
&__PhotoWrap {
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__PhotoBox {
|
||||
transform-origin: left top;
|
||||
}
|
||||
}
|
||||
516
src/component/Viewers/ImageViewer/react-photo-view/PhotoBox.tsx
Executable file
516
src/component/Viewers/ImageViewer/react-photo-view/PhotoBox.tsx
Executable file
@@ -0,0 +1,516 @@
|
||||
import { grey } from "@mui/material/colors";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { loadMorePages } from "../../../../redux/thunks/filemanager.ts";
|
||||
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
|
||||
import { FileManagerIndex } from "../../../FileManager/FileManager.tsx";
|
||||
import useAnimationPosition from "./hooks/useAnimationPosition";
|
||||
import useContinuousTap from "./hooks/useContinuousTap";
|
||||
import useDebounceCallback from "./hooks/useDebounceCallback";
|
||||
import useEventListener from "./hooks/useEventListener";
|
||||
import useIsomorphicLayoutEffect from "./hooks/useIsomorphicLayoutEffect";
|
||||
import useMethods from "./hooks/useMethods";
|
||||
import useMountedRef from "./hooks/useMountedRef";
|
||||
import useScrollPosition from "./hooks/useScrollPosition";
|
||||
import useSetState from "./hooks/useSetState";
|
||||
import type { IPhotoLoadedParams } from "./Photo";
|
||||
import Photo from "./Photo";
|
||||
import "./PhotoBox.less";
|
||||
import type {
|
||||
BrokenElementParams,
|
||||
DataType,
|
||||
ExposedProperties,
|
||||
PhotoTapFunction,
|
||||
ReachFunction,
|
||||
ReachMoveFunction,
|
||||
ReachType,
|
||||
TouchStartType,
|
||||
} from "./types";
|
||||
import { computePositionEdge, getReachType } from "./utils/edgeHandle";
|
||||
import getMultipleTouchPosition from "./utils/getMultipleTouchPosition";
|
||||
import getPositionOnMoveOrScale from "./utils/getPositionOnMoveOrScale";
|
||||
import getRotateSize from "./utils/getRotateSize";
|
||||
import getSuitableImageSize from "./utils/getSuitableImageSize";
|
||||
import isTouchDevice from "./utils/isTouchDevice";
|
||||
import { limitScale } from "./utils/limitTarget";
|
||||
import { minStartTouchOffset, scaleBuffer } from "./variables";
|
||||
|
||||
export interface PhotoBoxProps {
|
||||
// 图片信息
|
||||
item: DataType;
|
||||
// 是否可见
|
||||
visible: boolean;
|
||||
// 动画时间
|
||||
speed: number;
|
||||
// 动画函数
|
||||
easing: string;
|
||||
// 容器类名
|
||||
wrapClassName?: string;
|
||||
// 图片类名
|
||||
className?: string;
|
||||
// style
|
||||
style?: object;
|
||||
// 自定义 loading
|
||||
loadingElement?: JSX.Element;
|
||||
// 加载失败 Element
|
||||
brokenElement?: JSX.Element | ((photoProps: BrokenElementParams) => JSX.Element);
|
||||
|
||||
// Photo 点击事件
|
||||
onPhotoTap: PhotoTapFunction;
|
||||
// Mask 点击事件
|
||||
onMaskTap: PhotoTapFunction;
|
||||
// 到达边缘滑动事件
|
||||
onReachMove: ReachMoveFunction;
|
||||
// 触摸解除事件
|
||||
onReachUp: ReachFunction;
|
||||
// Resize 事件
|
||||
onPhotoResize: () => void;
|
||||
// 向父组件导出属性
|
||||
expose: (state: ExposedProperties) => void;
|
||||
// 是否在当前操作中
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
// 真实宽度
|
||||
naturalWidth: undefined as number | undefined,
|
||||
// 真实高度
|
||||
naturalHeight: undefined as number | undefined,
|
||||
// 宽度
|
||||
width: undefined as number | undefined,
|
||||
// 高度
|
||||
height: undefined as number | undefined,
|
||||
// 加载成功状态
|
||||
loaded: undefined as boolean | undefined,
|
||||
// 破碎状态
|
||||
broken: false,
|
||||
|
||||
// 图片 X 偏移量
|
||||
x: 0,
|
||||
// 图片 y 偏移量
|
||||
y: 0,
|
||||
// 图片处于触摸的状态
|
||||
touched: false,
|
||||
// 背景处于触摸状态
|
||||
maskTouched: false,
|
||||
// 旋转状态
|
||||
rotate: 0,
|
||||
// 放大缩小
|
||||
scale: 1,
|
||||
|
||||
// 触摸开始时 x 原始坐标
|
||||
CX: 0,
|
||||
// 触摸开始时 y 原始坐标
|
||||
CY: 0,
|
||||
|
||||
// 触摸开始时图片 x 偏移量
|
||||
lastX: 0,
|
||||
// 触摸开始时图片 y 偏移量
|
||||
lastY: 0,
|
||||
// 上一个触摸状态 x 原始坐标
|
||||
lastCX: 0,
|
||||
// 上一个触摸状态 y 原始坐标
|
||||
lastCY: 0,
|
||||
// 上一个触摸状态的 scale
|
||||
lastScale: 1,
|
||||
|
||||
// 触摸开始时时间
|
||||
touchTime: 0,
|
||||
// 多指触控间距
|
||||
touchLength: 0,
|
||||
// 是否暂停 transition
|
||||
pause: true,
|
||||
// 停止 Raf
|
||||
stopRaf: true,
|
||||
// 当前边缘触发状态
|
||||
reach: undefined as ReachType,
|
||||
};
|
||||
|
||||
export default function PhotoBox({
|
||||
item: {
|
||||
render,
|
||||
file,
|
||||
version,
|
||||
width: customWidth = 0,
|
||||
height: customHeight = 0,
|
||||
originRef,
|
||||
loadMorePlaceholder,
|
||||
key,
|
||||
},
|
||||
visible,
|
||||
speed,
|
||||
easing,
|
||||
wrapClassName,
|
||||
className,
|
||||
style,
|
||||
loadingElement,
|
||||
brokenElement,
|
||||
|
||||
onPhotoTap,
|
||||
onMaskTap,
|
||||
onReachMove,
|
||||
onReachUp,
|
||||
onPhotoResize,
|
||||
isActive,
|
||||
expose,
|
||||
}: PhotoBoxProps) {
|
||||
const [state, updateState] = useSetState(initialState);
|
||||
const initialTouchRef = useRef<TouchStartType>(0);
|
||||
const mounted = useMountedRef();
|
||||
|
||||
const {
|
||||
naturalWidth = customWidth,
|
||||
naturalHeight = customHeight,
|
||||
width = customWidth,
|
||||
height = customHeight,
|
||||
loaded = !file,
|
||||
broken,
|
||||
x,
|
||||
y,
|
||||
touched,
|
||||
stopRaf,
|
||||
maskTouched,
|
||||
rotate,
|
||||
scale,
|
||||
CX,
|
||||
CY,
|
||||
lastX,
|
||||
lastY,
|
||||
lastCX,
|
||||
lastCY,
|
||||
lastScale,
|
||||
touchTime,
|
||||
touchLength,
|
||||
pause,
|
||||
reach,
|
||||
} = state;
|
||||
|
||||
const sideBarOpen = useAppSelector((state) => state.globalState.sidebarOpen);
|
||||
const dynamicInnerWidth = sideBarOpen ? window.innerWidth - 300 : window.innerWidth;
|
||||
|
||||
const fn = useMethods({
|
||||
onScale: (current: number) => onScale(limitScale(current)),
|
||||
onRotate(current: number) {
|
||||
if (rotate !== current) {
|
||||
expose({ rotate: current });
|
||||
updateState({
|
||||
rotate: current,
|
||||
...getSuitableImageSize(naturalWidth, naturalHeight, current, dynamicInnerWidth),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 默认为屏幕中心缩放
|
||||
function onScale(current: number, clientX?: number, clientY?: number) {
|
||||
if (scale !== current) {
|
||||
expose({ scale: current });
|
||||
updateState({
|
||||
scale: current,
|
||||
...getPositionOnMoveOrScale(x, y, width, height, scale, current, clientX, clientY),
|
||||
...(current <= 1 && { x: 0, y: 0 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = useDebounceCallback(
|
||||
(nextClientX: number, nextClientY: number, currentTouchLength: number = 0) => {
|
||||
if ((touched || maskTouched) && isActive) {
|
||||
// 通过旋转调换宽高
|
||||
const [currentWidth, currentHeight] = getRotateSize(rotate, width, height);
|
||||
// 单指最小缩放下,以初始移动距离来判断意图
|
||||
if (currentTouchLength === 0 && initialTouchRef.current === 0) {
|
||||
const isStillX = Math.abs(nextClientX - CX) <= minStartTouchOffset;
|
||||
const isStillY = Math.abs(nextClientY - CY) <= minStartTouchOffset;
|
||||
// 初始移动距离不足
|
||||
if (isStillX && isStillY) {
|
||||
// 方向记录上次移动距离,以便平滑过渡
|
||||
updateState({ lastCX: nextClientX, lastCY: nextClientY });
|
||||
return;
|
||||
}
|
||||
// 设置响应状态
|
||||
initialTouchRef.current = !isStillX ? 1 : nextClientY > CY ? 3 : 2;
|
||||
}
|
||||
|
||||
const offsetX = nextClientX - lastCX;
|
||||
const offsetY = nextClientY - lastCY;
|
||||
// 边缘触发状态
|
||||
let currentReach: ReachType;
|
||||
if (currentTouchLength === 0) {
|
||||
// 边缘超出状态
|
||||
const [horizontalCloseEdge] = computePositionEdge(offsetX + lastX, scale, currentWidth, dynamicInnerWidth);
|
||||
const [verticalCloseEdge] = computePositionEdge(offsetY + lastY, scale, currentHeight, innerHeight);
|
||||
// 边缘触发检测
|
||||
currentReach = getReachType(initialTouchRef.current, horizontalCloseEdge, verticalCloseEdge, reach);
|
||||
|
||||
// 接触边缘
|
||||
if (currentReach !== undefined) {
|
||||
onReachMove(currentReach, nextClientX, nextClientY, scale);
|
||||
}
|
||||
}
|
||||
// 横向边缘触发、背景触发禁用当前滑动
|
||||
if (currentReach === "x" || maskTouched) {
|
||||
updateState({ reach: "x" });
|
||||
return;
|
||||
}
|
||||
// 目标倍数
|
||||
const toScale = limitScale(
|
||||
scale + ((currentTouchLength - touchLength) / 100 / 2) * scale,
|
||||
naturalWidth / width,
|
||||
scaleBuffer,
|
||||
);
|
||||
// 导出变量
|
||||
expose({ scale: toScale });
|
||||
updateState({
|
||||
touchLength: currentTouchLength,
|
||||
reach: currentReach,
|
||||
scale: toScale,
|
||||
...getPositionOnMoveOrScale(x, y, width, height, scale, toScale, nextClientX, nextClientY, offsetX, offsetY),
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
maxWait: 8,
|
||||
},
|
||||
);
|
||||
|
||||
function updateRaf(position: { x?: number; y?: number }) {
|
||||
if (stopRaf || touched) {
|
||||
return false;
|
||||
}
|
||||
if (mounted.current) {
|
||||
// 下拉关闭时可以有动画
|
||||
updateState({ ...position, pause: visible });
|
||||
}
|
||||
return mounted.current;
|
||||
}
|
||||
|
||||
const slideToPosition = useScrollPosition(
|
||||
(nextX) => updateRaf({ x: nextX }),
|
||||
(nextY) => updateRaf({ y: nextY }),
|
||||
(nextScale) => {
|
||||
if (mounted.current) {
|
||||
expose({ scale: nextScale });
|
||||
updateState({ scale: nextScale });
|
||||
}
|
||||
return !touched && mounted.current;
|
||||
},
|
||||
dynamicInnerWidth,
|
||||
);
|
||||
|
||||
const handlePhotoTap = useContinuousTap(onPhotoTap, (currentClientX: number, currentClientY: number) => {
|
||||
if (!reach) {
|
||||
// 若图片足够大,则放大适应的倍数
|
||||
const endScale = scale !== 1 ? 1 : Math.max(2, naturalWidth / width);
|
||||
onScale(endScale, currentClientX, currentClientY);
|
||||
}
|
||||
});
|
||||
|
||||
function handleUp(nextClientX: number, nextClientY: number) {
|
||||
// 重置响应状态
|
||||
initialTouchRef.current = 0;
|
||||
if ((touched || maskTouched) && isActive) {
|
||||
updateState({
|
||||
touched: false,
|
||||
maskTouched: false,
|
||||
pause: false,
|
||||
stopRaf: false,
|
||||
reach: undefined,
|
||||
});
|
||||
const safeScale = limitScale(scale, naturalWidth / width);
|
||||
// Go
|
||||
slideToPosition(x, y, lastX, lastY, width, height, scale, safeScale, lastScale, rotate, touchTime);
|
||||
|
||||
onReachUp(nextClientX, nextClientY);
|
||||
// 触发 Tap 事件
|
||||
if (CX === nextClientX && CY === nextClientY) {
|
||||
if (touched) {
|
||||
handlePhotoTap(nextClientX, nextClientY);
|
||||
return;
|
||||
}
|
||||
if (maskTouched) {
|
||||
onMaskTap(nextClientX, nextClientY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(isTouchDevice ? undefined : "mousemove", (e) => {
|
||||
handleMove(e.clientX, e.clientY);
|
||||
});
|
||||
useEventListener(isTouchDevice ? undefined : "mouseup", (e) => {
|
||||
handleUp(e.clientX, e.clientY);
|
||||
});
|
||||
useEventListener(
|
||||
isTouchDevice ? "touchmove" : undefined,
|
||||
(e) => {
|
||||
const position = getMultipleTouchPosition(e);
|
||||
handleMove(...position);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
useEventListener(
|
||||
isTouchDevice ? "touchend" : undefined,
|
||||
({ changedTouches }) => {
|
||||
const touch = changedTouches[0];
|
||||
handleUp(touch.clientX, touch.clientY);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
useEventListener(
|
||||
"resize",
|
||||
useDebounceCallback(
|
||||
() => {
|
||||
if (loaded && !touched) {
|
||||
updateState(getSuitableImageSize(naturalWidth, naturalHeight, rotate, dynamicInnerWidth));
|
||||
onPhotoResize();
|
||||
}
|
||||
},
|
||||
{ maxWait: 8 },
|
||||
),
|
||||
);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
expose({ scale, rotate, ...fn });
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
function handlePhotoLoad(params: IPhotoLoadedParams) {
|
||||
updateState({
|
||||
...params,
|
||||
...(params.loaded &&
|
||||
getSuitableImageSize(params.naturalWidth || 0, params.naturalHeight || 0, rotate, dynamicInnerWidth)),
|
||||
});
|
||||
}
|
||||
|
||||
function handleStart(currentClientX: number, currentClientY: number, currentTouchLength: number = 0) {
|
||||
updateState({
|
||||
touched: true,
|
||||
CX: currentClientX,
|
||||
CY: currentClientY,
|
||||
lastCX: currentClientX,
|
||||
lastCY: currentClientY,
|
||||
lastX: x,
|
||||
lastY: y,
|
||||
lastScale: scale,
|
||||
touchLength: currentTouchLength,
|
||||
touchTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function handleWheel(e: React.WheelEvent) {
|
||||
if (!reach) {
|
||||
// 限制最大倍数和最小倍数
|
||||
const toScale = limitScale(scale - e.deltaY / 100 / 2, naturalWidth / width);
|
||||
updateState({ stopRaf: true });
|
||||
onScale(toScale, e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMaskStart(e: { clientX: number; clientY: number }) {
|
||||
updateState({
|
||||
maskTouched: true,
|
||||
CX: e.clientX,
|
||||
CY: e.clientY,
|
||||
lastX: x,
|
||||
lastY: y,
|
||||
});
|
||||
}
|
||||
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
e.stopPropagation();
|
||||
handleStart(...getMultipleTouchPosition(e));
|
||||
}
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.button === 0) {
|
||||
handleStart(e.clientX, e.clientY, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算位置
|
||||
const [translateX, translateY, currentWidth, currentHeight, currentScale, opacity, easingMode, FIT] =
|
||||
useAnimationPosition(
|
||||
dynamicInnerWidth,
|
||||
visible,
|
||||
originRef,
|
||||
loaded,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
speed,
|
||||
(isPause: boolean) => updateState({ pause: isPause }),
|
||||
);
|
||||
// 图片 objectFit 渐变时间
|
||||
const transitionLayoutTime = easingMode < 4 ? speed / 2 : easingMode > 4 ? speed : 0;
|
||||
const transitionCSS = `transform ${speed}ms ${easing}`;
|
||||
|
||||
const attrs = {
|
||||
className,
|
||||
onMouseDown: isTouchDevice ? undefined : handleMouseDown,
|
||||
onTouchStart: isTouchDevice ? handleTouchStart : undefined,
|
||||
onWheel: handleWheel,
|
||||
style: {
|
||||
width: currentWidth,
|
||||
height: currentHeight,
|
||||
opacity,
|
||||
objectFit: easingMode === 4 ? undefined : FIT,
|
||||
transform: rotate ? `rotate(${rotate}deg)` : undefined,
|
||||
transition:
|
||||
// 初始状态无渐变
|
||||
easingMode > 2
|
||||
? `${transitionCSS}, opacity ${speed}ms ease, height ${transitionLayoutTime}ms ${easing}`
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`PhotoView__PhotoWrap${wrapClassName ? ` ${wrapClassName}` : ""}`}
|
||||
style={style}
|
||||
onMouseDown={!isTouchDevice && isActive ? handleMaskStart : undefined}
|
||||
onTouchStart={isTouchDevice && isActive ? (e) => handleMaskStart(e.touches[0]) : undefined}
|
||||
>
|
||||
<div
|
||||
className="PhotoView__PhotoBox"
|
||||
style={{
|
||||
transform: `matrix(${currentScale}, 0, 0, ${currentScale}, ${translateX}, ${translateY})`,
|
||||
transition: touched || pause ? undefined : transitionCSS,
|
||||
willChange: isActive ? "transform" : undefined,
|
||||
}}
|
||||
>
|
||||
{file ? (
|
||||
<Photo
|
||||
file={file}
|
||||
version={version}
|
||||
loaded={loaded}
|
||||
broken={broken}
|
||||
{...attrs}
|
||||
onPhotoLoad={handlePhotoLoad}
|
||||
loadingElement={loadingElement}
|
||||
brokenElement={brokenElement}
|
||||
/>
|
||||
) : (
|
||||
render && render({ attrs, scale: currentScale, rotate })
|
||||
)}
|
||||
{loadMorePlaceholder && <LoadMorePlaceholder key={key} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LoadMorePlaceholder = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
dispatch(loadMorePages(FileManagerIndex.main));
|
||||
}
|
||||
}, [inView]);
|
||||
return <FacebookCircularProgress ref={ref} fgColor={"#fff"} bgColor={grey[800]} />;
|
||||
};
|
||||
107
src/component/Viewers/ImageViewer/react-photo-view/PhotoProvider.tsx
Executable file
107
src/component/Viewers/ImageViewer/react-photo-view/PhotoProvider.tsx
Executable file
@@ -0,0 +1,107 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import type { DataType, PhotoProviderBase } from "./types";
|
||||
import useMethods from "./hooks/useMethods";
|
||||
import useSetState from "./hooks/useSetState";
|
||||
import PhotoContext from "./photo-context";
|
||||
import PhotoSlider from "./PhotoSlider";
|
||||
|
||||
export interface PhotoProviderProps extends PhotoProviderBase {
|
||||
children: React.ReactNode;
|
||||
onIndexChange?: (index: number, state: PhotoProviderState) => void;
|
||||
onVisibleChange?: (visible: boolean, index: number, state: PhotoProviderState) => void;
|
||||
}
|
||||
|
||||
type PhotoProviderState = {
|
||||
images: DataType[];
|
||||
visible: boolean;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const initialState: PhotoProviderState = {
|
||||
images: [],
|
||||
visible: false,
|
||||
index: 0,
|
||||
};
|
||||
|
||||
export default function PhotoProvider({ children, onIndexChange, onVisibleChange, ...restProps }: PhotoProviderProps) {
|
||||
const [state, updateState] = useSetState(initialState);
|
||||
const uniqueIdRef = useRef(0);
|
||||
const { images, visible, index } = state;
|
||||
|
||||
const methods = useMethods({
|
||||
nextId() {
|
||||
return (uniqueIdRef.current += 1);
|
||||
},
|
||||
update(imageItem: DataType) {
|
||||
const currentIndex = images.findIndex((n) => n.key === imageItem.key);
|
||||
if (currentIndex > -1) {
|
||||
const nextImages = images.slice();
|
||||
nextImages.splice(currentIndex, 1, imageItem);
|
||||
updateState({
|
||||
images: nextImages,
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateState((prev) => ({
|
||||
images: prev.images.concat(imageItem),
|
||||
}));
|
||||
},
|
||||
remove(key: number) {
|
||||
updateState((prev) => {
|
||||
const nextImages = prev.images.filter((item) => item.key !== key);
|
||||
const nextEndIndex = nextImages.length - 1;
|
||||
return {
|
||||
images: nextImages,
|
||||
index: Math.min(nextEndIndex, index),
|
||||
};
|
||||
});
|
||||
},
|
||||
show(key: number) {
|
||||
const currentIndex = images.findIndex((item) => item.key === key);
|
||||
updateState({
|
||||
visible: true,
|
||||
index: currentIndex,
|
||||
});
|
||||
if (onVisibleChange) {
|
||||
onVisibleChange(true, currentIndex, state);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const fn = useMethods({
|
||||
close() {
|
||||
updateState({
|
||||
visible: false,
|
||||
});
|
||||
|
||||
if (onVisibleChange) {
|
||||
onVisibleChange(false, index, state);
|
||||
}
|
||||
},
|
||||
changeIndex(nextIndex: number) {
|
||||
updateState({
|
||||
index: nextIndex,
|
||||
});
|
||||
|
||||
if (onIndexChange) {
|
||||
onIndexChange(nextIndex, state);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const value = useMemo(() => ({ ...state, ...methods }), [state, methods]);
|
||||
|
||||
return (
|
||||
<PhotoContext.Provider value={value}>
|
||||
{children}
|
||||
<PhotoSlider
|
||||
images={images}
|
||||
visible={visible}
|
||||
index={index}
|
||||
onIndexChange={fn.changeIndex}
|
||||
onClose={fn.close}
|
||||
{...restProps}
|
||||
/>
|
||||
</PhotoContext.Provider>
|
||||
);
|
||||
}
|
||||
131
src/component/Viewers/ImageViewer/react-photo-view/PhotoSlider.less
Executable file
131
src/component/Viewers/ImageViewer/react-photo-view/PhotoSlider.less
Executable file
@@ -0,0 +1,131 @@
|
||||
@keyframes PhotoView__fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.PhotoView {
|
||||
&-Slider__clean {
|
||||
.PhotoView-Slider__BannerWrap,
|
||||
.PhotoView-Slider__ArrowLeft,
|
||||
.PhotoView-Slider__ArrowRight,
|
||||
.PhotoView-Slider__Overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-Slider__willClose {
|
||||
.PhotoView-Slider__BannerWrap:hover {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-Slider__Backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 1);
|
||||
transition-property: background-color;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&-Slider__fadeIn {
|
||||
opacity: 0;
|
||||
animation: PhotoView__fade linear both;
|
||||
}
|
||||
|
||||
&-Slider__fadeOut {
|
||||
opacity: 0;
|
||||
animation: PhotoView__fade linear both reverse;
|
||||
}
|
||||
|
||||
&-Slider__BannerWrap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
background-image: linear-gradient(0deg, transparent, rgba(0, 0, 0, 0.38));
|
||||
color: white;
|
||||
transition: opacity 0.2s ease-out;
|
||||
z-index: 20;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-Slider__Counter {
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&-Slider__BannerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin: 4px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&-Slider__toolbarIcon {
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
fill: white;
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s linear;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-Slider__ArrowLeft,
|
||||
&-Slider__ArrowRight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 70px;
|
||||
height: 100px;
|
||||
margin: auto;
|
||||
opacity: 0.75;
|
||||
z-index: 20;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s linear;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
box-sizing: content-box;
|
||||
padding: 10px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: white;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&-Slider__ArrowLeft {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&-Slider__ArrowRight {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
527
src/component/Viewers/ImageViewer/react-photo-view/PhotoSlider.tsx
Executable file
527
src/component/Viewers/ImageViewer/react-photo-view/PhotoSlider.tsx
Executable file
@@ -0,0 +1,527 @@
|
||||
import { Box, IconButton, Tooltip, useMediaQuery, useTheme } from "@mui/material";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { setSidebar } from "../../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { downloadSingleFile } from "../../../../redux/thunks/download.ts";
|
||||
import { openFileContextMenu } from "../../../../redux/thunks/file.ts";
|
||||
import { switchToImageEditor } from "../../../../redux/thunks/viewer.ts";
|
||||
import { fileExtension } from "../../../../util";
|
||||
import useActionDisplayOpt, { canUpdate } from "../../../FileManager/ContextMenu/useActionDisplayOpt.ts";
|
||||
import { FileManagerIndex } from "../../../FileManager/FileManager.tsx";
|
||||
import Dismiss from "../../../Icons/Dismiss.tsx";
|
||||
import Download from "../../../Icons/Download.tsx";
|
||||
import ImageEdit from "../../../Icons/ImageEdit.tsx";
|
||||
import Info from "../../../Icons/Info.tsx";
|
||||
import MoreHorizontal from "../../../Icons/MoreHorizontal.tsx";
|
||||
import { editorSupportedExt } from "../ImageEditor.tsx";
|
||||
import ArrowLeft from "./components/ArrowLeft";
|
||||
import ArrowRight from "./components/ArrowRight";
|
||||
import SlidePortal from "./components/SlidePortal";
|
||||
import useAdjacentImages from "./hooks/useAdjacentImages";
|
||||
import useAnimationVisible from "./hooks/useAnimationVisible";
|
||||
import useEventListener from "./hooks/useEventListener";
|
||||
import useIsomorphicLayoutEffect from "./hooks/useIsomorphicLayoutEffect";
|
||||
import useMethods from "./hooks/useMethods";
|
||||
import useSetState from "./hooks/useSetState";
|
||||
import PhotoBox from "./PhotoBox";
|
||||
import "./PhotoSlider.less";
|
||||
import type { DataType, OverlayRenderProps, PhotoProviderBase, ReachType } from "./types";
|
||||
import isTouchDevice from "./utils/isTouchDevice";
|
||||
import { limitNumber } from "./utils/limitTarget";
|
||||
import { defaultEasing, defaultOpacity, defaultSpeed, horizontalOffset, maxMoveOffset } from "./variables";
|
||||
|
||||
export interface IPhotoSliderProps extends PhotoProviderBase {
|
||||
// 图片列表
|
||||
images: DataType[];
|
||||
// 图片当前索引
|
||||
index?: number;
|
||||
// 索引改变回调
|
||||
onIndexChange?: (index: number) => void;
|
||||
// 可见
|
||||
visible: boolean;
|
||||
// 关闭回调
|
||||
onClose: (evt?: React.MouseEvent | React.TouchEvent) => void;
|
||||
// 关闭动画结束后回调
|
||||
afterClose?: () => void;
|
||||
moreFiles?: boolean;
|
||||
}
|
||||
|
||||
type PhotoSliderState = {
|
||||
// 偏移量
|
||||
x: number;
|
||||
// 图片处于触摸的状态
|
||||
touched: boolean;
|
||||
// 是否暂停 transition
|
||||
pause: boolean;
|
||||
// Reach 开始时 x 坐标
|
||||
lastCX: number | undefined;
|
||||
// Reach 开始时 y 坐标
|
||||
lastCY: number | undefined;
|
||||
// 背景透明度
|
||||
bg: number | null | undefined;
|
||||
// 上次关闭的背景透明度
|
||||
lastBg: number | null | undefined;
|
||||
// 是否显示 overlay
|
||||
overlay: boolean;
|
||||
// 是否为最小状态,可下拉关闭
|
||||
minimal: boolean;
|
||||
// 缩放
|
||||
scale: number;
|
||||
// 旋转
|
||||
rotate: number;
|
||||
// 缩放回调
|
||||
onScale?: (scale: number) => void;
|
||||
// 旋转回调
|
||||
onRotate?: (rotate: number) => void;
|
||||
};
|
||||
|
||||
const initialState: PhotoSliderState = {
|
||||
x: 0,
|
||||
touched: false,
|
||||
pause: false,
|
||||
lastCX: undefined,
|
||||
lastCY: undefined,
|
||||
bg: undefined,
|
||||
lastBg: undefined,
|
||||
overlay: true,
|
||||
minimal: true,
|
||||
scale: 1,
|
||||
rotate: 0,
|
||||
};
|
||||
|
||||
export default function PhotoSlider(props: IPhotoSliderProps) {
|
||||
const {
|
||||
loop = 3,
|
||||
speed: speedFn,
|
||||
easing: easingFn,
|
||||
photoClosable,
|
||||
maskClosable = true,
|
||||
maskOpacity = defaultOpacity,
|
||||
pullClosable = true,
|
||||
bannerVisible = true,
|
||||
overlayRender,
|
||||
toolbarRender,
|
||||
className,
|
||||
maskClassName,
|
||||
photoClassName,
|
||||
photoWrapClassName,
|
||||
loadingElement,
|
||||
brokenElement,
|
||||
images,
|
||||
index: controlledIndex = 0,
|
||||
onIndexChange: controlledIndexChange,
|
||||
visible,
|
||||
onClose,
|
||||
afterClose,
|
||||
portalContainer,
|
||||
moreFiles,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const dispatch = useAppDispatch();
|
||||
const containerRef = useRef(null);
|
||||
const sideBarOpen = useAppSelector((state) => state.globalState.sidebarOpen);
|
||||
const [state, updateState] = useSetState(initialState);
|
||||
const [innerIndex, updateInnerIndex] = useState(0);
|
||||
const dynamicInnerWidth = sideBarOpen ? window.innerWidth - 300 : window.innerWidth;
|
||||
|
||||
const {
|
||||
x,
|
||||
touched,
|
||||
pause,
|
||||
|
||||
lastCX,
|
||||
lastCY,
|
||||
|
||||
bg = maskOpacity,
|
||||
lastBg,
|
||||
overlay,
|
||||
minimal,
|
||||
|
||||
scale,
|
||||
rotate,
|
||||
onScale,
|
||||
onRotate,
|
||||
} = state;
|
||||
|
||||
// 受控 index
|
||||
const isControlled = props.hasOwnProperty("index");
|
||||
const index = isControlled ? controlledIndex : innerIndex;
|
||||
const onIndexChange = isControlled ? controlledIndexChange : updateInnerIndex;
|
||||
// 内部虚拟 index
|
||||
const virtualIndexRef = useRef(index);
|
||||
|
||||
// 当前图片
|
||||
const imageLength = images.length;
|
||||
const currentImage: DataType | undefined = images[index];
|
||||
|
||||
// 是否开启
|
||||
// noinspection SuspiciousTypeOfGuard
|
||||
const enableLoop = typeof loop === "boolean" ? loop : imageLength > loop;
|
||||
|
||||
// 显示动画处理
|
||||
const [realVisible, activeAnimation, onAnimationEnd] = useAnimationVisible(visible, afterClose);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
}, 500);
|
||||
return () => {
|
||||
setTimeout(() => {
|
||||
document.body.style.overflow = isMobile ? "initial" : "hidden";
|
||||
}, 500);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
// 显示弹出层,修正正确的指向
|
||||
if (realVisible) {
|
||||
updateState({
|
||||
pause: true,
|
||||
x: index * -(dynamicInnerWidth + horizontalOffset),
|
||||
});
|
||||
virtualIndexRef.current = index;
|
||||
return;
|
||||
}
|
||||
// 关闭后清空状态
|
||||
updateState(initialState);
|
||||
}, [realVisible]);
|
||||
|
||||
const { close, changeIndex } = useMethods({
|
||||
close(evt?: React.MouseEvent | React.TouchEvent) {
|
||||
if (onRotate) {
|
||||
onRotate(0);
|
||||
}
|
||||
updateState({
|
||||
overlay: true,
|
||||
// 记录当前关闭时的透明度
|
||||
lastBg: bg,
|
||||
});
|
||||
onClose(evt);
|
||||
},
|
||||
changeIndex(nextIndex: number, isPause: boolean = false) {
|
||||
// 当前索引
|
||||
const currentIndex = enableLoop ? virtualIndexRef.current + (nextIndex - index) : nextIndex;
|
||||
const max = imageLength - 1;
|
||||
// 虚拟 index
|
||||
// 非循环模式,限制区间
|
||||
const limitIndex = limitNumber(currentIndex, 0, max);
|
||||
const nextVirtualIndex = enableLoop ? currentIndex : limitIndex;
|
||||
// 单个屏幕宽度
|
||||
const singlePageWidth = dynamicInnerWidth + horizontalOffset;
|
||||
|
||||
updateState({
|
||||
touched: false,
|
||||
lastCX: undefined,
|
||||
lastCY: undefined,
|
||||
x: -singlePageWidth * nextVirtualIndex,
|
||||
pause: isPause,
|
||||
});
|
||||
|
||||
virtualIndexRef.current = nextVirtualIndex;
|
||||
// 更新真实的 index
|
||||
const realLoopIndex = nextIndex < 0 ? max : nextIndex > max ? 0 : nextIndex;
|
||||
if (onIndexChange) {
|
||||
onIndexChange(enableLoop ? realLoopIndex : limitIndex);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEventListener("keydown", (evt: KeyboardEvent) => {
|
||||
if (visible) {
|
||||
switch (evt.key) {
|
||||
case "ArrowLeft":
|
||||
changeIndex(index - 1, true);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
changeIndex(index + 1, true);
|
||||
break;
|
||||
case "Escape":
|
||||
close();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handlePhotoTap(closeable: boolean | undefined) {
|
||||
return closeable ? close() : updateState({ overlay: !overlay });
|
||||
}
|
||||
|
||||
const handleResize = (dynamicInnerWidth: number) => () => {
|
||||
updateState({
|
||||
x: -(dynamicInnerWidth + horizontalOffset) * index,
|
||||
lastCX: undefined,
|
||||
lastCY: undefined,
|
||||
pause: true,
|
||||
});
|
||||
virtualIndexRef.current = index;
|
||||
};
|
||||
|
||||
function handleReachVerticalMove(clientY: number, nextScale?: number) {
|
||||
if (lastCY === undefined) {
|
||||
updateState({
|
||||
touched: true,
|
||||
lastCY: clientY,
|
||||
bg,
|
||||
minimal: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const opacity =
|
||||
maskOpacity === null ? null : limitNumber(maskOpacity, 0.01, maskOpacity - Math.abs(clientY - lastCY) / 100 / 4);
|
||||
|
||||
updateState({
|
||||
touched: true,
|
||||
lastCY,
|
||||
bg: nextScale === 1 ? opacity : maskOpacity,
|
||||
minimal: nextScale === 1,
|
||||
});
|
||||
}
|
||||
|
||||
function handleReachHorizontalMove(clientX: number) {
|
||||
if (lastCX === undefined) {
|
||||
updateState({
|
||||
touched: true,
|
||||
lastCX: clientX,
|
||||
x,
|
||||
pause: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const originOffsetClientX = clientX - lastCX;
|
||||
let offsetClientX = originOffsetClientX;
|
||||
|
||||
// 第一张和最后一张超出距离减半
|
||||
if (
|
||||
!enableLoop &&
|
||||
((index === 0 && originOffsetClientX > 0) || (index === imageLength - 1 && originOffsetClientX < 0))
|
||||
) {
|
||||
offsetClientX = originOffsetClientX / 2;
|
||||
}
|
||||
|
||||
updateState({
|
||||
touched: true,
|
||||
lastCX,
|
||||
x: -(dynamicInnerWidth + horizontalOffset) * virtualIndexRef.current + offsetClientX,
|
||||
pause: false,
|
||||
});
|
||||
}
|
||||
|
||||
function handleReachMove(reachPosition: ReachType, clientX: number, clientY: number, nextScale?: number) {
|
||||
if (reachPosition === "x") {
|
||||
handleReachHorizontalMove(clientX);
|
||||
} else if (reachPosition === "y") {
|
||||
handleReachVerticalMove(clientY, nextScale);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReachUp(clientX: number, clientY: number) {
|
||||
const offsetClientX = clientX - (lastCX ?? clientX);
|
||||
const offsetClientY = clientY - (lastCY ?? clientY);
|
||||
let willClose = false;
|
||||
// 下一张
|
||||
if (offsetClientX < -maxMoveOffset) {
|
||||
changeIndex(index + 1);
|
||||
return;
|
||||
}
|
||||
// 上一张
|
||||
if (offsetClientX > maxMoveOffset) {
|
||||
changeIndex(index - 1);
|
||||
return;
|
||||
}
|
||||
const singlePageWidth = dynamicInnerWidth + horizontalOffset;
|
||||
// 当前偏移
|
||||
const currentTranslateX = -singlePageWidth * virtualIndexRef.current;
|
||||
|
||||
if (Math.abs(offsetClientY) > 100 && minimal && pullClosable) {
|
||||
willClose = true;
|
||||
close();
|
||||
}
|
||||
updateState({
|
||||
touched: false,
|
||||
x: currentTranslateX,
|
||||
lastCX: undefined,
|
||||
lastCY: undefined,
|
||||
bg: maskOpacity,
|
||||
overlay: willClose ? true : overlay,
|
||||
});
|
||||
}
|
||||
// 截取相邻的图片
|
||||
const adjacentImages = useAdjacentImages(images, index, enableLoop);
|
||||
const currentFile = images[index]?.file;
|
||||
const displayOpt = useActionDisplayOpt(currentFile ? [currentFile] : []);
|
||||
|
||||
useEffect(() => {
|
||||
//handleReachMove("x", 0, 0);
|
||||
handleReachUp(0, 0);
|
||||
}, [sideBarOpen]);
|
||||
|
||||
if (!realVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentOverlayVisible = overlay && !activeAnimation;
|
||||
// 关闭过程中使用下拉保存的透明度
|
||||
const currentOpacity = visible ? bg : lastBg;
|
||||
// 覆盖物参数
|
||||
const overlayParams: OverlayRenderProps | undefined = onScale &&
|
||||
onRotate && {
|
||||
images,
|
||||
index,
|
||||
visible,
|
||||
onClose: close,
|
||||
onIndexChange: changeIndex,
|
||||
overlayVisible: currentOverlayVisible,
|
||||
overlay: currentImage && currentImage.overlay,
|
||||
scale,
|
||||
rotate,
|
||||
onScale,
|
||||
onRotate,
|
||||
};
|
||||
// 动画时间
|
||||
const currentSpeed = speedFn ? speedFn(activeAnimation) : defaultSpeed;
|
||||
const currentEasing = easingFn ? easingFn(activeAnimation) : defaultEasing;
|
||||
const slideSpeed = speedFn ? speedFn(3) : defaultSpeed + 200;
|
||||
const slideEasing = easingFn ? easingFn(3) : defaultEasing;
|
||||
|
||||
return (
|
||||
<Box ref={containerRef}>
|
||||
<SlidePortal
|
||||
className={`PhotoView-Portal${!currentOverlayVisible ? " PhotoView-Slider__clean" : ""}${
|
||||
!visible ? " PhotoView-Slider__willClose" : ""
|
||||
}${className ? ` ${className}` : ""}`}
|
||||
style={{
|
||||
width: sideBarOpen ? "calc(100% - 300px)" : "100%",
|
||||
}}
|
||||
role="dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
container={portalContainer}
|
||||
>
|
||||
<div
|
||||
className={`PhotoView-Slider__Backdrop${maskClassName ? ` ${maskClassName}` : ""}${
|
||||
activeAnimation === 1
|
||||
? " PhotoView-Slider__fadeIn"
|
||||
: activeAnimation === 2
|
||||
? " PhotoView-Slider__fadeOut"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
background: currentOpacity ? `rgba(0, 0, 0, ${currentOpacity})` : undefined,
|
||||
transitionTimingFunction: currentEasing,
|
||||
transitionDuration: `${touched ? 0 : currentSpeed}ms`,
|
||||
animationDuration: `${currentSpeed}ms`,
|
||||
}}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
/>
|
||||
{bannerVisible && (
|
||||
<div className="PhotoView-Slider__BannerWrap">
|
||||
<div className="PhotoView-Slider__Counter">
|
||||
{index + 1} / {moreFiles ? imageLength - 1 : imageLength}
|
||||
{moreFiles ? "+" : ""}
|
||||
</div>
|
||||
<div className="PhotoView-Slider__BannerRight">
|
||||
{toolbarRender && overlayParams && toolbarRender(overlayParams)}
|
||||
{currentFile && displayOpt.showDownload && (
|
||||
<Tooltip title={t("application:fileManager.download")}>
|
||||
<IconButton onClick={() => dispatch(downloadSingleFile(currentFile, images[index]?.version))}>
|
||||
<Download fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentFile && displayOpt.showInfo && (
|
||||
<Tooltip title={t("application:fileManager.details")}>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setSidebar({
|
||||
open: true,
|
||||
target: images[index].file,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Info fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentFile &&
|
||||
displayOpt &&
|
||||
canUpdate(displayOpt) &&
|
||||
editorSupportedExt.includes(fileExtension(currentFile.name) ?? "") && (
|
||||
<Tooltip title={t("application:fileManager.edit")}>
|
||||
<IconButton onClick={() => dispatch(switchToImageEditor(currentFile, images[index]?.version))}>
|
||||
<ImageEdit fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentFile && (
|
||||
<Tooltip title={t("application:fileManager.moreActions")}>
|
||||
<IconButton
|
||||
onClick={(e) => dispatch(openFileContextMenu(FileManagerIndex.main, currentFile, false, e))}
|
||||
>
|
||||
<MoreHorizontal fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton onClick={close}>
|
||||
<Dismiss fontSize={"small"} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{adjacentImages.map((item: DataType, currentIndex) => {
|
||||
// 截取之前的索引位置
|
||||
const nextIndex =
|
||||
!enableLoop && index === 0 ? index + currentIndex : virtualIndexRef.current - 1 + currentIndex;
|
||||
|
||||
return (
|
||||
<PhotoBox
|
||||
key={enableLoop ? `${item.key}/${item.src}/${nextIndex}` : item.key}
|
||||
item={item}
|
||||
speed={currentSpeed}
|
||||
easing={currentEasing}
|
||||
visible={visible}
|
||||
onReachMove={handleReachMove}
|
||||
onReachUp={handleReachUp}
|
||||
onPhotoTap={() => handlePhotoTap(photoClosable)}
|
||||
onMaskTap={() => handlePhotoTap(maskClosable)}
|
||||
wrapClassName={photoWrapClassName}
|
||||
className={photoClassName}
|
||||
style={{
|
||||
left: `${(dynamicInnerWidth + horizontalOffset) * nextIndex}px`,
|
||||
transform: `translate3d(${x}px, 0px, 0)`,
|
||||
transition: touched || pause ? undefined : `transform ${slideSpeed}ms ${slideEasing}`,
|
||||
}}
|
||||
loadingElement={loadingElement}
|
||||
brokenElement={brokenElement}
|
||||
onPhotoResize={handleResize(dynamicInnerWidth)}
|
||||
isActive={virtualIndexRef.current === nextIndex}
|
||||
expose={updateState}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{!isTouchDevice && bannerVisible && (
|
||||
<>
|
||||
{(enableLoop || index !== 0) && (
|
||||
<div className="PhotoView-Slider__ArrowLeft" onClick={() => changeIndex(index - 1, true)}>
|
||||
<ArrowLeft />
|
||||
</div>
|
||||
)}
|
||||
{(enableLoop || index + 1 < imageLength) && (
|
||||
<div className="PhotoView-Slider__ArrowRight" onClick={() => changeIndex(index + 1, true)}>
|
||||
<ArrowRight />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{overlayRender && overlayParams && (
|
||||
<div className="PhotoView-Slider__Overlay">{overlayRender(overlayParams)}</div>
|
||||
)}
|
||||
</SlidePortal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
106
src/component/Viewers/ImageViewer/react-photo-view/PhotoView.tsx
Executable file
106
src/component/Viewers/ImageViewer/react-photo-view/PhotoView.tsx
Executable file
@@ -0,0 +1,106 @@
|
||||
import type React from "react";
|
||||
import { useImperativeHandle, Children, cloneElement, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import useInitial from "./hooks/useInitial";
|
||||
import useMethods from "./hooks/useMethods";
|
||||
import type { PhotoContextType } from "./photo-context";
|
||||
import PhotoContext from "./photo-context";
|
||||
import type { PhotoRenderParams } from "./types";
|
||||
|
||||
export interface PhotoViewProps {
|
||||
/**
|
||||
* 图片地址
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* 自定义渲染,优先级比 src 低
|
||||
*/
|
||||
render?: (props: PhotoRenderParams) => React.ReactNode;
|
||||
/**
|
||||
* 自定义覆盖节点
|
||||
*/
|
||||
overlay?: React.ReactNode;
|
||||
/**
|
||||
* 自定义渲染节点宽度
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* 自定义渲染节点高度
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* 子节点,一般为缩略图
|
||||
*/
|
||||
children?: React.ReactElement;
|
||||
/**
|
||||
* 触发的事件
|
||||
*/
|
||||
triggers?: ("onClick" | "onDoubleClick")[];
|
||||
}
|
||||
|
||||
const PhotoView: React.FC<PhotoViewProps> = ({
|
||||
src,
|
||||
render,
|
||||
overlay,
|
||||
width,
|
||||
height,
|
||||
triggers = ["onClick"],
|
||||
children,
|
||||
}) => {
|
||||
const photoContext = useContext<PhotoContextType>(PhotoContext);
|
||||
const key = useInitial(() => photoContext.nextId());
|
||||
const originRef = useRef<HTMLElement>(null);
|
||||
|
||||
useImperativeHandle((children as React.FunctionComponentElement<HTMLElement>)?.ref, () => originRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
photoContext.remove(key);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function invokeChildrenFn(eventName: string, e: React.SyntheticEvent) {
|
||||
if (children) {
|
||||
const eventFn = children.props[eventName];
|
||||
if (eventFn) {
|
||||
eventFn(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn = useMethods({
|
||||
render(props: PhotoRenderParams) {
|
||||
return render && render(props);
|
||||
},
|
||||
show(eventName: string, e: React.MouseEvent) {
|
||||
photoContext.show(key);
|
||||
invokeChildrenFn(eventName, e);
|
||||
},
|
||||
});
|
||||
|
||||
const eventListeners = useMemo(() => {
|
||||
const listener = {};
|
||||
triggers.forEach((eventName) => {
|
||||
listener[eventName] = fn.show.bind(null, eventName);
|
||||
});
|
||||
return listener;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
photoContext.update({
|
||||
key,
|
||||
src,
|
||||
originRef,
|
||||
render: fn.render,
|
||||
overlay,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [src]);
|
||||
|
||||
if (children) {
|
||||
return Children.only(cloneElement(children, { ...eventListeners, ref: originRef }));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default PhotoView;
|
||||
11
src/component/Viewers/ImageViewer/react-photo-view/components/ArrowLeft.tsx
Executable file
11
src/component/Viewers/ImageViewer/react-photo-view/components/ArrowLeft.tsx
Executable file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
function ArrowLeft(props: React.HTMLAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 768 768" {...props}>
|
||||
<path d="M640.5 352.5v63h-390l178.5 180-45 45-256.5-256.5 256.5-256.5 45 45-178.5 180h390z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrowLeft;
|
||||
11
src/component/Viewers/ImageViewer/react-photo-view/components/ArrowRight.tsx
Executable file
11
src/component/Viewers/ImageViewer/react-photo-view/components/ArrowRight.tsx
Executable file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
function ArrowRight(props: React.HTMLAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 768 768" {...props}>
|
||||
<path d="M384 127.5l256.5 256.5-256.5 256.5-45-45 178.5-180h-390v-63h390l-178.5-180z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrowRight;
|
||||
11
src/component/Viewers/ImageViewer/react-photo-view/components/CloseIcon.tsx
Executable file
11
src/component/Viewers/ImageViewer/react-photo-view/components/CloseIcon.tsx
Executable file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
function CloseIcon(props: React.HTMLAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 768 768" {...props}>
|
||||
<path d="M607.5 205.5l-178.5 178.5 178.5 178.5-45 45-178.5-178.5-178.5 178.5-45-45 178.5-178.5-178.5-178.5 45-45 178.5 178.5 178.5-178.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default CloseIcon;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTheme } from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function PreventScroll() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
alert(isMobile);
|
||||
document.body.style.overflow = isMobile ? "initial" : "hidden";
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.PhotoView {
|
||||
&-Portal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1250;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "./SlidePortal.less";
|
||||
|
||||
export interface ISliderPortalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
container?: HTMLElement;
|
||||
}
|
||||
|
||||
function SlidePortal({ container = document.body, ...rest }: ISliderPortalProps) {
|
||||
return createPortal(<div {...rest} />, container);
|
||||
}
|
||||
|
||||
export default SlidePortal;
|
||||
30
src/component/Viewers/ImageViewer/react-photo-view/components/Spinner.less
Executable file
30
src/component/Viewers/ImageViewer/react-photo-view/components/Spinner.less
Executable file
@@ -0,0 +1,30 @@
|
||||
@keyframes PhotoView__rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes PhotoView__delayIn {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.PhotoView {
|
||||
&__Spinner {
|
||||
animation: PhotoView__delayIn 0.4s linear both;
|
||||
|
||||
svg {
|
||||
animation: PhotoView__rotate 0.6s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/component/Viewers/ImageViewer/react-photo-view/components/Spinner.tsx
Executable file
16
src/component/Viewers/ImageViewer/react-photo-view/components/Spinner.tsx
Executable file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import "./Spinner.less";
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
function Spinner({ className = "", ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
return (
|
||||
<div className={`PhotoView__Spinner ${className}`} {...props}>
|
||||
<svg viewBox="0 0 32 32" width="36" height="36" fill="white">
|
||||
<path opacity=".25" d="M16 0 A16 16 0 0 0 16 32 A16 16 0 0 0 16 0 M16 4 A12 12 0 0 1 16 28 A12 12 0 0 1 16 4" />
|
||||
<path d="M16 0 A16 16 0 0 1 32 16 L28 16 A12 12 0 0 0 16 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Spinner;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
import type { DataType } from "../types";
|
||||
|
||||
/**
|
||||
* 截取相邻三张图片
|
||||
*/
|
||||
export default function useAdjacentImages(images: DataType[], index: number, loop: boolean) {
|
||||
return useMemo(() => {
|
||||
const imageLength = images.length;
|
||||
if (loop) {
|
||||
const connected = images.concat(images).concat(images);
|
||||
return connected.slice(imageLength + index - 1, imageLength + index + 2);
|
||||
}
|
||||
return images.slice(Math.max(index - 1, 0), Math.min(index + 2, imageLength + 1));
|
||||
}, [images, index, loop]);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { EasingMode, OriginRectType } from "../types";
|
||||
import useMethods from "./useMethods";
|
||||
import { maxWaitAnimationTime } from "../variables";
|
||||
|
||||
const initialRect: OriginRectType = {
|
||||
T: 0,
|
||||
L: 0,
|
||||
W: 0,
|
||||
H: 0,
|
||||
// 图像填充方式
|
||||
FIT: undefined,
|
||||
};
|
||||
|
||||
export default function useAnimationOrigin(
|
||||
visible: boolean | undefined,
|
||||
originRef: MutableRefObject<HTMLElement | null> | undefined,
|
||||
loaded: boolean,
|
||||
speed: number,
|
||||
updateEasing: (pause: boolean) => void,
|
||||
): [
|
||||
// 动画状态
|
||||
easingMode: EasingMode,
|
||||
originRect: OriginRectType,
|
||||
] {
|
||||
const [originRect, updateOriginRect] = useState(initialRect);
|
||||
// 动画状态
|
||||
const [easingMode, updateEasingMode] = useState<EasingMode>(0);
|
||||
const initialTime = useRef<number>();
|
||||
|
||||
const fn = useMethods({
|
||||
OK: () => visible && updateEasingMode(4),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 记录初始打开的时间
|
||||
if (!initialTime.current) {
|
||||
initialTime.current = Date.now();
|
||||
}
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
handleUpdateOrigin(originRef, updateOriginRect);
|
||||
// 打开动画处理
|
||||
if (visible) {
|
||||
// 小于最大允许动画时间,则执行缩放动画
|
||||
if (Date.now() - initialTime.current < maxWaitAnimationTime) {
|
||||
updateEasingMode(1);
|
||||
// 延时执行动画,保持 transition 生效
|
||||
requestAnimationFrame(() => {
|
||||
updateEasingMode(2);
|
||||
requestAnimationFrame(() => handleToShape(3));
|
||||
});
|
||||
setTimeout(fn.OK, speed);
|
||||
return;
|
||||
}
|
||||
// 超出则不执行
|
||||
updateEasingMode(4);
|
||||
return;
|
||||
}
|
||||
// 关闭动画处理
|
||||
handleToShape(5);
|
||||
}, [visible, loaded]);
|
||||
|
||||
function handleToShape(currentShape: EasingMode) {
|
||||
updateEasing(false);
|
||||
updateEasingMode(currentShape);
|
||||
}
|
||||
|
||||
return [easingMode, originRect];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新缩略图位置信息
|
||||
*/
|
||||
function handleUpdateOrigin(
|
||||
originRef: MutableRefObject<HTMLElement | null> | undefined,
|
||||
updateOriginRect: Dispatch<SetStateAction<typeof initialRect>>,
|
||||
) {
|
||||
const element = originRef && originRef.current;
|
||||
|
||||
if (element && element.nodeType === 1) {
|
||||
// 获取触发时节点位置
|
||||
const { top, left, width, height } = element.getBoundingClientRect();
|
||||
const isImage = element.tagName === "IMG";
|
||||
updateOriginRect({
|
||||
T: top,
|
||||
L: left,
|
||||
W: width,
|
||||
H: height,
|
||||
FIT: isImage ? (getComputedStyle(element).objectFit as "contain" | "cover" | "fill" | undefined) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import useAnimationOrigin from "./useAnimationOrigin";
|
||||
import useTargetScale from "./useTargetScale";
|
||||
|
||||
export default function useAnimationPosition(
|
||||
dynamicInnerWidth: number,
|
||||
visible: boolean | undefined,
|
||||
originRef: MutableRefObject<HTMLElement | null> | undefined,
|
||||
loaded: boolean,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
scale: number,
|
||||
speed: number,
|
||||
updateEasing: (pause: boolean) => void,
|
||||
) {
|
||||
// 延迟更新 width/height
|
||||
const [autoWidth, autoHeight, autoScale] = useTargetScale(width, height, scale, speed, updateEasing);
|
||||
// 动画源处理
|
||||
const [easingMode, originRect] = useAnimationOrigin(visible, originRef, loaded, speed, updateEasing);
|
||||
|
||||
// 计算动画位置
|
||||
const { T, L, W, H, FIT } = originRect;
|
||||
// 偏移量,x: 0, y: 0 居中为初始
|
||||
const centerWidth = dynamicInnerWidth / 2;
|
||||
const centerHeight = innerHeight / 2;
|
||||
const offsetX = centerWidth - (width * scale) / 2;
|
||||
const offsetY = centerHeight - (height * scale) / 2;
|
||||
// 缩略图状态
|
||||
const miniMode = easingMode < 3 || easingMode > 4;
|
||||
// 有缩略图时,则为缩略图的位置,否则居中
|
||||
const translateX = miniMode ? (W ? L : centerWidth) : x + offsetX;
|
||||
const translateY = miniMode ? (W ? T : centerHeight) : y + offsetY;
|
||||
|
||||
// 最小值缩放
|
||||
const minScale = W / (width * scale) || 0.01;
|
||||
|
||||
// 适应 objectFit 保持缩略图宽高比
|
||||
const currentHeight = miniMode && FIT ? autoWidth * (H / W) : autoHeight;
|
||||
// 初始加载情况无缩放
|
||||
const currentScale = easingMode === 0 ? autoScale : miniMode ? minScale : autoScale;
|
||||
const opacity = miniMode ? (FIT ? 1 : 0) : 1;
|
||||
|
||||
return [translateX, translateY, autoWidth, currentHeight, currentScale, opacity, easingMode, FIT] as const;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useReducer, useRef } from "react";
|
||||
import type { ActiveAnimationType } from "../types";
|
||||
import useForkedVariable from "./useForkedVariable";
|
||||
|
||||
/**
|
||||
* 动画关闭处理真实关闭状态
|
||||
* 通过 onAnimationEnd 回调实现 leaveCallback
|
||||
*/
|
||||
export default function useAnimationVisible(
|
||||
visible: boolean | undefined,
|
||||
afterClose?: () => void,
|
||||
): [realVisible: boolean | undefined, activeAnimation: ActiveAnimationType, onAnimationEnd: () => void] {
|
||||
const [, handleRender] = useReducer((c) => !c, false);
|
||||
|
||||
const activeAnimation = useRef<ActiveAnimationType>(0);
|
||||
|
||||
// 可见状态分支
|
||||
const [realVisible, modifyRealVisible] = useForkedVariable(visible, (modify) => {
|
||||
// 可见状态:设置进入动画
|
||||
if (visible) {
|
||||
modify(visible);
|
||||
activeAnimation.current = 1;
|
||||
} else {
|
||||
activeAnimation.current = 2;
|
||||
}
|
||||
});
|
||||
|
||||
function onAnimationEnd() {
|
||||
// 动画结束后触发渲染
|
||||
handleRender();
|
||||
// 结束动画:设置隐藏状态
|
||||
if (activeAnimation.current === 2) {
|
||||
modifyRealVisible(false);
|
||||
// 触发隐藏回调
|
||||
if (afterClose) {
|
||||
afterClose();
|
||||
}
|
||||
}
|
||||
// 重置状态
|
||||
activeAnimation.current = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
/**
|
||||
* 真实可见状态
|
||||
*/
|
||||
realVisible,
|
||||
/**
|
||||
* 正在进行的动画
|
||||
*/
|
||||
activeAnimation.current,
|
||||
/**
|
||||
* 动画结束后回调
|
||||
*/
|
||||
onAnimationEnd,
|
||||
];
|
||||
}
|
||||
36
src/component/Viewers/ImageViewer/react-photo-view/hooks/useContinuousTap.ts
Executable file
36
src/component/Viewers/ImageViewer/react-photo-view/hooks/useContinuousTap.ts
Executable file
@@ -0,0 +1,36 @@
|
||||
import { useRef } from "react";
|
||||
import useDebounceCallback from "./useDebounceCallback";
|
||||
|
||||
export type TapFuncType<T> = (...args: T[]) => void;
|
||||
|
||||
/**
|
||||
* 单击和双击事件处理
|
||||
* @param singleTap - 单击事件
|
||||
* @param doubleTap - 双击事件
|
||||
* @return invokeTap
|
||||
*/
|
||||
export default function useContinuousTap<T>(singleTap: TapFuncType<T>, doubleTap: TapFuncType<T>): TapFuncType<T> {
|
||||
// 当前连续点击次数
|
||||
const continuousClick = useRef(0);
|
||||
|
||||
const debounceTap = useDebounceCallback(
|
||||
(...args) => {
|
||||
continuousClick.current = 0;
|
||||
singleTap(...args);
|
||||
},
|
||||
{
|
||||
wait: 300,
|
||||
},
|
||||
);
|
||||
|
||||
return function invokeTap(...args) {
|
||||
continuousClick.current += 1;
|
||||
debounceTap(...args);
|
||||
// 双击
|
||||
if (continuousClick.current >= 2) {
|
||||
debounceTap.cancel();
|
||||
continuousClick.current = 0;
|
||||
doubleTap(...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
interface DebounceCallback<CallbackArguments extends any[]> {
|
||||
(...args: CallbackArguments): void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export default function useDebounceCallback<CallbackArguments extends any[]>(
|
||||
callback: (...args: CallbackArguments) => void,
|
||||
{
|
||||
leading = false,
|
||||
maxWait,
|
||||
wait = maxWait || 0,
|
||||
}: {
|
||||
leading?: boolean;
|
||||
maxWait?: number;
|
||||
wait?: number;
|
||||
},
|
||||
): DebounceCallback<CallbackArguments> {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
const prev = useRef(0);
|
||||
const trailingTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const clearTrailing = () => trailingTimeout.current && clearTimeout(trailingTimeout.current);
|
||||
|
||||
const fn = useCallback(
|
||||
(...args: CallbackArguments) => {
|
||||
const now = Date.now();
|
||||
|
||||
function call() {
|
||||
prev.current = now;
|
||||
clearTrailing();
|
||||
callbackRef.current.apply(null, args);
|
||||
}
|
||||
const last = prev.current;
|
||||
const offset = now - last;
|
||||
// leading
|
||||
if (last === 0) {
|
||||
if (leading) {
|
||||
call();
|
||||
}
|
||||
prev.current = now;
|
||||
}
|
||||
|
||||
// body
|
||||
if (maxWait !== undefined) {
|
||||
if (offset > maxWait) {
|
||||
call();
|
||||
return;
|
||||
}
|
||||
} else if (offset < wait) {
|
||||
prev.current = now;
|
||||
}
|
||||
|
||||
// trailing
|
||||
clearTrailing();
|
||||
trailingTimeout.current = setTimeout(() => {
|
||||
call();
|
||||
prev.current = 0;
|
||||
}, wait);
|
||||
},
|
||||
[wait, maxWait, leading],
|
||||
);
|
||||
(fn as DebounceCallback<CallbackArguments>).cancel = clearTrailing;
|
||||
|
||||
return fn as DebounceCallback<CallbackArguments>;
|
||||
}
|
||||
24
src/component/Viewers/ImageViewer/react-photo-view/hooks/useEventListener.ts
Executable file
24
src/component/Viewers/ImageViewer/react-photo-view/hooks/useEventListener.ts
Executable file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function useEventListener<K extends keyof WindowEventMap>(
|
||||
type: K | undefined,
|
||||
fn: (evt: WindowEventMap[K]) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) {
|
||||
const latest = useRef(fn);
|
||||
latest.current = fn;
|
||||
|
||||
useEffect(() => {
|
||||
function wrapper(evt: WindowEventMap[K]) {
|
||||
latest.current(evt);
|
||||
}
|
||||
if (type) {
|
||||
window.addEventListener(type, wrapper, options);
|
||||
}
|
||||
return () => {
|
||||
if (type) {
|
||||
window.removeEventListener(type, wrapper);
|
||||
}
|
||||
};
|
||||
}, [type]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useRef, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* 逻辑分叉变量处理
|
||||
* 此 hook 不触发额外渲染
|
||||
*/
|
||||
export default function useForkedVariable<T>(initial: T, updater: (modify: (variable: T) => void) => void) {
|
||||
// 初始分叉变量
|
||||
const forkedRef = useRef(initial);
|
||||
|
||||
function modify(next: T) {
|
||||
forkedRef.current = next;
|
||||
}
|
||||
|
||||
useMemo(() => {
|
||||
// 参数变化之后同步内部分叉变量
|
||||
updater(modify);
|
||||
}, [initial]);
|
||||
|
||||
return [forkedRef.current, modify] as const;
|
||||
}
|
||||
10
src/component/Viewers/ImageViewer/react-photo-view/hooks/useInitial.ts
Executable file
10
src/component/Viewers/ImageViewer/react-photo-view/hooks/useInitial.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export default function useInitial<T extends (...args: any) => any>(callback: T) {
|
||||
const { current } = useRef({ sign: false, fn: undefined as ReturnType<T> });
|
||||
if (!current.sign) {
|
||||
current.sign = true;
|
||||
current.fn = callback();
|
||||
}
|
||||
return current.fn;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
|
||||
const isSSR = typeof window === "undefined" || /ServerSideRendering/.test(navigator && navigator.userAgent);
|
||||
|
||||
export default isSSR ? useEffect : useLayoutEffect;
|
||||
22
src/component/Viewers/ImageViewer/react-photo-view/hooks/useMethods.ts
Executable file
22
src/component/Viewers/ImageViewer/react-photo-view/hooks/useMethods.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
/**
|
||||
* Hook of persistent methods
|
||||
*/
|
||||
export default function useMethods<T extends Record<string, (...args: any[]) => any>>(fn: T) {
|
||||
const { current } = useRef({
|
||||
fn,
|
||||
curr: undefined as T | undefined,
|
||||
});
|
||||
current.fn = fn;
|
||||
|
||||
if (!current.curr) {
|
||||
const curr = Object.create(null);
|
||||
Object.keys(fn).forEach((key) => {
|
||||
curr[key] = (...args: unknown[]) => current.fn[key].call(current.fn, ...args);
|
||||
});
|
||||
current.curr = curr;
|
||||
}
|
||||
|
||||
return current.curr as T;
|
||||
}
|
||||
14
src/component/Viewers/ImageViewer/react-photo-view/hooks/useMountedRef.ts
Executable file
14
src/component/Viewers/ImageViewer/react-photo-view/hooks/useMountedRef.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const useMountedRef = () => {
|
||||
const mountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
return mountedRef;
|
||||
};
|
||||
|
||||
export default useMountedRef;
|
||||
207
src/component/Viewers/ImageViewer/react-photo-view/hooks/useScrollPosition.ts
Executable file
207
src/component/Viewers/ImageViewer/react-photo-view/hooks/useScrollPosition.ts
Executable file
@@ -0,0 +1,207 @@
|
||||
import { computePositionEdge } from "../utils/edgeHandle";
|
||||
import getPositionOnMoveOrScale from "../utils/getPositionOnMoveOrScale";
|
||||
import getRotateSize from "../utils/getRotateSize";
|
||||
import { defaultSpeed, maxTouchTime } from "../variables";
|
||||
import useMethods from "./useMethods";
|
||||
|
||||
// 触边运动反馈
|
||||
const rebound = (start: number, bound: number, callback: (spatial: number) => boolean) =>
|
||||
easeOutMove(
|
||||
start,
|
||||
bound,
|
||||
callback,
|
||||
defaultSpeed / 4,
|
||||
(t) => t,
|
||||
() => easeOutMove(bound, start, callback),
|
||||
);
|
||||
|
||||
/**
|
||||
* 物理滚动到具体位置
|
||||
*/
|
||||
export default function useScrollPosition<C extends (spatial: number) => boolean>(
|
||||
callbackX: C,
|
||||
callbackY: C,
|
||||
callbackS: C,
|
||||
dynamicInnerWidth: number,
|
||||
) {
|
||||
const callback = useMethods({
|
||||
X: (spatial: number) => callbackX(spatial),
|
||||
Y: (spatial: number) => callbackY(spatial),
|
||||
S: (spatial: number) => callbackS(spatial),
|
||||
});
|
||||
|
||||
return (
|
||||
x: number,
|
||||
y: number,
|
||||
lastX: number,
|
||||
lastY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
scale: number,
|
||||
safeScale: number,
|
||||
lastScale: number,
|
||||
rotate: number,
|
||||
touchedTime: number,
|
||||
) => {
|
||||
const [currentWidth, currentHeight] = getRotateSize(rotate, width, height);
|
||||
// 开始状态下边缘触发状态
|
||||
const [beginEdgeX, beginX] = computePositionEdge(x, safeScale, currentWidth, dynamicInnerWidth);
|
||||
const [beginEdgeY, beginY] = computePositionEdge(y, safeScale, currentHeight, innerHeight);
|
||||
const moveTime = Date.now() - touchedTime;
|
||||
|
||||
// 时间过长、超出安全范围的情况下不执行滚动逻辑,恢复安全范围
|
||||
if (moveTime >= maxTouchTime || safeScale !== scale || Math.abs(lastScale - scale) > 1) {
|
||||
// 计算中心缩放点
|
||||
const { x: nextX, y: nextY } = getPositionOnMoveOrScale(x, y, width, height, scale, safeScale);
|
||||
const targetX = beginEdgeX ? beginX : nextX !== x ? nextX : null;
|
||||
const targetY = beginEdgeY ? beginY : nextY !== y ? nextY : null;
|
||||
|
||||
if (targetX !== null) {
|
||||
easeOutMove(x, targetX, callback.X);
|
||||
}
|
||||
if (targetY !== null) {
|
||||
easeOutMove(y, targetY, callback.Y);
|
||||
}
|
||||
if (safeScale !== scale) {
|
||||
easeOutMove(scale, safeScale, callback.S);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始速度
|
||||
const speedX = (x - lastX) / moveTime;
|
||||
const speedY = (y - lastY) / moveTime;
|
||||
const speedT = Math.sqrt(speedX ** 2 + speedY ** 2);
|
||||
// 是否接触到边缘
|
||||
let edgeX = false;
|
||||
let edgeY = false;
|
||||
|
||||
scrollMove(speedT, (spatial) => {
|
||||
const nextX = x + spatial * (speedX / speedT);
|
||||
const nextY = y + spatial * (speedY / speedT);
|
||||
|
||||
const [isEdgeX, currentX] = computePositionEdge(nextX, scale, currentWidth, innerWidth);
|
||||
const [isEdgeY, currentY] = computePositionEdge(nextY, scale, currentHeight, innerHeight);
|
||||
|
||||
if (isEdgeX && !edgeX) {
|
||||
edgeX = true;
|
||||
if (beginEdgeX) {
|
||||
easeOutMove(nextX, currentX, callback.X);
|
||||
} else {
|
||||
rebound(currentX, nextX + (nextX - currentX), callback.X);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdgeY && !edgeY) {
|
||||
edgeY = true;
|
||||
if (beginEdgeY) {
|
||||
easeOutMove(nextY, currentY, callback.Y);
|
||||
} else {
|
||||
rebound(currentY, nextY + (nextY - currentY), callback.Y);
|
||||
}
|
||||
}
|
||||
// 同时接触边缘的情况下停止滚动
|
||||
if (edgeX && edgeY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resultX = edgeX || callback.X(currentX);
|
||||
const resultY = edgeY || callback.Y(currentY);
|
||||
return resultX && resultY;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// 加速度
|
||||
const acceleration = -0.001;
|
||||
// 阻力
|
||||
const resistance = 0.0002;
|
||||
|
||||
/**
|
||||
* 通过速度滚动到停止
|
||||
*/
|
||||
function scrollMove(initialSpeed: number, callback: (spatial: number) => boolean) {
|
||||
let v = initialSpeed;
|
||||
let s = 0;
|
||||
let lastTime: number | undefined;
|
||||
let frameId = 0;
|
||||
|
||||
const calcMove = (now: number) => {
|
||||
if (!lastTime) {
|
||||
lastTime = now;
|
||||
}
|
||||
const dt = now - lastTime;
|
||||
const direction = Math.sign(initialSpeed);
|
||||
const a = direction * acceleration;
|
||||
const f = Math.sign(-v) * v ** 2 * resistance;
|
||||
const ds = v * dt + ((a + f) * dt ** 2) / 2;
|
||||
v += (a + f) * dt;
|
||||
|
||||
s += ds;
|
||||
// move to s
|
||||
lastTime = now;
|
||||
|
||||
if (direction * v <= 0) {
|
||||
caf();
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback(s)) {
|
||||
raf();
|
||||
return;
|
||||
}
|
||||
caf();
|
||||
};
|
||||
raf();
|
||||
|
||||
function raf() {
|
||||
frameId = requestAnimationFrame(calcMove);
|
||||
}
|
||||
function caf() {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓动函数
|
||||
*/
|
||||
const easeOutQuart = (x: number) => 1 - (1 - x) ** 4;
|
||||
|
||||
/**
|
||||
* 缓动回调
|
||||
*/
|
||||
function easeOutMove(
|
||||
start: number,
|
||||
end: number,
|
||||
callback: (spatial: number) => boolean,
|
||||
speed = defaultSpeed,
|
||||
easing = easeOutQuart,
|
||||
complete?: () => void,
|
||||
) {
|
||||
const distance = end - start;
|
||||
if (distance === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let frameId = 0;
|
||||
|
||||
const calcMove = () => {
|
||||
const time = Math.min(1, (Date.now() - startTime) / speed);
|
||||
const result = callback(start + easing(time) * distance);
|
||||
|
||||
if (result && time < 1) {
|
||||
raf();
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(frameId);
|
||||
if (time >= 1 && complete) {
|
||||
complete();
|
||||
}
|
||||
};
|
||||
raf();
|
||||
|
||||
function raf() {
|
||||
frameId = requestAnimationFrame(calcMove);
|
||||
}
|
||||
}
|
||||
11
src/component/Viewers/ImageViewer/react-photo-view/hooks/useSetState.ts
Executable file
11
src/component/Viewers/ImageViewer/react-photo-view/hooks/useSetState.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
import { useReducer } from "react";
|
||||
|
||||
export default function useSetState<S extends Record<string, any>>(initialState: S) {
|
||||
return useReducer(
|
||||
(state: S, action: Partial<S> | ((state: S) => Partial<S>)) => ({
|
||||
...state,
|
||||
...(typeof action === "function" ? action(state) : action),
|
||||
}),
|
||||
initialState,
|
||||
);
|
||||
}
|
||||
46
src/component/Viewers/ImageViewer/react-photo-view/hooks/useTargetScale.ts
Executable file
46
src/component/Viewers/ImageViewer/react-photo-view/hooks/useTargetScale.ts
Executable file
@@ -0,0 +1,46 @@
|
||||
import { useRef } from "react";
|
||||
import useSetState from "./useSetState";
|
||||
import useIsomorphicLayoutEffect from "./useIsomorphicLayoutEffect";
|
||||
import useDebounceCallback from "./useDebounceCallback";
|
||||
|
||||
/**
|
||||
* 目标缩放延迟处理
|
||||
*/
|
||||
export default function useTargetScale(
|
||||
realWidth: number,
|
||||
realHeight: number,
|
||||
realScale: number,
|
||||
speed: number,
|
||||
updateEasing: (pause: boolean) => void,
|
||||
) {
|
||||
const execRef = useRef(false);
|
||||
|
||||
const [{ lead, scale }, updateState] = useSetState({ lead: true, scale: realScale });
|
||||
|
||||
const moveScale = useDebounceCallback(
|
||||
async (current: number) => {
|
||||
updateEasing(true);
|
||||
updateState({ lead: false, scale: current });
|
||||
},
|
||||
{ wait: speed },
|
||||
);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (!execRef.current) {
|
||||
execRef.current = true;
|
||||
return;
|
||||
}
|
||||
updateEasing(false);
|
||||
updateState({ lead: true });
|
||||
|
||||
moveScale(realScale);
|
||||
}, [realScale]);
|
||||
|
||||
// 运动开始
|
||||
if (lead) {
|
||||
return [realWidth * scale, realHeight * scale, realScale / scale] as const;
|
||||
}
|
||||
|
||||
// 运动结束
|
||||
return [realWidth * realScale, realHeight * realScale, 1] as const;
|
||||
}
|
||||
5
src/component/Viewers/ImageViewer/react-photo-view/index.ts
Executable file
5
src/component/Viewers/ImageViewer/react-photo-view/index.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import PhotoProvider from "./PhotoProvider";
|
||||
import PhotoView from "./PhotoView";
|
||||
import PhotoSlider from "./PhotoSlider";
|
||||
|
||||
export { PhotoProvider, PhotoView, PhotoSlider };
|
||||
13
src/component/Viewers/ImageViewer/react-photo-view/photo-context.ts
Executable file
13
src/component/Viewers/ImageViewer/react-photo-view/photo-context.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
import type { DataType } from "./types";
|
||||
|
||||
export type UpdateItemType = (dataType: DataType) => void;
|
||||
|
||||
export interface PhotoContextType {
|
||||
show: (key: number) => void;
|
||||
update: UpdateItemType;
|
||||
remove: (key: number) => void;
|
||||
nextId: () => number;
|
||||
}
|
||||
|
||||
export default createContext<PhotoContextType>(undefined as unknown as PhotoContextType);
|
||||
258
src/component/Viewers/ImageViewer/react-photo-view/types.ts
Executable file
258
src/component/Viewers/ImageViewer/react-photo-view/types.ts
Executable file
@@ -0,0 +1,258 @@
|
||||
import type React from "react";
|
||||
import { FileResponse } from "../../../../api/explorer.ts";
|
||||
|
||||
/**
|
||||
* 资源数据类型
|
||||
*/
|
||||
export interface DataType {
|
||||
/**
|
||||
* 唯一标识
|
||||
*/
|
||||
key: number | string;
|
||||
/**
|
||||
* 资源地址
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* 自定义渲染,优先级比 src 低
|
||||
*/
|
||||
render?: (props: PhotoRenderParams) => React.ReactNode;
|
||||
/**
|
||||
* 自定义覆盖节点
|
||||
*/
|
||||
overlay?: React.ReactNode;
|
||||
/**
|
||||
* 指定渲染节点宽度
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* 指定渲染节点高度
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* 触发 ref
|
||||
*/
|
||||
originRef?: React.MutableRefObject<HTMLElement | null>;
|
||||
|
||||
// Cloudreve specific
|
||||
file?: FileResponse;
|
||||
version?: string;
|
||||
|
||||
loadMorePlaceholder?: boolean;
|
||||
}
|
||||
|
||||
export interface PhotoProviderBase {
|
||||
/**
|
||||
* 是否循环预览,达到该数量则启用
|
||||
* @defaultValue 3
|
||||
*/
|
||||
loop?: boolean | number;
|
||||
/**
|
||||
* 动画速度
|
||||
* @defaultValue 400
|
||||
*/
|
||||
speed?: (type: ActiveAnimationType) => number;
|
||||
/**
|
||||
* 动画函数
|
||||
* @defaultValue 'cubic-bezier(0.25, 0.8, 0.25, 1)'
|
||||
*/
|
||||
easing?: (type: ActiveAnimationType) => string;
|
||||
/**
|
||||
* 图片点击是否可关闭
|
||||
*/
|
||||
photoClosable?: boolean;
|
||||
/**
|
||||
* 背景点击是否可关闭
|
||||
* @defaultValue true
|
||||
*/
|
||||
maskClosable?: boolean;
|
||||
/**
|
||||
* 默认背景透明度
|
||||
* 设置 null 背景不响应下拉变化
|
||||
* @defaultValue 1
|
||||
*/
|
||||
maskOpacity?: number | null;
|
||||
/**
|
||||
* 下拉是否可关闭
|
||||
* @defaultValue true
|
||||
*/
|
||||
pullClosable?: boolean;
|
||||
/**
|
||||
* 导航条 visible
|
||||
* @defaultValue true
|
||||
*/
|
||||
bannerVisible?: boolean;
|
||||
/**
|
||||
* 自定义渲染覆盖物
|
||||
*/
|
||||
overlayRender?: (overlayProps: OverlayRenderProps) => React.ReactNode;
|
||||
/**
|
||||
* 自定义渲染工具栏
|
||||
*/
|
||||
toolbarRender?: (overlayProps: OverlayRenderProps) => React.ReactNode;
|
||||
className?: string;
|
||||
maskClassName?: string;
|
||||
photoWrapClassName?: string;
|
||||
photoClassName?: string;
|
||||
/**
|
||||
* 自定义 loading
|
||||
*/
|
||||
loadingElement?: JSX.Element;
|
||||
/**
|
||||
* 自定义加载失败渲染
|
||||
*/
|
||||
brokenElement?: JSX.Element | ((photoProps: BrokenElementParams) => JSX.Element);
|
||||
/**
|
||||
* @defaultValue document.body
|
||||
*/
|
||||
portalContainer?: HTMLElement;
|
||||
}
|
||||
|
||||
export type PhotoRenderParams = {
|
||||
/**
|
||||
* 自定义渲染 DOM 属性
|
||||
*/
|
||||
attrs: Partial<React.HTMLAttributes<HTMLElement>>;
|
||||
scale: number;
|
||||
rotate: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* brokenElement 函数参数
|
||||
*/
|
||||
export interface BrokenElementParams {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface OverlayRenderProps {
|
||||
/**
|
||||
* 图片列表
|
||||
*/
|
||||
images: DataType[];
|
||||
/**
|
||||
* 当前索引
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* 索引改变回调
|
||||
*/
|
||||
onIndexChange: (index: number) => void;
|
||||
/**
|
||||
* 是否可见
|
||||
*/
|
||||
visible: boolean;
|
||||
/**
|
||||
* 关闭事件回调
|
||||
*/
|
||||
onClose: (evt?: React.MouseEvent | React.TouchEvent) => void;
|
||||
/**
|
||||
* 覆盖物是否可见
|
||||
*/
|
||||
overlayVisible: boolean;
|
||||
/**
|
||||
* 自定义覆盖节点
|
||||
*/
|
||||
overlay?: React.ReactNode;
|
||||
/**
|
||||
* 当前旋转角度
|
||||
*/
|
||||
rotate: number;
|
||||
/**
|
||||
* 旋转事件回调
|
||||
*/
|
||||
onRotate: (rotate: number) => void;
|
||||
/**
|
||||
* 当前缩放
|
||||
*/
|
||||
scale: number;
|
||||
/**
|
||||
* 缩放事件回调
|
||||
*/
|
||||
onScale: (scale: number) => void;
|
||||
}
|
||||
|
||||
export interface ExposedProperties {
|
||||
// 缩放
|
||||
scale?: number;
|
||||
// 旋转
|
||||
rotate?: number;
|
||||
// 缩放回调
|
||||
onScale?: (scale: number) => void;
|
||||
// 旋转回调
|
||||
onRotate?: (rotate: number) => void;
|
||||
}
|
||||
|
||||
export type ReachMoveFunction = (reachPosition: ReachType, clientX: number, clientY: number, scale?: number) => void;
|
||||
|
||||
export type ReachFunction = (clientX: number, clientY: number) => void;
|
||||
|
||||
export type PhotoTapFunction = (clientX: number, clientY: number) => void;
|
||||
|
||||
/**
|
||||
* 边缘超出状态
|
||||
*/
|
||||
export type CloseEdgeType =
|
||||
| 1 // 小于屏幕宽度
|
||||
| 2 // 抵触左边/上边
|
||||
| 3 // 抵触右边/下边
|
||||
| undefined; // 正常滑动
|
||||
|
||||
/**
|
||||
* 边缘触发状态
|
||||
*/
|
||||
export type ReachType =
|
||||
| "x" // x 轴
|
||||
| "y" // y 轴
|
||||
| undefined; // 未触发
|
||||
|
||||
/**
|
||||
* 初始响应状态
|
||||
*/
|
||||
export type TouchStartType =
|
||||
| 0 // 未触发
|
||||
| 1 // X 轴优先
|
||||
| 2 // Y 轴往上 push
|
||||
| 3; // Y 轴往下 pull
|
||||
|
||||
export type OriginRectType = {
|
||||
// top
|
||||
T: number;
|
||||
// left
|
||||
L: number;
|
||||
// width
|
||||
W: number;
|
||||
// height
|
||||
H: number;
|
||||
// object-fit
|
||||
FIT: "contain" | "cover" | "fill" | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 动画状态
|
||||
*/
|
||||
export type EasingMode =
|
||||
// 未初始化
|
||||
| 0
|
||||
// 进入:开始
|
||||
| 1
|
||||
// 进入:动画开始
|
||||
| 2
|
||||
// 进入:动画第二帧
|
||||
| 3
|
||||
// 正常
|
||||
| 4
|
||||
// 关闭
|
||||
| 5;
|
||||
|
||||
/**
|
||||
* 进行中的动画
|
||||
*/
|
||||
export type ActiveAnimationType =
|
||||
// 未初始化
|
||||
| 0
|
||||
// 进入
|
||||
| 1
|
||||
// 离开
|
||||
| 2
|
||||
// 切换
|
||||
| 3;
|
||||
48
src/component/Viewers/ImageViewer/react-photo-view/utils/edgeHandle.ts
Executable file
48
src/component/Viewers/ImageViewer/react-photo-view/utils/edgeHandle.ts
Executable file
@@ -0,0 +1,48 @@
|
||||
import type { CloseEdgeType, ReachType, TouchStartType } from "../types";
|
||||
|
||||
/**
|
||||
* 获取接触边缘类型
|
||||
*/
|
||||
export const getReachType = (
|
||||
initialTouchState: TouchStartType,
|
||||
horizontalCloseEdge: CloseEdgeType,
|
||||
verticalCloseEdge: CloseEdgeType,
|
||||
reachPosition: ReachType,
|
||||
): ReachType => {
|
||||
if ((horizontalCloseEdge && initialTouchState === 1) || reachPosition === "x") {
|
||||
return "x";
|
||||
}
|
||||
if ((verticalCloseEdge && initialTouchState > 1) || reachPosition === "y") {
|
||||
return "y";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算接触边缘位置
|
||||
* @param position - x/y
|
||||
* @param scale
|
||||
* @param size - width/height
|
||||
* @param innerSize - innerWidth/innerHeight
|
||||
* @return [CloseEdgeType, position]
|
||||
*/
|
||||
export const computePositionEdge = (position: number, scale: number, size: number, innerSize: number) => {
|
||||
const currentWidth = size * scale;
|
||||
// 图片超出的宽度
|
||||
const outOffset = (currentWidth - innerSize) / 2;
|
||||
let closedEdge: CloseEdgeType;
|
||||
|
||||
let current = position;
|
||||
if (currentWidth <= innerSize) {
|
||||
closedEdge = 1;
|
||||
current = 0;
|
||||
} else if (position > 0 && outOffset - position <= 0) {
|
||||
closedEdge = 2;
|
||||
current = outOffset;
|
||||
} else if (position < 0 && outOffset + position <= 0) {
|
||||
closedEdge = 3;
|
||||
current = -outOffset;
|
||||
}
|
||||
|
||||
return [closedEdge, current] as const;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type React from "react";
|
||||
|
||||
/**
|
||||
* 从 Touch 事件中获取两个触控中心位置
|
||||
*/
|
||||
export default function getMultipleTouchPosition(
|
||||
evt: TouchEvent | React.TouchEvent,
|
||||
): [clientX: number, clientY: number, touchLength: number] {
|
||||
const { clientX, clientY } = evt.touches[0];
|
||||
if (evt.touches.length >= 2) {
|
||||
const { clientX: nextClientX, clientY: nextClientY } = evt.touches[1];
|
||||
return [
|
||||
(clientX + nextClientX) / 2,
|
||||
(clientY + nextClientY) / 2,
|
||||
Math.sqrt((nextClientX - clientX) ** 2 + (nextClientY - clientY) ** 2),
|
||||
];
|
||||
}
|
||||
return [clientX, clientY, 0];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { longModeRatio } from "../variables";
|
||||
import { computePositionEdge } from "./edgeHandle";
|
||||
|
||||
/**
|
||||
* 获取移动或缩放之后的中心点
|
||||
*/
|
||||
export default function getPositionOnMoveOrScale(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
scale: number,
|
||||
toScale: number,
|
||||
clientX: number = innerWidth / 2,
|
||||
clientY: number = innerHeight / 2,
|
||||
offsetX: number = 0,
|
||||
offsetY: number = 0,
|
||||
) {
|
||||
// 是否接触边缘
|
||||
const [closedEdgeX] = computePositionEdge(x, toScale, width, innerWidth);
|
||||
const [closedEdgeY] = computePositionEdge(y, toScale, height, innerHeight);
|
||||
|
||||
const centerClientX = innerWidth / 2;
|
||||
const centerClientY = innerHeight / 2;
|
||||
|
||||
// 坐标偏移
|
||||
const lastPositionX = centerClientX + x;
|
||||
const lastPositionY = centerClientY + y;
|
||||
|
||||
// 偏移位置
|
||||
const originX = clientX - (clientX - lastPositionX) * (toScale / scale) - centerClientX;
|
||||
const originY = clientY - (clientY - lastPositionY) * (toScale / scale) - centerClientY;
|
||||
// 长图模式无左右反馈
|
||||
const longModeEdge = height / width >= longModeRatio && width * toScale === innerWidth;
|
||||
// 超出边缘距离减半
|
||||
return {
|
||||
x: originX + (longModeEdge ? 0 : closedEdgeX ? offsetX / 2 : offsetX),
|
||||
y: originY + (closedEdgeY ? offsetY / 2 : offsetY),
|
||||
lastCX: clientX,
|
||||
lastCY: clientY,
|
||||
};
|
||||
}
|
||||
13
src/component/Viewers/ImageViewer/react-photo-view/utils/getRotateSize.ts
Executable file
13
src/component/Viewers/ImageViewer/react-photo-view/utils/getRotateSize.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 获取旋转后的宽高
|
||||
*/
|
||||
export default function getRotateSize(rotate: number, width: number, height: number) {
|
||||
const isVertical = rotate % 180 !== 0;
|
||||
|
||||
// 若图片不是水平则调换属性
|
||||
if (isVertical) {
|
||||
return [height, width, isVertical] as const;
|
||||
}
|
||||
|
||||
return [width, height, isVertical] as const;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { longModeRatio } from "../variables";
|
||||
import getRotateSize from "./getRotateSize";
|
||||
|
||||
/**
|
||||
* 获取图片合适的大小
|
||||
*/
|
||||
export default function getSuitableImageSize(
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
rotate: number,
|
||||
dynamicInnerWidth: number,
|
||||
) {
|
||||
const [currentWidth, currentHeight, isVertical] = getRotateSize(rotate, dynamicInnerWidth, innerHeight);
|
||||
|
||||
let y = 0;
|
||||
let width = currentWidth;
|
||||
let height = currentHeight;
|
||||
|
||||
// 自适应宽高
|
||||
const autoWidth = (naturalWidth / naturalHeight) * currentHeight;
|
||||
const autoHeight = (naturalHeight / naturalWidth) * currentWidth;
|
||||
|
||||
if (naturalWidth < currentWidth && naturalHeight < currentHeight) {
|
||||
width = naturalWidth;
|
||||
height = naturalHeight;
|
||||
} else if (naturalWidth < currentWidth && naturalHeight >= currentHeight) {
|
||||
width = autoWidth;
|
||||
} else if (naturalWidth >= currentWidth && naturalHeight < currentHeight) {
|
||||
height = autoHeight;
|
||||
} else if (naturalWidth / naturalHeight > currentWidth / currentHeight) {
|
||||
height = autoHeight;
|
||||
}
|
||||
// 长图模式
|
||||
else if (naturalHeight / naturalWidth >= longModeRatio && !isVertical) {
|
||||
height = autoHeight;
|
||||
y = (height - currentHeight) / 2;
|
||||
} else {
|
||||
width = autoWidth;
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
x: 0,
|
||||
y,
|
||||
pause: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 是否支持触摸设备
|
||||
*/
|
||||
const isTouchDevice = typeof window !== "undefined" && "ontouchstart" in window;
|
||||
|
||||
export default isTouchDevice;
|
||||
12
src/component/Viewers/ImageViewer/react-photo-view/utils/limitTarget.ts
Executable file
12
src/component/Viewers/ImageViewer/react-photo-view/utils/limitTarget.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
import { maxScale, minScale } from "../variables";
|
||||
|
||||
export const limitNumber = (value: number, min: number, max: number) => {
|
||||
return Math.max(Math.min(value, max), min);
|
||||
};
|
||||
|
||||
/**
|
||||
* 限制最大/最小缩放
|
||||
*/
|
||||
export const limitScale = (scale: number, max: number = 0, buffer: number = 0) => {
|
||||
return limitNumber(scale, minScale * (1 - buffer), Math.max(maxScale, max) * (1 + buffer));
|
||||
};
|
||||
59
src/component/Viewers/ImageViewer/react-photo-view/variables.ts
Executable file
59
src/component/Viewers/ImageViewer/react-photo-view/variables.ts
Executable file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 最大触摸时间
|
||||
*/
|
||||
export const maxTouchTime = 200;
|
||||
|
||||
/**
|
||||
* 默认动画速度
|
||||
*/
|
||||
export const defaultSpeed = 400;
|
||||
|
||||
/**
|
||||
* 默认动画函数
|
||||
*/
|
||||
export const defaultEasing = "cubic-bezier(0.25, 0.8, 0.25, 1)";
|
||||
|
||||
/**
|
||||
* 最大滑动切换图片距离
|
||||
*/
|
||||
export const maxMoveOffset = 40;
|
||||
|
||||
/**
|
||||
* 图片的间隔
|
||||
*/
|
||||
export const horizontalOffset = 20;
|
||||
|
||||
/**
|
||||
* 最小初始响应距离
|
||||
*/
|
||||
export const minStartTouchOffset = 20;
|
||||
|
||||
/**
|
||||
* 默认背景透明度
|
||||
*/
|
||||
export const defaultOpacity = 1;
|
||||
|
||||
/**
|
||||
* 最小缩放度
|
||||
*/
|
||||
export const minScale = 1;
|
||||
|
||||
/**
|
||||
* 最大缩放度(若图片足够大,则会超出)
|
||||
*/
|
||||
export const maxScale = 6;
|
||||
|
||||
/**
|
||||
* 最小长图模式比例
|
||||
*/
|
||||
export const longModeRatio = 3;
|
||||
|
||||
/**
|
||||
* 缩放弹性缓冲
|
||||
*/
|
||||
export const scaleBuffer = 0.2;
|
||||
|
||||
/**
|
||||
* 最大等待动画时间
|
||||
*/
|
||||
export const maxWaitAnimationTime = 250;
|
||||
268
src/component/Viewers/MarkdownEditor/Editor.tsx
Executable file
268
src/component/Viewers/MarkdownEditor/Editor.tsx
Executable file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
AdmonitionDirectiveDescriptor,
|
||||
BlockTypeSelect,
|
||||
BoldItalicUnderlineToggles,
|
||||
ChangeAdmonitionType,
|
||||
ChangeCodeMirrorLanguage,
|
||||
codeBlockPlugin,
|
||||
CodeMirrorEditor,
|
||||
codeMirrorPlugin,
|
||||
CodeToggle,
|
||||
ConditionalContents,
|
||||
CreateLink,
|
||||
diffSourcePlugin,
|
||||
DiffSourceToggleWrapper,
|
||||
DirectiveNode,
|
||||
directivesPlugin,
|
||||
EditorInFocus,
|
||||
frontmatterPlugin,
|
||||
headingsPlugin,
|
||||
imagePlugin,
|
||||
InsertAdmonition,
|
||||
InsertCodeBlock,
|
||||
InsertFrontmatter,
|
||||
InsertImage,
|
||||
InsertTable,
|
||||
InsertThematicBreak,
|
||||
linkDialogPlugin,
|
||||
linkPlugin,
|
||||
listsPlugin,
|
||||
ListsToggle,
|
||||
markdownShortcutPlugin,
|
||||
MDXEditor,
|
||||
quotePlugin,
|
||||
Separator,
|
||||
ShowSandpackInfo,
|
||||
StrikeThroughSupSubToggles,
|
||||
tablePlugin,
|
||||
thematicBreakPlugin,
|
||||
toolbarPlugin,
|
||||
UndoRedo,
|
||||
} from "@mdxeditor/editor";
|
||||
import "@mdxeditor/editor/style.css";
|
||||
import { Box } from "@mui/material";
|
||||
import i18next from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./editor.css";
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
value: string;
|
||||
darkMode?: boolean;
|
||||
initialValue: string;
|
||||
onChange: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
displayOnly?: boolean;
|
||||
onSaveShortcut?: () => void;
|
||||
imageAutocompleteSuggestions?: string[] | null;
|
||||
imagePreviewHandler?: (imageSource: string) => Promise<string>;
|
||||
imageUploadHandler?: ((image: File) => Promise<string>) | null;
|
||||
}
|
||||
|
||||
function whenInAdmonition(editorInFocus: EditorInFocus | null) {
|
||||
const node = editorInFocus?.rootNode;
|
||||
if (!node || node.getType() !== "directive") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ["note", "tip", "danger", "info", "caution"].includes((node as DirectiveNode).getMdastNode().name);
|
||||
}
|
||||
|
||||
function setEndOfContenteditable(contentEditableElement: HTMLElement) {
|
||||
let range: Range | null;
|
||||
let selection: Selection | null;
|
||||
if (document.createRange) {
|
||||
range = document.createRange(); //Create a range (a range is a like the selection but invisible)
|
||||
range.selectNodeContents(contentEditableElement); //Select the entire contents of the element with the range
|
||||
range.collapse(false); //collapse the range to the end point. false means collapse to end rather than the start
|
||||
selection = window.getSelection(); //get the selection object (allows you to change selection)
|
||||
selection?.removeAllRanges(); //remove any selections already made
|
||||
selection?.addRange(range); //make the range you have just created the visible selection
|
||||
}
|
||||
}
|
||||
|
||||
const MarkdownEditor = (props: MarkdownEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [nsLoaded, setNsLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
i18next.loadNamespaces(["markdown_editor"]).then(() => {
|
||||
setNsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: props.displayOnly ? "100%" : "calc(100vh - 200px)",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
props.onSaveShortcut?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{nsLoaded && (
|
||||
<MDXEditor
|
||||
className={props.darkMode ? "dark-theme dark-editor" : undefined}
|
||||
translation={(key, _defaultValue, interpolations) => {
|
||||
return t("markdown_editor:" + key, interpolations);
|
||||
}}
|
||||
readOnly={props.readOnly}
|
||||
onChange={props.onChange}
|
||||
plugins={[
|
||||
diffSourcePlugin({
|
||||
diffMarkdown: props.initialValue,
|
||||
viewMode: "rich-text",
|
||||
}),
|
||||
...(props.displayOnly
|
||||
? []
|
||||
: [
|
||||
toolbarPlugin({
|
||||
toolbarContents: () => (
|
||||
<ConditionalContents
|
||||
options={[
|
||||
{
|
||||
when: (editor) => editor?.editorType === "codeblock",
|
||||
contents: () => <ChangeCodeMirrorLanguage />,
|
||||
},
|
||||
{
|
||||
when: (editor) => editor?.editorType === "sandpack",
|
||||
contents: () => <ShowSandpackInfo />,
|
||||
},
|
||||
{
|
||||
fallback: () => (
|
||||
<DiffSourceToggleWrapper>
|
||||
<UndoRedo />
|
||||
<Separator />
|
||||
<BoldItalicUnderlineToggles />
|
||||
<CodeToggle />
|
||||
<Separator />
|
||||
<StrikeThroughSupSubToggles />
|
||||
<Separator />
|
||||
<ListsToggle />
|
||||
<Separator />
|
||||
|
||||
<ConditionalContents
|
||||
options={[
|
||||
{
|
||||
when: whenInAdmonition,
|
||||
contents: () => <ChangeAdmonitionType />,
|
||||
},
|
||||
{ fallback: () => <BlockTypeSelect /> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CreateLink />
|
||||
<InsertImage />
|
||||
|
||||
<Separator />
|
||||
|
||||
<InsertTable />
|
||||
<InsertThematicBreak />
|
||||
|
||||
<Separator />
|
||||
<InsertCodeBlock />
|
||||
|
||||
<ConditionalContents
|
||||
options={[
|
||||
{
|
||||
when: (editorInFocus) => !whenInAdmonition(editorInFocus),
|
||||
contents: () => (
|
||||
<>
|
||||
<Separator />
|
||||
<InsertAdmonition />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<InsertFrontmatter />
|
||||
</DiffSourceToggleWrapper>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
]),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }),
|
||||
linkPlugin(),
|
||||
linkDialogPlugin(),
|
||||
imagePlugin({
|
||||
imageUploadHandler: props.imageUploadHandler,
|
||||
imagePreviewHandler: props.imagePreviewHandler,
|
||||
imageAutocompleteSuggestions: props.imageAutocompleteSuggestions ?? undefined,
|
||||
}),
|
||||
tablePlugin(),
|
||||
thematicBreakPlugin(),
|
||||
frontmatterPlugin(),
|
||||
codeBlockPlugin({
|
||||
defaultCodeBlockLanguage: "",
|
||||
codeBlockEditorDescriptors: [{ priority: -10, match: (_) => true, Editor: CodeMirrorEditor }],
|
||||
}),
|
||||
codeMirrorPlugin({
|
||||
codeBlockLanguages: {
|
||||
js: "JavaScript",
|
||||
jsx: "JSX",
|
||||
css: "CSS",
|
||||
txt: "Plain Text",
|
||||
tsx: "TSX",
|
||||
ts: "TypeScript",
|
||||
html: "HTML",
|
||||
json: "JSON",
|
||||
sh: "Shell",
|
||||
bash: "Bash",
|
||||
yaml: "YAML",
|
||||
markdown: "Markdown",
|
||||
dockerfile: "Dockerfile",
|
||||
sql: "SQL",
|
||||
python: "Python",
|
||||
go: "Go",
|
||||
java: "Java",
|
||||
c: "C",
|
||||
cpp: "C++",
|
||||
php: "PHP",
|
||||
ruby: "Ruby",
|
||||
perl: "Perl",
|
||||
swift: "Swift",
|
||||
r: "R",
|
||||
rust: "Rust",
|
||||
kotlin: "Kotlin",
|
||||
scala: "Scala",
|
||||
"": "Unspecified",
|
||||
},
|
||||
}),
|
||||
directivesPlugin({
|
||||
directiveDescriptors: [AdmonitionDirectiveDescriptor],
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
]}
|
||||
contentEditableClassName={props.darkMode ? "markdown-body-dark" : "markdown-body-light"}
|
||||
markdown={props.value}
|
||||
/>
|
||||
)}
|
||||
{!nsLoaded && <div className={"mdxeditor"}></div>}
|
||||
<Box
|
||||
onClick={() => {
|
||||
setEndOfContenteditable(
|
||||
document.querySelector(props.darkMode ? ".markdown-body-dark" : ".markdown-body-light") as HTMLElement,
|
||||
);
|
||||
}}
|
||||
sx={{
|
||||
cursor: "text",
|
||||
flexGrow: 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownEditor;
|
||||
198
src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx
Executable file
198
src/component/Viewers/MarkdownEditor/MarkdownViewer.tsx
Executable file
@@ -0,0 +1,198 @@
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import { Box, Button, ButtonGroup, ListItemText, Menu, useTheme } from "@mui/material";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closeMarkdownViewer } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { getEntityContent } from "../../../redux/thunks/file.ts";
|
||||
import {
|
||||
markdownImageAutocompleteSuggestions,
|
||||
markdownImagePreviewHandler,
|
||||
saveMarkdown,
|
||||
uploadMarkdownImage,
|
||||
} from "../../../redux/thunks/viewer.ts";
|
||||
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import useActionDisplayOpt, { canUpdate } from "../../FileManager/ContextMenu/useActionDisplayOpt.ts";
|
||||
import CaretDown from "../../Icons/CaretDown.tsx";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
|
||||
const MarkdownEditor = lazy(() => import("./Editor.tsx"));
|
||||
|
||||
const MarkdownViewer = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewerState = useAppSelector((state) => state.globalState.markdownViewer);
|
||||
|
||||
const displayOpt = useActionDisplayOpt(viewerState?.file ? [viewerState?.file] : []);
|
||||
const supportUpdate = canUpdate(displayOpt);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [changedValue, setChangedValue] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [optionAnchorEl, setOptionAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const saveFunction = useRef(() => {});
|
||||
|
||||
const loadContent = useCallback(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoaded(false);
|
||||
setOptionAnchorEl(null);
|
||||
dispatch(getEntityContent(viewerState.file, viewerState.version))
|
||||
.then((res) => {
|
||||
const content = new TextDecoder().decode(res);
|
||||
setValue(content);
|
||||
setChangedValue(content);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
onClose();
|
||||
});
|
||||
}, [viewerState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
loadContent();
|
||||
}, [viewerState?.open]);
|
||||
|
||||
const imageAutocompleteSuggestions = useMemo(() => {
|
||||
if (!viewerState?.open) {
|
||||
return null;
|
||||
}
|
||||
return dispatch(markdownImageAutocompleteSuggestions());
|
||||
}, [viewerState?.open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeMarkdownViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const openMore = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
(saveAs?: boolean) => {
|
||||
if (!viewerState?.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(saveMarkdown(changedValue, viewerState.file, viewerState.version, saveAs))
|
||||
.then(() => {
|
||||
setSaved(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[changedValue, viewerState],
|
||||
);
|
||||
|
||||
const onChange = useCallback((v: string) => {
|
||||
setChangedValue(v);
|
||||
setSaved(false);
|
||||
}, []);
|
||||
|
||||
const onSaveShortcut = useCallback(() => {
|
||||
if (!saved && supportUpdate) {
|
||||
onSave(false);
|
||||
}
|
||||
}, [saved, supportUpdate, onSave]);
|
||||
|
||||
useEffect(() => {
|
||||
saveFunction.current = () => {
|
||||
if (!saved && supportUpdate) {
|
||||
onSave(false);
|
||||
}
|
||||
};
|
||||
}, [saved, supportUpdate, onSave]);
|
||||
|
||||
const imagePreviewHandler = useCallback(
|
||||
(imageSource: string) => {
|
||||
return dispatch(markdownImagePreviewHandler(imageSource, viewerState?.file?.path ?? ""));
|
||||
},
|
||||
[dispatch, viewerState?.file?.path],
|
||||
);
|
||||
|
||||
const onImageUpload = useCallback(
|
||||
async (file: File): Promise<string> => {
|
||||
return dispatch(uploadMarkdownImage(file));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
readOnly={!supportUpdate}
|
||||
actions={
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
{supportUpdate && (
|
||||
<ButtonGroup disabled={loading || !loaded || saved} disableElevation variant="contained">
|
||||
<LoadingButton loading={loading} variant={"contained"} onClick={() => onSave(false)}>
|
||||
<span>{t("fileManager.save")}</span>
|
||||
</LoadingButton>
|
||||
<Button size="small" onClick={openMore}>
|
||||
<CaretDown sx={{ fontSize: "12px!important" }} />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
fullScreenToggle
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem onClick={() => onSave(true)} dense>
|
||||
<ListItemText>{t("modals.saveAs")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
{!loaded && <ViewerLoading />}
|
||||
{loaded && (
|
||||
<Suspense fallback={<ViewerLoading />}>
|
||||
<MarkdownEditor
|
||||
value={changedValue}
|
||||
readOnly={!supportUpdate}
|
||||
darkMode={theme.palette.mode === "dark"}
|
||||
initialValue={value}
|
||||
onChange={(v) => onChange(v as string)}
|
||||
onSaveShortcut={onSaveShortcut}
|
||||
imagePreviewHandler={imagePreviewHandler}
|
||||
imageAutocompleteSuggestions={imageAutocompleteSuggestions}
|
||||
imageUploadHandler={onImageUpload}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownViewer;
|
||||
2238
src/component/Viewers/MarkdownEditor/editor.css
Executable file
2238
src/component/Viewers/MarkdownEditor/editor.css
Executable file
File diff suppressed because it is too large
Load Diff
209
src/component/Viewers/MusicPlayer/MusicPlayer.tsx
Executable file
209
src/component/Viewers/MusicPlayer/MusicPlayer.tsx
Executable file
@@ -0,0 +1,209 @@
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getFileEntityUrl } from "../../../api/api.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { getFileLinkedUri } from "../../../util";
|
||||
import MusicNote2 from "../../Icons/MusicNote2.tsx";
|
||||
import MusicNote2Play from "../../Icons/MusicNote2Play.tsx";
|
||||
import PlayerPopup from "./PlayerPopup.tsx";
|
||||
|
||||
export const LoopMode = {
|
||||
list_repeat: 0,
|
||||
single_repeat: 1,
|
||||
shuffle: 2,
|
||||
};
|
||||
|
||||
const MusicPlayer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const playerState = useAppSelector((state) => state.globalState.musicPlayer);
|
||||
const audio = useRef<HTMLAudioElement>(null);
|
||||
const icon = useRef<HTMLButtonElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.2);
|
||||
const [index, setIndex] = useState<number | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [loopMode, setLoopMode] = useState(LoopMode.list_repeat);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const playHistory = useRef<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playerState) {
|
||||
playHistory.current = [];
|
||||
setPlaying(true);
|
||||
setPopoverOpen(true);
|
||||
const volume = SessionManager.getWithFallback(UserSettings.MusicVolume);
|
||||
setVolume(volume);
|
||||
playIndex(playerState.startIndex, volume);
|
||||
}
|
||||
|
||||
audio.current?.addEventListener("timeupdate", timeUpdate);
|
||||
return () => {
|
||||
setPlaying(false);
|
||||
audio.current?.removeEventListener("timeupdate", timeUpdate);
|
||||
};
|
||||
}, [playerState]);
|
||||
|
||||
const playIndex = useCallback(
|
||||
async (index: number, latestVolume?: number) => {
|
||||
if (audio.current && playerState) {
|
||||
audio.current.pause();
|
||||
setIndex(index);
|
||||
try {
|
||||
const res = await dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(playerState.files[index])],
|
||||
entity: playerState.version,
|
||||
}),
|
||||
);
|
||||
audio.current.src = res.urls[0].url;
|
||||
audio.current.currentTime = 0;
|
||||
audio.current.play();
|
||||
audio.current.volume = latestVolume ?? volume;
|
||||
audio.current.playbackRate = playbackSpeed;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[playerState, volume, playbackSpeed],
|
||||
);
|
||||
|
||||
const loopProceed = useCallback(
|
||||
(isNext: boolean) => {
|
||||
if (!playerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
playHistory.current.push(index ?? 0);
|
||||
|
||||
switch (loopMode) {
|
||||
case LoopMode.list_repeat:
|
||||
if (isNext) {
|
||||
playIndex(((index ?? 0) + 1) % playerState?.files.length);
|
||||
} else {
|
||||
playIndex(((index ?? 0) - 1 + playerState?.files.length) % playerState?.files.length);
|
||||
}
|
||||
break;
|
||||
case LoopMode.single_repeat:
|
||||
playIndex(index ?? 0);
|
||||
break;
|
||||
case LoopMode.shuffle:
|
||||
if (isNext) {
|
||||
const nextIndex = Math.floor(Math.random() * playerState?.files.length);
|
||||
playIndex(nextIndex);
|
||||
} else {
|
||||
playHistory.current.pop();
|
||||
playIndex(playHistory.current.pop() ?? index ?? 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[loopMode, playIndex, playerState, index],
|
||||
);
|
||||
|
||||
const onPlayEnded = useCallback(() => {
|
||||
loopProceed(true);
|
||||
}, []);
|
||||
|
||||
const timeUpdate = useCallback(() => {
|
||||
setCurrent(Math.floor(audio.current?.currentTime || 0));
|
||||
setDuration(Math.floor(audio.current?.duration || 0));
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
if (audio.current) {
|
||||
audio.current.currentTime = time;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const playingTooltip = playerState
|
||||
? `[${(index ?? 0) + 1}/${playerState.files.length}] ${playerState?.files[index ?? 0]?.name}`
|
||||
: "";
|
||||
|
||||
const onPlayerPopoverClose = useCallback(() => {
|
||||
setPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const onPlayerPopoverOpen = useCallback(() => {
|
||||
setPopoverOpen(true);
|
||||
}, []);
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
if (audio.current) {
|
||||
if (audio.current.paused) {
|
||||
audio.current.play();
|
||||
setPlaying(true);
|
||||
} else {
|
||||
audio.current.pause();
|
||||
setPlaying(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setVolumeLevel = useCallback((volume: number) => {
|
||||
if (audio.current) {
|
||||
audio.current.volume = volume;
|
||||
setVolume(volume);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleLoopMode = useCallback(() => {
|
||||
setLoopMode((loopMode) => (loopMode + 1) % 3);
|
||||
}, []);
|
||||
|
||||
const setLoopModeHandler = useCallback((mode: number) => {
|
||||
setLoopMode(mode);
|
||||
}, []);
|
||||
|
||||
const setPlaybackSpeedHandler = useCallback((speed: number) => {
|
||||
setPlaybackSpeed(speed);
|
||||
if (audio.current) {
|
||||
audio.current.playbackRate = speed;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio
|
||||
ref={audio}
|
||||
onPause={() => setPlaying(false)}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onEnded={() => loopProceed(true)}
|
||||
/>
|
||||
<Tooltip title={playingTooltip} enterDelay={0}>
|
||||
<IconButton ref={icon} onClick={onPlayerPopoverOpen} size="large">
|
||||
{playing ? <MusicNote2Play /> : <MusicNote2 />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{index !== undefined && (
|
||||
<PlayerPopup
|
||||
playIndex={playIndex}
|
||||
loopProceed={loopProceed}
|
||||
file={playerState?.files[index]}
|
||||
duration={duration}
|
||||
current={current}
|
||||
open={popoverOpen}
|
||||
setVolumeLevel={setVolumeLevel}
|
||||
volume={volume}
|
||||
onSeek={seek}
|
||||
togglePause={togglePause}
|
||||
playing={playing}
|
||||
playlist={playerState?.files}
|
||||
loopMode={loopMode}
|
||||
toggleLoopMode={toggleLoopMode}
|
||||
setLoopMode={setLoopModeHandler}
|
||||
playbackSpeed={playbackSpeed}
|
||||
setPlaybackSpeed={setPlaybackSpeedHandler}
|
||||
anchorEl={icon.current}
|
||||
onClose={onPlayerPopoverClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MusicPlayer;
|
||||
478
src/component/Viewers/MusicPlayer/PlayerPopup.tsx
Executable file
478
src/component/Viewers/MusicPlayer/PlayerPopup.tsx
Executable file
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
FastForwardRounded,
|
||||
FastRewindRounded,
|
||||
PauseRounded,
|
||||
PlayArrowRounded,
|
||||
VolumeDownRounded,
|
||||
VolumeUpRounded,
|
||||
} from "@mui/icons-material";
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverProps,
|
||||
Slider,
|
||||
Stack,
|
||||
styled,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FileResponse, Metadata } from "../../../api/explorer.ts";
|
||||
import { useMediaSession } from "../../../hooks/useMediaSession";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { loadFileThumb } from "../../../redux/thunks/file.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { FileManagerIndex } from "../../FileManager/FileManager.tsx";
|
||||
import { MediaMetaElements } from "../../FileManager/Sidebar/MediaMetaCard.tsx";
|
||||
import AppsList from "../../Icons/AppsList.tsx";
|
||||
import ArrowRepeatAll from "../../Icons/ArrowRepeatAll.tsx";
|
||||
import ArrowRepeatOne from "../../Icons/ArrowRepeatOne.tsx";
|
||||
import ArrowShuffle from "../../Icons/ArrowShuffle.tsx";
|
||||
import MusicNote1 from "../../Icons/MusicNote1.tsx";
|
||||
import { LoopMode } from "./MusicPlayer.tsx";
|
||||
import Playlist from "./Playlist.tsx";
|
||||
import RepeatModePopover from "./RepeatModePopover.tsx";
|
||||
|
||||
const WallPaper = styled("div")({
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
top: 0,
|
||||
left: 0,
|
||||
overflow: "hidden",
|
||||
background: "linear-gradient(rgb(255, 38, 142) 0%, rgb(255, 105, 79) 100%)",
|
||||
transition: "all 500ms cubic-bezier(0.175, 0.885, 0.32, 1.275) 0s",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
width: "140%",
|
||||
height: "140%",
|
||||
position: "absolute",
|
||||
top: "-40%",
|
||||
right: "-50%",
|
||||
background: "radial-gradient(at center center, rgb(62, 79, 249) 0%, rgba(62, 79, 249, 0) 64%)",
|
||||
},
|
||||
"&::after": {
|
||||
content: '""',
|
||||
width: "140%",
|
||||
height: "140%",
|
||||
position: "absolute",
|
||||
bottom: "-50%",
|
||||
left: "-30%",
|
||||
background: "radial-gradient(at center center, rgb(247, 237, 225) 0%, rgba(247, 237, 225, 0) 70%)",
|
||||
transform: "rotate(30deg)",
|
||||
},
|
||||
});
|
||||
|
||||
const Widget = styled("div")(({ theme }) => ({
|
||||
padding: 16,
|
||||
width: 343,
|
||||
maxWidth: "100%",
|
||||
margin: "auto",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
backgroundColor: theme.palette.mode === "dark" ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.4)",
|
||||
backdropFilter: "blur(40px)",
|
||||
}));
|
||||
|
||||
const CoverImage = styled("div")({
|
||||
width: 100,
|
||||
height: 100,
|
||||
objectFit: "cover",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.08)",
|
||||
"& > img": {
|
||||
width: "100%",
|
||||
},
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
});
|
||||
|
||||
const TinyText = styled(Typography)({
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.38,
|
||||
fontWeight: 500,
|
||||
letterSpacing: 0.2,
|
||||
});
|
||||
|
||||
// Scrolling text component for long text
|
||||
const ScrollingText = ({ children, text, ...props }: { children: React.ReactNode; text: string }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldScroll, setShouldScroll] = useState(false);
|
||||
const [animationDuration, setAnimationDuration] = useState(15);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (containerRef.current && contentRef.current) {
|
||||
const isOverflowing = contentRef.current.scrollWidth > containerRef.current.clientWidth;
|
||||
setShouldScroll(isOverflowing);
|
||||
|
||||
// Calculate animation duration based on text length
|
||||
if (isOverflowing) {
|
||||
const textLength = contentRef.current.scrollWidth;
|
||||
// Adjust speed based on text length (faster for longer text)
|
||||
const calculatedDuration = Math.max(5, Math.min(10, textLength / 15));
|
||||
setAnimationDuration(calculatedDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setShouldScroll(false);
|
||||
setTimeout(() => {
|
||||
checkOverflow();
|
||||
}, 1000);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{shouldScroll ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
animation: `marquee ${animationDuration}s linear infinite`,
|
||||
"@keyframes marquee": {
|
||||
"0%": { transform: "translateX(0%)" },
|
||||
"100%": { transform: "translateX(-100%)" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box ref={contentRef} sx={{ whiteSpace: "nowrap", paddingRight: "50px" }}>
|
||||
{children}
|
||||
</Box>
|
||||
<Box sx={{ whiteSpace: "nowrap", paddingRight: "50px" }}>{children}</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box ref={contentRef}>{children}</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PlayerPopupProps extends PopoverProps {
|
||||
file?: FileResponse;
|
||||
playlist?: FileResponse[];
|
||||
duration: number;
|
||||
current: number;
|
||||
onSeek: (time: number) => void;
|
||||
playing: boolean;
|
||||
togglePause: () => void;
|
||||
setVolumeLevel: (volume: number) => void;
|
||||
volume: number;
|
||||
loopProceed: (isNext: boolean) => void;
|
||||
loopMode: number;
|
||||
toggleLoopMode: () => void;
|
||||
setLoopMode: (mode: number) => void;
|
||||
playbackSpeed: number;
|
||||
setPlaybackSpeed: (speed: number) => void;
|
||||
playIndex: (index: number, volume?: number) => void;
|
||||
}
|
||||
|
||||
const isIOS = /iPad|iPhone/.test(navigator.userAgent);
|
||||
|
||||
export const PlayerPopup = ({
|
||||
file,
|
||||
duration,
|
||||
current,
|
||||
onSeek,
|
||||
playing,
|
||||
togglePause,
|
||||
volume,
|
||||
setVolumeLevel,
|
||||
loopMode,
|
||||
loopProceed,
|
||||
toggleLoopMode,
|
||||
setLoopMode,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
playlist,
|
||||
playIndex,
|
||||
...rest
|
||||
}: PlayerPopupProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const dispatch = useAppDispatch();
|
||||
const [thumbSrc, setThumbSrc] = useState<string | null>(null);
|
||||
const [thumbBgLoaded, setThumbBgLoaded] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const seeking = useRef(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [repeatAnchorEl, setRepeatAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
function formatDuration(value: number) {
|
||||
const minute = Math.floor(value / 60);
|
||||
const secondLeft = value - minute * 60;
|
||||
return `${minute}:${secondLeft < 10 ? `0${secondLeft}` : secondLeft}`;
|
||||
}
|
||||
const mainIconColor = theme.palette.mode === "dark" ? "#fff" : "#000";
|
||||
const lightIconColor = theme.palette.mode === "dark" ? "rgba(255,255,255,0.4)" : "rgba(0,0,0,0.4)";
|
||||
|
||||
useEffect(() => {
|
||||
setThumbBgLoaded(false);
|
||||
if (file && (!file.metadata || file.metadata[Metadata.thumbDisabled] === undefined)) {
|
||||
dispatch(loadFileThumb(FileManagerIndex.main, file)).then((src) => {
|
||||
setThumbSrc(src);
|
||||
});
|
||||
} else {
|
||||
setThumbSrc(null);
|
||||
}
|
||||
}, [file, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (seeking.current) {
|
||||
return;
|
||||
}
|
||||
setProgress(current);
|
||||
}, [current]);
|
||||
|
||||
const onSeekCommit = (time: number) => {
|
||||
seeking.current = false;
|
||||
onSeek(time);
|
||||
};
|
||||
|
||||
// Initialize Media Session API
|
||||
useMediaSession({
|
||||
file,
|
||||
playing,
|
||||
duration,
|
||||
current,
|
||||
thumbSrc,
|
||||
onPlay: togglePause,
|
||||
onPause: togglePause,
|
||||
onPrevious: () => loopProceed(false),
|
||||
onNext: () => loopProceed(true),
|
||||
onSeek,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{playlist && file && (
|
||||
<Playlist
|
||||
playIndex={playIndex}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
file={file}
|
||||
playlist={playlist}
|
||||
/>
|
||||
)}
|
||||
<RepeatModePopover
|
||||
open={Boolean(repeatAnchorEl)}
|
||||
anchorEl={repeatAnchorEl}
|
||||
onClose={() => setRepeatAnchorEl(null)}
|
||||
loopMode={loopMode}
|
||||
onLoopModeChange={setLoopMode}
|
||||
playbackSpeed={playbackSpeed}
|
||||
onPlaybackSpeedChange={setPlaybackSpeed}
|
||||
/>
|
||||
<Widget>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<CoverImage>
|
||||
{!thumbSrc && <MusicNote1 fontSize={"large"} />}
|
||||
{thumbSrc && <img src={thumbSrc} onError={() => setThumbSrc(null)} alt="cover" />}
|
||||
</CoverImage>
|
||||
<Box sx={{ ml: 1.5, minWidth: 0, maxWidth: "210px", width: "100%" }}>
|
||||
{file && file.metadata && file.metadata[Metadata.music_artist] && (
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||
<MediaMetaElements
|
||||
element={{
|
||||
display: file.metadata[Metadata.music_artist],
|
||||
searchValue: file.metadata[Metadata.music_artist],
|
||||
searchKey: Metadata.music_artist,
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
{file && (
|
||||
<ScrollingText text={file.metadata?.[Metadata.music_title] ?? file.name}>
|
||||
<b>
|
||||
{file.metadata?.[Metadata.music_title] ? (
|
||||
<MediaMetaElements
|
||||
element={{
|
||||
display: file.metadata[Metadata.music_title],
|
||||
searchValue: file.metadata[Metadata.music_title],
|
||||
searchKey: Metadata.music_title,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
file.name
|
||||
)}
|
||||
</b>
|
||||
</ScrollingText>
|
||||
)}
|
||||
{file && file.metadata && file.metadata[Metadata.music_album] && (
|
||||
<ScrollingText text={file.metadata[Metadata.music_album]}>
|
||||
<Typography variant={"body2"} letterSpacing={-0.25}>
|
||||
<MediaMetaElements
|
||||
element={{
|
||||
display: file.metadata[Metadata.music_album],
|
||||
searchValue: file.metadata[Metadata.music_album],
|
||||
searchKey: Metadata.music_album,
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</ScrollingText>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Slider
|
||||
aria-label="time-indicator"
|
||||
size="small"
|
||||
value={progress}
|
||||
onMouseDown={() => (seeking.current = true)}
|
||||
min={0}
|
||||
step={1}
|
||||
max={duration}
|
||||
onChange={(_, value) => setProgress(value as number)}
|
||||
onChangeCommitted={(_, value) => onSeekCommit(value as number)}
|
||||
sx={{
|
||||
color: theme.palette.mode === "dark" ? "#fff" : "rgba(0,0,0,0.87)",
|
||||
height: 4,
|
||||
"& .MuiSlider-thumb": {
|
||||
width: 8,
|
||||
height: 8,
|
||||
transition: "0.3s cubic-bezier(.47,1.64,.41,.8)",
|
||||
"&::before": {
|
||||
boxShadow: "0 2px 12px 0 rgba(0,0,0,0.4)",
|
||||
},
|
||||
"&:hover, &.Mui-focusVisible": {
|
||||
boxShadow: `0px 0px 0px 8px ${
|
||||
theme.palette.mode === "dark" ? "rgb(255 255 255 / 16%)" : "rgb(0 0 0 / 16%)"
|
||||
}`,
|
||||
},
|
||||
"&.Mui-active": {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
opacity: 0.28,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mt: -2,
|
||||
}}
|
||||
>
|
||||
<TinyText>{formatDuration(current)}</TinyText>
|
||||
<TinyText>-{formatDuration(duration - current)}</TinyText>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mt: -1,
|
||||
}}
|
||||
>
|
||||
<IconButton aria-label="loop mode" onClick={(e) => setRepeatAnchorEl(e.currentTarget)}>
|
||||
{loopMode == LoopMode.list_repeat && <ArrowRepeatAll fontSize={"medium"} htmlColor={mainIconColor} />}
|
||||
{loopMode == LoopMode.single_repeat && <ArrowRepeatOne fontSize={"medium"} htmlColor={mainIconColor} />}
|
||||
{loopMode == LoopMode.shuffle && <ArrowShuffle fontSize={"medium"} htmlColor={mainIconColor} />}
|
||||
</IconButton>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<IconButton aria-label="previous song" onClick={() => loopProceed(false)}>
|
||||
<FastRewindRounded fontSize="large" htmlColor={mainIconColor} />
|
||||
</IconButton>
|
||||
<IconButton aria-label={!playing ? "play" : "pause"} onClick={togglePause}>
|
||||
{!playing ? (
|
||||
<PlayArrowRounded sx={{ fontSize: "3rem" }} htmlColor={mainIconColor} />
|
||||
) : (
|
||||
<PauseRounded sx={{ fontSize: "3rem" }} htmlColor={mainIconColor} />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton aria-label="next song" onClick={() => loopProceed(true)}>
|
||||
<FastForwardRounded fontSize="large" htmlColor={mainIconColor} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<IconButton aria-label="play list" onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
<AppsList fontSize="medium" htmlColor={mainIconColor} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{!isIOS && (
|
||||
<Stack spacing={2} direction="row" sx={{ mb: 1, px: 1 }} alignItems="center">
|
||||
<VolumeDownRounded htmlColor={lightIconColor} />
|
||||
<Slider
|
||||
aria-label="Volume"
|
||||
value={volume}
|
||||
min={0}
|
||||
max={1}
|
||||
onChange={(_e, value) => setVolumeLevel(value as number)}
|
||||
onChangeCommitted={(_e, value) => SessionManager.set(UserSettings.MusicVolume, value as number)}
|
||||
step={0.01}
|
||||
sx={{
|
||||
color: theme.palette.mode === "dark" ? "#fff" : "rgba(0,0,0,0.87)",
|
||||
"& .MuiSlider-track": {
|
||||
border: "none",
|
||||
},
|
||||
"& .MuiSlider-thumb": {
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: "#fff",
|
||||
"&::before": {
|
||||
boxShadow: "0 4px 8px rgba(0,0,0,0.4)",
|
||||
},
|
||||
"&:hover, &.Mui-focusVisible, &.Mui-active": {
|
||||
boxShadow: "none",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<VolumeUpRounded htmlColor={lightIconColor} />
|
||||
</Stack>
|
||||
)}
|
||||
</Widget>
|
||||
{thumbSrc && (
|
||||
<Box
|
||||
component={"img"}
|
||||
onLoad={() => setThumbBgLoaded(true)}
|
||||
sx={{
|
||||
transition: "opacity 0.3s cubic-bezier(.47,1.64,.41,.8) 0s",
|
||||
opacity: thumbBgLoaded ? 1 : 0,
|
||||
position: "absolute",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
src={thumbSrc}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayerPopup;
|
||||
48
src/component/Viewers/MusicPlayer/Playlist.tsx
Executable file
48
src/component/Viewers/MusicPlayer/Playlist.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import { ListItemIcon, ListItemText, MenuProps } from "@mui/material";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { SquareMenu, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import FileIcon from "../../FileManager/Explorer/FileIcon.tsx";
|
||||
|
||||
export interface PlaylistProps extends MenuProps {
|
||||
file: FileResponse;
|
||||
playlist: FileResponse[];
|
||||
playIndex: (index: number, volume?: number) => void;
|
||||
}
|
||||
|
||||
const Playlist = ({ file, playlist, playIndex, onClose, ...rest }: PlaylistProps) => {
|
||||
return (
|
||||
<SquareMenu
|
||||
MenuListProps={{
|
||||
dense: true,
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
onClose={onClose}
|
||||
{...rest}
|
||||
>
|
||||
{playlist.map((item, index) => (
|
||||
<SquareMenuItem key={item.id} onClick={() => playIndex(index)} selected={item.path == file.path}>
|
||||
<ListItemIcon>
|
||||
<FileIcon
|
||||
sx={{ px: 0, py: 0, height: "20px" }}
|
||||
file={item}
|
||||
variant={"small"}
|
||||
iconProps={{
|
||||
fontSize: "small",
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{item.name}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</SquareMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playlist;
|
||||
145
src/component/Viewers/MusicPlayer/RepeatModePopover.tsx
Executable file
145
src/component/Viewers/MusicPlayer/RepeatModePopover.tsx
Executable file
@@ -0,0 +1,145 @@
|
||||
import { Box, Divider, Popover, ToggleButton, ToggleButtonGroup, Typography, styled } from "@mui/material";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowRepeatAll from "../../Icons/ArrowRepeatAll.tsx";
|
||||
import ArrowRepeatOne from "../../Icons/ArrowRepeatOne.tsx";
|
||||
import ArrowShuffle from "../../Icons/ArrowShuffle.tsx";
|
||||
import { LoopMode } from "./MusicPlayer.tsx";
|
||||
|
||||
interface RepeatModePopoverProps {
|
||||
open?: boolean;
|
||||
anchorEl?: HTMLElement | null;
|
||||
onClose?: () => void;
|
||||
loopMode: number;
|
||||
onLoopModeChange: (mode: number) => void;
|
||||
playbackSpeed: number;
|
||||
onPlaybackSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
const NoWrapToggleButton = styled(ToggleButton)({
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
export const RepeatModePopover = ({
|
||||
open,
|
||||
anchorEl,
|
||||
onClose,
|
||||
loopMode,
|
||||
onLoopModeChange,
|
||||
playbackSpeed,
|
||||
onPlaybackSpeedChange,
|
||||
}: RepeatModePopoverProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentLoopMode = useMemo(() => {
|
||||
switch (loopMode) {
|
||||
case LoopMode.list_repeat:
|
||||
return "list_repeat";
|
||||
case LoopMode.single_repeat:
|
||||
return "single_repeat";
|
||||
case LoopMode.shuffle:
|
||||
return "shuffle";
|
||||
default:
|
||||
return "list_repeat";
|
||||
}
|
||||
}, [loopMode]);
|
||||
|
||||
const currentSpeed = useMemo(() => {
|
||||
return playbackSpeed.toString();
|
||||
}, [playbackSpeed]);
|
||||
|
||||
const handleLoopModeChange = (_event: React.MouseEvent<HTMLElement>, newMode: string) => {
|
||||
if (!newMode) return;
|
||||
|
||||
let newLoopMode: number;
|
||||
switch (newMode) {
|
||||
case "list_repeat":
|
||||
newLoopMode = LoopMode.list_repeat;
|
||||
break;
|
||||
case "single_repeat":
|
||||
newLoopMode = LoopMode.single_repeat;
|
||||
break;
|
||||
case "shuffle":
|
||||
newLoopMode = LoopMode.shuffle;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
onLoopModeChange(newLoopMode);
|
||||
};
|
||||
|
||||
const handleSpeedChange = (_event: React.MouseEvent<HTMLElement>, newSpeed: string) => {
|
||||
if (!newSpeed) return;
|
||||
const speed = parseFloat(newSpeed);
|
||||
if (!isNaN(speed)) {
|
||||
onPlaybackSpeedChange(speed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!!open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, minWidth: 300 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
{t("fileManager.repeatMode")}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
color="primary"
|
||||
value={currentLoopMode}
|
||||
exclusive
|
||||
onChange={handleLoopModeChange}
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<NoWrapToggleButton value="list_repeat">
|
||||
<ArrowRepeatAll fontSize="small" sx={{ mr: 1 }} />
|
||||
{t("fileManager.listRepeat")}
|
||||
</NoWrapToggleButton>
|
||||
<NoWrapToggleButton value="single_repeat">
|
||||
<ArrowRepeatOne fontSize="small" sx={{ mr: 1 }} />
|
||||
{t("fileManager.singleRepeat")}
|
||||
</NoWrapToggleButton>
|
||||
<NoWrapToggleButton value="shuffle">
|
||||
<ArrowShuffle fontSize="small" sx={{ mr: 1 }} />
|
||||
{t("fileManager.shuffle")}
|
||||
</NoWrapToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
{t("fileManager.playbackSpeed")}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
color="primary"
|
||||
value={currentSpeed}
|
||||
exclusive
|
||||
onChange={handleSpeedChange}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
<ToggleButton value="0.5">0.5×</ToggleButton>
|
||||
<ToggleButton value="0.75">0.75×</ToggleButton>
|
||||
<ToggleButton value="1">1×</ToggleButton>
|
||||
<ToggleButton value="1.25">1.25×</ToggleButton>
|
||||
<ToggleButton value="1.5">1.5×</ToggleButton>
|
||||
<ToggleButton value="2">2×</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepeatModePopover;
|
||||
80
src/component/Viewers/PdfViewer.tsx
Executable file
80
src/component/Viewers/PdfViewer.tsx
Executable file
@@ -0,0 +1,80 @@
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import i18next from "i18next";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getFileEntityUrl } from "../../api/api.ts";
|
||||
import { closePdfViewer } from "../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../redux/hooks.ts";
|
||||
import { getFileLinkedUri } from "../../util";
|
||||
import ViewerDialog, { ViewerLoading } from "./ViewerDialog.tsx";
|
||||
|
||||
const viewerBase = "/pdfviewer.html";
|
||||
|
||||
const PdfViewer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewerState = useAppSelector((state) => state.globalState.pdfViewer);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [src, setSrc] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSrc("");
|
||||
dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(viewerState.file)],
|
||||
entity: viewerState.version,
|
||||
}),
|
||||
)
|
||||
.then((res) => {
|
||||
const search = new URLSearchParams();
|
||||
search.set("file", res.urls[0].url);
|
||||
search.set("lng", i18next.language);
|
||||
search.set("darkMode", theme.palette.mode == "dark" ? "2" : "1");
|
||||
setSrc(`${viewerBase}?${search.toString()}`);
|
||||
})
|
||||
.catch(() => {
|
||||
onClose();
|
||||
});
|
||||
}, [viewerState]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closePdfViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
fullScreenToggle
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
{!src && <ViewerLoading />}
|
||||
{src && (
|
||||
<Box
|
||||
onLoad={() => setLoading(false)}
|
||||
src={src}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: loading ? 0 : "100%",
|
||||
border: "none",
|
||||
minHeight: loading ? 0 : "calc(100vh - 200px)",
|
||||
}}
|
||||
component={"iframe"}
|
||||
/>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PdfViewer;
|
||||
237
src/component/Viewers/Photopea/Photopea.tsx
Executable file
237
src/component/Viewers/Photopea/Photopea.tsx
Executable file
@@ -0,0 +1,237 @@
|
||||
import { Box, Button, ButtonGroup, ListItemText, Menu } from "@mui/material";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileEntityUrl } from "../../../api/api.ts";
|
||||
import { closePhotopeaViewer, GeneralViewerState } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { savePhotopea } from "../../../redux/thunks/viewer.ts";
|
||||
import { fileExtension, getFileLinkedUri } from "../../../util";
|
||||
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import useActionDisplayOpt, { canUpdate } from "../../FileManager/ContextMenu/useActionDisplayOpt.ts";
|
||||
import CaretDown from "../../Icons/CaretDown.tsx";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
import SaveAsNewFormat from "./SaveAsNewFormat.tsx";
|
||||
|
||||
const photopeiaOrigin = "https://www.photopea.com";
|
||||
const photopeiaUrl =
|
||||
"https://www.photopea.com#%7B%22environment%22%3A%7B%22customIO%22%3A%7B%22save%22%3A%22app.echoToOE(%5C%22SAVE%5C%22)%3B%22%2C%22saveAsPSD%22%3A%22app.echoToOE(%5C%22SAVEPSD%5C%22)%3B%22%7D%7D%7D";
|
||||
const saveCommand = "SAVE";
|
||||
const savePSDCommand = "SAVEPSD";
|
||||
|
||||
const appendBuffer = function (buffer1: ArrayBuffer, buffer2: ArrayBuffer): ArrayBuffer {
|
||||
var tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
|
||||
tmp.set(new Uint8Array(buffer1), 0);
|
||||
tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
|
||||
return tmp.buffer;
|
||||
};
|
||||
|
||||
const saveOpt = {
|
||||
started: 1,
|
||||
saveAs: 2,
|
||||
};
|
||||
|
||||
const Photopea = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const viewerState = useAppSelector((state) => state.globalState.photopeaViewer);
|
||||
|
||||
const displayOpt = useActionDisplayOpt(viewerState?.file ? [viewerState?.file] : []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [newFormatDialog, setNewFormatDialog] = useState(false);
|
||||
|
||||
const entityUrl = useRef<string>("");
|
||||
const pp = useRef<HTMLIFrameElement | undefined>();
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
const doneCount = useRef(0);
|
||||
const saveStarted = useRef<number>(0);
|
||||
const currentState = useRef<GeneralViewerState | undefined>(undefined);
|
||||
const buffer = useRef(new ArrayBuffer(0));
|
||||
const supportUpdate = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("message", eventHandler);
|
||||
return () => {
|
||||
window.removeEventListener("message", eventHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
supportUpdate.current = canUpdate(displayOpt);
|
||||
setLoaded(false);
|
||||
currentState.current = viewerState;
|
||||
buffer.current = new ArrayBuffer(0);
|
||||
doneCount.current = 0;
|
||||
saveStarted.current = 0;
|
||||
setSrc(undefined);
|
||||
dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(viewerState.file)],
|
||||
entity: viewerState.version,
|
||||
}),
|
||||
)
|
||||
.then((res) => {
|
||||
entityUrl.current = res.urls[0].url;
|
||||
setSrc(photopeiaUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
onClose();
|
||||
});
|
||||
}, [viewerState]);
|
||||
|
||||
const save = (ext?: string, newFile?: boolean) => {
|
||||
setAnchorEl(null);
|
||||
if (!pp.current || !supportUpdate.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
if (!ext) {
|
||||
ext = fileExtension(currentState.current?.file.name ?? "") ?? "jpg";
|
||||
if (ext == "psd") {
|
||||
ext += ":true";
|
||||
}
|
||||
}
|
||||
|
||||
pp.current.contentWindow?.postMessage(`app.activeDocument.saveToOE("${ext}")`, "*");
|
||||
saveStarted.current = newFile ? saveOpt.saveAs : saveOpt.started;
|
||||
};
|
||||
|
||||
const eventHandler = (e: MessageEvent) => {
|
||||
if (e.origin != photopeiaOrigin) {
|
||||
return;
|
||||
}
|
||||
console.log(e);
|
||||
if (e.data == "done") {
|
||||
if (doneCount.current == 0) {
|
||||
pp.current?.contentWindow?.postMessage(`app.open("${entityUrl.current}","",false)`, "*");
|
||||
} else if (doneCount.current == 2) {
|
||||
pp.current?.contentWindow?.postMessage(
|
||||
`app.activeDocument.name="${currentState.current?.file.name.replace(/"/g, '\\"') ?? ""}"`,
|
||||
"*",
|
||||
);
|
||||
setLoaded(true);
|
||||
} else if (saveStarted.current > 0 && currentState.current) {
|
||||
dispatch(
|
||||
savePhotopea(
|
||||
buffer.current,
|
||||
currentState.current.file,
|
||||
currentState.current.version,
|
||||
saveStarted.current == saveOpt.saveAs,
|
||||
),
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
doneCount.current++;
|
||||
} else if (e.data == saveCommand) {
|
||||
save();
|
||||
} else if (e.data == savePSDCommand) {
|
||||
save("psd:true", true);
|
||||
} else if (e.data instanceof ArrayBuffer && saveStarted.current) {
|
||||
buffer.current = appendBuffer(buffer.current, e.data);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closePhotopeaViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const onLoad = useCallback((e: React.SyntheticEvent<HTMLIFrameElement>) => (pp.current = e.currentTarget), []);
|
||||
|
||||
const openMore = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const openSaveAsOtherFormat = () => {
|
||||
setNewFormatDialog(true);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const onSaveAsNewFormat = (ext: string, quality?: number) => {
|
||||
if (quality) {
|
||||
ext += `:${quality}`;
|
||||
}
|
||||
|
||||
save(ext, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SaveAsNewFormat
|
||||
onSaveSubmit={onSaveAsNewFormat}
|
||||
open={newFormatDialog}
|
||||
onClose={() => setNewFormatDialog(false)}
|
||||
/>
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
readOnly={!supportUpdate.current}
|
||||
actions={
|
||||
supportUpdate.current ? (
|
||||
<ButtonGroup disabled={loading || !loaded} disableElevation variant="contained">
|
||||
<Button onClick={() => save()} variant={"contained"}>
|
||||
{t("fileManager.save")}
|
||||
</Button>
|
||||
<Button size="small" onClick={openMore}>
|
||||
<CaretDown sx={{ fontSize: "12px!important" }} />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
) : undefined
|
||||
}
|
||||
fullScreenToggle
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem dense onClick={() => save(undefined, true)}>
|
||||
<ListItemText>{t("modals.saveAs")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem onClick={openSaveAsOtherFormat} dense>
|
||||
<ListItemText>{t("modals.saveAsOtherFormat")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
{!src && <ViewerLoading />}
|
||||
{src && (
|
||||
<Box
|
||||
ref={pp}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
minHeight: "calc(100vh - 200px)",
|
||||
}}
|
||||
component={"iframe"}
|
||||
title={"ms"}
|
||||
src={src}
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
</ViewerDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Photopea;
|
||||
159
src/component/Viewers/Photopea/SaveAsNewFormat.tsx
Executable file
159
src/component/Viewers/Photopea/SaveAsNewFormat.tsx
Executable file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
DialogContent,
|
||||
DialogProps,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
Slider,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import DialogAccordion from "../../Dialogs/DialogAccordion.tsx";
|
||||
import AutoHeight from "../../Common/AutoHeight.tsx";
|
||||
|
||||
export interface SaveAsNewFormatProps extends DialogProps {
|
||||
onSaveSubmit: (ext: string, quality?: number) => void;
|
||||
}
|
||||
export interface Format {
|
||||
ext: string;
|
||||
display: string;
|
||||
quality?: boolean;
|
||||
}
|
||||
const formats: Format[] = [
|
||||
{
|
||||
ext: "png",
|
||||
display: "PNG",
|
||||
},
|
||||
{
|
||||
ext: "jpg",
|
||||
display: "JPEG",
|
||||
quality: true,
|
||||
},
|
||||
{
|
||||
ext: "webp",
|
||||
display: "WebP",
|
||||
quality: true,
|
||||
},
|
||||
{
|
||||
ext: "pdf",
|
||||
display: "PDF",
|
||||
},
|
||||
{
|
||||
ext: "svg",
|
||||
display: "SVG",
|
||||
},
|
||||
{
|
||||
ext: "gif",
|
||||
display: "GIF",
|
||||
},
|
||||
{
|
||||
ext: "mp4",
|
||||
display: "MP4",
|
||||
},
|
||||
{
|
||||
ext: "dds",
|
||||
display: "DDS",
|
||||
},
|
||||
{
|
||||
ext: "tiff",
|
||||
display: "TIFF",
|
||||
},
|
||||
{
|
||||
ext: "tga",
|
||||
display: "TGA",
|
||||
},
|
||||
{
|
||||
ext: "bmp",
|
||||
display: "BMP",
|
||||
},
|
||||
{
|
||||
ext: "ico",
|
||||
display: "ICO",
|
||||
},
|
||||
{
|
||||
ext: "dxf",
|
||||
display: "DXF",
|
||||
},
|
||||
{
|
||||
ext: "raw",
|
||||
display: "RAW",
|
||||
},
|
||||
{
|
||||
ext: "emf",
|
||||
display: "EMF",
|
||||
},
|
||||
{
|
||||
ext: "ppm",
|
||||
display: "PPM",
|
||||
},
|
||||
];
|
||||
|
||||
const SaveAsNewFormat = ({ onSaveSubmit, onClose, ...rest }: SaveAsNewFormatProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<Format>(formats[0]);
|
||||
const [quality, setQuality] = useState(0.9);
|
||||
|
||||
const handleChange = (event: SelectChangeEvent) => {
|
||||
setSelected(formats.find((f) => f.ext === event.target.value) ?? formats[0]);
|
||||
};
|
||||
|
||||
const handleSliderChange = (_event: Event, newValue: number | number[]) => {
|
||||
setQuality(newValue as number);
|
||||
};
|
||||
|
||||
const onAccept = () => {
|
||||
onSaveSubmit(selected.ext, selected.quality ? quality : undefined);
|
||||
onClose && onClose({}, "backdropClick");
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:modals.saveAsOtherFormat")}
|
||||
showActions
|
||||
showCancel
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
fullWidth: true,
|
||||
onClose,
|
||||
maxWidth: "xs",
|
||||
...rest,
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<AutoHeight>
|
||||
<Stack spacing={2}>
|
||||
<FormControl variant="filled" sx={{ width: "100%" }}>
|
||||
<InputLabel>{t("fileManager.format")}</InputLabel>
|
||||
<Select value={selected.ext} onChange={handleChange}>
|
||||
{formats.map((f) => (
|
||||
<MenuItem key={f.ext} value={f.ext}>
|
||||
{f.display}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selected.quality && (
|
||||
<DialogAccordion title={t("modals.quality")}>
|
||||
<Slider
|
||||
size={"small"}
|
||||
valueLabelDisplay="auto"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
max={1.0}
|
||||
value={quality}
|
||||
onChange={handleSliderChange}
|
||||
/>
|
||||
</DialogAccordion>
|
||||
)}
|
||||
</Stack>
|
||||
</AutoHeight>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveAsNewFormat;
|
||||
186
src/component/Viewers/Video/Artplayer.tsx
Executable file
186
src/component/Viewers/Video/Artplayer.tsx
Executable file
@@ -0,0 +1,186 @@
|
||||
import { Box, BoxProps } from "@mui/material";
|
||||
import { fileExtension } from "../../../util";
|
||||
import Artplayer from "artplayer";
|
||||
import artplayerPluginChapter from "artplayer-plugin-chapter";
|
||||
import artplayerPluginHlsControl from "artplayer-plugin-hls-control";
|
||||
import { CrMaskedPrefix } from "./VideoViewer";
|
||||
import Hls, { HlsConfig } from "hls.js";
|
||||
import mpegts from "mpegts.js";
|
||||
import i18next from "i18next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import "./artplayer.css";
|
||||
|
||||
export interface PlayerProps extends BoxProps {
|
||||
option: any;
|
||||
getInstance?: (instance: Artplayer) => void;
|
||||
chapters?: any;
|
||||
m3u8UrlTransform?: (url: string, isPlaylist?: boolean) => Promise<string>;
|
||||
getEntityUrl?: (url: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const playM3u8 =
|
||||
(
|
||||
urlTransform?: (url: string, isPlaylist?: boolean) => Promise<string>,
|
||||
getEntityUrl?: (url: string) => Promise<string>,
|
||||
) =>
|
||||
(video: HTMLVideoElement, url: string, art: Artplayer) => {
|
||||
if (Hls.isSupported()) {
|
||||
if (art.hls) art.hls.destroy();
|
||||
const hls = new Hls({
|
||||
fLoader: class extends Hls.DefaultConfig.loader {
|
||||
constructor(config: HlsConfig) {
|
||||
super(config);
|
||||
var load = this.load.bind(this);
|
||||
this.load = function (context, config, callbacks) {
|
||||
if (urlTransform) {
|
||||
urlTransform(context.url).then((url) => {
|
||||
const complete = callbacks.onSuccess;
|
||||
callbacks.onSuccess = (loaderResponse, stats, successContext, networkDetails) => {
|
||||
// Do something with loaderResponse.data
|
||||
loaderResponse.url = url;
|
||||
complete(loaderResponse, stats, successContext, networkDetails);
|
||||
};
|
||||
load({ ...context, frag: { ...context.frag, relurl: url, _url: url }, url }, config, callbacks);
|
||||
});
|
||||
} else {
|
||||
load(context, config, callbacks);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
pLoader: class extends Hls.DefaultConfig.loader {
|
||||
constructor(config: HlsConfig) {
|
||||
super(config);
|
||||
var load = this.load.bind(this);
|
||||
this.load = function (context, config, callbacks) {
|
||||
if (urlTransform) {
|
||||
urlTransform(context.url, true).then((url) => {
|
||||
const complete = callbacks.onSuccess;
|
||||
callbacks.onSuccess = (loaderResponse, stats, successContext, networkDetails) => {
|
||||
// Do something with loaderResponse.data
|
||||
loaderResponse.url = url;
|
||||
complete(loaderResponse, stats, successContext, networkDetails);
|
||||
};
|
||||
load({ ...context, url }, config, callbacks);
|
||||
});
|
||||
} else {
|
||||
load(context, config, callbacks);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
xhrSetup: async (xhr, url) => {
|
||||
// Always send cookies, even for cross-origin calls.
|
||||
if (url.startsWith(CrMaskedPrefix)) {
|
||||
if (getEntityUrl) {
|
||||
xhr.open("GET", await getEntityUrl(url), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
art.hls = hls;
|
||||
art.on("destroy", () => hls.destroy());
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = url;
|
||||
} else {
|
||||
art.notice.show = "Unsupported playback format: m3u8";
|
||||
}
|
||||
};
|
||||
|
||||
const playFlv = (video: HTMLVideoElement, url: string, art: Artplayer) => {
|
||||
if (mpegts.isSupported()) {
|
||||
if (art.flv) art.flv.destroy();
|
||||
const flv = mpegts.createPlayer(
|
||||
{
|
||||
type: "flv",
|
||||
url: url,
|
||||
},
|
||||
{
|
||||
lazyLoadMaxDuration: 5 * 60,
|
||||
accurateSeek: true,
|
||||
},
|
||||
);
|
||||
flv.attachMediaElement(video);
|
||||
flv.load();
|
||||
art.flv = flv;
|
||||
art.on("destroy", () => flv.destroy());
|
||||
} else {
|
||||
art.notice.show = "Unsupported playback format: flv";
|
||||
}
|
||||
};
|
||||
|
||||
export default function Player({
|
||||
option,
|
||||
chapters,
|
||||
getInstance,
|
||||
m3u8UrlTransform,
|
||||
getEntityUrl,
|
||||
...rest
|
||||
}: PlayerProps) {
|
||||
const artRef = useRef<Artplayer>();
|
||||
const ext = fileExtension(option.title);
|
||||
|
||||
useEffect(() => {
|
||||
const opts = {
|
||||
...option,
|
||||
plugins: [...option.plugins],
|
||||
container: artRef.current,
|
||||
customType: {
|
||||
...option.customType,
|
||||
m3u8: playM3u8(m3u8UrlTransform, getEntityUrl),
|
||||
flv: playFlv,
|
||||
},
|
||||
type: ext,
|
||||
};
|
||||
|
||||
if (chapters) {
|
||||
opts.plugins.push(artplayerPluginChapter({ chapters }));
|
||||
}
|
||||
|
||||
if (ext === "m3u8") {
|
||||
opts.plugins.push(
|
||||
artplayerPluginHlsControl({
|
||||
quality: {
|
||||
// Show qualitys in control
|
||||
control: true,
|
||||
// Show qualitys in setting
|
||||
setting: true,
|
||||
// Get the quality name from level
|
||||
getName: (level) => (level.height ? level.height + "P" : i18next.t("application:fileManager.default")),
|
||||
// I18n
|
||||
title: i18next.t("application:fileManager.quality"),
|
||||
auto: i18next.t("application:fileManager.auto"),
|
||||
},
|
||||
audio: {
|
||||
// Show audios in control
|
||||
control: true,
|
||||
// Show audios in setting
|
||||
setting: true,
|
||||
// Get the audio name from track
|
||||
getName: (track) => track.name,
|
||||
// I18n
|
||||
title: i18next.t("application:fileManager.audioTrack"),
|
||||
auto: i18next.t("application:fileManager.auto"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const art = new Artplayer(opts);
|
||||
|
||||
if (getInstance && typeof getInstance === "function") {
|
||||
getInstance(art);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (art && art.destroy) {
|
||||
art.destroy(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Box ref={artRef} {...rest}></Box>;
|
||||
}
|
||||
110
src/component/Viewers/Video/SubtitleStyleDialog.tsx
Executable file
110
src/component/Viewers/Video/SubtitleStyleDialog.tsx
Executable file
@@ -0,0 +1,110 @@
|
||||
import { Box, DialogContent, DialogProps, Slider, Stack, Typography, useTheme } from "@mui/material";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CSSProperties, useEffect, useState } from "react";
|
||||
import { SubtitleStyle } from "./VideoViewer.tsx";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import Sketch from "@uiw/react-color-sketch";
|
||||
|
||||
export interface SubtitleStyleProps extends DialogProps {
|
||||
onSaveSubmit: (setting: SubtitleStyle) => void;
|
||||
}
|
||||
|
||||
const SubtitleStyleDialog = ({ onSaveSubmit, onClose, open, ...rest }: SubtitleStyleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [style, setStyle] = useState<SubtitleStyle>({});
|
||||
|
||||
const handleSliderChange = (_event: Event, newValue: number | number[]) => {
|
||||
setStyle((s) => ({
|
||||
...s,
|
||||
fontSize: newValue as number,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStyle(SessionManager.getWithFallback(UserSettings.SubtitleStyle));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onAccept = () => {
|
||||
onSaveSubmit(style);
|
||||
onClose && onClose({}, "backdropClick");
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.subtitleStyles")}
|
||||
showActions
|
||||
showCancel
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
fullWidth: true,
|
||||
onClose,
|
||||
open,
|
||||
maxWidth: "sm",
|
||||
...rest,
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} direction={"row"}>
|
||||
<Box>
|
||||
<Typography variant={"body2"} gutterBottom>
|
||||
{t("fileManager.color")}
|
||||
</Typography>
|
||||
<Sketch
|
||||
presetColors={false}
|
||||
style={
|
||||
{
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
background: theme.palette.background.default + "!important",
|
||||
} as CSSProperties
|
||||
}
|
||||
disableAlpha={true}
|
||||
color={style.fontColor ?? "#fff"}
|
||||
onChange={(color) => {
|
||||
setStyle((s) => ({
|
||||
...s,
|
||||
fontColor: color.hex,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant={"body2"} gutterBottom>
|
||||
{t("fileManager.fontSize")}
|
||||
</Typography>
|
||||
<Slider
|
||||
size={"small"}
|
||||
valueLabelDisplay="auto"
|
||||
min={5}
|
||||
step={1}
|
||||
max={50}
|
||||
value={style.fontSize ?? 20}
|
||||
onChange={handleSliderChange}
|
||||
/>
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
textShadow:
|
||||
"#000 1px 0 1px,#000 0 1px 1px,#000 -1px 0 1px,#000 0 -1px 1px,#000 1px 1px 1px,#000 -1px -1px 1px,#000 1px -1px 1px,#000 -1px 1px 1px",
|
||||
fontSize: `${style.fontSize ?? 20}px`,
|
||||
color: style.fontColor ?? "#fff",
|
||||
}}
|
||||
>
|
||||
{t("fileManager.testSubtitleStyle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtitleStyleDialog;
|
||||
481
src/component/Viewers/Video/VideoViewer.tsx
Executable file
481
src/component/Viewers/Video/VideoViewer.tsx
Executable file
@@ -0,0 +1,481 @@
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import Artplayer from "artplayer";
|
||||
import dayjs from "dayjs";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileEntityUrl } from "../../../api/api.ts";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { closeVideoViewer } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { findSubtitleOptions } from "../../../redux/thunks/viewer.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { fileExtension, fileNameNoExt, getFileLinkedUri } from "../../../util";
|
||||
import CrUri from "../../../util/uri.ts";
|
||||
import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import Checkmark from "../../Icons/Checkmark.tsx";
|
||||
import Subtitles from "../../Icons/Subtitles.tsx";
|
||||
import TextEditStyle from "../../Icons/TextEditStyle.tsx";
|
||||
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
|
||||
import SubtitleStyleDialog from "./SubtitleStyleDialog.tsx";
|
||||
|
||||
const Player = lazy(() => import("./Artplayer.tsx"));
|
||||
|
||||
export const CrMaskedPrefix = "https://cloudreve_masked/";
|
||||
|
||||
export interface SubtitleStyle {
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
}
|
||||
|
||||
const srcRefreshMargin = 5 * 1000;
|
||||
const VideoViewer = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const viewerState = useAppSelector((state) => state.globalState.videoViewer);
|
||||
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [art, setArt] = useState<Artplayer | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const currentExpire = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [subtitles, setSubtitles] = useState<FileResponse[]>([]);
|
||||
const [subtitleSelected, setSubtitleSelected] = useState<FileResponse | null>(null);
|
||||
const [subtitleStyleOpen, setSubtitleStyleOpen] = useState(false);
|
||||
const currentUrl = useRef<string | null>(null);
|
||||
|
||||
const subtitleStyle = useMemo(() => {
|
||||
return SessionManager.getWithFallback(UserSettings.SubtitleStyle) as SubtitleStyle;
|
||||
}, []);
|
||||
|
||||
const switchSubtitle = useCallback(
|
||||
async (subtitle?: FileResponse) => {
|
||||
if (!art) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAnchorEl(null);
|
||||
|
||||
if (!subtitle) {
|
||||
setSubtitleSelected(null);
|
||||
art.subtitle.show = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setSubtitleSelected(subtitle);
|
||||
try {
|
||||
const subtitleUrl = await dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(subtitle)],
|
||||
}),
|
||||
);
|
||||
|
||||
art.subtitle.switch(subtitleUrl.urls[0].url, {
|
||||
type: fileExtension(subtitle.name) ?? "",
|
||||
});
|
||||
art.subtitle.show = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[art],
|
||||
);
|
||||
|
||||
const loadSubtitles = useCallback(() => {
|
||||
if (!viewerState?.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subs = dispatch(findSubtitleOptions());
|
||||
setSubtitles(subs);
|
||||
if (subs.length > 0 && subs[0].name.startsWith(fileNameNoExt(viewerState.file.name) + ".")) {
|
||||
switchSubtitle(subs[0]);
|
||||
}
|
||||
}, [viewerState?.file, switchSubtitle]);
|
||||
|
||||
// refresh video src before entity url expires
|
||||
const refreshSrc = useCallback(() => {
|
||||
if (!viewerState || !viewerState.file || !art) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstLoad = !currentExpire.current;
|
||||
const isM3u8 = fileExtension(viewerState.file.name) === "m3u8";
|
||||
if (isM3u8) {
|
||||
// For m3u8, use masked url
|
||||
const crFileUrl = new CrUri(getFileLinkedUri(viewerState.file));
|
||||
const maskedUrl = `${CrMaskedPrefix}${crFileUrl.path()}`;
|
||||
art.switchUrl(maskedUrl);
|
||||
loadSubtitles();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
getFileEntityUrl({
|
||||
uris: [getFileLinkedUri(viewerState.file)],
|
||||
entity: viewerState.version,
|
||||
}),
|
||||
)
|
||||
.then((res) => {
|
||||
const current = art.currentTime;
|
||||
currentUrl.current = res.urls[0].url;
|
||||
|
||||
let timeOut = dayjs(res.expires).diff(dayjs(), "millisecond") - srcRefreshMargin;
|
||||
if (timeOut < 0) {
|
||||
timeOut = 2000;
|
||||
}
|
||||
currentExpire.current = setTimeout(refreshSrc, timeOut);
|
||||
|
||||
art.switchUrl(res.urls[0].url).then(() => {
|
||||
art.currentTime = current;
|
||||
});
|
||||
|
||||
if (firstLoad) {
|
||||
const subs = dispatch(findSubtitleOptions());
|
||||
setSubtitles(subs);
|
||||
if (subs.length > 0 && subs[0].name.startsWith(fileNameNoExt(viewerState.file.name) + ".")) {
|
||||
switchSubtitle(subs[0]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
onClose();
|
||||
});
|
||||
}, [viewerState?.file, art, loadSubtitles]);
|
||||
|
||||
const chapters = useMemo(() => {
|
||||
if (!viewerState || !viewerState.file?.metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chapterMap: {
|
||||
[key: string]: {
|
||||
start: number;
|
||||
end: number;
|
||||
title: string;
|
||||
};
|
||||
} = {};
|
||||
|
||||
Object.keys(viewerState.file.metadata).map((k) => {
|
||||
if (k.startsWith("stream:chapter_")) {
|
||||
const id = k.split("_")[1];
|
||||
// type = remove prefix
|
||||
const type = k.replace(`stream:chapter_${id}_`, "");
|
||||
if (!chapterMap[id]) {
|
||||
chapterMap[id] = {
|
||||
start: 0,
|
||||
end: 0,
|
||||
title: "",
|
||||
};
|
||||
}
|
||||
switch (type) {
|
||||
case "start_time":
|
||||
chapterMap[id].start = parseFloat(viewerState.file?.metadata?.[k] ?? "0");
|
||||
break;
|
||||
case "end_time":
|
||||
chapterMap[id].end = parseFloat(viewerState.file?.metadata?.[k] ?? "0");
|
||||
break;
|
||||
case "name":
|
||||
chapterMap[id].title = viewerState.file?.metadata?.[k] ?? "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(chapterMap).map((c) => ({
|
||||
start: c.start,
|
||||
end: c.end,
|
||||
title: c.title,
|
||||
}));
|
||||
}, [viewerState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!art) {
|
||||
return;
|
||||
}
|
||||
art.on("ready", () => {
|
||||
art.autoHeight();
|
||||
art.autoSize();
|
||||
});
|
||||
art.query(".art-video").addEventListener(
|
||||
"leavepictureinpicture",
|
||||
() => {
|
||||
art.pause();
|
||||
},
|
||||
false,
|
||||
);
|
||||
refreshSrc();
|
||||
}, [art]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
if (currentExpire.current) {
|
||||
clearTimeout(currentExpire.current);
|
||||
currentExpire.current = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setArt(null);
|
||||
setSubtitles([]);
|
||||
setSubtitleSelected(null);
|
||||
}, [viewerState?.open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeVideoViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const openOption = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const openSubtitleStyle = useCallback(() => {
|
||||
setSubtitleStyleOpen(true);
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const applySubtitleStyle = useCallback(
|
||||
(style: SubtitleStyle) => {
|
||||
SessionManager.set(UserSettings.SubtitleStyle, style);
|
||||
setSubtitleStyleOpen(false);
|
||||
if (art) {
|
||||
art.subtitle.style({
|
||||
color: style.fontColor ?? "#fff",
|
||||
fontSize: `${style.fontSize ?? 20}px`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[art],
|
||||
);
|
||||
|
||||
const m3u8UrlTransform = useCallback(
|
||||
async (url: string, isPlaylist?: boolean): Promise<string> => {
|
||||
let realUrl = "";
|
||||
if (isPlaylist) {
|
||||
// Loading playlist
|
||||
if (!currentUrl.current) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const currentParsed = new URL(currentUrl.current);
|
||||
const requestParsed = new URL(url);
|
||||
if (currentParsed.origin != requestParsed.origin) {
|
||||
// Playlist is from different origin, return original URL
|
||||
return url;
|
||||
}
|
||||
|
||||
// Trim pfrefix(currentParsed.pathname) of requestParsed.pathname to get relative path
|
||||
const currentPathParts = currentParsed.pathname.split("/");
|
||||
const requestPathParts = requestParsed.pathname.split("/");
|
||||
|
||||
// Find where paths diverge
|
||||
let i = 0;
|
||||
while (
|
||||
i < currentPathParts.length &&
|
||||
i < requestPathParts.length &&
|
||||
currentPathParts[i] === requestPathParts[i]
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
|
||||
// Get relative path by joining remaining parts
|
||||
const relativePath = requestPathParts.slice(i).join("/");
|
||||
|
||||
if (!viewerState?.file) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const currentFileUrl = new CrUri(getFileLinkedUri(viewerState?.file));
|
||||
const base = i == 0 ? new CrUri(currentFileUrl.base()) : currentFileUrl.parent();
|
||||
realUrl = base.join(relativePath).path();
|
||||
return `${CrMaskedPrefix}${realUrl}`;
|
||||
} else {
|
||||
// Loading fragment
|
||||
if (url.startsWith("http://") || url.startsWith("https://") || !viewerState?.file) {
|
||||
// If fragment URL is not a path, return it
|
||||
return url;
|
||||
}
|
||||
|
||||
// Request real fragment/playlist URL
|
||||
const currentFileUrl = new CrUri(getFileLinkedUri(viewerState?.file));
|
||||
const base = url.startsWith("/") ? new CrUri(currentFileUrl.base()) : currentFileUrl.parent();
|
||||
realUrl = base.join(url).path();
|
||||
return `${CrMaskedPrefix}${realUrl}`;
|
||||
}
|
||||
},
|
||||
[viewerState?.file],
|
||||
);
|
||||
|
||||
const getUnmaskedEntityUrl = useCallback(
|
||||
async (url: string) => {
|
||||
if (!viewerState?.file) {
|
||||
return url;
|
||||
}
|
||||
// remove cloudreve_masked prefix of url
|
||||
if (!url.startsWith(CrMaskedPrefix)) {
|
||||
return url;
|
||||
}
|
||||
url = url.replace(CrMaskedPrefix, "");
|
||||
const currentFileUrl = new CrUri(getFileLinkedUri(viewerState.file));
|
||||
const base = new CrUri(currentFileUrl.base());
|
||||
const realUrl = base.join(...url.split("/"));
|
||||
try {
|
||||
const res = await dispatch(getFileEntityUrl({ uris: [realUrl.toString()] }));
|
||||
return res.urls[0].url;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return url;
|
||||
}
|
||||
},
|
||||
[dispatch, viewerState?.file],
|
||||
);
|
||||
|
||||
// TODO: Add artplayer-plugin-chapter after it's released to npm
|
||||
return (
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
actions={
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Tooltip title={t("fileManager.subtitles")}>
|
||||
<IconButton onClick={openOption}>
|
||||
<Subtitles fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
fullScreenToggle
|
||||
toggleFullScreen={() => art && (art.fullscreenWeb = true)}
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "md",
|
||||
}}
|
||||
>
|
||||
<SubtitleStyleDialog
|
||||
onSaveSubmit={applySubtitleStyle}
|
||||
open={subtitleStyleOpen}
|
||||
onClose={() => setSubtitleStyleOpen(false)}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
maxWidth: 250,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem dense onClick={() => openSubtitleStyle()}>
|
||||
<ListItemIcon>
|
||||
<TextEditStyle fontSize={"small"} />{" "}
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.subtitleStyles")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<DenseDivider />
|
||||
{subtitles.length == 0 && (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant={"caption"} color={"text.secondary"}>
|
||||
{t("application:fileManager.noSubtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{subtitles.length > 0 && (
|
||||
<SquareMenuItem onClick={() => switchSubtitle()} dense>
|
||||
<em>
|
||||
<ListItemText primary={t("application:fileManager.disableSubtitle")} />
|
||||
</em>
|
||||
</SquareMenuItem>
|
||||
)}
|
||||
{subtitles.map((sub) => (
|
||||
<Tooltip title={sub.name} key={sub.id}>
|
||||
<SquareMenuItem onClick={() => switchSubtitle(sub)} dense>
|
||||
<ListItemText
|
||||
primary={sub.name}
|
||||
slotProps={{
|
||||
primary: {
|
||||
sx: {
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{subtitleSelected?.id == sub.id && (
|
||||
<ListItemIcon sx={{ minWidth: "0!important" }}>
|
||||
<Checkmark />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
</SquareMenuItem>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Menu>
|
||||
<Suspense fallback={<ViewerLoading minHeight={"calc(100vh - 350px)"} />}>
|
||||
<Player
|
||||
key={viewerState?.file?.path}
|
||||
m3u8UrlTransform={m3u8UrlTransform}
|
||||
getEntityUrl={getUnmaskedEntityUrl}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "calc(100vh - 350px)",
|
||||
}}
|
||||
chapters={chapters}
|
||||
getInstance={(instance) => setArt(instance)}
|
||||
option={{
|
||||
title: viewerState?.file?.name,
|
||||
theme: theme.palette.primary.main,
|
||||
id: viewerState?.file?.path,
|
||||
autoPlayback: true,
|
||||
subtitleOffset: true,
|
||||
fastForward: true,
|
||||
flip: true,
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
aspectRatio: true,
|
||||
hotkey: true,
|
||||
pip: !isMobile,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
autoHeight: true,
|
||||
whitelist: ["*"],
|
||||
moreVideoAttr: {
|
||||
"webkit-playsinline": true,
|
||||
playsInline: true,
|
||||
},
|
||||
subtitle: {
|
||||
style: {
|
||||
color: subtitleStyle.fontColor ?? "#fff",
|
||||
fontSize: `${subtitleStyle.fontSize ?? 20}px`,
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
lang: t("artPlayerLocaleCode", { ns: "common" }), // TODO: review
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</ViewerDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoViewer;
|
||||
3
src/component/Viewers/Video/artplayer.css
Executable file
3
src/component/Viewers/Video/artplayer.css
Executable file
@@ -0,0 +1,3 @@
|
||||
.art-video-player {
|
||||
min-height: calc(100vh - 350px);
|
||||
}
|
||||
85
src/component/Viewers/ViewerDialog.tsx
Executable file
85
src/component/Viewers/ViewerDialog.tsx
Executable file
@@ -0,0 +1,85 @@
|
||||
import { Box, Chip, Dialog, DialogProps, Divider, IconButton, useMediaQuery, useTheme } from "@mui/material";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileResponse } from "../../api/explorer.ts";
|
||||
import FacebookCircularProgress from "../Common/CircularProgress.tsx";
|
||||
import { NoWrapTypography } from "../Common/StyledComponents.tsx";
|
||||
import { StyledDialogTitle } from "../Dialogs/DraggableDialog.tsx";
|
||||
import FileIcon from "../FileManager/Explorer/FileIcon.tsx";
|
||||
import Dismiss from "../Icons/Dismiss.tsx";
|
||||
import FullScreenMaximize from "../Icons/FullScreenMaximize.tsx";
|
||||
import FullScreenMinimize from "../Icons/FullScreenMinimize.tsx";
|
||||
|
||||
export interface ViewerDialogProps {
|
||||
file?: FileResponse;
|
||||
readOnly?: boolean;
|
||||
fullScreen?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
fullScreenToggle?: boolean;
|
||||
dialogProps: DialogProps;
|
||||
loading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
toggleFullScreen?: () => void;
|
||||
}
|
||||
|
||||
export const ViewerLoading = ({ minHeight = "calc(100vh - 200px)" }: { minHeight?: string }) => (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
minHeight,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FacebookCircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
const ViewerDialog = (props: ViewerDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [fullScreen, setFullScreen] = useState(props.fullScreen || isMobile);
|
||||
const onClose = useCallback(() => {
|
||||
props.dialogProps.onClose && props.dialogProps.onClose({}, "backdropClick");
|
||||
}, [props.dialogProps.onClose]);
|
||||
return (
|
||||
<Dialog
|
||||
fullScreen={fullScreen}
|
||||
{...props.dialogProps}
|
||||
onClose={props.loading ? undefined : props.dialogProps.onClose}
|
||||
>
|
||||
<Box>
|
||||
<StyledDialogTitle sx={{ py: "8px", px: "14px" }} id="draggable-dialog-title">
|
||||
{props.actions && props.actions}
|
||||
<Box sx={{ display: "flex", alignItems: "center", minWidth: 0 }}>
|
||||
<FileIcon variant={"default"} file={props.file} sx={{ px: 0, py: 0, pt: 0.5, mr: 1 }} fontSize={"small"} />
|
||||
<NoWrapTypography variant={"subtitle2"}>{props.file?.name}</NoWrapTypography>
|
||||
{props.readOnly && <Chip size="small" sx={{ ml: 1 }} label={t("fileManager.readOnly")} />}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex" }}>
|
||||
{props.fullScreenToggle && (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
props.toggleFullScreen ? props.toggleFullScreen() : setFullScreen((s) => !s);
|
||||
}}
|
||||
>
|
||||
{fullScreen ? <FullScreenMinimize fontSize={"small"} /> : <FullScreenMaximize fontSize={"small"} />}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton disabled={props.loading} onClick={onClose}>
|
||||
<Dismiss fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</StyledDialogTitle>
|
||||
<Divider />
|
||||
</Box>
|
||||
{props.children}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewerDialog;
|
||||
170
src/component/Viewers/Wopi.tsx
Executable file
170
src/component/Viewers/Wopi.tsx
Executable file
@@ -0,0 +1,170 @@
|
||||
import { Box, ListItemText, Menu, useTheme } from "@mui/material";
|
||||
import i18n from "i18next";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closeWopiViewer, setVersionControlDialog, WopiViewerState } from "../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../redux/hooks.ts";
|
||||
import { openShareDialog } from "../../redux/thunks/file.ts";
|
||||
import { SquareMenuItem } from "../FileManager/ContextMenu/ContextMenu.tsx";
|
||||
import useActionDisplayOpt, { canUpdate } from "../FileManager/ContextMenu/useActionDisplayOpt.ts";
|
||||
import { FileManagerIndex } from "../FileManager/FileManager.tsx";
|
||||
import ViewerDialog, { ViewerLoading } from "./ViewerDialog.tsx";
|
||||
|
||||
const WopiForm = ({
|
||||
replacedSrc,
|
||||
viewerState,
|
||||
onSubmit,
|
||||
}: {
|
||||
viewerState: WopiViewerState;
|
||||
replacedSrc: string;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
useEffect(() => {
|
||||
formRef.current?.submit();
|
||||
onSubmit();
|
||||
}, [viewerState]);
|
||||
return (
|
||||
<form ref={formRef} id="office_form" name="office_form" target="office_frame" action={replacedSrc} method="post">
|
||||
<input name="access_token" value={viewerState.session.access_token} type="hidden" />
|
||||
<input name="access_token_ttl" value={viewerState.session.expires} type="hidden" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Wopi = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const viewerState = useAppSelector((state) => state.globalState.wopiViewer);
|
||||
|
||||
const displayOpt = useActionDisplayOpt(viewerState?.file ? [viewerState?.file] : []);
|
||||
const canEdit = canUpdate(displayOpt);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const submitedRef = useRef<boolean>(false);
|
||||
|
||||
const replacedSrc = useMemo(() => {
|
||||
if (!viewerState?.src) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return viewerState.src
|
||||
.replace("lng", i18n.resolvedLanguage?.toLowerCase() ?? "")
|
||||
.replace("darkmode", theme.palette.mode === "dark" ? "2" : "1");
|
||||
}, [viewerState?.src, theme]);
|
||||
|
||||
const handlePostMessage = (e: MessageEvent) => {
|
||||
console.log("Received PostMessage from " + e.origin, e.data);
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(e.data);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!viewerState?.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.MessageId === "UI_Sharing" || msg.MessageId === "UI_Share") {
|
||||
dispatch(openShareDialog(FileManagerIndex.main, viewerState?.file));
|
||||
} else if (msg.MessageId == "UI_FileVersions") {
|
||||
dispatch(setVersionControlDialog({ open: true, file: viewerState.file }));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerState || !viewerState.open) {
|
||||
submitedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("message", handlePostMessage, false);
|
||||
return () => {
|
||||
window.removeEventListener("message", handlePostMessage, false);
|
||||
};
|
||||
}, [viewerState?.open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeWopiViewer());
|
||||
}, [dispatch]);
|
||||
|
||||
const openMore = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleIframeOnload = useCallback(() => {
|
||||
if (submitedRef.current) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewerDialog
|
||||
file={viewerState?.file}
|
||||
loading={loading}
|
||||
readOnly={!canEdit}
|
||||
fullScreenToggle
|
||||
dialogProps={{
|
||||
open: !!(viewerState && viewerState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem dense>
|
||||
<ListItemText>{t("modals.saveAs")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
{viewerState && (
|
||||
<WopiForm
|
||||
onSubmit={() => {
|
||||
submitedRef.current = true;
|
||||
}}
|
||||
viewerState={viewerState}
|
||||
replacedSrc={replacedSrc}
|
||||
/>
|
||||
)}
|
||||
{!loaded && <ViewerLoading />}
|
||||
<Box
|
||||
onLoad={handleIframeOnload}
|
||||
component={"iframe"}
|
||||
id={"office_frame"}
|
||||
name={"office_frame"}
|
||||
sandbox={
|
||||
"allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox allow-downloads allow-modals"
|
||||
}
|
||||
allowFullScreen={true}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: loaded ? "100%" : 0,
|
||||
border: "none",
|
||||
minHeight: loaded ? "calc(100vh - 200px)" : 0,
|
||||
}}
|
||||
/>
|
||||
</ViewerDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Wopi;
|
||||
Reference in New Issue
Block a user