mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Add loading spinners and mermaid error handling (#12358)
- Add loading spinners on editor and mermaid renderers - Add error handling and inline error box for mermaid - Fix Mermaid rendering by using the .init api
This commit is contained in:
		| @@ -7,6 +7,7 @@ package markdown | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"bytes" | 	"bytes" | ||||||
|  | 	"strings" | ||||||
| 	"sync" | 	"sync" | ||||||
|  |  | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| @@ -57,13 +58,33 @@ func render(body []byte, urlPrefix string, metas map[string]string, wikiMarkdown | |||||||
| 						chromahtml.PreventSurroundingPre(true), | 						chromahtml.PreventSurroundingPre(true), | ||||||
| 					), | 					), | ||||||
| 					highlighting.WithWrapperRenderer(func(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) { | 					highlighting.WithWrapperRenderer(func(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) { | ||||||
|  | 						if entering { | ||||||
| 							language, _ := c.Language() | 							language, _ := c.Language() | ||||||
| 							if language == nil { | 							if language == nil { | ||||||
| 								language = []byte("text") | 								language = []byte("text") | ||||||
| 							} | 							} | ||||||
| 						if entering { |  | ||||||
|  | 							languageStr := string(language) | ||||||
|  |  | ||||||
|  | 							preClasses := []string{} | ||||||
|  | 							if languageStr == "mermaid" { | ||||||
|  | 								preClasses = append(preClasses, "is-loading") | ||||||
|  | 							} | ||||||
|  |  | ||||||
|  | 							if len(preClasses) > 0 { | ||||||
|  | 								_, err := w.WriteString(`<pre class="` + strings.Join(preClasses, " ") + `">`) | ||||||
|  | 								if err != nil { | ||||||
|  | 									return | ||||||
|  | 								} | ||||||
|  | 							} else { | ||||||
|  | 								_, err := w.WriteString(`<pre>`) | ||||||
|  | 								if err != nil { | ||||||
|  | 									return | ||||||
|  | 								} | ||||||
|  | 							} | ||||||
|  |  | ||||||
| 							// include language-x class as part of commonmark spec | 							// include language-x class as part of commonmark spec | ||||||
| 							_, err := w.WriteString("<pre><code class=\"chroma language-" + string(language) + "\">") | 							_, err := w.WriteString(`<code class="chroma language-` + string(language) + `">`) | ||||||
| 							if err != nil { | 							if err != nil { | ||||||
| 								return | 								return | ||||||
| 							} | 							} | ||||||
|   | |||||||
| @@ -38,6 +38,7 @@ func NewSanitizer() { | |||||||
| func ReplaceSanitizer() { | func ReplaceSanitizer() { | ||||||
| 	sanitizer.policy = bluemonday.UGCPolicy() | 	sanitizer.policy = bluemonday.UGCPolicy() | ||||||
| 	// For Chroma markdown plugin | 	// For Chroma markdown plugin | ||||||
|  | 	sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^is-loading$`)).OnElements("pre") | ||||||
| 	sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code") | 	sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code") | ||||||
|  |  | ||||||
| 	// Checkboxes | 	// Checkboxes | ||||||
|   | |||||||
| @@ -41,9 +41,7 @@ | |||||||
| 						data-markdown-file-exts="{{.MarkdownFileExts}}" | 						data-markdown-file-exts="{{.MarkdownFileExts}}" | ||||||
| 						data-line-wrap-extensions="{{.LineWrapExtensions}}"> | 						data-line-wrap-extensions="{{.LineWrapExtensions}}"> | ||||||
| {{.FileContent}}</textarea> | {{.FileContent}}</textarea> | ||||||
| 					<div class="editor-loading"> | 					<div class="editor-loading is-loading"></div> | ||||||
| 						{{.i18n.Tr "loading"}} |  | ||||||
| 					</div> |  | ||||||
| 				</div> | 				</div> | ||||||
| 				<div class="ui bottom attached tab segment markdown" data-tab="preview"> | 				<div class="ui bottom attached tab segment markdown" data-tab="preview"> | ||||||
| 					{{.i18n.Tr "loading"}} | 					{{.i18n.Tr "loading"}} | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import {renderMermaid} from './mermaid.js'; | import {renderMermaid} from './mermaid.js'; | ||||||
|  |  | ||||||
| export default async function renderMarkdownContent() { | export default async function renderMarkdownContent() { | ||||||
|   await renderMermaid(document.querySelectorAll('.language-mermaid')); |   await renderMermaid(document.querySelectorAll('code.language-mermaid')); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,23 +1,56 @@ | |||||||
| import {random} from '../utils.js'; | const MAX_SOURCE_CHARACTERS = 5000; | ||||||
|  |  | ||||||
|  | function displayError(el, err) { | ||||||
|  |   el.closest('pre').classList.remove('is-loading'); | ||||||
|  |   const errorNode = document.createElement('div'); | ||||||
|  |   errorNode.setAttribute('class', 'ui message error markdown-block-error mono'); | ||||||
|  |   errorNode.textContent = err.str || err.message || String(err); | ||||||
|  |   el.closest('pre').before(errorNode); | ||||||
|  | } | ||||||
|  |  | ||||||
| export async function renderMermaid(els) { | export async function renderMermaid(els) { | ||||||
|   if (!els || !els.length) return; |   if (!els || !els.length) return; | ||||||
|  |  | ||||||
|   const {mermaidAPI} = await import(/* webpackChunkName: "mermaid" */'mermaid'); |   const mermaid = await import(/* webpackChunkName: "mermaid" */'mermaid'); | ||||||
|  |  | ||||||
|   mermaidAPI.initialize({ |   mermaid.initialize({ | ||||||
|  |     mermaid: { | ||||||
|       startOnLoad: false, |       startOnLoad: false, | ||||||
|  |     }, | ||||||
|  |     flowchart: { | ||||||
|  |       useMaxWidth: true, | ||||||
|  |       htmlLabels: false, | ||||||
|  |     }, | ||||||
|     theme: 'neutral', |     theme: 'neutral', | ||||||
|     securityLevel: 'strict', |     securityLevel: 'strict', | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   for (const el of els) { |   for (const el of els) { | ||||||
|     mermaidAPI.render(`mermaid-${random(12)}`, el.textContent, (svg, bindFunctions) => { |     if (el.textContent.length > MAX_SOURCE_CHARACTERS) { | ||||||
|       const div = document.createElement('div'); |       displayError(el, new Error(`Mermaid source of ${el.textContent.length} characters exceeds the maximum allowed length of ${MAX_SOURCE_CHARACTERS}.`)); | ||||||
|       div.classList.add('mermaid-chart'); |       continue; | ||||||
|       div.innerHTML = svg; |     } | ||||||
|       if (typeof bindFunctions === 'function') bindFunctions(div); |  | ||||||
|       el.closest('pre').replaceWith(div); |     let valid; | ||||||
|  |     try { | ||||||
|  |       valid = mermaid.parse(el.textContent); | ||||||
|  |     } catch (err) { | ||||||
|  |       displayError(el, err); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!valid) { | ||||||
|  |       el.closest('pre').classList.remove('is-loading'); | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       mermaid.init(undefined, el, (id) => { | ||||||
|  |         const svg = document.getElementById(id); | ||||||
|  |         svg.classList.add('mermaid-chart'); | ||||||
|  |         svg.closest('pre').replaceWith(svg); | ||||||
|       }); |       }); | ||||||
|  |     } catch (err) { | ||||||
|  |       displayError(el, err); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -495,10 +495,20 @@ | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| .mermaid-chart { | .markdown-block-error { | ||||||
|     display: flex; |     margin-bottom: 0 !important; | ||||||
|     justify-content: center; |     border-bottom-left-radius: 0 !important; | ||||||
|     align-items: center; |     border-bottom-right-radius: 0 !important; | ||||||
|     padding: 1rem; |     box-shadow: none !important; | ||||||
|     margin: 1rem 0; |     font-size: 85% !important; | ||||||
|  |     white-space: pre !important; | ||||||
|  |     padding: .5rem 1rem !important; | ||||||
|  |     text-align: left !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markdown-block-error + pre { | ||||||
|  |     border-top: none !important; | ||||||
|  |     margin-top: 0 !important; | ||||||
|  |     border-top-left-radius: 0 !important; | ||||||
|  |     border-top-right-radius: 0 !important; | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										34
									
								
								web_src/less/features/animations.less
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								web_src/less/features/animations.less
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | @keyframes isloadingspin { | ||||||
|  |     0% { transform: translate(-50%, -50%) rotate(0deg); } | ||||||
|  |     100% { transform: translate(-50%, -50%) rotate(360deg); } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .is-loading { | ||||||
|  |     background: transparent !important; | ||||||
|  |     color: transparent !important; | ||||||
|  |     border: transparent !important; | ||||||
|  |     pointer-events: none !important; | ||||||
|  |     position: relative !important; | ||||||
|  |     overflow: hidden !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .is-loading:after { | ||||||
|  |     content: ""; | ||||||
|  |     position: absolute; | ||||||
|  |     display: block; | ||||||
|  |     width: 4rem; | ||||||
|  |     height: 4rem; | ||||||
|  |     left: 50%; | ||||||
|  |     top: 50%; | ||||||
|  |     transform: translate(-50%, -50%); | ||||||
|  |     animation: isloadingspin 500ms infinite linear; | ||||||
|  |     border-width: 4px; | ||||||
|  |     border-style: solid; | ||||||
|  |     border-color: #ececec #ececec #666 #666; | ||||||
|  |     border-radius: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markdown pre.is-loading, | ||||||
|  | .editor-loading.is-loading { | ||||||
|  |     height: 12rem; | ||||||
|  | } | ||||||
| @@ -1,5 +1,7 @@ | |||||||
| @import "~font-awesome/css/font-awesome.css"; | @import "~font-awesome/css/font-awesome.css"; | ||||||
| @import "./vendor/gitGraph.css"; | @import "./vendor/gitGraph.css"; | ||||||
|  | @import "./features/animations.less"; | ||||||
|  | @import "./markdown/mermaid.less"; | ||||||
|  |  | ||||||
| @import "_svg"; | @import "_svg"; | ||||||
| @import "_tribute"; | @import "_tribute"; | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								web_src/less/markdown/mermaid.less
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								web_src/less/markdown/mermaid.less
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | .mermaid-chart { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: center; | ||||||
|  |     align-items: center; | ||||||
|  |     padding: 1rem; | ||||||
|  |     margin: 1rem 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* mermaid's errorRenderer seems to unavoidably spew stuff into <body>, hide it */ | ||||||
|  | body > div[id*="mermaid-"] { | ||||||
|  |     display: none !important; | ||||||
|  | } | ||||||
| @@ -1260,7 +1260,8 @@ input { | |||||||
|     border-color: #794f31; |     border-color: #794f31; | ||||||
| } | } | ||||||
|  |  | ||||||
| .ui.red.message { | .ui.red.message, | ||||||
|  | .ui.error.message { | ||||||
|     background-color: rgba(80, 23, 17, .6); |     background-color: rgba(80, 23, 17, .6); | ||||||
|     color: #f9cbcb; |     color: #f9cbcb; | ||||||
|     box-shadow: 0 0 0 1px rgba(121, 71, 66, .5) inset, 0 0 0 0 transparent; |     box-shadow: 0 0 0 1px rgba(121, 71, 66, .5) inset, 0 0 0 0 transparent; | ||||||
| @@ -1923,3 +1924,12 @@ footer .container .links > * { | |||||||
| .mermaid-chart { | .mermaid-chart { | ||||||
|     filter: invert(84%) hue-rotate(180deg); |     filter: invert(84%) hue-rotate(180deg); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .is-loading:after { | ||||||
|  |     border-color: #4a4c58 #4a4c58 #d7d7da #d7d7da; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markdown-block-error { | ||||||
|  |     border: 1px solid rgba(121, 71, 66, .5) !important; | ||||||
|  |     border-bottom: none !important; | ||||||
|  | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 silverwind
					silverwind