diff --git a/package-lock.json b/package-lock.json index d48702d31e..8625b4b50a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "minimatch": "10.0.2", "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", + "online-3d-viewer": "0.16.0", "pdfobject": "2.3.1", "perfect-debounce": "1.0.0", "postcss": "8.5.5", @@ -2026,6 +2027,16 @@ "vue": "^3.2.29" } }, + "node_modules/@simonwep/pickr": { + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/@simonwep/pickr/-/pickr-1.9.0.tgz", + "integrity": "sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==", + "license": "MIT", + "dependencies": { + "core-js": "3.32.2", + "nanopop": "2.3.0" + } + }, "node_modules/@stoplight/better-ajv-errors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", @@ -5337,6 +5348,17 @@ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.32.2", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.32.2.tgz", + "integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.43.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz", @@ -7721,6 +7743,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -10285,6 +10313,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanopop": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.3.0.tgz", + "integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", @@ -10525,6 +10559,17 @@ "wrappy": "1" } }, + "node_modules/online-3d-viewer": { + "version": "0.16.0", + "resolved": "https://registry.npmmirror.com/online-3d-viewer/-/online-3d-viewer-0.16.0.tgz", + "integrity": "sha512-Mcmo41TM3K+svlMDRH8ySKSY2e8s7Sssdb5U9LV3gkFKVWGGuS304Vk5gqxopAJbE72DpsC67Ve3YNtcAuROwQ==", + "license": "MIT", + "dependencies": { + "@simonwep/pickr": "1.9.0", + "fflate": "0.8.2", + "three": "0.176.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13193,6 +13238,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.176.0", + "resolved": "https://registry.npmmirror.com/three/-/three-0.176.0.tgz", + "integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==", + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", diff --git a/package.json b/package.json index 3dab385c6e..e64db88b0e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "minimatch": "10.0.2", "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", + "online-3d-viewer": "0.16.0", "pdfobject": "2.3.1", "perfect-debounce": "1.0.0", "postcss": "8.5.5", diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index b49818c6b7..0e09bdd9e8 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -110,7 +110,7 @@ {{else if .IsPDFFile}}
{{else}} - {{ctx.Locale.Tr "repo.file_view_raw"}} +
{{end}} {{else if .FileSize}} diff --git a/tests/integration/lfs_view_test.go b/tests/integration/lfs_view_test.go index 64ffebaa78..f550bc3bbe 100644 --- a/tests/integration/lfs_view_test.go +++ b/tests/integration/lfs_view_test.go @@ -73,9 +73,14 @@ func TestLFSRender(t *testing.T) { fileInfo := doc.Find("div.file-info-entry").First().Text() assert.Contains(t, fileInfo, "LFS") - rawLink, exists := doc.Find("div.file-view > div.view-raw > a").Attr("href") - assert.True(t, exists, "Download link should render instead of content because this is a binary file") - assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", rawLink, "The download link should use the proper /media link because it's in LFS") + // find new file view container + fileViewContainer := doc.Find("div.file-view-container") + assert.Positive(t, fileViewContainer.Length(), "File view container should exist") + + // check data attribute instead of link href + dataURL, exists := fileViewContainer.Attr("data-url") + assert.True(t, exists, "File view container should have data-url attribute") + assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", dataURL, "The data-url should use the proper /media link because it's in LFS") }) // check that a directory with a README file shows its text diff --git a/web_src/css/file-view.css b/web_src/css/file-view.css new file mode 100644 index 0000000000..811a294c84 --- /dev/null +++ b/web_src/css/file-view.css @@ -0,0 +1,71 @@ +/** + * File View & Render Plugin Styles + */ + +/* file view container */ +.file-view-container { + position: relative; + width: 100%; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; +} + +.file-view-container.is-loading { + position: relative; +} + +.file-view-container.is-loading::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 40px; + height: 40px; + margin-left: -20px; + margin-top: -20px; + border: 5px solid var(--color-secondary); + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.view-raw-fallback { + padding: 16px; + text-align: center; +} + +/* 3D model viewer */ +.model3d-content { + width: 100% !important; + min-height: 400px !important; + border: none !important; + display: flex; + align-items: center; + justify-content: center; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* error message */ +.file-view-container .ui.error.message { + margin: 1em 0; + width: 100%; +} + +.file-view-container .ui.error.message pre { + margin-top: 0.5em; + font-size: 12px; + max-height: 150px; + overflow: auto; + background-color: rgba(255, 255, 255, 0.1); + padding: 0.5em; +} \ No newline at end of file diff --git a/web_src/css/index.css b/web_src/css/index.css index 291cd04b2b..e0d0080d06 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -85,4 +85,6 @@ @import "./helpers.css"; +@import "./file-view.css"; + @tailwind utilities; diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts new file mode 100644 index 0000000000..62e204a21d --- /dev/null +++ b/web_src/js/features/file-view.ts @@ -0,0 +1,59 @@ +import {applyRenderPlugin} from '../modules/file-render-plugin.ts'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; + +/** + * init file view renderer + * + * detect renderable files and apply appropriate plugins + */ +export function initFileView(): void { + // register file view renderer init function + registerGlobalInitFunc('initFileView', async (container: HTMLElement) => { + // get file info + const filename = container.getAttribute('data-filename'); + const fileUrl = container.getAttribute('data-url'); + + // mark loading state + container.classList.add('is-loading'); + + try { + // check if filename and url exist + if (!filename || !fileUrl) { + console.error(`missing filename(${filename}) or file url(${fileUrl}) for rendering`); + throw new Error('missing necessary file info'); + } + + // try to apply render plugin + const success = await applyRenderPlugin(container); + + // if no suitable plugin is found, show default view + if (!success) { + // show default view raw file link + const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File'; + + container.innerHTML = ` +
+ ${fallbackText} +
+ `; + } + } catch (error) { + console.error('file view init error:', error); + + // show error message + const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File'; + + container.innerHTML = ` +
+
Failed to render file
+

Error: ${String(error)}

+
${JSON.stringify({filename, fileUrl}, null, 2)}
+ ${fallbackText} +
+ `; + } finally { + // remove loading state + container.classList.remove('is-loading'); + } + }); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 7e84773bc1..a1da766ccf 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -20,6 +20,8 @@ import {initStopwatch} from './features/stopwatch.ts'; import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initMarkupContent} from './markup/content.ts'; import {initPdfViewer} from './render/pdf.ts'; +import {initFileView} from './features/file-view.ts'; +import {register3DViewerPlugin} from './render/plugins/3d-viewer.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; @@ -163,6 +165,9 @@ onDomReady(() => { initColorPickers, initOAuth2SettingsDisableCheckbox, + + initFileView, + register3DViewerPlugin, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/modules/file-render-plugin.ts b/web_src/js/modules/file-render-plugin.ts new file mode 100644 index 0000000000..4679f64f1b --- /dev/null +++ b/web_src/js/modules/file-render-plugin.ts @@ -0,0 +1,69 @@ +/** + * File Render Plugin System + * + * This module provides a plugin architecture for rendering different file types + * in the browser without requiring backend support for identifying file types. + */ + +/** + * Interface for file render plugins + */ +export type FileRenderPlugin = { + // unique plugin name + name: string; + + // test if plugin can handle specified file + canHandle: (filename: string, mimeType: string) => boolean; + + // render file content + render: (container: HTMLElement, fileUrl: string, options?: any) => Promise; +} + +// store registered render plugins +const plugins: FileRenderPlugin[] = []; + +/** + * register a file render plugin + */ +export function registerFileRenderPlugin(plugin: FileRenderPlugin): void { + plugins.push(plugin); +} + +/** + * find suitable render plugin by filename and mime type + */ +function findPlugin(filename: string, mimeType: string): FileRenderPlugin | null { + return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; +} + +/** + * apply render plugin to specified container + */ +export async function applyRenderPlugin(container: HTMLElement): Promise { + try { + // get file info from container element + const filename = container.getAttribute('data-filename') || ''; + const fileUrl = container.getAttribute('data-url') || ''; + + if (!filename || !fileUrl) { + console.warn('Missing filename or file URL for renderer'); + return false; + } + + // get mime type (optional) + const mimeType = container.getAttribute('data-mime-type') || ''; + + // find plugin that can handle this file + const plugin = findPlugin(filename, mimeType); + if (!plugin) { + return false; + } + + // apply plugin to render file + await plugin.render(container, fileUrl); + return true; + } catch (error) { + console.error('Error applying render plugin:', error); + return false; + } +} diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts new file mode 100644 index 0000000000..a3fe2ede7b --- /dev/null +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -0,0 +1,72 @@ +import type {FileRenderPlugin} from '../../modules/file-render-plugin.ts'; +import {registerFileRenderPlugin} from '../../modules/file-render-plugin.ts'; + +/** + * 3D model file render plugin + * + * support common 3D model file formats, use online-3d-viewer library for rendering + */ +export function register3DViewerPlugin(): void { + // supported 3D file extensions + const SUPPORTED_EXTENSIONS = [ + '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', + '.dae', '.fbx', '.fcstd', '.glb', '.gltf', + '.ifc', '.igs', '.iges', '.stp', '.step', + '.stl', '.obj', '.off', '.ply', '.wrl', + ]; + + // create and register plugin + const plugin: FileRenderPlugin = { + name: '3d-model-viewer', + + // check if file extension is supported 3D file + canHandle(filename: string, _mimeType: string): boolean { + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + const canHandle = SUPPORTED_EXTENSIONS.includes(ext); + return canHandle; + }, + + // render 3D model + async render(container: HTMLElement, fileUrl: string): Promise { + // add loading indicator + container.classList.add('is-loading'); + + try { + // dynamically load 3D rendering library + const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); + + // configure container style + container.classList.add('model3d-content'); + + // initialize 3D viewer + const viewer = new OV.EmbeddedViewer(container, { + backgroundColor: new OV.RGBAColor(59, 68, 76, 0), // transparent + defaultColor: new OV.RGBColor(65, 131, 196), + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), + }); + + // load model from url + viewer.LoadModelFromUrlList([fileUrl]); + } catch (error) { + // handle render error + console.error('error rendering 3D model:', error); + + // add error message and download button + const fallbackText = container.getAttribute('data-fallback-text') || 'View Raw File'; + container.innerHTML = ` +
+
Failed to render 3D model
+

The 3D model could not be displayed in the browser.

+ ${fallbackText} +
+ `; + } finally { + // remove loading state + container.classList.remove('is-loading'); + } + }, + }; + + // register plugin + registerFileRenderPlugin(plugin); +}