Add built-in 3D model viewer for fbx/obj/gltf/glb

New 'model3d' builtin viewer backed by three.js: GLTFLoader for
gltf/glb, FBXLoader and OBJLoader for the rest, OrbitControls for
camera, and bounding-sphere auto-framing. The renderer lazy-chunks
(~186KB gz) so it only loads when a model is opened. Registered in the
default file_viewers config; a schema patch appends it to existing
installs on the next release bump.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent d264d3c734
commit c6bf098b87

@ -82,6 +82,7 @@
"redux": "^5.0.0", "redux": "^5.0.0",
"streamsaver": "^2.0.6", "streamsaver": "^2.0.6",
"styled-components": "^6.1.11", "styled-components": "^6.1.11",
"three": "^0.186.0",
"timeago-react": "^3.0.6" "timeago-react": "^3.0.6"
}, },
"devDependencies": { "devDependencies": {
@ -90,6 +91,7 @@
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@types/three": "^0.186.0",
"@types/webappsec-credential-management": "^0.6.9", "@types/webappsec-credential-management": "^0.6.9",
"@types/wicg-file-system-access": "^2023.10.5", "@types/wicg-file-system-access": "^2023.10.5",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",

@ -235,6 +235,8 @@
"subtitleStyles": "Subtitle styles", "subtitleStyles": "Subtitle styles",
"subtitles": "Subtitles", "subtitles": "Subtitles",
"markdownEditor": "Markdown Editor", "markdownEditor": "Markdown Editor",
"modelViewer": "3D Model Viewer",
"modelLoadFailed": "Failed to load 3D model",
"saveSuccess": "Saved successfully at {{time}}", "saveSuccess": "Saved successfully at {{time}}",
"drawioLng": "en", "drawioLng": "en",
"charset": "Charset", "charset": "Charset",

@ -235,6 +235,8 @@
"subtitleStyles": "字幕样式", "subtitleStyles": "字幕样式",
"subtitles": "字幕", "subtitles": "字幕",
"markdownEditor": "Markdown 编辑器", "markdownEditor": "Markdown 编辑器",
"modelViewer": "3D 模型查看器",
"modelLoadFailed": "3D 模型加载失败",
"saveSuccess": "在 {{time}} 保存成功", "saveSuccess": "在 {{time}} 保存成功",
"drawioLng": "zh", "drawioLng": "zh",
"charset": "编码", "charset": "编码",

@ -22,6 +22,7 @@ import VideoViewer from "../../Viewers/Video/VideoViewer.tsx";
import PdfViewer from "../../Viewers/PdfViewer.tsx"; import PdfViewer from "../../Viewers/PdfViewer.tsx";
import CustomViewer from "../../Viewers/CustomViewer.tsx"; import CustomViewer from "../../Viewers/CustomViewer.tsx";
import EpubViewer from "../../Viewers/EpubViewer/EpubViewer.tsx"; import EpubViewer from "../../Viewers/EpubViewer/EpubViewer.tsx";
import ModelViewer from "../../Viewers/ModelViewer/ModelViewer.tsx";
import ExcalidrawViewer from "../../Viewers/Excalidraw/ExcalidrawViewer.tsx"; import ExcalidrawViewer from "../../Viewers/Excalidraw/ExcalidrawViewer.tsx";
import CreateNew from "./CreateNew.tsx"; import CreateNew from "./CreateNew.tsx";
import { useAppSelector } from "../../../redux/hooks.ts"; import { useAppSelector } from "../../../redux/hooks.ts";
@ -71,6 +72,7 @@ const Dialogs = () => {
<PdfViewer /> <PdfViewer />
<CustomViewer /> <CustomViewer />
<EpubViewer /> <EpubViewer />
<ModelViewer />
{showCreateArchive != undefined && <CreateArchive />} {showCreateArchive != undefined && <CreateArchive />}
{showExtractArchive != undefined && <ExtractArchive />} {showExtractArchive != undefined && <ExtractArchive />}
{showRemoteDownload != undefined && <CreateRemoteDownload />} {showRemoteDownload != undefined && <CreateRemoteDownload />}

@ -0,0 +1,75 @@
import { Suspense, useCallback, useEffect, useState } from "react";
import { enqueueSnackbar } from "notistack";
import React from "react";
import { useTranslation } from "react-i18next";
import { getFileEntityUrl } from "../../../api/api.ts";
import { closeModelViewer } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { getFileLinkedUri } from "../../../util";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx";
import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx";
const ModelViewerCanvas = React.lazy(() => import("./ModelViewerCanvas.tsx"));
const ModelViewer = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const viewerState = useAppSelector((state) => state.globalState.modelViewer);
const [loading, setLoading] = useState(false);
const [src, setSrc] = useState("");
useEffect(() => {
if (!viewerState || !viewerState.open) {
return;
}
setSrc("");
setLoading(true);
dispatch(
getFileEntityUrl({
uris: [getFileLinkedUri(viewerState.file)],
entity: viewerState.version,
}),
)
.then((res) => setSrc(res.urls[0].url))
.catch(() => onClose())
.finally(() => setLoading(false));
}, [viewerState]);
const onClose = useCallback(() => {
dispatch(closeModelViewer());
}, [dispatch]);
const onError = useCallback(() => {
enqueueSnackbar({
message: t("fileManager.modelLoadFailed"),
variant: "error",
action: DefaultCloseAction,
});
onClose();
}, [onClose, t]);
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 />}>
<ModelViewerCanvas src={src} fileName={viewerState?.file.name ?? ""} onError={onError} />
</Suspense>
)}
</ViewerDialog>
);
};
export default ModelViewer;

@ -0,0 +1,135 @@
import { Box } from "@mui/material";
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import { fileExtension } from "../../../util";
export interface ModelViewerCanvasProps {
src: string;
fileName: string;
onLoaded?: () => void;
onError?: () => void;
}
const loadModel = (src: string, ext: string): Promise<THREE.Object3D> => {
switch (ext) {
case "gltf":
case "glb":
return new Promise((resolve, reject) =>
new GLTFLoader().load(src, (g) => resolve(g.scene), undefined, reject),
);
case "fbx":
return new Promise((resolve, reject) => new FBXLoader().load(src, resolve, undefined, reject));
case "obj":
return new Promise((resolve, reject) => new OBJLoader().load(src, resolve, undefined, reject));
default:
return Promise.reject(new Error(`unsupported model format: ${ext}`));
}
};
const ModelViewerCanvas = ({ src, fileName, onLoaded, onError }: ModelViewerCanvasProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container || !src) {
return;
}
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 5000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2));
const dir = new THREE.DirectionalLight(0xffffff, 1.5);
dir.position.set(3, 5, 4);
scene.add(dir);
scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x2a2a2a));
let disposed = false;
let model: THREE.Object3D | undefined;
loadModel(src, fileExtension(fileName) ?? "")
.then((obj) => {
if (disposed) {
return;
}
model = obj;
// Center on origin and scale so the model fits the view.
const box = new THREE.Box3().setFromObject(obj);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const radius = Math.max(size.x, size.y, size.z, 1e-6);
obj.position.sub(center);
const scale = 4 / radius;
obj.scale.setScalar(scale);
scene.add(obj);
const sphere = new THREE.Box3().setFromObject(obj).getBoundingSphere(new THREE.Sphere());
camera.position.set(sphere.radius * 1.8, sphere.radius * 1.2, sphere.radius * 1.8);
camera.near = sphere.radius / 100;
camera.far = sphere.radius * 100;
camera.updateProjectionMatrix();
controls.target.set(0, 0, 0);
controls.update();
onLoaded?.();
})
.catch(() => onError?.());
const resize = () => {
const w = container.clientWidth;
const h = container.clientHeight;
if (w === 0 || h === 0) {
return;
}
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
const animate = () => {
if (disposed) {
return;
}
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
return () => {
disposed = true;
observer.disconnect();
controls.dispose();
model?.traverse((o) => {
const mesh = o as THREE.Mesh;
mesh.geometry?.dispose();
const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
if (Array.isArray(material)) {
material.forEach((m) => m.dispose());
} else {
material?.dispose();
}
});
renderer.dispose();
container.removeChild(renderer.domElement);
};
}, [src, fileName]);
return <Box ref={containerRef} sx={{ width: "100%", height: "100%", minHeight: "calc(100vh - 200px)" }} />;
};
export default ModelViewerCanvas;

@ -245,6 +245,7 @@ export interface GlobalStateSlice {
pdfViewer?: GeneralViewerState; pdfViewer?: GeneralViewerState;
customViewer?: CustomViewerState; customViewer?: CustomViewerState;
epubViewer?: GeneralViewerState; epubViewer?: GeneralViewerState;
modelViewer?: GeneralViewerState;
musicPlayer?: MusicPlayerState; musicPlayer?: MusicPlayerState;
excalidrawViewer?: GeneralViewerState; excalidrawViewer?: GeneralViewerState;
archiveViewer?: GeneralViewerState; archiveViewer?: GeneralViewerState;
@ -423,6 +424,7 @@ export const globalStateSlice = createSlice({
state.pdfViewer = undefined; state.pdfViewer = undefined;
state.customViewer = undefined; state.customViewer = undefined;
state.epubViewer = undefined; state.epubViewer = undefined;
state.modelViewer = undefined;
state.excalidrawViewer = undefined; state.excalidrawViewer = undefined;
state.archiveViewer = undefined; state.archiveViewer = undefined;
}, },
@ -575,6 +577,12 @@ export const globalStateSlice = createSlice({
closeEpubViewer: (state) => { closeEpubViewer: (state) => {
state.epubViewer && (state.epubViewer.open = false); state.epubViewer && (state.epubViewer.open = false);
}, },
setModelViewer: (state, action: PayloadAction<GeneralViewerState>) => {
state.modelViewer = action.payload;
},
closeModelViewer: (state) => {
state.modelViewer && (state.modelViewer.open = false);
},
setCustomViewer: (state, action: PayloadAction<CustomViewerState>) => { setCustomViewer: (state, action: PayloadAction<CustomViewerState>) => {
state.customViewer = action.payload; state.customViewer = action.payload;
}, },
@ -874,6 +882,8 @@ export const {
closeMusicPlayer, closeMusicPlayer,
setEpubViewer, setEpubViewer,
closeEpubViewer, closeEpubViewer,
setModelViewer,
closeModelViewer,
setCustomViewer, setCustomViewer,
closeCustomViewer, closeCustomViewer,
setPdfViewer, setPdfViewer,

@ -23,6 +23,7 @@ import {
setImageEditor, setImageEditor,
setImageViewer, setImageViewer,
setMarkdownViewer, setMarkdownViewer,
setModelViewer,
setMusicPlayer, setMusicPlayer,
setPdfViewer, setPdfViewer,
setPhotopeaViewer, setPhotopeaViewer,
@ -54,6 +55,7 @@ export const builtInViewers = {
music: "music", music: "music",
excalidraw: "excalidraw", excalidraw: "excalidraw",
archive: "archive", archive: "archive",
model3d: "model3d",
}; };
export function openViewers( export function openViewers(
@ -239,6 +241,15 @@ export function openViewer(
}), }),
); );
break; break;
case builtInViewers.model3d:
dispatch(
setModelViewer({
open: true,
file,
version: preferredVersion ?? primaryEntity,
}),
);
break;
case builtInViewers.archive: case builtInViewers.archive:
dispatch( dispatch(
setArchiveViewer({ setArchiveViewer({

@ -1297,6 +1297,11 @@
react-devtools-inline "4.4.0" react-devtools-inline "4.4.0"
react-is "^17.0.2" react-is "^17.0.2"
"@dimforge/rapier3d-compat@~0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389"
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
"@emotion/babel-plugin@^11.11.0": "@emotion/babel-plugin@^11.11.0":
version "11.11.0" version "11.11.0"
resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz"
@ -3800,6 +3805,11 @@
dependencies: dependencies:
tippy.js "^6.3.1" tippy.js "^6.3.1"
"@tweenjs/tween.js@~23.1.3":
version "23.1.3"
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d"
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
"@types/acorn@^4.0.0": "@types/acorn@^4.0.0":
version "4.0.6" version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22" resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22"
@ -4056,6 +4066,11 @@
resolved "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz" resolved "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz"
integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==
"@types/stats.js@*":
version "0.17.4"
resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e"
integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==
"@types/streamsaver@^2.0.4": "@types/streamsaver@^2.0.4":
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/streamsaver/-/streamsaver-2.0.4.tgz#b57e47f9246dc0ed69378f7d1387857c94043ecf" resolved "https://registry.yarnpkg.com/@types/streamsaver/-/streamsaver-2.0.4.tgz#b57e47f9246dc0ed69378f7d1387857c94043ecf"
@ -4073,6 +4088,18 @@
dependencies: dependencies:
"@types/geojson" "*" "@types/geojson" "*"
"@types/three@^0.186.0":
version "0.186.0"
resolved "https://registry.yarnpkg.com/@types/three/-/three-0.186.0.tgz#fc936323d7eeb885052fd5de352e3772f488ea99"
integrity sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==
dependencies:
"@dimforge/rapier3d-compat" "~0.12.0"
"@tweenjs/tween.js" "~23.1.3"
"@types/stats.js" "*"
"@types/webxr" ">=0.5.17"
fflate "~0.8.3"
meshoptimizer "~1.1.1"
"@types/trusted-types@^2.0.2": "@types/trusted-types@^2.0.2":
version "2.0.7" version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
@ -4103,6 +4130,11 @@
resolved "https://registry.yarnpkg.com/@types/webappsec-credential-management/-/webappsec-credential-management-0.6.9.tgz#c6c17c631afdfad5859deb69c6a106886b263f73" resolved "https://registry.yarnpkg.com/@types/webappsec-credential-management/-/webappsec-credential-management-0.6.9.tgz#c6c17c631afdfad5859deb69c6a106886b263f73"
integrity sha512-4uDknRaO9G1fonoEALN6SDYvyRb7luVYP7lm7y0OkdVdMcU7rIh+Nq/VepDe0arkh+gfUzwCr5cNfqmKm+x5wA== integrity sha512-4uDknRaO9G1fonoEALN6SDYvyRb7luVYP7lm7y0OkdVdMcU7rIh+Nq/VepDe0arkh+gfUzwCr5cNfqmKm+x5wA==
"@types/webxr@>=0.5.17":
version "0.5.24"
resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44"
integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==
"@types/wicg-file-system-access@^2023.10.5": "@types/wicg-file-system-access@^2023.10.5":
version "2023.10.5" version "2023.10.5"
resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.5.tgz#14b3c25eb4d914b5734795bdea71da229f918b9d" resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.5.tgz#14b3c25eb4d914b5734795bdea71da229f918b9d"
@ -6177,6 +6209,11 @@ fdir@^6.4.3:
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.3.tgz#011cdacf837eca9b811c89dbb902df714273db72" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.3.tgz#011cdacf837eca9b811c89dbb902df714273db72"
integrity sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw== integrity sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==
fflate@~0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc"
integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==
file-entry-cache@^8.0.0: file-entry-cache@^8.0.0:
version "8.0.0" version "8.0.0"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
@ -7702,6 +7739,11 @@ mermaid@10.9.3:
uuid "^9.0.0" uuid "^9.0.0"
web-worker "^1.2.0" web-worker "^1.2.0"
meshoptimizer@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz#20c7d050d59bdc573154814f6a37d47d89908cca"
integrity sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==
micromark-core-commonmark@^1.0.1: micromark-core-commonmark@^1.0.1:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8"
@ -9913,6 +9955,11 @@ terser@^5.17.4:
commander "^2.20.0" commander "^2.20.0"
source-map-support "~0.5.20" source-map-support "~0.5.20"
three@^0.186.0:
version "0.186.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.186.0.tgz#08f70ce80dffa9247a567b421165bec630e86f8d"
integrity sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==
timeago-react@^3.0.6: timeago-react@^3.0.6:
version "3.0.6" version "3.0.6"
resolved "https://registry.yarnpkg.com/timeago-react/-/timeago-react-3.0.6.tgz#d1b55af67ed21b1c5429b9ba8043d0d0f7dab295" resolved "https://registry.yarnpkg.com/timeago-react/-/timeago-react-3.0.6.tgz#d1b55af67ed21b1c5429b9ba8043d0d0f7dab295"

@ -728,6 +728,47 @@ var patches = []Patch{
return nil return nil
}, },
}, },
{
Name: "apply_default_model3d_viewer",
EndVersion: "4.15.0",
Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error {
fileViewersSetting, err := client.Setting.Query().Where(setting.Name("file_viewers")).First(ctx)
if err != nil {
return fmt.Errorf("failed to query file_viewers setting: %w", err)
}
var fileViewers []types.ViewerGroup
if err := json.Unmarshal([]byte(fileViewersSetting.Value), &fileViewers); err != nil {
return fmt.Errorf("failed to unmarshal file_viewers setting: %w", err)
}
for _, viewer := range fileViewers[0].Viewers {
if viewer.ID == "model3d" {
return nil
}
}
var defaultModelViewer types.Viewer
for _, viewer := range defaultFileViewers[0].Viewers {
if viewer.ID == "model3d" {
defaultModelViewer = viewer
break
}
}
fileViewers[0].Viewers = append(fileViewers[0].Viewers, defaultModelViewer)
newFileViewersSetting, err := json.Marshal(fileViewers)
if err != nil {
return fmt.Errorf("failed to marshal file_viewers setting: %w", err)
}
if _, err := client.Setting.UpdateOne(fileViewersSetting).SetValue(string(newFileViewersSetting)).Save(ctx); err != nil {
return fmt.Errorf("failed to update file_viewers setting: %w", err)
}
return nil
},
},
{ {
Name: "reset_secret_key", Name: "reset_secret_key",
EndVersion: "4.13.0", EndVersion: "4.13.0",

@ -294,6 +294,12 @@ var (
DisplayName: "fileManager.imageViewer", DisplayName: "fileManager.imageViewer",
Exts: []string{"bmp", "png", "gif", "jpg", "jpeg", "svg", "webp", "heic", "heif"}, Exts: []string{"bmp", "png", "gif", "jpg", "jpeg", "svg", "webp", "heic", "heif"},
}, },
{
ID: "model3d",
Type: types.ViewerTypeBuiltin,
DisplayName: "fileManager.modelViewer",
Exts: []string{"fbx", "obj", "gltf", "glb"},
},
{ {
ID: "monaco", ID: "monaco",
Type: types.ViewerTypeBuiltin, Type: types.ViewerTypeBuiltin,

Loading…
Cancel
Save