first commit

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

View 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;

View 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;

View 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;

View 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);

View 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;

View 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;

View 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), "*");
};

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,4 @@
.excalidraw {
min-height: calc(100vh - 200px);
--zIndex-modal: 1400;
}

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,9 @@
.SfxModal-Wrapper {
z-index: 20001 !important;
}
.SfxPopper-wrapper {
z-index: 20002 !important;
}
.FIE_root {
border-radius: 0 !important;
}

View 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.

View 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;
}

View 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;
}

View 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;
}
}

View 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]} />;
};

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;
}

View File

@@ -0,0 +1,12 @@
.PhotoView {
&-Portal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1250;
overflow: hidden;
touch-action: none;
}
}

View File

@@ -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;

View 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;
}
}
}

View 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;

View File

@@ -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]);
}

View File

@@ -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,
});
}
}

View File

@@ -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;
}

View File

@@ -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,
];
}

View 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);
}
};
}

View File

@@ -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>;
}

View 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]);
}

View File

@@ -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;
}

View 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;
}

View File

@@ -0,0 +1,5 @@
import { useEffect, useLayoutEffect } from "react";
const isSSR = typeof window === "undefined" || /ServerSideRendering/.test(navigator && navigator.userAgent);
export default isSSR ? useEffect : useLayoutEffect;

View 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;
}

View 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;

View 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);
}
}

View 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,
);
}

View 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;
}

View File

@@ -0,0 +1,5 @@
import PhotoProvider from "./PhotoProvider";
import PhotoView from "./PhotoView";
import PhotoSlider from "./PhotoSlider";
export { PhotoProvider, PhotoView, PhotoSlider };

View 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);

View 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;

View 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;
};

View File

@@ -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];
}

View File

@@ -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,
};
}

View 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;
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1,6 @@
/**
* 是否支持触摸设备
*/
const isTouchDevice = typeof window !== "undefined" && "ontouchstart" in window;
export default isTouchDevice;

View 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));
};

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>;
}

View 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;

View 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;

View File

@@ -0,0 +1,3 @@
.art-video-player {
min-height: calc(100vh - 350px);
}

View 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
View 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;