Support quote selected comments to reply (#32431)

Many existing tests were quite hacky, these could be improved later.

<details>

![image](https://github.com/user-attachments/assets/93aebb4f-9de5-4cb8-910b-50c64cbcd25a)

</details>
This commit is contained in:
wxiaoguang
2024-11-07 11:57:07 +08:00
committed by GitHub
parent 276500c314
commit 145e266987
16 changed files with 255 additions and 70 deletions

View File

@ -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
});
}

View 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('![image](link)');
expect(convertHtmlToMarkdown(h(`<img src="link" alt="name">`))).toBe('![name](link)');
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');
});

View 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 `![${alt}](${src})`;
},
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;
}