feat(file apps): add excalidraw

This commit is contained in:
Aaron Liu 2025-06-21 12:02:00 +08:00
parent 93d616e742
commit 44a696a2e7
10 changed files with 1602 additions and 24 deletions

View File

@ -13,6 +13,7 @@
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@excalidraw/excalidraw": "^0.18.0",
"@fontsource/roboto": "^5.0.8",
"@giscus/react": "^3.1.0",
"@marsidev/react-turnstile": "^1.1.0",

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000">
<rect width="1000" height="1000" rx="200" ry="200" fill="#fff" />
<svg viewBox="0 0 107 101" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
<path style="fill:none" d="M24 17h121v121H24z" transform="matrix(.8843 0 0 .83471 -21.223 -14.19)" />
<path d="M119.81 105.98a.549.549 0 0 0-.53-.12c-4.19-6.19-9.52-12.06-14.68-17.73l-.85-.93c0-.11-.05-.21-.12-.3a.548.548 0 0 0-.34-.2l-.17-.18-.12-.09c-.15-.32-.53-.56-.95-.35-1.58.81-3 1.97-4.4 3.04-1.87 1.43-3.7 2.92-5.42 4.52-.7.65-1.39 1.33-1.97 2.09-.28.37-.07.72.27.87-1.22 1.2-2.45 2.45-3.68 3.74-.11.12-.17.28-.16.44.01.16.09.31.22.41l2.16 1.65s.01.03.03.04c3.09 3.05 8.51 7.28 14.25 11.76.85.67 1.71 1.34 2.57 2.01.39.47.76.94 1.12 1.4.19.25.55.3.8.11.13.1.26.21.39.31a.57.57 0 0 0 .8-.1c.07-.09.1-.2.11-.31.04 0 .07.03.1.03.15 0 .31-.06.42-.18l10.18-11.12a.56.56 0 0 0-.04-.8l.01-.01Zm-29.23-3.85c.07.09.14.17.21.25 1.16.98 2.4 2.04 3.66 3.12l-5.12-3.91s-.32-.22-.52-.36c-.11-.08-.21-.16-.31-.24l-.38-.32s.07-.07.1-.11l.35-.35c1.72-1.74 4.67-4.64 6.19-6.06-1.61 1.62-4.87 6.37-4.17 7.98h-.01Zm17.53 13.81-4.22-3.22c-1.65-1.71-3.43-3.4-5.24-5.03 2.28 1.76 4.23 3.25 4.52 3.51 2.21 1.97 2.11 1.61 3.63 2.91l1.83 1.33c-.18.16-.36.33-.53.49l.01.01Zm1.06.81-.08-.06c.16-.13.33-.25.49-.38l-.4.44h-.01ZM42.24 51.45c.14.72.27 1.43.4 2.11.69 3.7 1.33 7.03 2.55 9.56l.48 1.92c.19.73.46 1.64.71 1.83 2.85 2.52 7.22 6.28 11.89 9.82.21.16.5.15.7-.01.01.02.03.03.04.04.11.1.24.15.38.15.16 0 .31-.06.42-.19 5.98-6.65 10.43-12.12 13.6-16.7.2-.25.3-.54.29-.84.2-.24.41-.48.6-.68a.558.558 0 0 0-.1-.86.578.578 0 0 0-.17-.36c-1.39-1.34-2.42-2.31-3.46-3.28-1.84-1.72-3.74-3.5-7.77-7.51-.02-.02-.05-.04-.07-.06a.555.555 0 0 0-.22-.14c-1.11-.39-3.39-.78-6.26-1.28-4.22-.72-10-1.72-15.2-3.27h-.04v-.01s-.02 0-.03.02h-.01l.04-.02s-.31.01-.37.04c-.08.04-.14.09-.19.15-.05.06-.09.12-.47.2-.38.08.08 0 .11 0h-.11v.03c.07.34.05.58.16.97-.02.1.21 1.02.24 1.11l1.83 7.26h.03Zm30.95 6.54s-.03.04-.04.05l-.64-.71c.22.21.44.42.68.66Zm-7.09 9.39s-.07.08-.1.12l-.02-.02c.04-.03.08-.07.13-.1h-.01Zm-7.07 8.47Zm3.02-28.57c.35.35 1.74 1.65 2.06 1.97-1.45-.66-5.06-2.34-6.74-2.88 1.65.29 3.93.66 4.68.91Zm-19.18-2.77c.84 1.44 1.5 6.49 2.16 11.4-.37-1.58-.69-3.12-.99-4.6-.52-2.56-1-4.85-1.67-6.88.14.01.31.03.49.05 0 .01 0 .02.02.03h-.01Zm-.29-1.21c-.23-.02-.44-.04-.62-.05-.02-.04-.03-.08-.04-.12l.66.18v-.01Zm-2.22.45v-.02.02ZM118.9 42.57c.04-.23-1.1-1.24-.74-1.26.85-.04.86-1.35 0-1.31-1.13.06-2.27.32-3.37.53-1.98.37-3.95.78-5.92 1.21-4.39.94-8.77 1.93-13.1 3.11-1.36.37-2.86.7-4.11 1.36-.42.22-.4.67-.17.95-.09.05-.18.08-.28.09-.37.07-.74.13-1.11.19a.566.566 0 0 0-.39.86c-2.32 3.1-4.96 6.44-7.82 9.95-2.81 3.21-5.73 6.63-8.72 10.14-9.41 11.06-20.08 23.6-31.9 34.64-.23.21-.24.57-.03.8.05.06.12.1.19.13-.16.15-.32.3-.48.44-.1.09-.14.2-.16.32-.08.08-.16.17-.23.25-.21.23-.2.59.03.8.23.21.59.2.8-.03.04-.04.08-.09.12-.13a.84.84 0 0 1 1.22 0c.69.74 1.34 1.44 1.95 2.09l-1.38-1.15a.57.57 0 0 0-.8.07c-.2.24-.17.6.07.8l14.82 12.43c.11.09.24.13.37.13.15 0 .29-.06.4-.17l.36-.36a.56.56 0 0 0 .63-.12c20.09-20.18 36.27-35.43 54.8-49.06.17-.12.25-.32.23-.51a.57.57 0 0 0 .48-.39c3.42-10.46 4.08-19.72 4.28-24.27 0-.03.01-.05.02-.07.02-.05.03-.1.04-.14.03-.11.05-.19.05-.19.26-.78.17-1.53-.15-2.15v.02ZM82.98 58.94c.9-1.03 1.79-2.04 2.67-3.02-5.76 7.58-15.3 19.26-28.81 33.14 9.2-10.18 18.47-20.73 26.14-30.12Zm-32.55 52.81-.03-.03c.11.02.19.04.2.04a.47.47 0 0 0-.17 0v-.01Zm6.9 6.42-.05-.04.03-.03c.02 0 .03.02.04.02 0 .02-.02.03-.03.05h.01Zm8.36-7.21 1.38-1.44c.01.01.02.03.03.05-.47.46-.94.93-1.42 1.39h.01Zm2.24-2.21c.26-.3.56-.65.87-1.02.01-.01.02-.03.04-.04 3.29-3.39 6.68-6.82 10.18-10.25.02-.02.05-.04.07-.06.86-.66 1.82-1.39 2.72-2.08-4.52 4.32-9.11 8.78-13.88 13.46v-.01Zm21.65-55.88c-1.86 2.42-3.9 5.56-5.63 8.07-5.46 7.91-23.04 27.28-23.43 27.65-2.71 2.62-10.88 10.46-16.09 15.37-.14.13-.25.24-.34.35a.794.794 0 0 1 .03-1.13c24.82-23.4 39.88-42.89 46-51.38-.13.33-.24.69-.55 1.09l.01-.02Zm16.51 7.1-.01.02c0-.02-.02-.07.01-.02Zm-.91-5.13Zm-5.89 9.45c-2.26-1.31-3.32-3.27-2.71-5.25l.19-.66c.08-.19.17-.38.28-.57.59-.98 1.49-1.85 2.52-2.36.05-.02.1-.03.15-.04a.795.795 0 0 1-.04-.43c.05-.31.25-.58.66-.58.67 0 2.75.62 3.54 1.3.24.19.47.4.68.63.3.35.74.92.96 1.33.13.06.23.62.38.91.14.46.2.93.18 1.4 0 .02 0 .02.01.03-.03.07 0 .37-.04.4-.1.72-.36 1.43-.75 2.05-.04.05-.07.11-.11.16 0 .01-.02.02-.03.04-.3.43-.65.83-1.08 1.13-1.26.89-2.73 1.16-4.2.79a6.33 6.33 0 0 1-.57-.25l-.02-.03Zm16.27-1.63c-.49 2.05-1.09 4.19-1.8 6.38-.03.08-.03.16-.03.23-.1.01-.19.05-.27.11-4.44 3.26-8.73 6.62-12.98 10.11 3.67-3.32 7.39-6.62 11.23-9.95a6.409 6.409 0 0 0 2.11-3.74l.56-3.37.03-.1c.25-.71 1.34-.4 1.17.33h-.02Z" style="fill:#6965db;fill-rule:nonzero" transform="matrix(1 0 0 1 -26.41 -29.49)" />
</svg>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -20,6 +20,7 @@ import VideoViewer from "../../Viewers/Video/VideoViewer.tsx";
import PdfViewer from "../../Viewers/PdfViewer.tsx";
import CustomViewer from "../../Viewers/CustomViewer.tsx";
import EpubViewer from "../../Viewers/EpubViewer/EpubViewer.tsx";
import ExcalidrawViewer from "../../Viewers/Excalidraw/ExcalidrawViewer.tsx";
import CreateNew from "./CreateNew.tsx";
import { useAppSelector } from "../../../redux/hooks.ts";
import CreateArchive from "./CreateArchive.tsx";
@ -37,6 +38,7 @@ const Dialogs = () => {
const showAdvancedSearch = useAppSelector((state) => state.globalState.advanceSearchOpen);
const showListViewColumnSetting = useAppSelector((state) => state.globalState.listViewColumnSettingDialogOpen);
const directLink = useAppSelector((state) => state.globalState.directLinkDialogOpen);
const excalidrawViewer = useAppSelector((state) => state.globalState.excalidrawViewer);
return (
<>
@ -69,6 +71,7 @@ const Dialogs = () => {
{showAdvancedSearch != undefined && <AdvanceSearch />}
{showListViewColumnSetting != undefined && <ColumnSetting />}
{directLink != undefined && <DirectLinks />}
{excalidrawViewer != undefined && <ExcalidrawViewer />}
</>
);
};

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

@ -215,6 +215,7 @@ export interface GlobalStateSlice {
customViewer?: CustomViewerState;
epubViewer?: GeneralViewerState;
musicPlayer?: MusicPlayerState;
excalidrawViewer?: GeneralViewerState;
// Viewer selector
viewerSelector?: ViewerSelectorState;
@ -328,6 +329,7 @@ export const globalStateSlice = createSlice({
state.pdfViewer = undefined;
state.customViewer = undefined;
state.epubViewer = undefined;
state.excalidrawViewer = undefined;
},
setExtractArchiveDialog: (state, action: PayloadAction<{ open: boolean; file?: FileResponse }>) => {
state.extractArchiveDialogOpen = action.payload.open;
@ -466,6 +468,12 @@ export const globalStateSlice = createSlice({
closeMarkdownViewer: (state) => {
state.markdownViewer && (state.markdownViewer.open = false);
},
setExcalidrawViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.excalidrawViewer = action.payload;
},
closeExcalidrawViewer: (state) => {
state.excalidrawViewer && (state.excalidrawViewer.open = false);
},
addShareInfo: (state, action: PayloadAction<{ info: Share; id: string }>) => {
state.shareInfo[action.payload.id] = action.payload.info;
},
@ -785,4 +793,6 @@ export const {
resetDialogs,
setPolicyOptionCache,
setSearchPopup,
setExcalidrawViewer,
closeExcalidrawViewer,
} = globalStateSlice.actions;

View File

@ -17,6 +17,7 @@ import {
setCustomViewer,
setDrawIOViewer,
setEpubViewer,
setExcalidrawViewer,
setImageEditor,
setImageViewer,
setMarkdownViewer,
@ -48,6 +49,7 @@ export const builtInViewers = {
pdf: "pdf",
epub: "epub",
music: "music",
excalidraw: "excalidraw",
};
export function openViewers(
@ -180,6 +182,15 @@ export function openViewer(file: FileResponse, viewer: Viewer, size: number, pre
}),
);
break;
case builtInViewers.excalidraw:
dispatch(
setExcalidrawViewer({
open: true,
file,
version: preferredVersion ?? primaryEntity,
}),
);
break;
case builtInViewers.video:
dispatch(
setVideoViewer({
@ -580,6 +591,36 @@ export function saveDrawIO(
};
}
export function saveExcalidraw(
data: string,
file: FileResponse,
version?: string,
saveAsNew?: boolean,
): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const isLinkedFile = file.metadata?.[Metadata.share_redirect] ?? false;
if (!version && !isLinkedFile) {
version = file.primary_entity;
}
const savedFile = await dispatch(saveFile(getFileLinkedUri(file), data, version, saveAsNew));
if (savedFile) {
const {
globalState: { excalidrawViewer },
} = getState();
if (excalidrawViewer) {
dispatch(
setExcalidrawViewer({
...excalidrawViewer,
file: savedFile,
version: savedFile.primary_entity,
}),
);
}
}
};
}
export function saveMarkdown(
data: string,
file: FileResponse,

View File

@ -70,6 +70,17 @@ export default defineConfig({
if (id.includes("@codemirror")) {
return "codemirror";
}
if (
id.toLocaleLowerCase().includes("excalidraw") ||
id.includes("browser-fs-access") ||
id.includes("image-blob-reduce") ||
id.includes("pica")
) {
return "excalidraw";
}
if (id.includes("mermaid") || id.includes("katex")) {
return "mermaid";
}
},
},
},

1302
yarn.lock

File diff suppressed because it is too large Load Diff