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 = ` +