mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 02:46:04 +01:00 
			
		
		
		
	Add support for 3D/CAD file formats preview (#34794)
Fix #34775 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -9,17 +9,17 @@ const {i18n} = window.config; | ||||
| export function initCopyContent() { | ||||
|   registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => { | ||||
|     if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return; | ||||
|     let content; | ||||
|     let isRasterImage = false; | ||||
|     const link = btn.getAttribute('data-link'); | ||||
|     const rawFileLink = btn.getAttribute('data-raw-file-link'); | ||||
|  | ||||
|     // when data-link is present, we perform a fetch. this is either because | ||||
|     // the text to copy is not in the DOM, or it is an image which should be | ||||
|     let content, isRasterImage = false; | ||||
|  | ||||
|     // when "data-raw-link" is present, we perform a fetch. this is either because | ||||
|     // the text to copy is not in the DOM, or it is an image that should be | ||||
|     // fetched to copy in full resolution | ||||
|     if (link) { | ||||
|     if (rawFileLink) { | ||||
|       btn.classList.add('is-loading', 'loading-icon-2px'); | ||||
|       try { | ||||
|         const res = await GET(link, {credentials: 'include', redirect: 'follow'}); | ||||
|         const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'}); | ||||
|         const contentType = res.headers.get('content-type'); | ||||
|  | ||||
|         if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) { | ||||
|   | ||||
							
								
								
									
										76
									
								
								web_src/js/features/file-view.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								web_src/js/features/file-view.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
|   }); | ||||
| } | ||||
| @@ -19,7 +19,7 @@ import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; | ||||
| 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 {initRepoFileView} from './features/file-view.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'; | ||||
| @@ -159,10 +159,11 @@ onDomReady(() => { | ||||
|     initUserAuthWebAuthnRegister, | ||||
|     initUserSettings, | ||||
|     initRepoDiffView, | ||||
|     initPdfViewer, | ||||
|     initColorPickers, | ||||
|  | ||||
|     initOAuth2SettingsDisableCheckbox, | ||||
|  | ||||
|     initRepoFileView, | ||||
|   ]); | ||||
|  | ||||
|   // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. | ||||
|   | ||||
| @@ -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'); | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										10
									
								
								web_src/js/render/plugin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								web_src/js/render/plugin.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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>; | ||||
| } | ||||
							
								
								
									
										60
									
								
								web_src/js/render/plugins/3d-viewer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								web_src/js/render/plugins/3d-viewer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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]); | ||||
|     }, | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										20
									
								
								web_src/js/render/plugins/pdf-viewer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								web_src/js/render/plugins/pdf-viewer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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'); | ||||
|       } | ||||
|     }, | ||||
|   }; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user