diff options
Diffstat (limited to 'web_src/js/features')
-rw-r--r-- | web_src/js/features/common-button.ts | 28 | ||||
-rw-r--r-- | web_src/js/features/common-fetch-action.ts | 96 | ||||
-rw-r--r-- | web_src/js/features/comp/ConfirmModal.ts | 37 | ||||
-rw-r--r-- | web_src/js/features/comp/EditorUpload.test.ts | 12 | ||||
-rw-r--r-- | web_src/js/features/comp/EditorUpload.ts | 23 | ||||
-rw-r--r-- | web_src/js/features/comp/LabelEdit.ts | 4 | ||||
-rw-r--r-- | web_src/js/features/comp/SearchUserBox.ts | 2 | ||||
-rw-r--r-- | web_src/js/features/copycontent.ts | 14 | ||||
-rw-r--r-- | web_src/js/features/dropzone.ts | 6 | ||||
-rw-r--r-- | web_src/js/features/emoji.ts | 6 | ||||
-rw-r--r-- | web_src/js/features/file-view.ts | 76 | ||||
-rw-r--r-- | web_src/js/features/install.ts | 2 | ||||
-rw-r--r-- | web_src/js/features/repo-code.ts | 13 | ||||
-rw-r--r-- | web_src/js/features/repo-editor.ts | 62 | ||||
-rw-r--r-- | web_src/js/features/repo-issue-list.ts | 8 | ||||
-rw-r--r-- | web_src/js/features/repo-issue.ts | 27 | ||||
-rw-r--r-- | web_src/js/features/repo-new.ts | 2 | ||||
-rw-r--r-- | web_src/js/features/repo-projects.ts | 3 | ||||
-rw-r--r-- | web_src/js/features/repo-wiki.ts | 3 | ||||
-rw-r--r-- | web_src/js/features/stopwatch.ts | 2 | ||||
-rw-r--r-- | web_src/js/features/tribute.ts | 13 |
21 files changed, 289 insertions, 150 deletions
diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index ae399e48b3..22a7890857 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -43,13 +43,16 @@ export function initGlobalDeleteButton(): void { fomanticQuery(modal).modal({ closable: false, - onApprove: async () => { + onApprove: () => { // if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."` if (btn.getAttribute('data-type') === 'form') { const formSelector = btn.getAttribute('data-form'); const form = document.querySelector<HTMLFormElement>(formSelector); if (!form) throw new Error(`no form named ${formSelector} found`); + modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal + form.classList.add('is-loading'); form.submit(); + return false; // prevent modal from closing automatically } // prepare an AJAX form by data attributes @@ -62,12 +65,15 @@ export function initGlobalDeleteButton(): void { postData.append('id', value); } } - - const response = await POST(btn.getAttribute('data-url'), {data: postData}); - if (response.ok) { - const data = await response.json(); - window.location.href = data.redirect; - } + (async () => { + const response = await POST(btn.getAttribute('data-url'), {data: postData}); + if (response.ok) { + const data = await response.json(); + window.location.href = data.redirect; + } + })(); + modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal + return false; // prevent modal from closing automatically }, }).modal('show'); }); @@ -158,13 +164,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { } } - fomanticQuery(elModal).modal('setting', { - onApprove: () => { - // "form-fetch-action" can handle network errors gracefully, - // so keep the modal dialog to make users can re-submit the form if anything wrong happens. - if (elModal.querySelector('.form-fetch-action')) return false; - }, - }).modal('show'); + fomanticQuery(elModal).modal('show'); } export function initGlobalButtons(): void { diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts index 2da481e521..3ca361b6e2 100644 --- a/web_src/js/features/common-fetch-action.ts +++ b/web_src/js/features/common-fetch-action.ts @@ -1,11 +1,11 @@ import {request} from '../modules/fetch.ts'; -import {showErrorToast} from '../modules/toast.ts'; -import {addDelegatedEventListener, submitEventSubmitter} from '../utils/dom.ts'; -import {confirmModal} from './comp/ConfirmModal.ts'; +import {hideToastsAll, showErrorToast} from '../modules/toast.ts'; +import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts'; +import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts'; import type {RequestOpts} from '../types.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; -const {appSubUrl, i18n} = window.config; +const {appSubUrl} = window.config; // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location" // more details are in the backend's fetch-redirect handler @@ -23,10 +23,20 @@ function fetchActionDoRedirect(redirect: string) { } async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) { + const showErrorForResponse = (code: number, message: string) => { + showErrorToast(`Error ${code || 'request'}: ${message}`); + }; + + let respStatus = 0; + let respText = ''; try { + hideToastsAll(); const resp = await request(url, opt); - if (resp.status === 200) { - let {redirect} = await resp.json(); + respStatus = resp.status; + respText = await resp.text(); + const respJson = JSON.parse(respText); + if (respStatus === 200) { + let {redirect} = respJson; redirect = redirect || actionElem.getAttribute('data-redirect'); ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading if (redirect) { @@ -35,29 +45,32 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R window.location.reload(); } return; - } else if (resp.status >= 400 && resp.status < 500) { - const data = await resp.json(); + } + + if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) { // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. - if (data.errorMessage) { - showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'}); - } else { - showErrorToast(`server error: ${resp.status}`); - } + showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'}); } else { - showErrorToast(`server error: ${resp.status}`); + showErrorForResponse(respStatus, respText); } } catch (e) { - if (e.name !== 'AbortError') { - console.error('error when doRequest', e); - showErrorToast(`${i18n.network_error} ${e}`); + if (e.name === 'SyntaxError') { + showErrorForResponse(respStatus, (respText || '').substring(0, 100)); + } else if (e.name !== 'AbortError') { + console.error('fetchActionDoRequest error', e); + showErrorForResponse(respStatus, `${e}`); } } actionElem.classList.remove('is-loading', 'loading-icon-2px'); } -async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) { +async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) { e.preventDefault(); + await submitFormFetchAction(formEl, submitEventSubmitter(e)); +} + +export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitter?: HTMLElement) { if (formEl.classList.contains('is-loading')) return; formEl.classList.add('is-loading'); @@ -66,9 +79,8 @@ async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) { } const formMethod = formEl.getAttribute('method') || 'get'; - const formActionUrl = formEl.getAttribute('action'); + const formActionUrl = formEl.getAttribute('action') || window.location.href; const formData = new FormData(formEl); - const formSubmitter = submitEventSubmitter(e); const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')]; if (submitterName) { formData.append(submitterName, submitterValue || ''); @@ -96,36 +108,52 @@ async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) { await fetchActionDoRequest(formEl, reqUrl, reqOpt); } -async function linkAction(el: HTMLElement, e: Event) { +async function onLinkActionClick(el: HTMLElement, e: Event) { // A "link-action" can post AJAX request to its "data-url" // Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading. - // If the "link-action" has "data-modal-confirm" attribute, a confirm modal dialog will be shown before taking action. + // If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action. + // Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog. e.preventDefault(); const url = el.getAttribute('data-url'); const doRequest = async () => { - if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but A doesn't have disabled attribute + if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'}); if ('disabled' in el) el.disabled = false; }; - const modalConfirmContent = el.getAttribute('data-modal-confirm') || - el.getAttribute('data-modal-confirm-content') || ''; - if (!modalConfirmContent) { + let elModal: HTMLElement | null = null; + const dataModalConfirm = el.getAttribute('data-modal-confirm') || ''; + if (dataModalConfirm.startsWith('#')) { + // eslint-disable-next-line unicorn/prefer-query-selector + elModal = document.getElementById(dataModalConfirm.substring(1)); + if (elModal) { + elModal = createElementFromHTML(elModal.outerHTML); + elModal.removeAttribute('id'); + } + } + if (!elModal) { + const modalConfirmContent = dataModalConfirm || el.getAttribute('data-modal-confirm-content') || ''; + if (modalConfirmContent) { + const isRisky = el.classList.contains('red') || el.classList.contains('negative'); + elModal = createConfirmModal({ + header: el.getAttribute('data-modal-confirm-header') || '', + content: modalConfirmContent, + confirmButtonColor: isRisky ? 'red' : 'primary', + }); + } + } + + if (!elModal) { await doRequest(); return; } - const isRisky = el.classList.contains('red') || el.classList.contains('negative'); - if (await confirmModal({ - header: el.getAttribute('data-modal-confirm-header') || '', - content: modalConfirmContent, - confirmButtonColor: isRisky ? 'red' : 'primary', - })) { + if (await confirmModal(elModal)) { await doRequest(); } } export function initGlobalFetchAction() { - addDelegatedEventListener(document, 'submit', '.form-fetch-action', formFetchAction); - addDelegatedEventListener(document, 'click', '.link-action', linkAction); + addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit); + addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick); } diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts index 1ce490ec2e..97a73eace6 100644 --- a/web_src/js/features/comp/ConfirmModal.ts +++ b/web_src/js/features/comp/ConfirmModal.ts @@ -1,24 +1,33 @@ import {svg} from '../../svg.ts'; -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../../utils/html.ts'; import {createElementFromHTML} from '../../utils/dom.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {i18n} = window.config; -export function confirmModal({header = '', content = '', confirmButtonColor = 'primary'} = {}): Promise<boolean> { - return new Promise((resolve) => { - const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : ''; - const modal = createElementFromHTML(` - <div class="ui g-modal-confirm modal"> - ${headerHtml} - <div class="content">${htmlEscape(content)}</div> - <div class="actions"> - <button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button> - <button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button> - </div> +type ConfirmModalOptions = { + header?: string; + content?: string; + confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue'; +} + +export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement { + const headerHtml = header ? html`<div class="header">${header}</div>` : ''; + return createElementFromHTML(html` + <div class="ui g-modal-confirm modal"> + ${htmlRaw(headerHtml)} + <div class="content">${content}</div> + <div class="actions"> + <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button> + <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button> </div> - `); - document.body.append(modal); + </div> + `.trim()); +} + +export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> { + if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal); + return new Promise((resolve) => { const $modal = fomanticQuery(modal); $modal.modal({ onApprove() { diff --git a/web_src/js/features/comp/EditorUpload.test.ts b/web_src/js/features/comp/EditorUpload.test.ts index 55f3f74389..e6e5f4de13 100644 --- a/web_src/js/features/comp/EditorUpload.test.ts +++ b/web_src/js/features/comp/EditorUpload.test.ts @@ -1,4 +1,4 @@ -import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts'; +import {pasteAsMarkdownLink, removeAttachmentLinksFromMarkdown} from './EditorUpload.ts'; test('removeAttachmentLinksFromMarkdown', () => { expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b'); @@ -12,3 +12,13 @@ test('removeAttachmentLinksFromMarkdown', () => { expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b'); expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b'); }); + +test('preparePasteAsMarkdownLink', () => { + expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull(); + expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull(); + expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull(); + expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)'); + expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)'); + expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull(); + expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull(); +}); diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts index f6d5731422..bf78f58daf 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -114,21 +114,30 @@ async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, drop export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) { text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), ''); - text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); + text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); return text; } -function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, text: string, isShiftDown: boolean) { +export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null { + const {value, selectionStart, selectionEnd} = textarea; + const selectedText = value.substring(selectionStart, selectionEnd); + const trimmedText = pastedText.trim(); + const beforeSelection = value.substring(0, selectionStart); + const afterSelection = value.substring(selectionEnd); + const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')'); + const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink; + return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null; +} + +function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) { // pasting with "shift" means "paste as original content" in most applications if (isShiftDown) return; // let the browser handle it // when pasting links over selected text, turn it into [text](link) - const {value, selectionStart, selectionEnd} = textarea; - const selectedText = value.substring(selectionStart, selectionEnd); - const trimmedText = text.trim(); - if (selectedText && isUrl(trimmedText) && !isUrl(selectedText)) { + const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText); + if (pastedAsMarkdown) { e.preventDefault(); - replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); + replaceTextareaSelection(textarea, pastedAsMarkdown); } // else, let the browser handle it } diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts index 55351cd900..423440129c 100644 --- a/web_src/js/features/comp/LabelEdit.ts +++ b/web_src/js/features/comp/LabelEdit.ts @@ -1,5 +1,6 @@ import {toggleElem} from '../../utils/dom.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; +import {submitFormFetchAction} from '../common-fetch-action.ts'; function nameHasScope(name: string): boolean { return /.*[^/]\/[^/].*/.test(name); @@ -70,7 +71,8 @@ export function initCompLabelEdit(pageSelector: string) { form.reportValidity(); return false; } - form.submit(); + submitFormFetchAction(form); + return false; }, }).modal('show'); }; diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts index 9fedb3ed24..4b13a2141f 100644 --- a/web_src/js/features/comp/SearchUserBox.ts +++ b/web_src/js/features/comp/SearchUserBox.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {htmlEscape} from '../../utils/html.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {appSubUrl} = window.config; diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts index d58f6c8246..0fec2a6235 100644 --- a/web_src/js/features/copycontent.ts +++ b/web_src/js/features/copycontent.ts @@ -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')) { diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts index b2ba7651c4..20f7ceb6c3 100644 --- a/web_src/js/features/dropzone.ts +++ b/web_src/js/features/dropzone.ts @@ -1,5 +1,5 @@ import {svg} from '../svg.ts'; -import {htmlEscape} from 'escape-goat'; +import {html} from '../utils/html.ts'; import {clippie} from 'clippie'; import {showTemporaryTooltip} from '../modules/tippy.ts'; import {GET, POST} from '../modules/fetch.ts'; @@ -33,14 +33,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi // Scale down images from HiDPI monitors. This uses the <img> tag because it's the only // method to change image size in Markdown that is supported by all implementations. // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" - fileMarkdown = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(file.name)}" src="attachments/${htmlEscape(file.uuid)}">`; + fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`; } else { // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" fileMarkdown = ``; } } else if (isVideoFile(file)) { - fileMarkdown = `<video src="attachments/${htmlEscape(file.uuid)}" title="${htmlEscape(file.name)}" controls></video>`; + fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`; } return fileMarkdown; } diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts index 135620e51e..69afe491e2 100644 --- a/web_src/js/features/emoji.ts +++ b/web_src/js/features/emoji.ts @@ -1,4 +1,5 @@ import emojis from '../../../assets/emoji.json' with {type: 'json'}; +import {html} from '../utils/html.ts'; const {assetUrlPrefix, customEmojis} = window.config; @@ -24,12 +25,11 @@ for (const key of emojiKeys) { export function emojiHTML(name: string) { let inner; if (Object.hasOwn(customEmojis, name)) { - inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`; + inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`; } else { inner = emojiString(name); } - - return `<span class="emoji" title=":${name}:">${inner}</span>`; + return html`<span class="emoji" title=":${name}:">${inner}</span>`; } // retrieve string for given emoji name diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts new file mode 100644 index 0000000000..d803f53c0d --- /dev/null +++ b/web_src/js/features/file-view.ts @@ -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 {html} from '../utils/html.ts'; +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(html`<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); + }); +} diff --git a/web_src/js/features/install.ts b/web_src/js/features/install.ts index 34df4757f9..ca4bcce881 100644 --- a/web_src/js/features/install.ts +++ b/web_src/js/features/install.ts @@ -104,7 +104,7 @@ function initPreInstall() { } function initPostInstall() { - const el = document.querySelector('#goto-user-login'); + const el = document.querySelector('#goto-after-install'); if (!el) return; const targetUrl = el.getAttribute('href'); diff --git a/web_src/js/features/repo-code.ts b/web_src/js/features/repo-code.ts index c699de59e6..bf7fd762b0 100644 --- a/web_src/js/features/repo-code.ts +++ b/web_src/js/features/repo-code.ts @@ -110,10 +110,15 @@ function showLineButton() { } export function initRepoCodeView() { - if (!document.querySelector('.code-view .lines-num')) return; + // When viewing a file or blame, there is always a ".file-view" element, + // but the ".code-view" class is only present when viewing the "code" of a file; it is not present when viewing a PDF file. + // Since the ".file-view" will be dynamically reloaded when navigating via the left file tree (eg: view a PDF file, then view a source code file, etc.) + // the "code-view" related event listeners should always be added when the current page contains ".file-view" element. + if (!document.querySelector('.repo-view-container .file-view')) return; + // "file code view" and "blame" pages need this "line number button" feature let selRangeStart: string; - addDelegatedEventListener(document, 'click', '.lines-num span', (el: HTMLElement, e: KeyboardEvent) => { + addDelegatedEventListener(document, 'click', '.code-view .lines-num span', (el: HTMLElement, e: KeyboardEvent) => { if (!selRangeStart || !e.shiftKey) { selRangeStart = el.getAttribute('id'); selectRange(selRangeStart); @@ -125,12 +130,14 @@ export function initRepoCodeView() { showLineButton(); }); + // apply the selected range from the URL hash const onHashChange = () => { if (!window.location.hash) return; + if (!document.querySelector('.code-view .lines-num')) return; const range = window.location.hash.substring(1); const first = selectRange(range); if (first) { - // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing + // set scrollRestoration to 'manual' when there is a hash in the URL, so that the scroll position will not be remembered after refreshing if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual'; first.scrollIntoView({block: 'start'}); showLineButton(); diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts index 0f77508f70..f3ca13460c 100644 --- a/web_src/js/features/repo-editor.ts +++ b/web_src/js/features/repo-editor.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; import {createCodeEditor} from './codeeditor.ts'; import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts'; import {attachRefIssueContextPopup} from './contextpopup.ts'; @@ -7,6 +7,7 @@ import {initDropzone} from './dropzone.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {submitFormFetchAction} from './common-fetch-action.ts'; function initEditPreviewTab(elForm: HTMLFormElement) { const elTabMenu = elForm.querySelector('.repo-editor-menu'); @@ -86,10 +87,10 @@ export function initRepoEditor() { if (i < parts.length - 1) { if (trimValue.length) { const linkElement = createElementFromHTML( - `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`, + html`<span class="section"><a href="#">${value}</a></span>`, ); const dividerElement = createElementFromHTML( - `<div class="breadcrumb-divider">/</div>`, + html`<div class="breadcrumb-divider">/</div>`, ); links.push(linkElement); dividers.push(dividerElement); @@ -112,7 +113,7 @@ export function initRepoEditor() { if (!warningDiv) { warningDiv = document.createElement('div'); warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related'); - warningDiv.innerHTML = '<p>File path contains leading or trailing whitespace.</p>'; + warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`; // Add display 'block' because display is set to 'none' in formantic\build\semantic.css warningDiv.style.display = 'block'; const inputContainer = document.querySelector('.repo-editor-header'); @@ -141,38 +142,36 @@ export function initRepoEditor() { } }); + const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form'); + // on the upload page, there is no editor(textarea) const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area'); if (!editArea) return; - const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form'); + // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage + // to enable or disable the commit button + const commitButton = document.querySelector<HTMLButtonElement>('#commit-button'); + const dirtyFileClass = 'dirty-file'; + + const syncCommitButtonState = () => { + const dirty = elForm.classList.contains(dirtyFileClass); + commitButton.disabled = !dirty; + }; + // Registering a custom listener for the file path and the file content + // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added + applyAreYouSure(elForm, { + silent: true, + dirtyClass: dirtyFileClass, + fieldSelector: ':input:not(.commit-form-wrapper :input)', + change: syncCommitButtonState, + }); + syncCommitButtonState(); // disable the "commit" button when no content changes + initEditPreviewTab(elForm); (async () => { const editor = await createCodeEditor(editArea, filenameInput); - // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage - // to enable or disable the commit button - const commitButton = document.querySelector<HTMLButtonElement>('#commit-button'); - const dirtyFileClass = 'dirty-file'; - - // Disabling the button at the start - if (document.querySelector<HTMLInputElement>('input[name="page_has_posted"]').value !== 'true') { - commitButton.disabled = true; - } - - // Registering a custom listener for the file path and the file content - // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added - applyAreYouSure(elForm, { - silent: true, - dirtyClass: dirtyFileClass, - fieldSelector: ':input:not(.commit-form-wrapper :input)', - change($form: any) { - const dirty = $form[0]?.classList.contains(dirtyFileClass); - commitButton.disabled = !dirty; - }, - }); - // Update the editor from query params, if available, // only after the dirtyFileClass initialization const params = new URLSearchParams(window.location.search); @@ -181,7 +180,7 @@ export function initRepoEditor() { editor.setValue(value); } - commitButton?.addEventListener('click', async (e) => { + commitButton.addEventListener('click', async (e) => { // A modal which asks if an empty file should be committed if (!editArea.value) { e.preventDefault(); @@ -190,14 +189,15 @@ export function initRepoEditor() { content: elForm.getAttribute('data-text-empty-confirm-content'), })) { ignoreAreYouSure(elForm); - elForm.submit(); + submitFormFetchAction(elForm); } } }); })(); } -export function renderPreviewPanelContent(previewPanel: Element, content: string) { - previewPanel.innerHTML = `<div class="render-content markup">${content}</div>`; +export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) { + // the content is from the server, so it is safe to use innerHTML + previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`; attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue')); } diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts index 3ea5fb70c0..762fbf51bb 100644 --- a/web_src/js/features/repo-issue-list.ts +++ b/web_src/js/features/repo-issue-list.ts @@ -1,6 +1,6 @@ import {updateIssuesMeta} from './repo-common.ts'; import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {html} from '../utils/html.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; import {showErrorToast} from '../modules/toast.ts'; import {createSortable} from '../modules/sortable.ts'; @@ -138,10 +138,10 @@ function initDropdownUserRemoteSearch(el: Element) { // the content is provided by backend IssuePosters handler processedResults.length = 0; for (const item of resp.results) { - let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`; - if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`; + let nameHtml = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${item.username}</span>`; + if (item.full_name) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`; if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username; - processedResults.push({value: item.username, name: html}); + processedResults.push({value: item.username, name: nameHtml}); } resp.results = processedResults; return resp; diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index bc7d4dee19..49e8fc40a2 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlEscape} from '../utils/html.ts'; import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts'; import { addDelegatedEventListener, @@ -17,6 +17,7 @@ import {showErrorToast} from '../modules/toast.ts'; import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; const {appSubUrl} = window.config; @@ -45,8 +46,7 @@ export function initRepoIssueSidebarDependency() { if (String(issue.id) === currIssueId) continue; filteredResponse.results.push({ value: issue.id, - name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div> -<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`, + name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`, }); } return filteredResponse; @@ -416,25 +416,20 @@ export function initRepoIssueWipNewTitle() { export function initRepoIssueWipToggle() { // Toggle WIP for existing PR - queryElems(document, '.toggle-wip', (el) => el.addEventListener('click', async (e) => { + registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => { e.preventDefault(); - const toggleWip = el; const title = toggleWip.getAttribute('data-title'); const wipPrefix = toggleWip.getAttribute('data-wip-prefix'); const updateUrl = toggleWip.getAttribute('data-update-url'); - try { - const params = new URLSearchParams(); - params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`); - - const response = await POST(updateUrl, {data: params}); - if (!response.ok) { - throw new Error('Failed to toggle WIP status'); - } - window.location.reload(); - } catch (error) { - console.error(error); + const params = new URLSearchParams(); + params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`); + const response = await POST(updateUrl, {data: params}); + if (!response.ok) { + showErrorToast(`Failed to toggle 'work in progress' status`); + return; } + window.location.reload(); })); } diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts index 0e4d78872d..e2aa13f490 100644 --- a/web_src/js/features/repo-new.ts +++ b/web_src/js/features/repo-new.ts @@ -1,5 +1,5 @@ import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {htmlEscape} from '../utils/html.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {sanitizeRepoName} from './repo-common.ts'; diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts index dc4aa8274b..ad0feb6101 100644 --- a/web_src/js/features/repo-projects.ts +++ b/web_src/js/features/repo-projects.ts @@ -114,7 +114,6 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void { window.location.reload(); // newly added column, need to reload the page return; } - fomanticQuery(elModal).modal('hide'); // update the newly saved column title and color in the project board (to avoid reload) const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`); @@ -134,6 +133,8 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void { elBoardColumn.style.removeProperty('color'); queryElemChildren<HTMLElement>(elBoardColumn, '.divider', (divider) => divider.style.removeProperty('color')); } + + fomanticQuery(elModal).modal('hide'); } finally { elForm.classList.remove('is-loading'); } diff --git a/web_src/js/features/repo-wiki.ts b/web_src/js/features/repo-wiki.ts index f94d3ef3d1..6ae0947077 100644 --- a/web_src/js/features/repo-wiki.ts +++ b/web_src/js/features/repo-wiki.ts @@ -2,6 +2,7 @@ import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMar import {fomanticMobileScreen} from '../modules/fomantic.ts'; import {POST} from '../modules/fetch.ts'; import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; +import {html, htmlRaw} from '../utils/html.ts'; async function initRepoWikiFormEditor() { const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea'); @@ -30,7 +31,7 @@ async function initRepoWikiFormEditor() { const response = await POST(editor.previewUrl, {data: formData}); const data = await response.text(); lastContent = newContent; - previewTarget.innerHTML = `<div class="render-content markup ui segment">${data}</div>`; + previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`; } catch (error) { console.error('Error rendering preview:', error); } finally { diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index a5cd5ae7c4..07f9c435b8 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -134,7 +134,7 @@ function updateStopwatchData(data: any) { const {repo_owner_name, repo_name, issue_index, seconds} = watch; const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`; document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl); - document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/toggle`); + document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`); document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`); const stopwatchIssue = document.querySelector('.stopwatch-issue'); if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`; diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts index cf98377ae7..43c21ebe6d 100644 --- a/web_src/js/features/tribute.ts +++ b/web_src/js/features/tribute.ts @@ -1,5 +1,5 @@ import {emojiKeys, emojiHTML, emojiString} from './emoji.ts'; -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; type TributeItem = Record<string, any>; @@ -26,17 +26,18 @@ export async function attachTribute(element: HTMLElement) { return emojiString(item.original); }, menuItemTemplate: (item: TributeItem) => { - return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`; + return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`; }, }, { // mentions values: window.config.mentionValues ?? [], requireLeadingSpace: true, menuItemTemplate: (item: TributeItem) => { - return ` + const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : ''; + return html` <div class="tribute-item"> - <img alt src="${htmlEscape(item.original.avatar)}" width="21" height="21"/> - <span class="name">${htmlEscape(item.original.name)}</span> - ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''} + <img alt src="${item.original.avatar}" width="21" height="21"/> + <span class="name">${item.original.name}</span> + ${htmlRaw(fullNameHtml)} </div> `; }, |