(frontend) open WOPI files in a new tab

WOPI editors (OnlyOffice / Collabora) work better as a full-page
experience than embedded inside the preview modal. Double-clicking a
WOPI file in the grid or activating one from the search modal now
opens /wopi/:id in a new tab. The preview modal still shows an
"Open in editor" placeholder when a user navigates onto a WOPI file
via keyboard, so the preview flow stays consistent.

The former WopiEditor component is renamed WopiEditorFrame and
hosted on the new page. The inline rename-sync path is removed
because the editor no longer lives inside the items list.
This commit is contained in:
Nathan Vasse
2026-04-16 18:13:08 +02:00
parent f90c5c8b3a
commit 42c34036e4
11 changed files with 124 additions and 23 deletions

View File

@@ -305,7 +305,6 @@ export const GlobalExplorerProvider = ({
currentItem={previewItem}
items={previewItems}
setPreviewItem={setPreviewItem}
onItemsChange={setPreviewItems}
/>
</GlobalExplorerContext.Provider>
);

View File

@@ -16,6 +16,7 @@ import { DefaultRoute, getDefaultRouteId } from "@/utils/defaultRoutes";
import { useMemo } from "react";
import { canCreateChildren } from "@/features/items/utils";
import { Spinner } from "@gouvfr-lasuite/ui-kit";
import { openWopiInNewTab } from "@/features/ui/preview/viewers/wopi/openWopi";
/**
* Wrapper around EmbeddedExplorerGrid to display a list of items in a table.
@@ -46,6 +47,10 @@ export const AppExplorerGrid = () => {
const effectiveOnNavigate = appExplorer.onNavigate ?? onNavigate;
const handleFileClick = appExplorer.onFileClick ?? ((item: Item) => {
if (item.is_wopi_supported) {
openWopiInNewTab(item.id);
return;
}
if (item.url) {
// We need to ensure the preview items list is updated when clicking on a file from the grid. Because this list
// can be updated when clicking on a file from the search modal which sets the preview items to a list of one item.

View File

@@ -31,6 +31,7 @@ import { Key } from "react-aria-components";
import { clearFromRoute, getItemTitle } from "@/features/explorer/utils/utils";
import { messageModalTrashNavigate } from "../../trash/utils";
import { useIsMinimalLayout } from "@/utils/useLayout";
import { openWopiInNewTab } from "@/features/ui/preview/viewers/wopi/openWopi";
type ExplorerSearchModalProps = Pick<ModalProps, "isOpen" | "onClose"> & {
defaultFilters?: ItemFilters;
@@ -99,6 +100,11 @@ export const ExplorerSearchModal = (props: ExplorerSearchModalProps) => {
props.onClose();
}
} else {
if (item.is_wopi_supported) {
openWopiInNewTab(item.id);
props.onClose();
return;
}
setPreviewItems([item]);
setPreviewItem(item);
inputTextSelected.current = false;

View File

@@ -20,7 +20,7 @@ import { AudioPlayer } from "./viewers/audio-player/AudioPlayer";
import { ImageViewer } from "./viewers/image-viewer/ImageViewer";
import { SuspiciousPreview } from "./viewers/suspicious/SuspiciousPreview";
import { NotSupportedPreview } from "./viewers/not-supported/NotSupportedPreview";
import { WopiEditor } from "./viewers/wopi/WopiEditor";
import { WopiOpenInEditor } from "./viewers/wopi/WopiOpenInEditor";
import { OPEN_DELAY } from "./viewers/pdf-preview/pdfConsts";
import { FileIcon } from "@/features/explorer/components/icons/ItemIcon";
@@ -69,7 +69,6 @@ interface FilePreviewProps {
onChangeFile?: (file?: FilePreviewType) => void;
handleDownloadFile?: (file?: FilePreviewType) => void;
hideCloseButton?: boolean;
onFileRename?: (file: FilePreviewType, newName: string) => void;
}
export const FilePreview = ({
@@ -84,7 +83,6 @@ export const FilePreview = ({
onChangeFile,
handleDownloadFile,
hideCloseButton,
onFileRename,
}: FilePreviewProps) => {
const { t } = useTranslation();
const [currentIndex, setCurrentIndex] = useState(initialIndexFile);
@@ -134,7 +132,7 @@ export const FilePreview = ({
return <SuspiciousPreview handleDownload={handleDownload} />;
}
if (currentFile.is_wopi_supported) {
return <WopiEditor item={currentFile} onFileRename={onFileRename} />;
return <WopiOpenInEditor file={currentFile} />;
}
switch (currentFile.category) {

View File

@@ -7,21 +7,17 @@ import { useDownloadItem } from "@/features/items/hooks/useDownloadItem";
import { ItemInfo } from "@/features/items/components/ItemInfo";
import { Button, useModal } from "@gouvfr-lasuite/cunningham-react";
import { ItemShareModal } from "@/features/explorer/components/modals/share/ItemShareModal";
import { useRefreshItemCache } from "@/features/explorer/hooks/useRefreshItems";
type CustomFilesPreviewProps = {
currentItem?: Item;
items: Item[];
setPreviewItem?: (item?: Item) => void;
/** Used for optimistic updates only ( when the file is renamed in the preview ) */
onItemsChange?: (items: Item[]) => void;
};
export const CustomFilesPreview = ({
currentItem,
items,
setPreviewItem,
onItemsChange,
}: CustomFilesPreviewProps) => {
const { t } = useTranslation();
@@ -42,15 +38,6 @@ export const CustomFilesPreview = ({
setPreviewItem?.(item);
};
const refreshItemCache = useRefreshItemCache();
const handleFileRename = (file: FilePreviewType, newName: string) => {
// Optimistic update of the items in the preview.
onItemsChange?.(items.map((item) => item.id === file.id ? { ...item, title: newName } : item));
// Update the item in the explorer if needed.
refreshItemCache(file.id, { title: newName });
};
return (
<FilePreview
isOpen={!!currentItem}
@@ -64,7 +51,6 @@ export const CustomFilesPreview = ({
<CustomFilesPreviewRightHeader currentItem={currentItem} />
}
sidebarContent={currentItem && <ItemInfo item={currentItem} />}
onFileRename={handleFileRename}
/>
);
};

View File

@@ -4,7 +4,7 @@
@use "./components/controls/PreviewControls.scss";
@use "./viewers/not-supported/NotSupportedPreview.scss";
@use "./viewers/suspicious/SuspiciousPreview.scss";
@use "./viewers/wopi/WopiEditor.scss";
@use "./viewers/wopi/WopiEditorFrame.scss";
@use "./viewers/error/ErrorPreview.scss";
@use "./viewers/audio-player/AudioPlayer.scss";
@use "./viewers/image-viewer/ImageViewer.scss";

View File

@@ -3,3 +3,7 @@
height: calc(100vh - 52px);
border: none;
}
.wopi-page .wopi-editor-iframe {
height: 100vh;
}

View File

@@ -5,12 +5,12 @@ import { getDriver } from "@/features/config/Config";
import { ErrorPreview } from "../error/ErrorPreview";
import { FilePreviewType } from "../../FilesPreview";
interface WopiEditorProps {
interface WopiEditorFrameProps {
item: FilePreviewType;
onFileRename?: (file: FilePreviewType, newName: string) => void;
}
export const WopiEditor = ({ item, onFileRename }: WopiEditorProps) => {
export const WopiEditorFrame = ({ item, onFileRename }: WopiEditorFrameProps) => {
const { t } = useTranslation();
const formRef = useRef<HTMLFormElement>(null);
const queryClient = useQueryClient();
@@ -49,7 +49,6 @@ export const WopiEditor = ({ item, onFileRename }: WopiEditorProps) => {
return;
}
// Handle rename notifications from the WOPI editor
if (data.MessageId === "File_Rename") {
onFileRename?.(item, data.Values.NewName);
}

View File

@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { FileIcon } from "@/features/explorer/components/icons/ItemIcon";
import { FilePreviewType } from "../../FilesPreview";
import { openWopiInNewTab } from "./openWopi";
interface WopiOpenInEditorProps {
file: FilePreviewType;
}
export const WopiOpenInEditor = ({ file }: WopiOpenInEditorProps) => {
const { t } = useTranslation();
return (
<div className="file-preview-unsupported">
<div className="file-preview-unsupported__icon">
<FileIcon file={file} size="xlarge" />
</div>
<p className="file-preview-unsupported__title">{file.title}</p>
<p className="file-preview-unsupported__description">
{t("file_preview.wopi.open_in_editor_description")}
</p>
<Button
className="file-preview-unsupported__download-button"
icon={<Icon name="open_in_new" type={IconType.OUTLINED} size={16} />}
onClick={() => openWopiInNewTab(file.id)}
>
{t("file_preview.wopi.open_in_editor")}
</Button>
</div>
);
};

View File

@@ -0,0 +1,9 @@
export const WOPI_TAB_PATH = "/wopi";
export const openWopiInNewTab = (itemId: string): void => {
window.open(
`${WOPI_TAB_PATH}/${encodeURIComponent(itemId)}`,
"_blank",
"noopener,noreferrer",
);
};

View File

@@ -0,0 +1,61 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
import { Icon } from "@gouvfr-lasuite/ui-kit";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { GenericDisclaimer } from "@/features/ui/components/generic-disclaimer/GenericDisclaimer";
import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage";
import { useItem } from "@/features/explorer/hooks/useQueries";
import { useRefreshItemCache } from "@/features/explorer/hooks/useRefreshItems";
import { itemToPreviewFile } from "@/features/explorer/utils/utils";
import { WopiEditorFrame } from "@/features/ui/preview/viewers/wopi/WopiEditorFrame";
import { FilePreviewType } from "@/features/ui/preview/FilesPreview";
export default function WopiPage() {
const { t } = useTranslation();
const router = useRouter();
const itemId = router.query.id as string;
const { data: item, isLoading, error } = useItem(itemId);
const refreshItemCache = useRefreshItemCache();
useEffect(() => {
if (!item) return;
const previousTitle = document.title;
document.title = `${item.title} - ${t("app_title")}`;
return () => {
document.title = previousTitle;
};
}, [item, t]);
if (isLoading || (error && [401, 403].includes(error.code))) {
return <SpinnerPage />;
}
if (!item) {
return (
<GenericDisclaimer
message={t("explorer.files.not_found.description")}
imageSrc="/assets/403-background.png"
>
<Button href="/" icon={<Icon name="home" />}>
{t("403.button")}
</Button>
</GenericDisclaimer>
);
}
const handleRename = (file: FilePreviewType, newName: string) => {
refreshItemCache(file.id, { title: newName });
};
return (
<div className="wopi-page">
<WopiEditorFrame
item={itemToPreviewFile(item)}
onFileRename={handleRename}
/>
</div>
);
}