mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Support quote selected comments to reply (#32431)
Many existing tests were quite hacky, these could be improved later. <details>  </details>
This commit is contained in:
		| @@ -1,4 +1,3 @@ | ||||
| import $ from 'jquery'; | ||||
| import {handleReply} from './repo-issue.ts'; | ||||
| import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; | ||||
| import {POST} from '../modules/fetch.ts'; | ||||
| @@ -7,11 +6,14 @@ import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts'; | ||||
| import {attachRefIssueContextPopup} from './contextpopup.ts'; | ||||
| import {initCommentContent, initMarkupContent} from '../markup/content.ts'; | ||||
| import {triggerUploadStateChanged} from './comp/EditorUpload.ts'; | ||||
| import {convertHtmlToMarkdown} from '../markup/html2markdown.ts'; | ||||
|  | ||||
| async function onEditContent(event) { | ||||
|   event.preventDefault(); | ||||
| async function tryOnEditContent(e) { | ||||
|   const clickTarget = e.target.closest('.edit-content'); | ||||
|   if (!clickTarget) return; | ||||
|  | ||||
|   const segment = this.closest('.header').nextElementSibling; | ||||
|   e.preventDefault(); | ||||
|   const segment = clickTarget.closest('.header').nextElementSibling; | ||||
|   const editContentZone = segment.querySelector('.edit-content-zone'); | ||||
|   const renderContent = segment.querySelector('.render-content'); | ||||
|   const rawContent = segment.querySelector('.raw-content'); | ||||
| @@ -102,33 +104,53 @@ async function onEditContent(event) { | ||||
|   triggerUploadStateChanged(comboMarkdownEditor.container); | ||||
| } | ||||
|  | ||||
| function extractSelectedMarkdown(container: HTMLElement) { | ||||
|   const selection = window.getSelection(); | ||||
|   if (!selection.rangeCount) return ''; | ||||
|   const range = selection.getRangeAt(0); | ||||
|   if (!container.contains(range.commonAncestorContainer)) return ''; | ||||
|  | ||||
|   // todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content | ||||
|   // otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes | ||||
|   const contents = selection.getRangeAt(0).cloneContents(); | ||||
|   const el = document.createElement('div'); | ||||
|   el.append(contents); | ||||
|   return convertHtmlToMarkdown(el); | ||||
| } | ||||
|  | ||||
| async function tryOnQuoteReply(e) { | ||||
|   const clickTarget = (e.target as HTMLElement).closest('.quote-reply'); | ||||
|   if (!clickTarget) return; | ||||
|  | ||||
|   e.preventDefault(); | ||||
|   const contentToQuoteId = clickTarget.getAttribute('data-target'); | ||||
|   const targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`); | ||||
|   const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector<HTMLElement>('.render-content.markup'); | ||||
|   let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote); | ||||
|   if (!contentToQuote) contentToQuote = targetRawToQuote.textContent; | ||||
|   const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`; | ||||
|  | ||||
|   let editor; | ||||
|   if (clickTarget.classList.contains('quote-reply-diff')) { | ||||
|     const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); | ||||
|     editor = await handleReply(replyBtn); | ||||
|   } else { | ||||
|     // for normal issue/comment page | ||||
|     editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor')); | ||||
|   } | ||||
|  | ||||
|   if (editor.value()) { | ||||
|     editor.value(`${editor.value()}\n\n${quotedContent}`); | ||||
|   } else { | ||||
|     editor.value(quotedContent); | ||||
|   } | ||||
|   editor.focus(); | ||||
|   editor.moveCursorToEnd(); | ||||
| } | ||||
|  | ||||
| export function initRepoIssueCommentEdit() { | ||||
|   // Edit issue or comment content | ||||
|   $(document).on('click', '.edit-content', onEditContent); | ||||
|  | ||||
|   // Quote reply | ||||
|   $(document).on('click', '.quote-reply', async function (event) { | ||||
|     event.preventDefault(); | ||||
|     const target = this.getAttribute('data-target'); | ||||
|     const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> '); | ||||
|     const content = `> ${quote}\n\n`; | ||||
|  | ||||
|     let editor; | ||||
|     if (this.classList.contains('quote-reply-diff')) { | ||||
|       const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); | ||||
|       editor = await handleReply(replyBtn); | ||||
|     } else { | ||||
|       // for normal issue/comment page | ||||
|       editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); | ||||
|     } | ||||
|     if (editor) { | ||||
|       if (editor.value()) { | ||||
|         editor.value(`${editor.value()}\n\n${content}`); | ||||
|       } else { | ||||
|         editor.value(content); | ||||
|       } | ||||
|       editor.focus(); | ||||
|       editor.moveCursorToEnd(); | ||||
|     } | ||||
|   document.addEventListener('click', (e) => { | ||||
|     tryOnEditContent(e); // Edit issue or comment content | ||||
|     tryOnQuoteReply(e); // Quote reply to the comment editor | ||||
|   }); | ||||
| } | ||||
|   | ||||
							
								
								
									
										24
									
								
								web_src/js/markup/html2markdown.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								web_src/js/markup/html2markdown.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import {convertHtmlToMarkdown} from './html2markdown.ts'; | ||||
| import {createElementFromHTML} from '../utils/dom.ts'; | ||||
|  | ||||
| const h = createElementFromHTML; | ||||
|  | ||||
| test('convertHtmlToMarkdown', () => { | ||||
|   expect(convertHtmlToMarkdown(h(`<h1>h</h1>`))).toBe('# h'); | ||||
|   expect(convertHtmlToMarkdown(h(`<strong>txt</strong>`))).toBe('**txt**'); | ||||
|   expect(convertHtmlToMarkdown(h(`<em>txt</em>`))).toBe('_txt_'); | ||||
|   expect(convertHtmlToMarkdown(h(`<del>txt</del>`))).toBe('~~txt~~'); | ||||
|  | ||||
|   expect(convertHtmlToMarkdown(h(`<a href="link">txt</a>`))).toBe('[txt](link)'); | ||||
|   expect(convertHtmlToMarkdown(h(`<a href="https://link">https://link</a>`))).toBe('https://link'); | ||||
|  | ||||
|   expect(convertHtmlToMarkdown(h(`<img src="link">`))).toBe(''); | ||||
|   expect(convertHtmlToMarkdown(h(`<img src="link" alt="name">`))).toBe(''); | ||||
|   expect(convertHtmlToMarkdown(h(`<img src="link" width="1" height="1">`))).toBe('<img alt="image" width="1" height="1" src="link">'); | ||||
|  | ||||
|   expect(convertHtmlToMarkdown(h(`<p>txt</p>`))).toBe('txt\n'); | ||||
|   expect(convertHtmlToMarkdown(h(`<blockquote>a\nb</blockquote>`))).toBe('> a\n> b\n'); | ||||
|  | ||||
|   expect(convertHtmlToMarkdown(h(`<ol><li>a<ul><li>b</li></ul></li></ol>`))).toBe('1. a\n    * b\n\n'); | ||||
|   expect(convertHtmlToMarkdown(h(`<ol><li><input checked>a</li></ol>`))).toBe('1. [x] a\n'); | ||||
| }); | ||||
							
								
								
									
										119
									
								
								web_src/js/markup/html2markdown.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								web_src/js/markup/html2markdown.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | ||||
| import {htmlEscape} from 'escape-goat'; | ||||
|  | ||||
| type Processors = { | ||||
|   [tagName: string]: (el: HTMLElement) => string | HTMLElement | void; | ||||
| } | ||||
|  | ||||
| type ProcessorContext = { | ||||
|   elementIsFirst: boolean; | ||||
|   elementIsLast: boolean; | ||||
|   listNestingLevel: number; | ||||
| } | ||||
|  | ||||
| function prepareProcessors(ctx:ProcessorContext): Processors { | ||||
|   const processors = { | ||||
|     H1(el) { | ||||
|       const level = parseInt(el.tagName.slice(1)); | ||||
|       el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`; | ||||
|     }, | ||||
|     STRONG(el) { | ||||
|       return `**${el.textContent}**`; | ||||
|     }, | ||||
|     EM(el) { | ||||
|       return `_${el.textContent}_`; | ||||
|     }, | ||||
|     DEL(el) { | ||||
|       return `~~${el.textContent}~~`; | ||||
|     }, | ||||
|  | ||||
|     A(el) { | ||||
|       const text = el.textContent || 'link'; | ||||
|       const href = el.getAttribute('href'); | ||||
|       if (/^https?:/.test(text) && text === href) { | ||||
|         return text; | ||||
|       } | ||||
|       return href ? `[${text}](${href})` : text; | ||||
|     }, | ||||
|     IMG(el) { | ||||
|       const alt = el.getAttribute('alt') || 'image'; | ||||
|       const src = el.getAttribute('src'); | ||||
|       const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : ''; | ||||
|       const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : ''; | ||||
|       if (widthAttr || heightAttr) { | ||||
|         return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`; | ||||
|       } | ||||
|       return ``; | ||||
|     }, | ||||
|  | ||||
|     P(el) { | ||||
|       el.textContent = `${el.textContent}\n`; | ||||
|     }, | ||||
|     BLOCKQUOTE(el) { | ||||
|       el.textContent = `${el.textContent.replace(/^/mg, '> ')}\n`; | ||||
|     }, | ||||
|  | ||||
|     OL(el) { | ||||
|       const preNewLine = ctx.listNestingLevel ? '\n' : ''; | ||||
|       el.textContent = `${preNewLine}${el.textContent}\n`; | ||||
|     }, | ||||
|     LI(el) { | ||||
|       const parent = el.parentNode; | ||||
|       const bullet = parent.tagName === 'OL' ? `1. ` : '* '; | ||||
|       const nestingIdentLevel = Math.max(0, ctx.listNestingLevel - 1); | ||||
|       el.textContent = `${' '.repeat(nestingIdentLevel * 4)}${bullet}${el.textContent}${ctx.elementIsLast ? '' : '\n'}`; | ||||
|       return el; | ||||
|     }, | ||||
|     INPUT(el) { | ||||
|       return el.checked ? '[x] ' : '[ ] '; | ||||
|     }, | ||||
|  | ||||
|     CODE(el) { | ||||
|       const text = el.textContent; | ||||
|       if (el.parentNode && el.parentNode.tagName === 'PRE') { | ||||
|         el.textContent = `\`\`\`\n${text}\n\`\`\`\n`; | ||||
|         return el; | ||||
|       } | ||||
|       if (text.includes('`')) { | ||||
|         return `\`\` ${text} \`\``; | ||||
|       } | ||||
|       return `\`${text}\``; | ||||
|     }, | ||||
|   }; | ||||
|   processors['UL'] = processors.OL; | ||||
|   for (let level = 2; level <= 6; level++) { | ||||
|     processors[`H${level}`] = processors.H1; | ||||
|   } | ||||
|   return processors; | ||||
| } | ||||
|  | ||||
| function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement) { | ||||
|   if (el.hasAttribute('data-markdown-generated-content')) return el.textContent; | ||||
|   if (el.tagName === 'A' && el.children.length === 1 && el.children[0].tagName === 'IMG') { | ||||
|     return processElement(ctx, processors, el.children[0] as HTMLElement); | ||||
|   } | ||||
|  | ||||
|   const isListContainer = el.tagName === 'OL' || el.tagName === 'UL'; | ||||
|   if (isListContainer) ctx.listNestingLevel++; | ||||
|   for (let i = 0; i < el.children.length; i++) { | ||||
|     ctx.elementIsFirst = i === 0; | ||||
|     ctx.elementIsLast = i === el.children.length - 1; | ||||
|     processElement(ctx, processors, el.children[i] as HTMLElement); | ||||
|   } | ||||
|   if (isListContainer) ctx.listNestingLevel--; | ||||
|  | ||||
|   if (processors[el.tagName]) { | ||||
|     const ret = processors[el.tagName](el); | ||||
|     if (ret && ret !== el) { | ||||
|       el.replaceWith(typeof ret === 'string' ? document.createTextNode(ret) : ret); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function convertHtmlToMarkdown(el: HTMLElement): string { | ||||
|   const div = document.createElement('div'); | ||||
|   div.append(el); | ||||
|   const ctx = {} as ProcessorContext; | ||||
|   ctx.listNestingLevel = 0; | ||||
|   processElement(ctx, prepareProcessors(ctx), el); | ||||
|   return div.textContent; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user