mirror of
https://github.com/go-gitea/gitea
synced 2025-07-13 22:17:20 +00:00
Merge branch 'main' into lunny/automerge_support_delete_branch
This commit is contained in:
@ -1,3 +1,5 @@
|
||||
import {querySingleVisibleElem} from '../../utils/dom.ts';
|
||||
|
||||
export function handleGlobalEnterQuickSubmit(target) {
|
||||
let form = target.closest('form');
|
||||
if (form) {
|
||||
@ -12,7 +14,11 @@ export function handleGlobalEnterQuickSubmit(target) {
|
||||
}
|
||||
form = target.closest('.ui.form');
|
||||
if (form) {
|
||||
form.querySelector('.ui.primary.button')?.click();
|
||||
// A form should only have at most one "primary" button to do quick-submit.
|
||||
// Here we don't use a special class to mark the primary button,
|
||||
// because there could be a lot of forms with a primary button, the quick submit should work out-of-box,
|
||||
// but not keeps asking developers to add that special class again and again (it could be forgotten easily)
|
||||
querySingleVisibleElem<HTMLButtonElement>(form, '.ui.primary.button')?.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@ -1,17 +1,19 @@
|
||||
import $ from 'jquery';
|
||||
import {handleReply} from './repo-issue.ts';
|
||||
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
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');
|
||||
@ -77,20 +79,22 @@ async function onEditContent(event) {
|
||||
}
|
||||
};
|
||||
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
if (!comboMarkdownEditor) {
|
||||
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML;
|
||||
const saveButton = editContentZone.querySelector('.ui.primary.button');
|
||||
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
|
||||
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
|
||||
editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset);
|
||||
saveButton.addEventListener('click', saveAndRefresh);
|
||||
}
|
||||
|
||||
// Show write/preview tab and copy raw content as needed
|
||||
showElem(editContentZone);
|
||||
hideElem(renderContent);
|
||||
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
if (!comboMarkdownEditor) {
|
||||
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML;
|
||||
const saveButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.primary.button');
|
||||
const cancelButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.cancel.button');
|
||||
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
|
||||
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
|
||||
cancelButton.addEventListener('click', cancelAndReset);
|
||||
saveButton.addEventListener('click', saveAndRefresh);
|
||||
}
|
||||
|
||||
// FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data
|
||||
if (!comboMarkdownEditor.value()) {
|
||||
comboMarkdownEditor.value(rawContent.textContent);
|
||||
@ -100,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
|
||||
});
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import {POST} from '../modules/fetch.ts';
|
||||
import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {htmlEscape} from 'escape-goat';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
|
||||
// if there are draft comments, confirm before reloading, to avoid losing comments
|
||||
function reloadConfirmDraftComment() {
|
||||
@ -258,8 +259,22 @@ function selectItem(select_id, input_id) {
|
||||
});
|
||||
}
|
||||
|
||||
function initRepoIssueDue() {
|
||||
const form = document.querySelector<HTMLFormElement>('.issue-due-form');
|
||||
if (!form) return;
|
||||
const deadline = form.querySelector<HTMLInputElement>('input[name=deadline]');
|
||||
document.querySelector('.issue-due-edit')?.addEventListener('click', () => {
|
||||
toggleElem(form);
|
||||
});
|
||||
document.querySelector('.issue-due-remove')?.addEventListener('click', () => {
|
||||
deadline.value = '';
|
||||
form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueSidebar() {
|
||||
initBranchSelector();
|
||||
initRepoIssueDue();
|
||||
|
||||
// Init labels and assignees
|
||||
initListSubmits('select-label', 'labels');
|
||||
|
@ -43,52 +43,6 @@ export function initRepoIssueTimeTracking() {
|
||||
});
|
||||
}
|
||||
|
||||
async function updateDeadline(deadlineString) {
|
||||
hideElem('#deadline-err-invalid-date');
|
||||
document.querySelector('#deadline-loader')?.classList.add('is-loading');
|
||||
|
||||
let realDeadline = null;
|
||||
if (deadlineString !== '') {
|
||||
const newDate = Date.parse(deadlineString);
|
||||
|
||||
if (Number.isNaN(newDate)) {
|
||||
document.querySelector('#deadline-loader')?.classList.remove('is-loading');
|
||||
showElem('#deadline-err-invalid-date');
|
||||
return false;
|
||||
}
|
||||
realDeadline = new Date(newDate);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await POST(document.querySelector('#update-issue-deadline-form').getAttribute('action'), {
|
||||
data: {due_date: realDeadline},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
throw new Error('Invalid response');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
document.querySelector('#deadline-loader').classList.remove('is-loading');
|
||||
showElem('#deadline-err-invalid-date');
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoIssueDue() {
|
||||
$(document).on('click', '.issue-due-edit', () => {
|
||||
toggleElem('#deadlineForm');
|
||||
});
|
||||
$(document).on('click', '.issue-due-remove', () => {
|
||||
updateDeadline('');
|
||||
});
|
||||
$(document).on('submit', '.issue-due-form', () => {
|
||||
updateDeadline($('#deadlineDate').val());
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} item
|
||||
*/
|
||||
|
@ -1,11 +1,9 @@
|
||||
import $ from 'jquery';
|
||||
|
||||
export function initRepoMilestone() {
|
||||
// Milestones
|
||||
if ($('.repository.new.milestone').length > 0) {
|
||||
$('#clear-date').on('click', () => {
|
||||
$('#deadline').val('');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
const page = document.querySelector('.repository.new.milestone');
|
||||
if (!page) return;
|
||||
|
||||
const deadline = page.querySelector<HTMLInputElement>('form input[name=deadline]');
|
||||
document.querySelector('#milestone-clear-deadline').addEventListener('click', () => {
|
||||
deadline.value = '';
|
||||
});
|
||||
}
|
||||
|
@ -25,7 +25,6 @@ import {initPdfViewer} from './render/pdf.ts';
|
||||
|
||||
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
|
||||
import {
|
||||
initRepoIssueDue,
|
||||
initRepoIssueReferenceRepositorySearch,
|
||||
initRepoIssueTimeTracking,
|
||||
initRepoIssueWipTitle,
|
||||
@ -181,7 +180,6 @@ onDomReady(() => {
|
||||
initRepoEditor,
|
||||
initRepoGraphGit,
|
||||
initRepoIssueContentHistory,
|
||||
initRepoIssueDue,
|
||||
initRepoIssueList,
|
||||
initRepoIssueSidebarList,
|
||||
initArchivedLabelHandler,
|
||||
|
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;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import {createElementFromAttrs, createElementFromHTML} from './dom.ts';
|
||||
import {createElementFromAttrs, createElementFromHTML, querySingleVisibleElem} from './dom.ts';
|
||||
|
||||
test('createElementFromHTML', () => {
|
||||
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
|
||||
@ -16,3 +16,12 @@ test('createElementFromAttrs', () => {
|
||||
}, 'txt', createElementFromHTML('<span>inner</span>'));
|
||||
expect(el.outerHTML).toEqual('<button id="the-id" class="cls-1 cls-2" disabled="" tabindex="0" data-foo="the-data">txt<span>inner</span></button>');
|
||||
});
|
||||
|
||||
test('querySingleVisibleElem', () => {
|
||||
let el = createElementFromHTML('<div><span>foo</span></div>');
|
||||
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('foo');
|
||||
el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>');
|
||||
expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar');
|
||||
el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>');
|
||||
expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element');
|
||||
});
|
||||
|
@ -269,8 +269,8 @@ export function initSubmitEventPolyfill() {
|
||||
*/
|
||||
export function isElemVisible(element: HTMLElement): boolean {
|
||||
if (!element) return false;
|
||||
|
||||
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
// checking element.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout
|
||||
return Boolean((element.offsetWidth || element.offsetHeight || element.getClientRects().length) && element.style.display !== 'none');
|
||||
}
|
||||
|
||||
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
|
||||
@ -330,3 +330,10 @@ export function animateOnce(el: Element, animationClassName: string): Promise<vo
|
||||
el.classList.add(animationClassName);
|
||||
});
|
||||
}
|
||||
|
||||
export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, selector: string): T | null {
|
||||
const elems = parent.querySelectorAll<HTMLElement>(selector);
|
||||
const candidates = Array.from(elems).filter(isElemVisible);
|
||||
if (candidates.length > 1) throw new Error(`Expected exactly one visible element matching selector "${selector}", but found ${candidates.length}`);
|
||||
return candidates.length ? candidates[0] as T : null;
|
||||
}
|
||||
|
Reference in New Issue
Block a user