mirror of https://github.com/go-gitea/gitea.git
Add support for 3D/CAD file formats preview (#34794)
Fix #34775 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>pull/34909/head
parent
f74a13610d
commit
176962c03e
@ -0,0 +1,76 @@
|
|||||||
|
import type {FileRenderPlugin} from '../render/plugin.ts';
|
||||||
|
import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
|
||||||
|
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
|
||||||
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||||
|
import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts';
|
||||||
|
import {htmlEscape} from 'escape-goat';
|
||||||
|
import {basename} from '../utils.ts';
|
||||||
|
|
||||||
|
const plugins: FileRenderPlugin[] = [];
|
||||||
|
|
||||||
|
function initPluginsOnce(): void {
|
||||||
|
if (plugins.length) return;
|
||||||
|
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
|
||||||
|
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
|
||||||
|
const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons');
|
||||||
|
showElem(toggleButtons);
|
||||||
|
const displayingRendered = Boolean(renderContainer);
|
||||||
|
toggleClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist
|
||||||
|
toggleClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered);
|
||||||
|
// TODO: if there is only one button, hide it?
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
|
||||||
|
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
|
||||||
|
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
|
||||||
|
|
||||||
|
let rendered = false, errorMsg = '';
|
||||||
|
try {
|
||||||
|
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
|
||||||
|
if (plugin) {
|
||||||
|
container.classList.add('is-loading');
|
||||||
|
container.setAttribute('data-render-name', plugin.name); // not used yet
|
||||||
|
await plugin.render(container, rawFileLink);
|
||||||
|
rendered = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg = `${e}`;
|
||||||
|
} finally {
|
||||||
|
container.classList.remove('is-loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rendered) {
|
||||||
|
elViewRawPrompt.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove all children from the container, and only show the raw file link
|
||||||
|
container.replaceChildren(elViewRawPrompt);
|
||||||
|
|
||||||
|
if (errorMsg) {
|
||||||
|
const elErrorMessage = createElementFromHTML(htmlEscape`<div class="ui error message">${errorMsg}</div>`);
|
||||||
|
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initRepoFileView(): void {
|
||||||
|
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
|
||||||
|
initPluginsOnce();
|
||||||
|
const rawFileLink = elFileView.getAttribute('data-raw-file-link');
|
||||||
|
const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
|
||||||
|
// TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not
|
||||||
|
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
|
||||||
|
showRenderRawFileButton(elFileView, renderContainer);
|
||||||
|
// maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it
|
||||||
|
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import {htmlEscape} from 'escape-goat';
|
|
||||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
|
||||||
|
|
||||||
export async function initPdfViewer() {
|
|
||||||
registerGlobalInitFunc('initPdfViewer', async (el: HTMLInputElement) => {
|
|
||||||
const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
|
|
||||||
|
|
||||||
const src = el.getAttribute('data-src');
|
|
||||||
const fallbackText = el.getAttribute('data-fallback-button-text');
|
|
||||||
pdfobject.embed(src, el, {
|
|
||||||
fallbackLink: htmlEscape`
|
|
||||||
<a role="button" class="ui basic button pdf-fallback-button" href="[url]">${fallbackText}</a>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
el.classList.remove('is-loading');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
export type FileRenderPlugin = {
|
||||||
|
// unique plugin name
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
// test if plugin can handle a specified file
|
||||||
|
canHandle: (filename: string, mimeType: string) => boolean;
|
||||||
|
|
||||||
|
// render file content
|
||||||
|
render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>;
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import type {FileRenderPlugin} from '../plugin.ts';
|
||||||
|
import {extname} from '../../utils.ts';
|
||||||
|
|
||||||
|
// support common 3D model file formats, use online-3d-viewer library for rendering
|
||||||
|
|
||||||
|
// eslint-disable-next-line multiline-comment-style
|
||||||
|
/* a simple text STL file example:
|
||||||
|
solid SimpleTriangle
|
||||||
|
facet normal 0 0 1
|
||||||
|
outer loop
|
||||||
|
vertex 0 0 0
|
||||||
|
vertex 1 0 0
|
||||||
|
vertex 0 1 0
|
||||||
|
endloop
|
||||||
|
endfacet
|
||||||
|
endsolid SimpleTriangle
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function newRenderPlugin3DViewer(): FileRenderPlugin {
|
||||||
|
// Some extensions are text-based formats:
|
||||||
|
// .3mf .amf .brep: XML
|
||||||
|
// .fbx: XML or BINARY
|
||||||
|
// .dae .gltf: JSON
|
||||||
|
// .ifc, .igs, .iges, .stp, .step are: TEXT
|
||||||
|
// .stl .ply: TEXT or BINARY
|
||||||
|
// .obj .off .wrl: TEXT
|
||||||
|
// So we need to be able to render when the file is recognized as plaintext file by backend.
|
||||||
|
//
|
||||||
|
// It needs more logic to make it overall right (render a text 3D model automatically):
|
||||||
|
// we need to distinguish the ambiguous filename extensions.
|
||||||
|
// For example: "*.obj, *.off, *.step" might be or not be a 3D model file.
|
||||||
|
// So when it is a text file, we can't assume that "we only render it by 3D plugin",
|
||||||
|
// otherwise the end users would be impossible to view its real content when the file is not a 3D model.
|
||||||
|
const SUPPORTED_EXTENSIONS = [
|
||||||
|
'.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep',
|
||||||
|
'.dae', '.fbx', '.fcstd', '.glb', '.gltf',
|
||||||
|
'.ifc', '.igs', '.iges', '.stp', '.step',
|
||||||
|
'.stl', '.obj', '.off', '.ply', '.wrl',
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: '3d-model-viewer',
|
||||||
|
|
||||||
|
canHandle(filename: string, _mimeType: string): boolean {
|
||||||
|
const ext = extname(filename).toLowerCase();
|
||||||
|
return SUPPORTED_EXTENSIONS.includes(ext);
|
||||||
|
},
|
||||||
|
|
||||||
|
async render(container: HTMLElement, fileUrl: string): Promise<void> {
|
||||||
|
// TODO: height and/or max-height?
|
||||||
|
const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer');
|
||||||
|
const viewer = new OV.EmbeddedViewer(container, {
|
||||||
|
backgroundColor: new OV.RGBAColor(59, 68, 76, 0),
|
||||||
|
defaultColor: new OV.RGBColor(65, 131, 196),
|
||||||
|
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
|
||||||
|
});
|
||||||
|
viewer.LoadModelFromUrlList([fileUrl]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
import type {FileRenderPlugin} from '../plugin.ts';
|
||||||
|
|
||||||
|
export function newRenderPluginPdfViewer(): FileRenderPlugin {
|
||||||
|
return {
|
||||||
|
name: 'pdf-viewer',
|
||||||
|
|
||||||
|
canHandle(filename: string, _mimeType: string): boolean {
|
||||||
|
return filename.toLowerCase().endsWith('.pdf');
|
||||||
|
},
|
||||||
|
|
||||||
|
async render(container: HTMLElement, fileUrl: string): Promise<void> {
|
||||||
|
const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
|
||||||
|
// TODO: the PDFObject library does not support dynamic height adjustment,
|
||||||
|
container.style.height = `${window.innerHeight - 100}px`;
|
||||||
|
if (!PDFObject.default.embed(fileUrl, container)) {
|
||||||
|
throw new Error('Unable to render the PDF file');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue