Skip to content

Commit 057ee0e

Browse files
committed
feat: add plugin-based file rendering system with 3D file preview support
1 parent cda90ec commit 057ee0e

File tree

8 files changed

+280
-1
lines changed

8 files changed

+280
-1
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"minimatch": "10.0.2",
4040
"monaco-editor": "0.52.2",
4141
"monaco-editor-webpack-plugin": "7.1.0",
42+
"online-3d-viewer": "0.16.0",
4243
"pdfobject": "2.3.1",
4344
"perfect-debounce": "1.0.0",
4445
"postcss": "8.5.5",

templates/repo/view_file.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
{{else if .IsPDFFile}}
111111
<div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div>
112112
{{else}}
113-
<a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a>
113+
<div class="file-view-container" data-global-init="initFileView" data-filename="{{.TreePath}}" data-url="{{$.RawFileLink}}" data-fallback-text="{{ctx.Locale.Tr "repo.file_view_raw"}}"></div>
114114
{{end}}
115115
</div>
116116
{{else if .FileSize}}

web_src/css/file-view.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* File View & Render Plugin Styles
3+
*/
4+
5+
/* file view container */
6+
.file-view-container {
7+
position: relative;
8+
width: 100%;
9+
min-height: 200px;
10+
display: flex;
11+
align-items: center;
12+
justify-content: center;
13+
}
14+
15+
.file-view-container.is-loading {
16+
position: relative;
17+
}
18+
19+
.file-view-container.is-loading::after {
20+
content: "";
21+
position: absolute;
22+
left: 50%;
23+
top: 50%;
24+
width: 40px;
25+
height: 40px;
26+
margin-left: -20px;
27+
margin-top: -20px;
28+
border: 5px solid var(--color-secondary);
29+
border-top-color: transparent;
30+
border-radius: 50%;
31+
animation: spin 1s linear infinite;
32+
}
33+
34+
.view-raw-fallback {
35+
padding: 16px;
36+
text-align: center;
37+
}
38+
39+
/* 3D model viewer */
40+
.model3d-content {
41+
width: 100% !important;
42+
min-height: 400px !important;
43+
border: none !important;
44+
display: flex;
45+
align-items: center;
46+
justify-content: center;
47+
}
48+
49+
@keyframes spin {
50+
0% {
51+
transform: rotate(0deg);
52+
}
53+
100% {
54+
transform: rotate(360deg);
55+
}
56+
}
57+
58+
/* error message */
59+
.file-view-container .ui.error.message {
60+
margin: 1em 0;
61+
width: 100%;
62+
}
63+
64+
.file-view-container .ui.error.message pre {
65+
margin-top: 0.5em;
66+
font-size: 12px;
67+
max-height: 150px;
68+
overflow: auto;
69+
background-color: rgba(255, 255, 255, 0.1);
70+
padding: 0.5em;
71+
}

web_src/css/index.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,6 @@
8585

8686
@import "./helpers.css";
8787

88+
@import "./file-view.css";
89+
8890
@tailwind utilities;

web_src/js/features/file-view.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {applyRenderPlugin} from '../modules/file-render-plugin.ts';
2+
import {registerGlobalInitFunc} from '../modules/observer.ts';
3+
4+
/**
5+
* init file view renderer
6+
*
7+
* detect renderable files and apply appropriate plugins
8+
*/
9+
export function initFileView(): void {
10+
// register file view renderer init function
11+
registerGlobalInitFunc('initFileView', async (container: HTMLElement) => {
12+
// get file info
13+
const filename = container.getAttribute('data-filename');
14+
const fileUrl = container.getAttribute('data-url');
15+
16+
// mark loading state
17+
container.classList.add('is-loading');
18+
19+
try {
20+
// check if filename and url exist
21+
if (!filename || !fileUrl) {
22+
console.error(`missing filename(${filename}) or file url(${fileUrl}) for rendering`);
23+
throw new Error('missing necessary file info');
24+
}
25+
26+
// try to apply render plugin
27+
const success = await applyRenderPlugin(container);
28+
29+
// if no suitable plugin is found, show default view
30+
if (!success) {
31+
// show default view raw file link
32+
const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File';
33+
34+
container.innerHTML = `
35+
<div class="view-raw-fallback">
36+
<a href="${fileUrl}" class="ui basic button" target="_blank">${fallbackText}</a>
37+
</div>
38+
`;
39+
}
40+
} catch (error) {
41+
console.error('file view init error:', error);
42+
43+
// show error message
44+
const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File';
45+
46+
container.innerHTML = `
47+
<div class="ui error message">
48+
<div class="header">Failed to render file</div>
49+
<p>Error: ${String(error)}</p>
50+
<pre>${JSON.stringify({filename, fileUrl}, null, 2)}</pre>
51+
<a class="ui basic button" href="${fileUrl || '#'}" target="_blank">${fallbackText}</a>
52+
</div>
53+
`;
54+
} finally {
55+
// remove loading state
56+
container.classList.remove('is-loading');
57+
}
58+
});
59+
}

web_src/js/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {initStopwatch} from './features/stopwatch.ts';
2020
import {initFindFileInRepo} from './features/repo-findfile.ts';
2121
import {initMarkupContent} from './markup/content.ts';
2222
import {initPdfViewer} from './render/pdf.ts';
23+
import {initFileView} from './features/file-view.ts';
24+
import {register3DViewerPlugin} from './render/plugins/3d-viewer.ts';
2325
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
2426
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
2527
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
@@ -163,6 +165,9 @@ onDomReady(() => {
163165
initColorPickers,
164166

165167
initOAuth2SettingsDisableCheckbox,
168+
169+
initFileView,
170+
register3DViewerPlugin,
166171
]);
167172

168173
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* File Render Plugin System
3+
*
4+
* This module provides a plugin architecture for rendering different file types
5+
* in the browser without requiring backend support for identifying file types.
6+
*/
7+
8+
/**
9+
* Interface for file render plugins
10+
*/
11+
export type FileRenderPlugin = {
12+
// unique plugin name
13+
name: string;
14+
15+
// test if plugin can handle specified file
16+
canHandle: (filename: string, mimeType: string) => boolean;
17+
18+
// render file content
19+
render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>;
20+
}
21+
22+
// store registered render plugins
23+
const plugins: FileRenderPlugin[] = [];
24+
25+
/**
26+
* register a file render plugin
27+
*/
28+
export function registerFileRenderPlugin(plugin: FileRenderPlugin): void {
29+
plugins.push(plugin);
30+
}
31+
32+
/**
33+
* find suitable render plugin by filename and mime type
34+
*/
35+
function findPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
36+
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
37+
}
38+
39+
/**
40+
* apply render plugin to specified container
41+
*/
42+
export async function applyRenderPlugin(container: HTMLElement): Promise<boolean> {
43+
try {
44+
// get file info from container element
45+
const filename = container.getAttribute('data-filename') || '';
46+
const fileUrl = container.getAttribute('data-url') || '';
47+
48+
if (!filename || !fileUrl) {
49+
console.warn('Missing filename or file URL for renderer');
50+
return false;
51+
}
52+
53+
// get mime type (optional)
54+
const mimeType = container.getAttribute('data-mime-type') || '';
55+
56+
// find plugin that can handle this file
57+
const plugin = findPlugin(filename, mimeType);
58+
if (!plugin) {
59+
return false;
60+
}
61+
62+
// apply plugin to render file
63+
await plugin.render(container, fileUrl);
64+
return true;
65+
} catch (error) {
66+
console.error('Error applying render plugin:', error);
67+
return false;
68+
}
69+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type {FileRenderPlugin} from '../../modules/file-render-plugin.ts';
2+
import {registerFileRenderPlugin} from '../../modules/file-render-plugin.ts';
3+
4+
/**
5+
* 3D model file render plugin
6+
*
7+
* support common 3D model file formats, use online-3d-viewer library for rendering
8+
*/
9+
export function register3DViewerPlugin(): void {
10+
// supported 3D file extensions
11+
const SUPPORTED_EXTENSIONS = [
12+
'.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep',
13+
'.dae', '.fbx', '.fcstd', '.glb', '.gltf',
14+
'.ifc', '.igs', '.iges', '.stp', '.step',
15+
'.stl', '.obj', '.off', '.ply', '.wrl',
16+
];
17+
18+
// create and register plugin
19+
const plugin: FileRenderPlugin = {
20+
name: '3d-model-viewer',
21+
22+
// check if file extension is supported 3D file
23+
canHandle(filename: string, _mimeType: string): boolean {
24+
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
25+
const canHandle = SUPPORTED_EXTENSIONS.includes(ext);
26+
return canHandle;
27+
},
28+
29+
// render 3D model
30+
async render(container: HTMLElement, fileUrl: string): Promise<void> {
31+
// add loading indicator
32+
container.classList.add('is-loading');
33+
34+
try {
35+
// dynamically load 3D rendering library
36+
const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer');
37+
38+
// configure container style
39+
container.classList.add('model3d-content');
40+
41+
// initialize 3D viewer
42+
const viewer = new OV.EmbeddedViewer(container, {
43+
backgroundColor: new OV.RGBAColor(59, 68, 76, 0), // transparent
44+
defaultColor: new OV.RGBColor(65, 131, 196),
45+
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
46+
});
47+
48+
// load model from url
49+
viewer.LoadModelFromUrlList([fileUrl]);
50+
} catch (error) {
51+
// handle render error
52+
console.error('error rendering 3D model:', error);
53+
54+
// add error message and download button
55+
const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File';
56+
container.innerHTML = `
57+
<div class="ui error message">
58+
<div class="header">Failed to render 3D model</div>
59+
<p>The 3D model could not be displayed in the browser.</p>
60+
<a class="ui basic button" href="${fileUrl}" target="_blank">${fallbackText}</a>
61+
</div>
62+
`;
63+
} finally {
64+
// remove loading state
65+
container.classList.remove('is-loading');
66+
}
67+
},
68+
};
69+
70+
// register plugin
71+
registerFileRenderPlugin(plugin);
72+
}

0 commit comments

Comments
 (0)