diff options
Diffstat (limited to 'web_src/js/features')
67 files changed, 1409 insertions, 966 deletions
diff --git a/web_src/js/features/admin/common.ts b/web_src/js/features/admin/common.ts index 6c725a3efe..4ed5d62eee 100644 --- a/web_src/js/features/admin/common.ts +++ b/web_src/js/features/admin/common.ts @@ -1,7 +1,7 @@ -import $ from 'jquery'; import {checkAppUrl} from '../common-page.ts'; -import {hideElem, showElem, toggleElem} from '../../utils/dom.ts'; +import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts'; import {POST} from '../../modules/fetch.ts'; +import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {appSubUrl} = window.config; @@ -19,32 +19,47 @@ export function initAdminCommon(): void { // check whether appUrl(ROOT_URL) is correct, if not, show an error message checkAppUrl(); - // New user - if ($('.admin.new.user').length > 0 || $('.admin.edit.user').length > 0) { - document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () { - if (this.value?.startsWith('0')) { - document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled'); - document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required'); - hideElem('.non-local'); - showElem('.local'); - document.querySelector<HTMLInputElement>('#user_name')?.focus(); + initAdminUser(); + initAdminAuthentication(); + initAdminNotice(); +} - if (this.getAttribute('data-password') === 'required') { - document.querySelector('#password')?.setAttribute('required', 'required'); - } - } else { - if (document.querySelector<HTMLDivElement>('.admin.edit.user')) { - document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled'); - } - document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required'); - showElem('.non-local'); - hideElem('.local'); - document.querySelector<HTMLInputElement>('#login_name')?.focus(); +function initAdminUser() { + const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user'); + if (!pageContent) return; - document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required'); + document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () { + if (this.value?.startsWith('0')) { + document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled'); + document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required'); + hideElem('.non-local'); + showElem('.local'); + document.querySelector<HTMLInputElement>('#user_name')?.focus(); + + if (this.getAttribute('data-password') === 'required') { + document.querySelector('#password')?.setAttribute('required', 'required'); } - }); - } + } else { + if (document.querySelector<HTMLDivElement>('.admin.edit.user')) { + document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled'); + } + document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required'); + showElem('.non-local'); + hideElem('.local'); + document.querySelector<HTMLInputElement>('#login_name')?.focus(); + + document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required'); + } + }); +} + +function initAdminAuthentication() { + const pageContent = document.querySelector('.page-content.admin.authentication'); + if (!pageContent) return; + + const isNewPage = pageContent.classList.contains('new'); + const isEditPage = pageContent.classList.contains('edit'); + if (!isNewPage && !isEditPage) return; function onUsePagedSearchChange() { const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size'); @@ -90,7 +105,7 @@ export function initAdminCommon(): void { onOAuth2UseCustomURLChange(applyDefaultValues); } - function onOAuth2UseCustomURLChange(applyDefaultValues) { + function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) { const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value; hideElem('.oauth2_use_custom_url_field'); for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) { @@ -119,9 +134,11 @@ export function initAdminCommon(): void { toggleElem(document.querySelector('#ldap-group-options'), checked); } + const elAuthType = document.querySelector<HTMLInputElement>('#auth_type'); + // New authentication - if (document.querySelector<HTMLDivElement>('.admin.new.authentication')) { - document.querySelector<HTMLInputElement>('#auth_type')?.addEventListener('change', function () { + if (isNewPage) { + const onAuthTypeChange = function () { hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi'); for (const input of document.querySelectorAll<HTMLInputElement>('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]')) { @@ -130,7 +147,7 @@ export function initAdminCommon(): void { document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required'); - const authType = this.value; + const authType = elAuthType.value; switch (authType) { case '2': // LDAP showElem('.ldap'); @@ -179,20 +196,23 @@ export function initAdminCommon(): void { if (authType === '2') { onUsePagedSearchChange(); } - }); - $('#auth_type').trigger('change'); + }; + elAuthType.addEventListener('change', onAuthTypeChange); + onAuthTypeChange(); + document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange); document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange); document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true)); document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true)); - $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange); + + document.querySelector('.js-ldap-group-toggle').addEventListener('change', onEnableLdapGroupsChange); } // Edit authentication - if (document.querySelector<HTMLDivElement>('.admin.edit.authentication')) { - const authType = document.querySelector<HTMLInputElement>('#auth_type')?.value; + if (isEditPage) { + const authType = elAuthType.value; if (authType === '2' || authType === '5') { document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange); - $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange); + document.querySelector('.js-ldap-group-toggle').addEventListener('change', onEnableLdapGroupsChange); onEnableLdapGroupsChange(); if (authType === '2') { document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange); @@ -204,58 +224,63 @@ export function initAdminCommon(): void { } } - if (document.querySelector<HTMLDivElement>('.admin.authentication')) { - $('#auth_name').on('input', function () { - // appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash. - document.querySelector('#oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent((this as HTMLInputElement).value)}/callback`; - }).trigger('input'); - } + const elAuthName = document.querySelector<HTMLInputElement>('#auth_name'); + const onAuthNameChange = function () { + // appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash. + document.querySelector('#oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent(elAuthName.value)}/callback`; + }; + elAuthName.addEventListener('input', onAuthNameChange); + onAuthNameChange(); +} - // Notice - if (document.querySelector<HTMLDivElement>('.admin.notice')) { - const detailModal = document.querySelector<HTMLDivElement>('#detail-modal'); +function initAdminNotice() { + const pageContent = document.querySelector('.page-content.admin.notice'); + if (!pageContent) return; - // Attach view detail modals - $('.view-detail').on('click', function () { - const description = this.closest('tr').querySelector('.notice-description').textContent; - detailModal.querySelector('.content pre').textContent = description; - $(detailModal).modal('show'); - return false; - }); + const detailModal = document.querySelector<HTMLDivElement>('#detail-modal'); - // Select actions - const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input'); + // Attach view detail modals + queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => { + e.preventDefault(); + const elNoticeDesc = el.closest('tr').querySelector('.notice-description'); + const elModalDesc = detailModal.querySelector('.content pre'); + elModalDesc.textContent = elNoticeDesc.textContent; + fomanticQuery(detailModal).modal('show'); + })); - $('.select.action').on('click', function () { - switch ($(this).data('action')) { - case 'select-all': - for (const checkbox of checkboxes) { - checkbox.checked = true; - } - break; - case 'deselect-all': - for (const checkbox of checkboxes) { - checkbox.checked = false; - } - break; - case 'inverse': - for (const checkbox of checkboxes) { - checkbox.checked = !checkbox.checked; - } - break; - } - }); - document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) { - e.preventDefault(); - this.classList.add('is-loading', 'disabled'); - const data = new FormData(); - for (const checkbox of checkboxes) { - if (checkbox.checked) { - data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id')); + // Select actions + const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input'); + + queryElems(pageContent, '.select.action', (el) => el.addEventListener('click', () => { + switch (el.getAttribute('data-action')) { + case 'select-all': + for (const checkbox of checkboxes) { + checkbox.checked = true; } + break; + case 'deselect-all': + for (const checkbox of checkboxes) { + checkbox.checked = false; + } + break; + case 'inverse': + for (const checkbox of checkboxes) { + checkbox.checked = !checkbox.checked; + } + break; + } + })); + + document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) { + e.preventDefault(); + this.classList.add('is-loading', 'disabled'); + const data = new FormData(); + for (const checkbox of checkboxes) { + if (checkbox.checked) { + data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id')); } - await POST(this.getAttribute('data-link'), {data}); - window.location.href = this.getAttribute('data-redirect'); - }); - } + } + await POST(this.getAttribute('data-link'), {data}); + window.location.href = this.getAttribute('data-redirect'); + }); } diff --git a/web_src/js/features/autofocus-end.ts b/web_src/js/features/autofocus-end.ts deleted file mode 100644 index 53e475b543..0000000000 --- a/web_src/js/features/autofocus-end.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function initAutoFocusEnd() { - for (const el of document.querySelectorAll<HTMLInputElement>('.js-autofocus-end')) { - el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus. - el.setSelectionRange(el.value.length, el.value.length); - } -} diff --git a/web_src/js/features/captcha.ts b/web_src/js/features/captcha.ts index 69b4aa6852..df234d0e5c 100644 --- a/web_src/js/features/captcha.ts +++ b/web_src/js/features/captcha.ts @@ -34,13 +34,18 @@ export async function initCaptcha() { break; } case 'm-captcha': { - const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue'); - // @ts-expect-error + const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue'); + + // FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run + // * the "vanilla-glue" has some problems with es6 module. + // * the INPUT_NAME is a "const", it should not be changed. + // * the "mCaptcha.default" is actually the "Widget". + + // @ts-expect-error TS2540: Cannot assign to 'INPUT_NAME' because it is a read-only property. mCaptcha.INPUT_NAME = 'm-captcha-response'; const instanceURL = captchaEl.getAttribute('data-instance-url'); - // @ts-expect-error - mCaptcha.default({ + new mCaptcha.default({ siteKey: { instanceUrl: new URL(instanceURL), key: siteKey, diff --git a/web_src/js/features/citation.ts b/web_src/js/features/citation.ts index fc5bb38f0a..3c9fe0afc8 100644 --- a/web_src/js/features/citation.ts +++ b/web_src/js/features/citation.ts @@ -5,9 +5,13 @@ const {pageData} = window.config; async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) { const [{Cite, plugins}] = await Promise.all([ + // @ts-expect-error: module exports no types import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'), + // @ts-expect-error: module exports no types import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'), + // @ts-expect-error: module exports no types import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'), + // @ts-expect-error: module exports no types import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'), ]); const {citationFileContent} = pageData; diff --git a/web_src/js/features/common-button.test.ts b/web_src/js/features/common-button.test.ts new file mode 100644 index 0000000000..f41bafbc79 --- /dev/null +++ b/web_src/js/features/common-button.test.ts @@ -0,0 +1,14 @@ +import {assignElementProperty} from './common-button.ts'; + +test('assignElementProperty', () => { + const elForm = document.createElement('form'); + assignElementProperty(elForm, 'action', '/test-link'); + expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL + assignElementProperty(elForm, 'text-content', 'dummy'); + expect(elForm.textContent).toBe('dummy'); + + const elInput = document.createElement('input'); + expect(elInput.readOnly).toBe(false); + assignElementProperty(elInput, 'read-only', 'true'); + expect(elInput.readOnly).toBe(true); +}); diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index 3162557b9b..22a7890857 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -1,5 +1,5 @@ import {POST} from '../modules/fetch.ts'; -import {addDelegatedEventListener, hideElem, queryElems, showElem, toggleElem} from '../utils/dom.ts'; +import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {camelize} from 'vue'; @@ -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,34 +65,36 @@ 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'); }); } } -function onShowPanelClick(e) { +function onShowPanelClick(el: HTMLElement, e: MouseEvent) { // a '.show-panel' element can show a panel, by `data-panel="selector"` // if it has "toggle" class, it toggles the panel - const el = e.currentTarget; e.preventDefault(); const sel = el.getAttribute('data-panel'); - if (el.classList.contains('toggle')) { - toggleElem(sel); - } else { - showElem(sel); + const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel); + for (const elem of elems) { + if (isElemVisible(elem as HTMLElement)) { + elem.querySelector<HTMLElement>('[autofocus]')?.focus(); + } } } -function onHidePanelClick(e) { +function onHidePanelClick(el: HTMLElement, e: MouseEvent) { // a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"` - const el = e.currentTarget; e.preventDefault(); let sel = el.getAttribute('data-panel'); if (sel) { @@ -98,21 +103,35 @@ function onHidePanelClick(e) { } sel = el.getAttribute('data-panel-closest'); if (sel) { - hideElem(el.parentNode.closest(sel)); + hideElem((el.parentNode as HTMLElement).closest(sel)); return; } throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code } -function onShowModalClick(e) { +export function assignElementProperty(el: any, name: string, val: string) { + name = camelize(name); + const old = el[name]; + if (typeof old === 'boolean') { + el[name] = val === 'true'; + } else if (typeof old === 'number') { + el[name] = parseFloat(val); + } else if (typeof old === 'string') { + el[name] = val; + } else { + // in the future, we could introduce a better typing system like `data-modal-form.action:string="..."` + throw new Error(`cannot assign element property ${name} by value ${val}`); + } +} + +function onShowModalClick(el: HTMLElement, e: MouseEvent) { // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute. // Each "data-modal-{target}" attribute will be filled to target element's value or text-content. // * First, try to query '#target' // * Then, try to query '[name=target]' // * Then, try to query '.target' // * Then, try to query 'target' as HTML tag - // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set. - const el = e.currentTarget; + // If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName". e.preventDefault(); const modalSelector = el.getAttribute('data-modal'); const elModal = document.querySelector(modalSelector); @@ -125,7 +144,7 @@ function onShowModalClick(e) { } const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length); - const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.'); + const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.'); // try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag" const attrTarget = elModal.querySelector(`#${attrTargetName}`) || elModal.querySelector(`[name=${attrTargetName}]`) || @@ -136,22 +155,16 @@ function onShowModalClick(e) { continue; } - if (attrTargetAttr) { - attrTarget[camelize(attrTargetAttr)] = attrib.value; + if (attrTargetProp) { + assignElementProperty(attrTarget, attrTargetProp, attrib.value); } else if (attrTarget.matches('input, textarea')) { - attrTarget.value = attrib.value; // FIXME: add more supports like checkbox + (attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox } else { attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p } } - 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 { @@ -160,7 +173,15 @@ export function initGlobalButtons(): void { // There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content") addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault()); - queryElems(document, '.show-panel', (el) => el.addEventListener('click', onShowPanelClick)); - queryElems(document, '.hide-panel', (el) => el.addEventListener('click', onHidePanelClick)); - queryElems(document, '.show-modal', (el) => el.addEventListener('click', onShowModalClick)); + // Ideally these "button" events should be handled by registerGlobalEventFunc + // Refactoring would involve too many changes, so at the moment, just use the global event listener. + addDelegatedEventListener(document, 'click', '.show-panel, .hide-panel, .show-modal', (el, e: MouseEvent) => { + if (el.classList.contains('show-panel')) { + onShowPanelClick(el, e); + } else if (el.classList.contains('hide-panel')) { + onHidePanelClick(el, e); + } else if (el.classList.contains('show-modal')) { + onShowModalClick(el, e); + } + }); } diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts index bc72f4089a..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,16 +79,18 @@ 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 || ''); } let reqUrl = formActionUrl; - const reqOpt = {method: formMethod.toUpperCase(), body: null}; + const reqOpt = { + method: formMethod.toUpperCase(), + body: null as FormData | null, + }; if (formMethod.toLowerCase() === 'get') { const params = new URLSearchParams(); for (const [key, value] of formData) { @@ -93,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/common-form.ts b/web_src/js/features/common-form.ts index 8532d397cd..7321d80c44 100644 --- a/web_src/js/features/common-form.ts +++ b/web_src/js/features/common-form.ts @@ -17,13 +17,13 @@ export function initGlobalEnterQuickSubmit() { if (e.key !== 'Enter') return; const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey); if (hasCtrlOrMeta && e.target.matches('textarea')) { - if (handleGlobalEnterQuickSubmit(e.target)) { + if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) { e.preventDefault(); } } else if (e.target.matches('input') && !e.target.closest('form')) { // input in a normal form could handle Enter key by default, so we only handle the input outside a form // eslint-disable-next-line unicorn/no-lonely-if - if (handleGlobalEnterQuickSubmit(e.target)) { + if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) { e.preventDefault(); } } diff --git a/web_src/js/features/common-issue-list.ts b/web_src/js/features/common-issue-list.ts index e207364794..037529bd10 100644 --- a/web_src/js/features/common-issue-list.ts +++ b/web_src/js/features/common-issue-list.ts @@ -1,4 +1,4 @@ -import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts'; +import {isElemVisible, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts'; import {GET} from '../modules/fetch.ts'; const {appSubUrl} = window.config; @@ -28,7 +28,7 @@ export function parseIssueListQuickGotoLink(repoLink: string, searchText: string } export function initCommonIssueListQuickGoto() { - const goto = document.querySelector('#issue-list-quick-goto'); + const goto = document.querySelector<HTMLElement>('#issue-list-quick-goto'); if (!goto) return; const form = goto.closest('form'); @@ -37,7 +37,7 @@ export function initCommonIssueListQuickGoto() { form.addEventListener('submit', (e) => { // if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly - let doQuickGoto = !isElemHidden(goto); + let doQuickGoto = isElemVisible(goto); const submitter = submitEventSubmitter(e); if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false; if (!doQuickGoto) return; diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts index 56c5915b6d..5a02ee7a6a 100644 --- a/web_src/js/features/common-page.ts +++ b/web_src/js/features/common-page.ts @@ -2,6 +2,8 @@ import {GET} from '../modules/fetch.ts'; import {showGlobalErrorMessage} from '../bootstrap.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {queryElems} from '../utils/dom.ts'; +import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts'; +import {initAvatarUploaderWithCropper} from './comp/Cropper.ts'; const {appUrl} = window.config; @@ -28,51 +30,78 @@ export function initFootLanguageMenu() { } export function initGlobalDropdown() { - // Semantic UI modules. - const $uiDropdowns = fomanticQuery('.ui.dropdown'); - // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. - $uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'}); + registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => { + const $dropdown = fomanticQuery(el); + if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it. - // The "jump" means this dropdown is mainly used for "menu" purpose, - // clicking an item will jump to somewhere else or trigger an action/function. - // When a dropdown is used for non-refresh actions with tippy, - // it must have this "jump" class to hide the tippy when dropdown is closed. - $uiDropdowns.filter('.jump').dropdown('setting', { - action: 'hide', - onShow() { - // hide associated tooltip while dropdown is open - this._tippy?.hide(); - this._tippy?.disable(); - }, - onHide() { - this._tippy?.enable(); - // eslint-disable-next-line unicorn/no-this-assignment - const elDropdown = this; + $dropdown.dropdown('setting', {hideDividers: 'empty'}); - // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu - setTimeout(() => { - const $dropdown = fomanticQuery(elDropdown); - if ($dropdown.dropdown('is hidden')) { - queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide()); - } - }, 2000); - }, - }); + if (el.classList.contains('jump')) { + // The "jump" means this dropdown is mainly used for "menu" purpose, + // clicking an item will jump to somewhere else or trigger an action/function. + // When a dropdown is used for non-refresh actions with tippy, + // it must have this "jump" class to hide the tippy when dropdown is closed. + $dropdown.dropdown('setting', { + action: 'hide', + onShow() { + // hide associated tooltip while dropdown is open + this._tippy?.hide(); + this._tippy?.disable(); + }, + onHide() { + this._tippy?.enable(); + // eslint-disable-next-line unicorn/no-this-assignment + const elDropdown = this; + + // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu + setTimeout(() => { + const $dropdown = fomanticQuery(elDropdown); + if ($dropdown.dropdown('is hidden')) { + queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide()); + } + }, 2000); + }, + }); + } - // Special popup-directions, prevent Fomantic from guessing the popup direction. - // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward, - // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view. - // eg: Issue List "Sort" dropdown - // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position, - // which would make some dropdown popups slightly shift out of the right viewport edge in some cases. - // eg: the "Create New Repo" menu on the navbar. - $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward'); - $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward'); + // Special popup-directions, prevent Fomantic from guessing the popup direction. + // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward, + // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view. + // eg: Issue List "Sort" dropdown + // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position, + // which would make some dropdown popups slightly shift out of the right viewport edge in some cases. + // eg: the "Create New Repo" menu on the navbar. + if (el.classList.contains('upward')) $dropdown.dropdown('setting', 'direction', 'upward'); + if (el.classList.contains('downward')) $dropdown.dropdown('setting', 'direction', 'downward'); + }); } export function initGlobalTabularMenu() { - fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false}); + fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab(); +} + +export function initGlobalAvatarUploader() { + registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper); +} + +// for performance considerations, it only uses performant syntax +function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) { + if (el.type !== 'hidden' && + el.type !== 'checkbox' && + el.type !== 'radio' && + el.type !== 'range' && + el.type !== 'color') { + el.dir = 'auto'; + } +} + +export function initGlobalInput() { + registerGlobalSelectorFunc('input, textarea', attachInputDirAuto); + registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => { + el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus. + el.setSelectionRange(el.value.length, el.value.length); + }); } /** diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index bba50a1296..d3773a89c4 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -29,10 +29,10 @@ let elementIdCounter = 0; /** * validate if the given textarea is non-empty. - * @param {HTMLElement} textarea - The textarea element to be validated. + * @param {HTMLTextAreaElement} textarea - The textarea element to be validated. * @returns {boolean} returns true if validation succeeded. */ -export function validateTextareaNonEmpty(textarea) { +export function validateTextareaNonEmpty(textarea: HTMLTextAreaElement) { // When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation. // The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert. if (!textarea.value) { @@ -49,16 +49,25 @@ export function validateTextareaNonEmpty(textarea) { return true; } +type Heights = { + minHeight?: string, + height?: string, + maxHeight?: string, +}; + type ComboMarkdownEditorOptions = { - editorHeights?: {minHeight?: string, height?: string, maxHeight?: string}, + editorHeights?: Heights, easyMDEOptions?: EasyMDE.Options, }; +type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any}; +type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any}; + export class ComboMarkdownEditor { static EventEditorContentChanged = EventEditorContentChanged; static EventUploadStateChanged = EventUploadStateChanged; - public container : HTMLElement; + public container: HTMLElement; options: ComboMarkdownEditorOptions; @@ -70,7 +79,7 @@ export class ComboMarkdownEditor { easyMDEToolbarActions: any; easyMDEToolbarDefault: any; - textarea: HTMLTextAreaElement & {_giteaComboMarkdownEditor: any}; + textarea: ComboMarkdownEditorTextarea; textareaMarkdownToolbar: HTMLElement; textareaAutosize: any; @@ -81,7 +90,7 @@ export class ComboMarkdownEditor { previewUrl: string; previewContext: string; - constructor(container, options:ComboMarkdownEditorOptions = {}) { + constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) { if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized'); container._giteaComboMarkdownEditor = this; this.options = options; @@ -98,7 +107,7 @@ export class ComboMarkdownEditor { await this.switchToUserPreference(); } - applyEditorHeights(el, heights) { + applyEditorHeights(el: HTMLElement, heights: Heights) { if (!heights) return; if (heights.minHeight) el.style.minHeight = heights.minHeight; if (heights.height) el.style.height = heights.height; @@ -283,7 +292,7 @@ export class ComboMarkdownEditor { ]; } - parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions) { + parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: any) { this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this); const processed = []; for (const action of actions) { @@ -332,21 +341,21 @@ export class ComboMarkdownEditor { this.easyMDE = new EasyMDE(easyMDEOpt); this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container)); this.easyMDE.codemirror.setOption('extraKeys', { - 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), - 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), - Enter: (cm) => { + 'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()), + 'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()), + Enter: (cm: any) => { const tributeContainer = document.querySelector<HTMLElement>('.tribute-container'); if (!tributeContainer || tributeContainer.style.display === 'none') { cm.execCommand('newlineAndIndent'); } }, - Up: (cm) => { + Up: (cm: any) => { const tributeContainer = document.querySelector<HTMLElement>('.tribute-container'); if (!tributeContainer || tributeContainer.style.display === 'none') { return cm.execCommand('goLineUp'); } }, - Down: (cm) => { + Down: (cm: any) => { const tributeContainer = document.querySelector<HTMLElement>('.tribute-container'); if (!tributeContainer || tributeContainer.style.display === 'none') { return cm.execCommand('goLineDown'); @@ -354,14 +363,14 @@ export class ComboMarkdownEditor { }, }); this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights); - await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true}); + await attachTribute(this.easyMDE.codemirror.getInputField()); if (this.dropzone) { initEasyMDEPaste(this.easyMDE, this.dropzone); } hideElem(this.textareaMarkdownToolbar); } - value(v = undefined) { + value(v: any = undefined) { if (v === undefined) { if (this.easyMDE) { return this.easyMDE.value(); @@ -402,7 +411,7 @@ export class ComboMarkdownEditor { } } -export function getComboMarkdownEditor(el) { +export function getComboMarkdownEditor(el: any) { if (!el) return null; if (el.length) el = el[0]; return el._giteaComboMarkdownEditor; 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/Cropper.ts b/web_src/js/features/comp/Cropper.ts index e65dcfbe13..aaa1691152 100644 --- a/web_src/js/features/comp/Cropper.ts +++ b/web_src/js/features/comp/Cropper.ts @@ -6,7 +6,7 @@ type CropperOpts = { fileInput: HTMLInputElement, } -export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) { +async function initCompCropper({container, fileInput, imageSource}: CropperOpts) { const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs'); let currentFileName = ''; let currentFileLastModified = 0; @@ -38,3 +38,10 @@ export async function initCompCropper({container, fileInput, imageSource}: Cropp } }); } + +export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) { + const panel = fileInput.nextElementSibling as HTMLElement; + if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader'); + const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source'); + await initCompCropper({container: panel, fileInput, imageSource}); +} diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index d3ed492396..6e66c15763 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -1,10 +1,10 @@ export const EventEditorContentChanged = 'ce-editor-content-changed'; -export function triggerEditorContentChanged(target) { +export function triggerEditorContentChanged(target: HTMLElement) { target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true})); } -export function textareaInsertText(textarea, value) { +export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) { const startPos = textarea.selectionStart; const endPos = textarea.selectionEnd; textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos); @@ -20,7 +20,7 @@ type TextareaValueSelection = { selEnd: number; } -function handleIndentSelection(textarea: HTMLTextAreaElement, e) { +function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) { const selStart = textarea.selectionStart; const selEnd = textarea.selectionEnd; if (selEnd === selStart) return; // do not process when no selection @@ -184,8 +184,13 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) { triggerEditorContentChanged(textarea); } -export function initTextareaMarkdown(textarea) { +function isTextExpanderShown(textarea: HTMLElement): boolean { + return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions')); +} + +export function initTextareaMarkdown(textarea: HTMLTextAreaElement) { textarea.addEventListener('keydown', (e) => { + if (isTextExpanderShown(textarea)) return; if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { // use Tab/Shift-Tab to indent/unindent the selected lines handleIndentSelection(textarea, e); 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 89982747ea..bf78f58daf 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -8,43 +8,46 @@ import { generateMarkdownLinkForAttachment, } from '../dropzone.ts'; import type CodeMirror from 'codemirror'; +import type EasyMDE from 'easymde'; +import type {DropzoneFile} from 'dropzone'; let uploadIdCounter = 0; export const EventUploadStateChanged = 'ce-upload-state-changed'; -export function triggerUploadStateChanged(target) { +export function triggerUploadStateChanged(target: HTMLElement) { target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true})); } -function uploadFile(dropzoneEl, file) { +function uploadFile(dropzoneEl: HTMLElement, file: File) { return new Promise((resolve) => { const curUploadId = uploadIdCounter++; - file._giteaUploadId = curUploadId; + (file as any)._giteaUploadId = curUploadId; const dropzoneInst = dropzoneEl.dropzone; - const onUploadDone = ({file}) => { + const onUploadDone = ({file}: {file: any}) => { if (file._giteaUploadId === curUploadId) { dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone); resolve(file); } }; dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone); - dropzoneInst.handleFiles([file]); + // FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time) + dropzoneInst.addFile(file as DropzoneFile); }); } class TextareaEditor { - editor : HTMLTextAreaElement; + editor: HTMLTextAreaElement; - constructor(editor) { + constructor(editor: HTMLTextAreaElement) { this.editor = editor; } - insertPlaceholder(value) { + insertPlaceholder(value: string) { textareaInsertText(this.editor, value); } - replacePlaceholder(oldVal, newVal) { + replacePlaceholder(oldVal: string, newVal: string) { const editor = this.editor; const startPos = editor.selectionStart; const endPos = editor.selectionEnd; @@ -65,11 +68,11 @@ class TextareaEditor { class CodeMirrorEditor { editor: CodeMirror.EditorFromTextArea; - constructor(editor) { + constructor(editor: CodeMirror.EditorFromTextArea) { this.editor = editor; } - insertPlaceholder(value) { + insertPlaceholder(value: string) { const editor = this.editor; const startPoint = editor.getCursor('start'); const endPoint = editor.getCursor('end'); @@ -80,7 +83,7 @@ class CodeMirrorEditor { triggerEditorContentChanged(editor.getTextArea()); } - replacePlaceholder(oldVal, newVal) { + replacePlaceholder(oldVal: string, newVal: string) { const editor = this.editor; const endPoint = editor.getCursor('end'); if (editor.getSelection() === oldVal) { @@ -96,7 +99,7 @@ class CodeMirrorEditor { } } -async function handleUploadFiles(editor, dropzoneEl, files, e) { +async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, dropzoneEl: HTMLElement, files: Array<File> | FileList, e: Event) { e.preventDefault(); for (const file of files) { const name = file.name.slice(0, file.name.lastIndexOf('.')); @@ -109,29 +112,38 @@ async function handleUploadFiles(editor, dropzoneEl, files, e) { } } -export function removeAttachmentLinksFromMarkdown(text, fileUuid) { +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, e, {text, isShiftDown}) { +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 } // extract text and images from "paste" event -function getPastedContent(e) { +function getPastedContent(e: ClipboardEvent) { const images = []; for (const item of e.clipboardData?.items ?? []) { if (item.type?.startsWith('image/')) { @@ -142,8 +154,8 @@ function getPastedContent(e) { return {text, images}; } -export function initEasyMDEPaste(easyMDE, dropzoneEl) { - const editor = new CodeMirrorEditor(easyMDE.codemirror); +export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) { + const editor = new CodeMirrorEditor(easyMDE.codemirror as any); easyMDE.codemirror.on('paste', (_, e) => { const {images} = getPastedContent(e); if (!images.length) return; @@ -160,28 +172,28 @@ export function initEasyMDEPaste(easyMDE, dropzoneEl) { }); } -export function initTextareaEvents(textarea, dropzoneEl) { +export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) { let isShiftDown = false; - textarea.addEventListener('keydown', (e) => { + textarea.addEventListener('keydown', (e: KeyboardEvent) => { if (e.shiftKey) isShiftDown = true; }); - textarea.addEventListener('keyup', (e) => { + textarea.addEventListener('keyup', (e: KeyboardEvent) => { if (!e.shiftKey) isShiftDown = false; }); - textarea.addEventListener('paste', (e) => { + textarea.addEventListener('paste', (e: ClipboardEvent) => { const {images, text} = getPastedContent(e); if (images.length && dropzoneEl) { handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e); } else if (text) { - handleClipboardText(textarea, e, {text, isShiftDown}); + handleClipboardText(textarea, e, text, isShiftDown); } }); - textarea.addEventListener('drop', (e) => { + textarea.addEventListener('drop', (e: DragEvent) => { if (!e.dataTransfer.files.length) return; if (!dropzoneEl) return; handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e); }); - dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => { + dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}: {fileUuid: string}) => { const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid); if (textarea.value !== newText) textarea.value = newText; }); diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts index 7bceb636bb..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); @@ -18,6 +19,8 @@ export function initCompLabelEdit(pageSelector: string) { const elExclusiveField = elModal.querySelector('.label-exclusive-input-field'); const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input'); const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning'); + const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field'); + const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input'); const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field'); const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input'); const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input'); @@ -29,6 +32,13 @@ export function initCompLabelEdit(pageSelector: string) { const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive'); toggleElem(elExclusiveWarning, showExclusiveWarning); if (!hasScope) elExclusiveInput.checked = false; + toggleElem(elExclusiveOrderField, elExclusiveInput.checked); + + if (parseInt(elExclusiveOrderInput.value) <= 0) { + elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important'; + } else { + elExclusiveOrderInput.style.color = null; + } }; const showLabelEditModal = (btn:HTMLElement) => { @@ -36,6 +46,7 @@ export function initCompLabelEdit(pageSelector: string) { const form = elModal.querySelector<HTMLFormElement>('form'); elLabelId.value = btn.getAttribute('data-label-id') || ''; elNameInput.value = btn.getAttribute('data-label-name') || ''; + elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0'; elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true'; elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true'; elDescInput.value = btn.getAttribute('data-label-description') || ''; @@ -60,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/QuickSubmit.ts b/web_src/js/features/comp/QuickSubmit.ts index 385acb319f..0a41f69132 100644 --- a/web_src/js/features/comp/QuickSubmit.ts +++ b/web_src/js/features/comp/QuickSubmit.ts @@ -1,6 +1,6 @@ import {querySingleVisibleElem} from '../../utils/dom.ts'; -export function handleGlobalEnterQuickSubmit(target) { +export function handleGlobalEnterQuickSubmit(target: HTMLElement) { let form = target.closest('form'); if (form) { if (!form.checkValidity()) { diff --git a/web_src/js/features/comp/ReactionSelector.ts b/web_src/js/features/comp/ReactionSelector.ts index e93e3b8377..bb54593f11 100644 --- a/web_src/js/features/comp/ReactionSelector.ts +++ b/web_src/js/features/comp/ReactionSelector.ts @@ -1,37 +1,31 @@ import {POST} from '../../modules/fetch.ts'; -import {fomanticQuery} from '../../modules/fomantic/base.ts'; import type {DOMEvent} from '../../utils/dom.ts'; +import {registerGlobalEventFunc} from '../../modules/observer.ts'; -export function initCompReactionSelector(parent: ParentNode = document) { - for (const container of parent.querySelectorAll<HTMLElement>('.issue-content, .diff-file-body')) { - container.addEventListener('click', async (e: DOMEvent<MouseEvent>) => { - // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment - const target = e.target.closest('.comment-reaction-button'); - if (!target) return; - e.preventDefault(); +export function initCompReactionSelector() { + registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: DOMEvent<MouseEvent>) => { + // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment + e.preventDefault(); - if (target.classList.contains('disabled')) return; + if (target.classList.contains('disabled')) return; - const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url'); - const reactionContent = target.getAttribute('data-reaction-content'); + const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url'); + const reactionContent = target.getAttribute('data-reaction-content'); - const commentContainer = target.closest('.comment-container'); + const commentContainer = target.closest('.comment-container'); - const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction - const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`); - const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true'; + const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction + const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`); + const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true'; - const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { - data: new URLSearchParams({content: reactionContent}), - }); - - const data = await res.json(); - bottomReactions?.remove(); - if (data.html) { - commentContainer.insertAdjacentHTML('beforeend', data.html); - const bottomReactionsDropdowns = commentContainer.querySelectorAll('.bottom-reactions .dropdown.select-reaction'); - fomanticQuery(bottomReactionsDropdowns).dropdown(); // re-init the dropdown - } + const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { + data: new URLSearchParams({content: reactionContent}), }); - } + + const data = await res.json(); + bottomReactions?.remove(); + if (data.html) { + commentContainer.insertAdjacentHTML('beforeend', data.html); + } + }); } diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts index 2e3b3f83be..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; @@ -14,7 +14,7 @@ export function initCompSearchUserBox() { minCharacters: 2, apiSettings: { url: `${appSubUrl}/user/search_candidates?q={query}`, - onResponse(response) { + onResponse(response: any) { const resultItems = []; const searchQuery = searchUserBox.querySelector('input').value; const searchQueryUppercase = searchQuery.toUpperCase(); diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts index e0c4abed75..2d79fe5029 100644 --- a/web_src/js/features/comp/TextExpander.ts +++ b/web_src/js/features/comp/TextExpander.ts @@ -1,18 +1,25 @@ import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts'; import {emojiString} from '../emoji.ts'; import {svg} from '../../svg.ts'; -import {parseIssueHref, parseIssueNewHref} from '../../utils.ts'; +import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts'; import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts'; import {getIssueColor, getIssueIcon} from '../issue.ts'; import {debounce} from 'perfect-debounce'; +import type TextExpanderElement from '@github/text-expander-element'; +import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element'; -const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => { - let issuePathInfo = parseIssueHref(window.location.href); - if (!issuePathInfo.ownerName) issuePathInfo = parseIssueNewHref(window.location.href); - if (!issuePathInfo.ownerName) return resolve({matched: false}); +async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderResult> { + const issuePathInfo = parseIssueHref(window.location.href); + if (!issuePathInfo.ownerName) { + const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname); + issuePathInfo.ownerName = repoOwnerPathInfo.ownerName; + issuePathInfo.repoName = repoOwnerPathInfo.repoName; + // then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue" + } + if (!issuePathInfo.ownerName) return {matched: false}; const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text); - if (!matches.length) return resolve({matched: false}); + if (!matches.length) return {matched: false}; const ul = createElementFromAttrs('ul', {class: 'suggestions'}); for (const issue of matches) { @@ -24,11 +31,40 @@ const debouncedSuggestIssues = debounce((key: string, text: string) => new Promi ); ul.append(li); } - resolve({matched: true, fragment: ul}); -}), 100); + return {matched: true, fragment: ul}; +} + +export function initTextExpander(expander: TextExpanderElement) { + if (!expander) return; + + const textarea = expander.querySelector<HTMLTextAreaElement>('textarea'); -export function initTextExpander(expander) { - expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { + // help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line + const shouldShowIssueSuggestions = () => { + const posVal = textarea.value.substring(0, textarea.selectionStart); + const lineStart = posVal.lastIndexOf('\n'); + const keyStart = posVal.lastIndexOf('#'); + return keyStart > lineStart; + }; + + const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderResult> => { + // https://github.com/github/text-expander-element/issues/71 + // Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position. + // To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below, + // then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`) + // There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27). + + // check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug) + if (!shouldShowIssueSuggestions()) return {matched: false}; + // await sleep(Math.random() * 1000); // help to reproduce the text-expander bug + const ret = await fetchIssueSuggestions(key, text); + // check the input again to avoid text-expander using incorrect position (upstream bug) + if (!shouldShowIssueSuggestions()) return {matched: false}; + return ret; + }, 300); // to match onInputDebounce delay + + expander.addEventListener('text-expander-change', (e: TextExpanderChangeEvent) => { + const {key, text, provide} = e.detail; if (key === ':') { const matches = matchEmoji(text); if (!matches.length) return provide({matched: false}); @@ -61,6 +97,7 @@ export function initTextExpander(expander) { li.append(img); const nameSpan = document.createElement('span'); + nameSpan.classList.add('name'); nameSpan.textContent = name; li.append(nameSpan); @@ -76,10 +113,11 @@ export function initTextExpander(expander) { provide({matched: true, fragment: ul}); } else if (key === '#') { - provide(debouncedSuggestIssues(key, text)); + provide(debouncedIssueSuggestions(key, text)); } }); - expander?.addEventListener('text-expander-value', ({detail}) => { + + expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => { if (detail?.item) { // add a space after @mentions and #issue as it's likely the user wants one const suffix = ['@', '#'].includes(detail.key) ? ' ' : ''; diff --git a/web_src/js/features/contextpopup.ts b/web_src/js/features/contextpopup.ts index 33eead8431..7477331dbe 100644 --- a/web_src/js/features/contextpopup.ts +++ b/web_src/js/features/contextpopup.ts @@ -4,11 +4,11 @@ import {parseIssueHref} from '../utils.ts'; import {createTippy} from '../modules/tippy.ts'; export function initContextPopups() { - const refIssues = document.querySelectorAll('.ref-issue'); + const refIssues = document.querySelectorAll<HTMLElement>('.ref-issue'); attachRefIssueContextPopup(refIssues); } -export function attachRefIssueContextPopup(refIssues) { +export function attachRefIssueContextPopup(refIssues: NodeListOf<HTMLElement>) { for (const refIssue of refIssues) { if (refIssue.classList.contains('ref-external-issue')) continue; diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts index af867463b2..0fec2a6235 100644 --- a/web_src/js/features/copycontent.ts +++ b/web_src/js/features/copycontent.ts @@ -2,26 +2,24 @@ import {clippie} from 'clippie'; import {showTemporaryTooltip} from '../modules/tippy.ts'; import {convertImage} from '../utils.ts'; import {GET} from '../modules/fetch.ts'; +import {registerGlobalEventFunc} from '../modules/observer.ts'; const {i18n} = window.config; export function initCopyContent() { - const btn = document.querySelector('#copy-content'); - if (!btn || btn.classList.contains('disabled')) return; + registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => { + if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return; + const rawFileLink = btn.getAttribute('data-raw-file-link'); - btn.addEventListener('click', async () => { - if (btn.classList.contains('is-loading')) return; - let content; - let isRasterImage = false; - const link = btn.getAttribute('data-link'); + let content, isRasterImage = false; - // 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 + // 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')) { @@ -40,13 +38,13 @@ export function initCopyContent() { content = Array.from(lineEls, (el) => el.textContent).join(''); } - // try copy original first, if that fails and it's an image, convert it to png + // try copy original first, if that fails, and it's an image, convert it to png const success = await clippie(content); if (success) { showTemporaryTooltip(btn, i18n.copy_success); } else { if (isRasterImage) { - const success = await clippie(await convertImage(content, 'image/png')); + const success = await clippie(await convertImage(content as Blob, 'image/png')); showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error); } else { showTemporaryTooltip(btn, i18n.copy_error); diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts index 666c645230..20f7ceb6c3 100644 --- a/web_src/js/features/dropzone.ts +++ b/web_src/js/features/dropzone.ts @@ -1,21 +1,23 @@ 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'; import {showErrorToast} from '../modules/toast.ts'; import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts'; import {isImageFile, isVideoFile} from '../utils.ts'; -import type {DropzoneFile} from 'dropzone/index.js'; +import type {DropzoneFile, DropzoneOptions} from 'dropzone/index.js'; const {csrfToken, i18n} = window.config; +type CustomDropzoneFile = DropzoneFile & {uuid: string}; + // dropzone has its owner event dispatcher (emitter) export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files'; export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file'; export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done'; -async function createDropzone(el, opts) { +async function createDropzone(el: HTMLElement, opts: DropzoneOptions) { const [{default: Dropzone}] = await Promise.all([ import(/* webpackChunkName: "dropzone" */'dropzone'), import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'), @@ -23,7 +25,7 @@ async function createDropzone(el, opts) { return new Dropzone(el, opts); } -export function generateMarkdownLinkForAttachment(file, {width, dppx}: {width?: number, dppx?: number} = {}) { +export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFile>, {width, dppx}: {width?: number, dppx?: number} = {}) { let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; if (isImageFile(file)) { fileMarkdown = `!${fileMarkdown}`; @@ -31,19 +33,19 @@ export function generateMarkdownLinkForAttachment(file, {width, dppx}: {width?: // 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; } -function addCopyLink(file) { +function addCopyLink(file: Partial<CustomDropzoneFile>) { // Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard // The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone const copyLinkEl = createElementFromHTML(` @@ -58,6 +60,8 @@ function addCopyLink(file) { file.previewTemplate.append(copyLinkEl); } +type FileUuidDict = Record<string, {submitted: boolean}>; + /** * @param {HTMLElement} dropzoneEl */ @@ -67,7 +71,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) { const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url'); let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event - let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone + let fileUuidDict: FileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone const opts: Record<string, any> = { url: dropzoneEl.getAttribute('data-upload-url'), headers: {'X-Csrf-Token': csrfToken}, @@ -89,7 +93,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) { // "http://localhost:3000/owner/repo/issues/[object%20Event]" // the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">' const dzInst = await createDropzone(dropzoneEl, opts); - dzInst.on('success', (file: DropzoneFile & {uuid: string}, resp: any) => { + dzInst.on('success', (file: CustomDropzoneFile, resp: any) => { file.uuid = resp.uuid; fileUuidDict[file.uuid] = {submitted: false}; const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid}); @@ -98,7 +102,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) { dzInst.emit(DropzoneCustomEventUploadDone, {file}); }); - dzInst.on('removedfile', async (file: DropzoneFile & {uuid: string}) => { + dzInst.on('removedfile', async (file: CustomDropzoneFile) => { if (disableRemovedfileEvent) return; dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid}); diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts index 933aa951c5..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; @@ -15,24 +16,23 @@ export const emojiKeys = Object.keys(tempMap).sort((a, b) => { return a.localeCompare(b); }); -const emojiMap = {}; +const emojiMap: Record<string, string> = {}; for (const key of emojiKeys) { emojiMap[key] = tempMap[key]; } // retrieve HTML for given emoji name -export function emojiHTML(name) { +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 -export function emojiString(name) { +export function emojiString(name: string) { return emojiMap[name] || `:${name}:`; } diff --git a/web_src/js/features/file-fold.ts b/web_src/js/features/file-fold.ts index 6fe068341a..74b36c0096 100644 --- a/web_src/js/features/file-fold.ts +++ b/web_src/js/features/file-fold.ts @@ -5,15 +5,15 @@ import {svg} from '../svg.ts'; // The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class. // The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class. // -export function setFileFolding(fileContentBox, foldArrow, newFold) { +export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) { foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18); - fileContentBox.setAttribute('data-folded', newFold); + fileContentBox.setAttribute('data-folded', String(newFold)); if (newFold && fileContentBox.getBoundingClientRect().top < 0) { fileContentBox.scrollIntoView(); } } // Like `setFileFolding`, except that it automatically inverts the current file folding state. -export function invertFileFolding(fileContentBox, foldArrow) { +export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement) { setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true'); } 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/heatmap.ts b/web_src/js/features/heatmap.ts index 53eebc93e5..7cec82108b 100644 --- a/web_src/js/features/heatmap.ts +++ b/web_src/js/features/heatmap.ts @@ -7,7 +7,7 @@ export function initHeatmap() { if (!el) return; try { - const heatmap = {}; + const heatmap: Record<string, number> = {}; for (const {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data'))) { // Convert to user timezone and sum contributions by date const dateStr = new Date(timestamp * 1000).toDateString(); diff --git a/web_src/js/features/imagediff.ts b/web_src/js/features/imagediff.ts index cd61888f83..20682f74d9 100644 --- a/web_src/js/features/imagediff.ts +++ b/web_src/js/features/imagediff.ts @@ -3,7 +3,7 @@ import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts import {parseDom} from '../utils.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; -function getDefaultSvgBoundsIfUndefined(text, src) { +function getDefaultSvgBoundsIfUndefined(text: string, src: string) { const defaultSize = 300; const maxSize = 99999; @@ -38,7 +38,7 @@ function getDefaultSvgBoundsIfUndefined(text, src) { return null; } -function createContext(imageAfter, imageBefore) { +function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement) { const sizeAfter = { width: imageAfter?.width || 0, height: imageAfter?.height || 0, @@ -75,7 +75,7 @@ class ImageDiff { this.containerEl = containerEl; containerEl.setAttribute('data-image-diff-loaded', 'true'); - fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab({autoTabActivation: false}); + fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab(); // the container may be hidden by "viewed" checkbox, so use the parent's width for reference this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box').clientWidth - 300, 100); @@ -123,7 +123,7 @@ class ImageDiff { queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading')); } - initSideBySide(sizes) { + initSideBySide(sizes: Record<string, any>) { let factor = 1; if (sizes.maxSize.width > (this.diffContainerWidth - 24) / 2) { factor = (this.diffContainerWidth - 24) / 2 / sizes.maxSize.width; @@ -176,7 +176,7 @@ class ImageDiff { } } - initSwipe(sizes) { + initSwipe(sizes: Record<string, any>) { let factor = 1; if (sizes.maxSize.width > this.diffContainerWidth - 12) { factor = (this.diffContainerWidth - 12) / sizes.maxSize.width; @@ -215,14 +215,14 @@ class ImageDiff { this.containerEl.querySelector('.swipe-bar').addEventListener('mousedown', (e) => { e.preventDefault(); - this.initSwipeEventListeners(e.currentTarget); + this.initSwipeEventListeners(e.currentTarget as HTMLElement); }); } - initSwipeEventListeners(swipeBar) { - const swipeFrame = swipeBar.parentNode; + initSwipeEventListeners(swipeBar: HTMLElement) { + const swipeFrame = swipeBar.parentNode as HTMLElement; const width = swipeFrame.clientWidth; - const onSwipeMouseMove = (e) => { + const onSwipeMouseMove = (e: MouseEvent) => { e.preventDefault(); const rect = swipeFrame.getBoundingClientRect(); const value = Math.max(0, Math.min(e.clientX - rect.left, width)); @@ -237,7 +237,7 @@ class ImageDiff { document.addEventListener('mouseup', removeEventListeners); } - initOverlay(sizes) { + initOverlay(sizes: Record<string, any>) { let factor = 1; if (sizes.maxSize.width > this.diffContainerWidth - 12) { factor = (this.diffContainerWidth - 12) / sizes.maxSize.width; diff --git a/web_src/js/features/install.ts b/web_src/js/features/install.ts index dddeb1e954..ca4bcce881 100644 --- a/web_src/js/features/install.ts +++ b/web_src/js/features/install.ts @@ -12,11 +12,12 @@ export function initInstall() { initPreInstall(); } } + function initPreInstall() { const defaultDbUser = 'gitea'; const defaultDbName = 'gitea'; - const defaultDbHosts = { + const defaultDbHosts: Record<string, string> = { mysql: '127.0.0.1:3306', postgres: '127.0.0.1:5432', mssql: '127.0.0.1:1433', @@ -103,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/org-team.ts b/web_src/js/features/org-team.ts index e160f07bf2..d07818b0ac 100644 --- a/web_src/js/features/org-team.ts +++ b/web_src/js/features/org-team.ts @@ -21,7 +21,7 @@ function initOrgTeamSearchRepoBox() { minCharacters: 2, apiSettings: { url: `${appSubUrl}/repo/search?q={query}&uid=${$searchRepoBox.data('uid')}`, - onResponse(response) { + onResponse(response: any) { const items = []; for (const item of response.data) { items.push({ diff --git a/web_src/js/features/pull-view-file.ts b/web_src/js/features/pull-view-file.ts index 5202d84b28..1124886238 100644 --- a/web_src/js/features/pull-view-file.ts +++ b/web_src/js/features/pull-view-file.ts @@ -1,4 +1,4 @@ -import {diffTreeStore} from '../modules/stores.ts'; +import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts'; import {setFileFolding} from './file-fold.ts'; import {POST} from '../modules/fetch.ts'; @@ -58,14 +58,11 @@ export function initViewedCheckboxListenerFor() { const fileName = checkbox.getAttribute('name'); - // check if the file is in our difftreestore and if we find it -> change the IsViewed status - const fileInPageData = diffTreeStore().files.find((x) => x.Name === fileName); - if (fileInPageData) { - fileInPageData.IsViewed = this.checked; - } + // check if the file is in our diffTreeStore and if we find it -> change the IsViewed status + diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked); // Unfortunately, actual forms cause too many problems, hence another approach is needed - const files = {}; + const files: Record<string, boolean> = {}; files[fileName] = this.checked; const data: Record<string, any> = {files}; const headCommitSHA = form.getAttribute('data-headcommit'); @@ -82,13 +79,13 @@ export function initViewedCheckboxListenerFor() { export function initExpandAndCollapseFilesButton() { // expand btn document.querySelector(expandFilesBtnSelector)?.addEventListener('click', () => { - for (const box of document.querySelectorAll('.file-content[data-folded="true"]')) { + for (const box of document.querySelectorAll<HTMLElement>('.file-content[data-folded="true"]')) { setFileFolding(box, box.querySelector('.fold-file'), false); } }); // collapse btn, need to exclude the div of “show more” document.querySelector(collapseFilesBtnSelector)?.addEventListener('click', () => { - for (const box of document.querySelectorAll('.file-content:not([data-folded="true"])')) { + for (const box of document.querySelectorAll<HTMLElement>('.file-content:not([data-folded="true"])')) { if (box.getAttribute('id') === 'diff-incomplete') continue; setFileFolding(box, box.querySelector('.fold-file'), true); } diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index cbd0429c04..8d93fce53f 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -24,6 +24,7 @@ export function initRepositoryActionView() { pushedBy: el.getAttribute('data-locale-runs-pushed-by'), artifactsTitle: el.getAttribute('data-locale-artifacts-title'), areYouSure: el.getAttribute('data-locale-are-you-sure'), + artifactExpired: el.getAttribute('data-locale-artifact-expired'), confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'), showTimeStamps: el.getAttribute('data-locale-show-timestamps'), showLogSeconds: el.getAttribute('data-locale-show-log-seconds'), diff --git a/web_src/js/features/repo-code.ts b/web_src/js/features/repo-code.ts index 207022ca42..bf7fd762b0 100644 --- a/web_src/js/features/repo-code.ts +++ b/web_src/js/features/repo-code.ts @@ -1,6 +1,5 @@ import {svg} from '../svg.ts'; import {createTippy} from '../modules/tippy.ts'; -import {clippie} from 'clippie'; import {toAbsoluteUrl} from '../utils.ts'; import {addDelegatedEventListener} from '../utils/dom.ts'; @@ -43,7 +42,8 @@ function selectRange(range: string): Element { if (!copyPermalink) return; let link = copyPermalink.getAttribute('data-url'); link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`; - copyPermalink.setAttribute('data-url', link); + copyPermalink.setAttribute('data-clipboard-text', link); + copyPermalink.setAttribute('data-clipboard-text-type', 'url'); }; const rangeFields = range ? range.split('-') : []; @@ -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(); @@ -138,8 +145,4 @@ export function initRepoCodeView() { }; onHashChange(); window.addEventListener('hashchange', onHashChange); - - addDelegatedEventListener(document, 'click', '.copy-line-permalink', (el) => { - clippie(toAbsoluteUrl(el.getAttribute('data-url'))); - }); } diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index 8994a57f4a..98ec2328ec 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -1,27 +1,26 @@ import {createTippy} from '../modules/tippy.ts'; import {toggleElem} from '../utils/dom.ts'; +import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts'; export function initRepoEllipsisButton() { - for (const button of document.querySelectorAll<HTMLButtonElement>('.js-toggle-commit-body')) { - button.addEventListener('click', function (e) { - e.preventDefault(); - const expanded = this.getAttribute('aria-expanded') === 'true'; - toggleElem(this.parentElement.querySelector('.commit-body')); - this.setAttribute('aria-expanded', String(!expanded)); - }); - } + registerGlobalEventFunc('click', 'onRepoEllipsisButtonClick', async (el: HTMLInputElement, e: Event) => { + e.preventDefault(); + const expanded = el.getAttribute('aria-expanded') === 'true'; + toggleElem(el.parentElement.querySelector('.commit-body')); + el.setAttribute('aria-expanded', String(!expanded)); + }); } export function initCommitStatuses() { - for (const element of document.querySelectorAll('[data-tippy="commit-statuses"]')) { - const top = document.querySelector('.repository.file.list') || document.querySelector('.repository.diff'); - - createTippy(element, { - content: element.nextElementSibling, - placement: top ? 'top-start' : 'bottom-start', + registerGlobalInitFunc('initCommitStatuses', (el: HTMLElement) => { + const nextEl = el.nextElementSibling; + if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target'); + createTippy(el, { + content: nextEl, + placement: 'bottom-start', interactive: true, role: 'dialog', theme: 'box-with-header', }); - } + }); } diff --git a/web_src/js/features/repo-common.test.ts b/web_src/js/features/repo-common.test.ts new file mode 100644 index 0000000000..33a29ecb2c --- /dev/null +++ b/web_src/js/features/repo-common.test.ts @@ -0,0 +1,22 @@ +import {sanitizeRepoName, substituteRepoOpenWithUrl} from './repo-common.ts'; + +test('substituteRepoOpenWithUrl', () => { + // For example: "x-github-client://openRepo/https://github.com/go-gitea/gitea" + expect(substituteRepoOpenWithUrl('proto://a/{url}', 'https://gitea')).toEqual('proto://a/https://gitea'); + expect(substituteRepoOpenWithUrl('proto://a?link={url}', 'https://gitea')).toEqual('proto://a?link=https%3A%2F%2Fgitea'); +}); + +test('sanitizeRepoName', () => { + expect(sanitizeRepoName(' a b ')).toEqual('a-b'); + expect(sanitizeRepoName('a-b_c.git ')).toEqual('a-b_c'); + expect(sanitizeRepoName('/x.git/')).toEqual('-x.git-'); + expect(sanitizeRepoName('.profile')).toEqual('.profile'); + expect(sanitizeRepoName('.profile.')).toEqual('.profile'); + expect(sanitizeRepoName('.pro..file')).toEqual('.pro.file'); + + expect(sanitizeRepoName('foo.rss.atom.git.wiki')).toEqual('foo'); + + expect(sanitizeRepoName('.')).toEqual(''); + expect(sanitizeRepoName('..')).toEqual(''); + expect(sanitizeRepoName('-')).toEqual(''); +}); diff --git a/web_src/js/features/repo-common.ts b/web_src/js/features/repo-common.ts index 90860720e4..ebb6881c67 100644 --- a/web_src/js/features/repo-common.ts +++ b/web_src/js/features/repo-common.ts @@ -1,4 +1,4 @@ -import {queryElems} from '../utils/dom.ts'; +import {queryElems, type DOMEvent} from '../utils/dom.ts'; import {POST} from '../modules/fetch.ts'; import {showErrorToast} from '../modules/toast.ts'; import {sleep} from '../utils.ts'; @@ -7,10 +7,10 @@ import {createApp} from 'vue'; import {toOriginUrl} from '../utils/url.ts'; import {createTippy} from '../modules/tippy.ts'; -async function onDownloadArchive(e) { +async function onDownloadArchive(e: DOMEvent<MouseEvent>) { e.preventDefault(); // there are many places using the "archive-link", eg: the dropdown on the repo code page, the release list - const el = e.target.closest('a.archive-link[href]'); + const el = e.target.closest<HTMLAnchorElement>('a.archive-link[href]'); const targetLoading = el.closest('.ui.dropdown') ?? el; targetLoading.classList.add('is-loading', 'loading-icon-2px'); try { @@ -42,23 +42,60 @@ export function initRepoActivityTopAuthorsChart() { } } +export function substituteRepoOpenWithUrl(tmpl: string, url: string): string { + const pos = tmpl.indexOf('{url}'); + if (pos === -1) return tmpl; + const posQuestionMark = tmpl.indexOf('?'); + const needEncode = posQuestionMark >= 0 && posQuestionMark < pos; + return tmpl.replace('{url}', needEncode ? encodeURIComponent(url) : url); +} + function initCloneSchemeUrlSelection(parent: Element) { const elCloneUrlInput = parent.querySelector<HTMLInputElement>('.repo-clone-url'); - const tabSsh = parent.querySelector('.repo-clone-ssh'); const tabHttps = parent.querySelector('.repo-clone-https'); + const tabSsh = parent.querySelector('.repo-clone-ssh'); + const tabTea = parent.querySelector('.repo-clone-tea'); const updateClonePanelUi = function() { - const scheme = localStorage.getItem('repo-clone-protocol') || 'https'; - const isSSH = scheme === 'ssh' && Boolean(tabSsh) || scheme !== 'ssh' && !tabHttps; + let scheme = localStorage.getItem('repo-clone-protocol'); + if (!['https', 'ssh', 'tea'].includes(scheme)) { + scheme = 'https'; + } + + // Fallbacks if the scheme preference is not available in the tabs, for example: empty repo page, there are only HTTPS and SSH + if (scheme === 'tea' && !tabTea) { + scheme = 'https'; + } + if (scheme === 'https' && !tabHttps) { + scheme = 'ssh'; + } else if (scheme === 'ssh' && !tabSsh) { + scheme = 'https'; + } + + const isHttps = scheme === 'https'; + const isSsh = scheme === 'ssh'; + const isTea = scheme === 'tea'; + if (tabHttps) { tabHttps.textContent = window.origin.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS" - tabHttps.classList.toggle('active', !isSSH); + tabHttps.classList.toggle('active', isHttps); } if (tabSsh) { - tabSsh.classList.toggle('active', isSSH); + tabSsh.classList.toggle('active', isSsh); + } + if (tabTea) { + tabTea.classList.toggle('active', isTea); + } + + let tab: Element; + if (isHttps) { + tab = tabHttps; + } else if (isSsh) { + tab = tabSsh; + } else if (isTea) { + tab = tabTea; } - const tab = isSSH ? tabSsh : tabHttps; if (!tab) return; const link = toOriginUrl(tab.getAttribute('data-link')); @@ -70,18 +107,22 @@ function initCloneSchemeUrlSelection(parent: Element) { } } for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) { - el.href = el.getAttribute('data-href-template').replace('{url}', encodeURIComponent(link)); + el.href = substituteRepoOpenWithUrl(el.getAttribute('data-href-template'), link); } }; updateClonePanelUi(); // tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server + tabHttps?.addEventListener('click', () => { + localStorage.setItem('repo-clone-protocol', 'https'); + updateClonePanelUi(); + }); tabSsh?.addEventListener('click', () => { localStorage.setItem('repo-clone-protocol', 'ssh'); updateClonePanelUi(); }); - tabHttps?.addEventListener('click', () => { - localStorage.setItem('repo-clone-protocol', 'https'); + tabTea?.addEventListener('click', () => { + localStorage.setItem('repo-clone-protocol', 'tea'); updateClonePanelUi(); }); elCloneUrlInput.addEventListener('focus', () => { @@ -99,6 +140,7 @@ function initClonePanelButton(btn: HTMLButtonElement) { placement: 'bottom-end', interactive: true, hideOnClick: true, + arrow: false, }); } @@ -107,7 +149,7 @@ export function initRepoCloneButtons() { queryElems(document, '.clone-buttons-combo', initCloneSchemeUrlSelection); } -export async function updateIssuesMeta(url, action, issue_ids, id) { +export async function updateIssuesMeta(url: string, action: string, issue_ids: string, id: string) { try { const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})}); if (!response.ok) { @@ -117,3 +159,19 @@ export async function updateIssuesMeta(url, action, issue_ids, id) { console.error(error); } } + +export function sanitizeRepoName(name: string): string { + name = name.trim().replace(/[^-.\w]/g, '-'); + for (let lastName = ''; lastName !== name;) { + lastName = name; + name = name.replace(/\.+$/g, ''); + name = name.replace(/\.{2,}/g, '.'); + for (const ext of ['.git', '.wiki', '.rss', '.atom']) { + if (name.endsWith(ext)) { + name = name.substring(0, name.length - ext.length); + } + } + } + if (['.', '..', '-'].includes(name)) name = ''; + return name; +} diff --git a/web_src/js/features/repo-diff-filetree.ts b/web_src/js/features/repo-diff-filetree.ts index bc275a90f6..cc4576a846 100644 --- a/web_src/js/features/repo-diff-filetree.ts +++ b/web_src/js/features/repo-diff-filetree.ts @@ -1,6 +1,5 @@ import {createApp} from 'vue'; import DiffFileTree from '../components/DiffFileTree.vue'; -import DiffFileList from '../components/DiffFileList.vue'; export function initDiffFileTree() { const el = document.querySelector('#diff-file-tree'); @@ -9,11 +8,3 @@ export function initDiffFileTree() { const fileTreeView = createApp(DiffFileTree); fileTreeView.mount(el); } - -export function initDiffFileList() { - const fileListElement = document.querySelector('#diff-file-list'); - if (!fileListElement) return; - - const fileListView = createApp(DiffFileList); - fileListView.mount(fileListElement); -} diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 0cb2e566c0..ad1da5c2fa 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -1,41 +1,31 @@ -import $ from 'jquery'; -import {initCompReactionSelector} from './comp/ReactionSelector.ts'; import {initRepoIssueContentHistory} from './repo-issue-content.ts'; -import {initDiffFileTree, initDiffFileList} from './repo-diff-filetree.ts'; +import {initDiffFileTree} from './repo-diff-filetree.ts'; import {initDiffCommitSelect} from './repo-diff-commitselect.ts'; import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts'; import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.ts'; import {initImageDiff} from './imagediff.ts'; import {showErrorToast} from '../modules/toast.ts'; -import { - submitEventSubmitter, - queryElemSiblings, - hideElem, - showElem, - animateOnce, - addDelegatedEventListener, - createElementFromHTML, -} from '../utils/dom.ts'; +import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts'; import {POST, GET} from '../modules/fetch.ts'; -import {fomanticQuery} from '../modules/fomantic/base.ts'; import {createTippy} from '../modules/tippy.ts'; import {invertFileFolding} from './file-fold.ts'; +import {parseDom} from '../utils.ts'; +import {registerGlobalSelectorFunc} from '../modules/observer.ts'; -const {pageData, i18n} = window.config; +const {i18n} = window.config; -function initRepoDiffFileViewToggle() { - $('.file-view-toggle').on('click', function () { - for (const el of queryElemSiblings(this)) { - el.classList.remove('active'); - } - this.classList.add('active'); +function initRepoDiffFileBox(el: HTMLElement) { + // switch between "rendered" and "source", for image and CSV files + queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => { + queryElemSiblings(btn, '.file-view-toggle', (el) => el.classList.remove('active')); + btn.classList.add('active'); - const target = document.querySelector(this.getAttribute('data-toggle-selector')); - if (!target) return; + const target = document.querySelector(btn.getAttribute('data-toggle-selector')); + if (!target) throw new Error('Target element not found'); hideElem(queryElemSiblings(target)); showElem(target); - }); + })); } function initRepoDiffConversationForm() { @@ -83,7 +73,6 @@ function initRepoDiffConversationForm() { el.classList.add('tw-invisible'); } } - fomanticQuery(newConversationHolder.querySelectorAll('.ui.dropdown')).dropdown(); // the default behavior is to add a pending review, so if no submitter, it also means "pending_review" if (!submitter || submitter?.matches('button[name="pending_review"]')) { @@ -103,22 +92,21 @@ function initRepoDiffConversationForm() { } }); - $(document).on('click', '.resolve-conversation', async function (e) { + addDelegatedEventListener(document, 'click', '.resolve-conversation', async (el, e) => { e.preventDefault(); - const comment_id = $(this).data('comment-id'); - const origin = $(this).data('origin'); - const action = $(this).data('action'); - const url = $(this).data('update-url'); + const comment_id = el.getAttribute('data-comment-id'); + const origin = el.getAttribute('data-origin'); + const action = el.getAttribute('data-action'); + const url = el.getAttribute('data-update-url'); try { const response = await POST(url, {data: new URLSearchParams({origin, action, comment_id})}); const data = await response.text(); - if ($(this).closest('.conversation-holder').length) { - const $conversation = $(data); - $(this).closest('.conversation-holder').replaceWith($conversation); - $conversation.find('.dropdown').dropdown(); - initCompReactionSelector($conversation[0]); + const elConversationHolder = el.closest('.conversation-holder'); + if (elConversationHolder) { + const elNewConversation = createElementFromHTML(data); + elConversationHolder.replaceWith(elNewConversation); } else { window.location.reload(); } @@ -128,24 +116,19 @@ function initRepoDiffConversationForm() { }); } -export function initRepoDiffConversationNav() { +function initRepoDiffConversationNav() { // Previous/Next code review conversation - $(document).on('click', '.previous-conversation', (e) => { - const $conversation = $(e.currentTarget).closest('.comment-code-cloud'); - const $conversations = $('.comment-code-cloud:not(.tw-hidden)'); - const index = $conversations.index($conversation); - const previousIndex = index > 0 ? index - 1 : $conversations.length - 1; - const $previousConversation = $conversations.eq(previousIndex); - const anchor = $previousConversation.find('.comment').first()[0].getAttribute('id'); - window.location.href = `#${anchor}`; - }); - $(document).on('click', '.next-conversation', (e) => { - const $conversation = $(e.currentTarget).closest('.comment-code-cloud'); - const $conversations = $('.comment-code-cloud:not(.tw-hidden)'); - const index = $conversations.index($conversation); - const nextIndex = index < $conversations.length - 1 ? index + 1 : 0; - const $nextConversation = $conversations.eq(nextIndex); - const anchor = $nextConversation.find('.comment').first()[0].getAttribute('id'); + addDelegatedEventListener(document, 'click', '.previous-conversation, .next-conversation', (el, e) => { + e.preventDefault(); + const isPrevious = el.matches('.previous-conversation'); + const elCurConversation = el.closest('.comment-code-cloud'); + const elAllConversations = document.querySelectorAll('.comment-code-cloud:not(.tw-hidden)'); + const index = Array.from(elAllConversations).indexOf(elCurConversation); + const previousIndex = index > 0 ? index - 1 : elAllConversations.length - 1; + const nextIndex = index < elAllConversations.length - 1 ? index + 1 : 0; + const navIndex = isPrevious ? previousIndex : nextIndex; + const elNavConversation = elAllConversations[navIndex]; + const anchor = elNavConversation.querySelector('.comment').id; window.location.href = `#${anchor}`; }); } @@ -161,6 +144,7 @@ function initDiffHeaderPopup() { // Will be called when the show more (files) button has been pressed function onShowMoreFiles() { + // TODO: replace these calls with the "observer.ts" methods initRepoIssueContentHistory(); initViewedCheckboxListenerFor(); countAndUpdateViewedFiles(); @@ -168,84 +152,111 @@ function onShowMoreFiles() { initDiffHeaderPopup(); } -export async function loadMoreFiles(url) { - const target = document.querySelector('a#diff-show-more-files'); - if (target?.classList.contains('disabled') || pageData.diffFileInfo.isLoadingNewData) { - return; +async function loadMoreFiles(btn: Element): Promise<boolean> { + if (btn.classList.contains('disabled')) { + return false; } - pageData.diffFileInfo.isLoadingNewData = true; - target?.classList.add('disabled'); - + btn.classList.add('disabled'); + const url = btn.getAttribute('data-href'); try { const response = await GET(url); const resp = await response.text(); - const $resp = $(resp); + const respDoc = parseDom(resp, 'text/html'); + const respFileBoxes = respDoc.querySelector('#diff-file-boxes'); // the response is a full HTML page, we need to extract the relevant contents: - // 1. append the newly loaded file list items to the existing list - $('#diff-incomplete').replaceWith($resp.find('#diff-file-boxes').children()); - // 2. re-execute the script to append the newly loaded items to the JS variables to refresh the DiffFileTree - $('body').append($resp.find('script#diff-data-script')); - + // * append the newly loaded file list items to the existing list + document.querySelector('#diff-incomplete').replaceWith(...Array.from(respFileBoxes.children)); onShowMoreFiles(); + return true; } catch (error) { console.error('Error:', error); showErrorToast('An error occurred while loading more files.'); } finally { - target?.classList.remove('disabled'); - pageData.diffFileInfo.isLoadingNewData = false; + btn.classList.remove('disabled'); } + return false; } function initRepoDiffShowMore() { - $(document).on('click', 'a#diff-show-more-files', (e) => { + addDelegatedEventListener(document, 'click', 'a#diff-show-more-files', (el, e) => { e.preventDefault(); - - const linkLoadMore = e.target.getAttribute('data-href'); - loadMoreFiles(linkLoadMore); + loadMoreFiles(el); }); - $(document).on('click', 'a.diff-load-button', async (e) => { + addDelegatedEventListener(document, 'click', 'a.diff-load-button', async (el, e) => { e.preventDefault(); - const $target = $(e.target); - - if (e.target.classList.contains('disabled')) { - return; - } + if (el.classList.contains('disabled')) return; - e.target.classList.add('disabled'); - - const url = $target.data('href'); + el.classList.add('disabled'); + const url = el.getAttribute('data-href'); try { const response = await GET(url); const resp = await response.text(); - - if (!resp) { - return; - } - $target.parent().replaceWith($(resp).find('#diff-file-boxes .diff-file-body .file-body').children()); + const respDoc = parseDom(resp, 'text/html'); + const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body'); + const respFileBodyChildren = Array.from(respFileBody.children); // respFileBody.children will be empty after replaceWith + el.parentElement.replaceWith(...respFileBodyChildren); + for (const el of respFileBodyChildren) window.htmx.process(el); + // FIXME: calling onShowMoreFiles is not quite right here. + // But since onShowMoreFiles mixes "init diff box" and "init diff body" together, + // so it still needs to call it to make the "ImageDiff" and something similar work. onShowMoreFiles(); } catch (error) { console.error('Error:', error); } finally { - e.target.classList.remove('disabled'); + el.classList.remove('disabled'); } }); } +async function loadUntilFound() { + const hashTargetSelector = window.location.hash; + if (!hashTargetSelector.startsWith('#diff-') && !hashTargetSelector.startsWith('#issuecomment-')) { + return; + } + + while (true) { + // use getElementById to avoid querySelector throws an error when the hash is invalid + // eslint-disable-next-line unicorn/prefer-query-selector + const targetElement = document.getElementById(hashTargetSelector.substring(1)); + if (targetElement) { + targetElement.scrollIntoView(); + return; + } + + // the button will be refreshed after each "load more", so query it every time + const showMoreButton = document.querySelector('#diff-show-more-files'); + if (!showMoreButton) { + return; // nothing more to load + } + + // Load more files, await ensures we don't block progress + const ok = await loadMoreFiles(showMoreButton); + if (!ok) return; // failed to load more files + } +} + +function initRepoDiffHashChangeListener() { + window.addEventListener('hashchange', loadUntilFound); + loadUntilFound(); +} + export function initRepoDiffView() { - initRepoDiffConversationForm(); - if (!$('#diff-file-list').length) return; + initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page + + if (!document.querySelector('#diff-file-boxes')) return; + initRepoDiffConversationNav(); // "previous" and "next" buttons only appear on "diff" page initDiffFileTree(); - initDiffFileList(); initDiffCommitSelect(); initRepoDiffShowMore(); initDiffHeaderPopup(); - initRepoDiffFileViewToggle(); initViewedCheckboxListenerFor(); initExpandAndCollapseFilesButton(); + initRepoDiffHashChangeListener(); + registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox); addDelegatedEventListener(document, 'click', '.fold-file', (el) => { invertFileFolding(el.closest('.file-content'), el); }); diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts index d7097787d2..f3ca13460c 100644 --- a/web_src/js/features/repo-editor.ts +++ b/web_src/js/features/repo-editor.ts @@ -1,13 +1,13 @@ -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 {initMarkupContent} from '../markup/content.ts'; import {attachRefIssueContextPopup} from './contextpopup.ts'; import {POST} from '../modules/fetch.ts'; 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'); @@ -87,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); @@ -113,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'); @@ -142,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) { - 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); @@ -182,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(); @@ -191,15 +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 = content; - initMarkupContent(); +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-findfile.ts b/web_src/js/features/repo-findfile.ts index 6500978bc8..59c827126f 100644 --- a/web_src/js/features/repo-findfile.ts +++ b/web_src/js/features/repo-findfile.ts @@ -4,13 +4,15 @@ import {pathEscapeSegments} from '../utils/url.ts'; import {GET} from '../modules/fetch.ts'; const threshold = 50; -let files = []; -let repoFindFileInput, repoFindFileTableBody, repoFindFileNoResult; +let files: Array<string> = []; +let repoFindFileInput: HTMLInputElement; +let repoFindFileTableBody: HTMLElement; +let repoFindFileNoResult: HTMLElement; // return the case-insensitive sub-match result as an array: [unmatched, matched, unmatched, matched, ...] // res[even] is unmatched, res[odd] is matched, see unit tests for examples // argument subLower must be a lower-cased string. -export function strSubMatch(full, subLower) { +export function strSubMatch(full: string, subLower: string) { const res = ['']; let i = 0, j = 0; const fullLower = full.toLowerCase(); @@ -38,7 +40,7 @@ export function strSubMatch(full, subLower) { return res; } -export function calcMatchedWeight(matchResult) { +export function calcMatchedWeight(matchResult: Array<any>) { let weight = 0; for (let i = 0; i < matchResult.length; i++) { if (i % 2 === 1) { // matches are on odd indices, see strSubMatch @@ -49,7 +51,7 @@ export function calcMatchedWeight(matchResult) { return weight; } -export function filterRepoFilesWeighted(files, filter) { +export function filterRepoFilesWeighted(files: Array<string>, filter: string) { let filterResult = []; if (filter) { const filterLower = filter.toLowerCase(); @@ -71,7 +73,7 @@ export function filterRepoFilesWeighted(files, filter) { return filterResult; } -function filterRepoFiles(filter) { +function filterRepoFiles(filter: string) { const treeLink = repoFindFileInput.getAttribute('data-url-tree-link'); repoFindFileTableBody.innerHTML = ''; diff --git a/web_src/js/features/repo-graph.ts b/web_src/js/features/repo-graph.ts index 6d1629a1c1..7579ee42c6 100644 --- a/web_src/js/features/repo-graph.ts +++ b/web_src/js/features/repo-graph.ts @@ -83,8 +83,8 @@ export function initRepoGraphGit() { } const flowSelectRefsDropdown = document.querySelector('#flow-select-refs-dropdown'); - fomanticQuery(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected); - fomanticQuery(flowSelectRefsDropdown).dropdown({ + const $dropdown = fomanticQuery(flowSelectRefsDropdown); + $dropdown.dropdown({ clearable: true, fullTextSeach: 'exact', onRemove(toRemove: string) { @@ -110,6 +110,7 @@ export function initRepoGraphGit() { updateGraph(); }, }); + $dropdown.dropdown('set selected', dropdownSelected); graphContainer.addEventListener('mouseenter', (e: DOMEvent<MouseEvent>) => { if (e.target.matches('#rev-list li')) { diff --git a/web_src/js/features/repo-home.ts b/web_src/js/features/repo-home.ts index 763f8e503f..04a1288626 100644 --- a/web_src/js/features/repo-home.ts +++ b/web_src/js/features/repo-home.ts @@ -92,7 +92,7 @@ export function initRepoTopicBar() { onResponse(this: any, res: any) { const formattedResponse = { success: false, - results: [], + results: [] as Array<Record<string, any>>, }; const query = stripTags(this.urlData.query.trim()); let found_query = false; @@ -134,12 +134,12 @@ export function initRepoTopicBar() { return formattedResponse; }, }, - onLabelCreate(value) { + onLabelCreate(value: string) { value = value.toLowerCase().trim(); this.attr('data-value', value).contents().first().replaceWith(value); return fomanticQuery(this); }, - onAdd(addedValue, _addedText, $addedChoice) { + onAdd(addedValue: string, _addedText: any, $addedChoice: any) { addedValue = addedValue.toLowerCase().trim(); $addedChoice[0].setAttribute('data-value', addedValue); $addedChoice[0].setAttribute('data-text', addedValue); diff --git a/web_src/js/features/repo-issue-content.ts b/web_src/js/features/repo-issue-content.ts index 2279c26beb..056b810be8 100644 --- a/web_src/js/features/repo-issue-content.ts +++ b/web_src/js/features/repo-issue-content.ts @@ -33,7 +33,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo $fomanticDropdownOptions.dropdown({ showOnFocus: false, allowReselection: true, - async onChange(_value, _text, $item) { + async onChange(_value: string, _text: string, $item: any) { const optionItem = $item.data('option-item'); if (optionItem === 'delete') { if (window.confirm(i18nTextDeleteFromHistoryConfirm)) { @@ -115,7 +115,7 @@ function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, co onHide() { $fomanticDropdown.dropdown('change values', null); }, - onChange(value, itemHtml, $item) { + onChange(value: string, itemHtml: string, $item: any) { if (value && !$item.find('[data-history-is-deleted=1]').length) { showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml); } diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts index 38dfea4743..e89e5a787a 100644 --- a/web_src/js/features/repo-issue-edit.ts +++ b/web_src/js/features/repo-issue-edit.ts @@ -2,33 +2,32 @@ 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, querySingleVisibleElem, showElem} from '../utils/dom.ts'; +import {hideElem, querySingleVisibleElem, showElem, type DOMEvent} 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'; import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts'; -async function tryOnEditContent(e) { +async function tryOnEditContent(e: DOMEvent<MouseEvent>) { const clickTarget = e.target.closest('.edit-content'); if (!clickTarget) return; e.preventDefault(); - const segment = clickTarget.closest('.header').nextElementSibling; + const segment = clickTarget.closest('.comment-header').nextElementSibling; const editContentZone = segment.querySelector('.edit-content-zone'); const renderContent = segment.querySelector('.render-content'); const rawContent = segment.querySelector('.raw-content'); let comboMarkdownEditor : ComboMarkdownEditor; - const cancelAndReset = (e) => { + const cancelAndReset = (e: Event) => { e.preventDefault(); showElem(renderContent); hideElem(editContentZone); comboMarkdownEditor.dropzoneReloadFiles(); }; - const saveAndRefresh = async (e) => { + const saveAndRefresh = async (e: Event) => { e.preventDefault(); // we are already in a form, do not bubble up to the document otherwise there will be other "form submit handlers" // at the moment, the form submit event conflicts with initRepoDiffConversationForm (global '.conversation-holder form' event handler) @@ -60,7 +59,7 @@ async function tryOnEditContent(e) { } else { renderContent.innerHTML = data.content; rawContent.textContent = comboMarkdownEditor.value(); - const refIssues = renderContent.querySelectorAll('p .ref-issue'); + const refIssues = renderContent.querySelectorAll<HTMLElement>('p .ref-issue'); attachRefIssueContextPopup(refIssues); } const content = segment; @@ -74,8 +73,6 @@ async function tryOnEditContent(e) { content.querySelector('.dropzone-attachments').outerHTML = data.attachments; } comboMarkdownEditor.dropzoneSubmitReload(); - initMarkupContent(); - initCommentContent(); } catch (error) { showErrorToast(`Failed to save the content: ${error}`); console.error(error); @@ -125,7 +122,7 @@ function extractSelectedMarkdown(container: HTMLElement) { return convertHtmlToMarkdown(el); } -async function tryOnQuoteReply(e) { +async function tryOnQuoteReply(e: Event) { const clickTarget = (e.target as HTMLElement).closest('.quote-reply'); if (!clickTarget) return; @@ -135,11 +132,11 @@ async function tryOnQuoteReply(e) { const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector<HTMLElement>('.render-content.markup'); let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote); if (!contentToQuote) contentToQuote = targetRawToQuote.textContent; - const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`; + const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n\n`; let editor; if (clickTarget.classList.contains('quote-reply-diff')) { - const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); + const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector<HTMLElement>('button.comment-form-reply'); editor = await handleReply(replyBtn); } else { // for normal issue/comment page diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts index 74d4362bfd..762fbf51bb 100644 --- a/web_src/js/features/repo-issue-list.ts +++ b/web_src/js/features/repo-issue-list.ts @@ -1,12 +1,13 @@ import {updateIssuesMeta} from './repo-common.ts'; -import {toggleElem, isElemHidden, queryElems} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; import {showErrorToast} from '../modules/toast.ts'; import {createSortable} from '../modules/sortable.ts'; import {DELETE, POST} from '../modules/fetch.ts'; import {parseDom} from '../utils.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; +import type {SortableEvent} from 'sortablejs'; function initRepoIssueListCheckboxes() { const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all'); @@ -32,8 +33,8 @@ function initRepoIssueListCheckboxes() { toggleElem('#issue-filters', !anyChecked); toggleElem('#issue-actions', anyChecked); // there are two panels but only one select-all checkbox, so move the checkbox to the visible panel - const panels = document.querySelectorAll('#issue-filters, #issue-actions'); - const visiblePanel = Array.from(panels).find((el) => !isElemHidden(el)); + const panels = document.querySelectorAll<HTMLElement>('#issue-filters, #issue-actions'); + const visiblePanel = Array.from(panels).find((el) => isElemVisible(el)); const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left'); toolbarLeft.prepend(issueSelectAll); }; @@ -104,7 +105,7 @@ function initDropdownUserRemoteSearch(el: Element) { $searchDropdown.dropdown('setting', { fullTextSearch: true, selectOnKeydown: false, - action: (_text, value) => { + action: (_text: string, value: string) => { window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value)); }, }); @@ -133,14 +134,14 @@ function initDropdownUserRemoteSearch(el: Element) { $searchDropdown.dropdown('setting', 'apiSettings', { cache: false, url: `${searchUrl}&q={query}`, - onResponse(resp) { + onResponse(resp: any) { // 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; @@ -153,7 +154,7 @@ function initDropdownUserRemoteSearch(el: Element) { const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')}; const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates'); $searchDropdown.dropdown('internal', 'setup', dropdownSetup); - dropdownSetup.menu = function (values) { + dropdownSetup.menu = function (values: any) { // remove old dynamic items for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) { el.remove(); @@ -193,7 +194,7 @@ function initPinRemoveButton() { } } -async function pinMoveEnd(e) { +async function pinMoveEnd(e: SortableEvent) { const url = e.item.getAttribute('data-move-url'); const id = Number(e.item.getAttribute('data-issue-id')); await POST(url, {data: {id, position: e.newIndex + 1}}); diff --git a/web_src/js/features/repo-issue-pr-form.ts b/web_src/js/features/repo-issue-pr-form.ts deleted file mode 100644 index 94a2857340..0000000000 --- a/web_src/js/features/repo-issue-pr-form.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {createApp} from 'vue'; -import PullRequestMergeForm from '../components/PullRequestMergeForm.vue'; - -export function initRepoPullRequestMergeForm() { - const el = document.querySelector('#pull-request-merge-form'); - if (!el) return; - - const view = createApp(PullRequestMergeForm); - view.mount(el); -} diff --git a/web_src/js/features/repo-issue-pr-status.ts b/web_src/js/features/repo-issue-pr-status.ts deleted file mode 100644 index 8426b389f0..0000000000 --- a/web_src/js/features/repo-issue-pr-status.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function initRepoPullRequestCommitStatus() { - for (const btn of document.querySelectorAll('.commit-status-hide-checks')) { - const panel = btn.closest('.commit-status-panel'); - const list = panel.querySelector<HTMLElement>('.commit-status-list'); - btn.addEventListener('click', () => { - list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle - btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all'); - }); - } -} diff --git a/web_src/js/features/repo-issue-pull.ts b/web_src/js/features/repo-issue-pull.ts new file mode 100644 index 0000000000..c415dad08f --- /dev/null +++ b/web_src/js/features/repo-issue-pull.ts @@ -0,0 +1,133 @@ +import {createApp} from 'vue'; +import PullRequestMergeForm from '../components/PullRequestMergeForm.vue'; +import {GET, POST} from '../modules/fetch.ts'; +import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; + +function initRepoPullRequestUpdate(el: HTMLElement) { + const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base'); + if (!prUpdateButtonContainer) return; + + const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button'); + const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown'); + prUpdateButton.addEventListener('click', async function (e) { + e.preventDefault(); + const redirect = this.getAttribute('data-redirect'); + this.classList.add('is-loading'); + let response: Response; + try { + response = await POST(this.getAttribute('data-do')); + } catch (error) { + console.error(error); + } finally { + this.classList.remove('is-loading'); + } + let data: Record<string, any>; + try { + data = await response?.json(); // the response is probably not a JSON + } catch (error) { + console.error(error); + } + if (data?.redirect) { + window.location.href = data.redirect; + } else if (redirect) { + window.location.href = redirect; + } else { + window.location.reload(); + } + }); + + fomanticQuery(prUpdateDropdown).dropdown({ + onChange(_text: string, _value: string, $choice: any) { + const choiceEl = $choice[0]; + const url = choiceEl.getAttribute('data-do'); + if (url) { + const buttonText = prUpdateButton.querySelector('.button-text'); + if (buttonText) { + buttonText.textContent = choiceEl.textContent; + } + prUpdateButton.setAttribute('data-do', url); + } + }, + }); +} + +function initRepoPullRequestCommitStatus(el: HTMLElement) { + for (const btn of el.querySelectorAll('.commit-status-hide-checks')) { + const panel = btn.closest('.commit-status-panel'); + const list = panel.querySelector<HTMLElement>('.commit-status-list'); + btn.addEventListener('click', () => { + list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle + btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all'); + }); + } +} + +function initRepoPullRequestMergeForm(box: HTMLElement) { + const el = box.querySelector('#pull-request-merge-form'); + if (!el) return; + + const view = createApp(PullRequestMergeForm); + view.mount(el); +} + +function executeScripts(elem: HTMLElement) { + for (const oldScript of elem.querySelectorAll('script')) { + // TODO: that's the only way to load the data for the merge form. In the future + // we need to completely decouple the page data and embedded script + // eslint-disable-next-line github/no-dynamic-script-tag + const newScript = document.createElement('script'); + for (const attr of oldScript.attributes) { + if (attr.name === 'type' && attr.value === 'module') continue; + newScript.setAttribute(attr.name, attr.value); + } + newScript.text = oldScript.text; + document.body.append(newScript); + } +} + +export function initRepoPullMergeBox(el: HTMLElement) { + initRepoPullRequestCommitStatus(el); + initRepoPullRequestUpdate(el); + initRepoPullRequestMergeForm(el); + + const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval'); + if (!reloadingIntervalValue) return; + + const reloadingInterval = parseInt(reloadingIntervalValue); + const pullLink = el.getAttribute('data-pull-link'); + let timerId: number; + + let reloadMergeBox: () => Promise<void>; + const stopReloading = () => { + if (!timerId) return; + clearTimeout(timerId); + timerId = null; + }; + const startReloading = () => { + if (timerId) return; + setTimeout(reloadMergeBox, reloadingInterval); + }; + const onVisibilityChange = () => { + if (document.hidden) { + stopReloading(); + } else { + startReloading(); + } + }; + reloadMergeBox = async () => { + const resp = await GET(`${pullLink}/merge_box`); + stopReloading(); + if (!resp.ok) { + startReloading(); + return; + } + document.removeEventListener('visibilitychange', onVisibilityChange); + const newElem = createElementFromHTML(await resp.text()); + executeScripts(newElem); + el.replaceWith(newElem); + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + startReloading(); +} diff --git a/web_src/js/features/repo-issue-sidebar-combolist.ts b/web_src/js/features/repo-issue-sidebar-combolist.ts index 24d620547f..f25c0a77c6 100644 --- a/web_src/js/features/repo-issue-sidebar-combolist.ts +++ b/web_src/js/features/repo-issue-sidebar-combolist.ts @@ -1,6 +1,6 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; import {POST} from '../modules/fetch.ts'; -import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; +import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; // if there are draft comments, confirm before reloading, to avoid losing comments function issueSidebarReloadConfirmDraftComment() { @@ -22,7 +22,7 @@ function issueSidebarReloadConfirmDraftComment() { window.location.reload(); } -class IssueSidebarComboList { +export class IssueSidebarComboList { updateUrl: string; updateAlgo: string; selectionMode: string; @@ -30,9 +30,11 @@ class IssueSidebarComboList { elList: HTMLElement; elComboValue: HTMLInputElement; initialValues: string[]; + container: HTMLElement; - constructor(private container: HTMLElement) { - this.updateUrl = this.container.getAttribute('data-update-url'); + constructor(container: HTMLElement) { + this.container = container; + this.updateUrl = container.getAttribute('data-update-url'); this.updateAlgo = container.getAttribute('data-update-algo'); this.selectionMode = container.getAttribute('data-selection-mode'); if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`); @@ -46,7 +48,7 @@ class IssueSidebarComboList { return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value')); } - updateUiList(changedValues) { + updateUiList(changedValues: Array<string>) { const elEmptyTip = this.elList.querySelector('.item.empty-list'); queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove()); for (const value of changedValues) { @@ -60,7 +62,7 @@ class IssueSidebarComboList { toggleElem(elEmptyTip, !hasItems); } - async updateToBackend(changedValues) { + async updateToBackend(changedValues: Array<string>) { if (this.updateAlgo === 'diff') { for (const value of this.initialValues) { if (!changedValues.includes(value)) { @@ -93,9 +95,7 @@ class IssueSidebarComboList { } } - async onItemClick(e) { - const elItem = (e.target as HTMLElement).closest('.item'); - if (!elItem) return; + async onItemClick(elItem: HTMLElement, e: Event) { e.preventDefault(); if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return; @@ -144,16 +144,13 @@ class IssueSidebarComboList { } this.initialValues = this.collectCheckedValues(); - this.elDropdown.addEventListener('click', (e) => this.onItemClick(e)); + addDelegatedEventListener(this.elDropdown, 'click', '.item', (el, e) => this.onItemClick(el, e)); fomanticQuery(this.elDropdown).dropdown('setting', { action: 'nothing', // do not hide the menu if user presses Enter fullTextSearch: 'exact', + hideDividers: 'empty', onHide: () => this.onHide(), }); } } - -export function initIssueSidebarComboList(container: HTMLElement) { - new IssueSidebarComboList(container).init(); -} diff --git a/web_src/js/features/repo-issue-sidebar.md b/web_src/js/features/repo-issue-sidebar.md index 6de013f1c2..e1ce0927e1 100644 --- a/web_src/js/features/repo-issue-sidebar.md +++ b/web_src/js/features/repo-issue-sidebar.md @@ -22,10 +22,13 @@ A sidebar combo (dropdown+list) is like this: When the selected items change, the `combo-value` input will be updated. If there is `data-update-url`, it also calls backend to attach/detach the changed items. -Also, the changed items will be syncronized to the `ui list` items. +Also, the changed items will be synchronized to the `ui list` items. The items with the same data-scope only allow one selected at a time. The dropdown selection could work in 2 modes: * single: only one item could be selected, it updates immediately when the item is selected. * multiple: multiple items could be selected, it defers the update until the dropdown is hidden. + +When using "scrolling menu", the items must be in the same level, +otherwise keyboard (ArrowUp/ArrowDown/Enter) won't work. diff --git a/web_src/js/features/repo-issue-sidebar.ts b/web_src/js/features/repo-issue-sidebar.ts index f84bed127f..290e1ae000 100644 --- a/web_src/js/features/repo-issue-sidebar.ts +++ b/web_src/js/features/repo-issue-sidebar.ts @@ -1,6 +1,6 @@ import {POST} from '../modules/fetch.ts'; import {queryElems, toggleElem} from '../utils/dom.ts'; -import {initIssueSidebarComboList} from './repo-issue-sidebar-combolist.ts'; +import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts'; function initBranchSelector() { // TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" @@ -48,5 +48,5 @@ export function initRepoIssueSidebar() { initRepoIssueDue(); // init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions - queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el)); + queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init()); } diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index d2a89682e8..49e8fc40a2 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -1,5 +1,4 @@ -import $ from 'jquery'; -import {htmlEscape} from 'escape-goat'; +import {html, htmlEscape} from '../utils/html.ts'; import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts'; import { addDelegatedEventListener, @@ -18,38 +17,40 @@ 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; -export function initRepoIssueSidebarList() { +export function initRepoIssueSidebarDependency() { + const elDropdown = document.querySelector('#new-dependency-drop-list'); + if (!elDropdown) return; + const issuePageInfo = parseIssuePageInfo(); - const crossRepoSearch = $('#crossRepoSearch').val(); + const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search'); let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`; if (crossRepoSearch === 'true') { issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`; } - fomanticQuery('#new-dependency-drop-list').dropdown({ + fomanticQuery(elDropdown).dropdown({ fullTextSearch: true, apiSettings: { + cache: false, + rawResponse: true, url: issueSearchUrl, - onResponse(response) { - const filteredResponse = {success: true, results: []}; - const currIssueId = $('#new-dependency-drop-list').data('issue-id'); + onResponse(response: any) { + const filteredResponse = {success: true, results: [] as Array<Record<string, any>>}; + const currIssueId = elDropdown.getAttribute('data-issue-id'); // Parse the response from the api to work with our dropdown - $.each(response, (_i, issue) => { + for (const issue of response) { // Don't list current issue in the dependency list. - if (issue.id === currIssueId) { - return; - } + if (String(issue.id) === currIssueId) continue; filteredResponse.results.push({ - name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div> -<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`, value: issue.id, + 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; }, - cache: false, }, }); } @@ -181,24 +182,6 @@ export function initRepoIssueCommentDelete() { }); } -export function initRepoIssueDependencyDelete() { - // Delete Issue dependency - $(document).on('click', '.delete-dependency-button', (e) => { - const id = e.currentTarget.getAttribute('data-id'); - const type = e.currentTarget.getAttribute('data-type'); - - $('.remove-dependency').modal({ - closable: false, - duration: 200, - onApprove: () => { - $('#removeDependencyID').val(id); - $('#dependencyType').val(type); - $('#removeDependencyForm').trigger('submit'); - }, - }).modal('show'); - }); -} - export function initRepoIssueCodeCommentCancel() { // Cancel inline code comment document.addEventListener('click', (e: DOMEvent<MouseEvent>) => { @@ -214,59 +197,6 @@ export function initRepoIssueCodeCommentCancel() { }); } -export function initRepoPullRequestUpdate() { - // Pull Request update button - const pullUpdateButton = document.querySelector<HTMLButtonElement>('.update-button > button'); - if (!pullUpdateButton) return; - - pullUpdateButton.addEventListener('click', async function (e) { - e.preventDefault(); - const redirect = this.getAttribute('data-redirect'); - this.classList.add('is-loading'); - let response: Response; - try { - response = await POST(this.getAttribute('data-do')); - } catch (error) { - console.error(error); - } finally { - this.classList.remove('is-loading'); - } - let data: Record<string, any>; - try { - data = await response?.json(); // the response is probably not a JSON - } catch (error) { - console.error(error); - } - if (data?.redirect) { - window.location.href = data.redirect; - } else if (redirect) { - window.location.href = redirect; - } else { - window.location.reload(); - } - }); - - $('.update-button > .dropdown').dropdown({ - onChange(_text, _value, $choice) { - const choiceEl = $choice[0]; - const url = choiceEl.getAttribute('data-do'); - if (url) { - const buttonText = pullUpdateButton.querySelector('.button-text'); - if (buttonText) { - buttonText.textContent = choiceEl.textContent; - } - pullUpdateButton.setAttribute('data-do', url); - } - }, - }); -} - -export function initRepoPullRequestMergeInstruction() { - $('.show-instruction').on('click', () => { - toggleElem($('.instruct-content')); - }); -} - export function initRepoPullRequestAllowMaintainerEdit() { const wrapper = document.querySelector('#allow-edits-from-maintainers'); if (!wrapper) return; @@ -293,54 +223,8 @@ export function initRepoPullRequestAllowMaintainerEdit() { }); } -export function initRepoIssueReferenceRepositorySearch() { - $('.issue_reference_repository_search') - .dropdown({ - apiSettings: { - url: `${appSubUrl}/repo/search?q={query}&limit=20`, - onResponse(response) { - const filteredResponse = {success: true, results: []}; - $.each(response.data, (_r, repo) => { - filteredResponse.results.push({ - name: htmlEscape(repo.repository.full_name), - value: repo.repository.full_name, - }); - }); - return filteredResponse; - }, - cache: false, - }, - onChange(_value, _text, $choice) { - const $form = $choice.closest('form'); - if (!$form.length) return; - - $form[0].setAttribute('action', `${appSubUrl}/${_text}/issues/new`); - }, - fullTextSearch: true, - }); -} - -export function initRepoIssueWipTitle() { - $('.title_wip_desc > a').on('click', (e) => { - e.preventDefault(); - - const $issueTitle = $('#issue_title'); - $issueTitle.trigger('focus'); - const value = ($issueTitle.val() as string).trim().toUpperCase(); - - const wipPrefixes = $('.title_wip_desc').data('wip-prefixes'); - for (const prefix of wipPrefixes) { - if (value.startsWith(prefix.toUpperCase())) { - return; - } - } - - $issueTitle.val(`${wipPrefixes[0]} ${$issueTitle.val()}`); - }); -} - export function initRepoIssueComments() { - if (!$('.repository.view.issue .timeline').length) return; + if (!document.querySelector('.repository.view.issue .timeline')) return; document.addEventListener('click', (e: DOMEvent<MouseEvent>) => { const urlTarget = document.querySelector(':target'); @@ -352,15 +236,15 @@ export function initRepoIssueComments() { if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return; if (!e.target.closest(`#${urlTargetId}`)) { - const scrollPosition = $(window).scrollTop(); - window.location.hash = ''; - $(window).scrollTop(scrollPosition); + // if the user clicks outside the comment, remove the hash from the url + // use empty hash and state to avoid scrolling + window.location.hash = ' '; window.history.pushState(null, null, ' '); } }); } -export async function handleReply(el) { +export async function handleReply(el: HTMLElement) { const form = el.closest('.comment-code-cloud').querySelector('.comment-form'); const textarea = form.querySelector('textarea'); @@ -379,7 +263,7 @@ export function initRepoPullRequestReview() { const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id'); if (groupID && groupID.startsWith('code-comments-')) { const id = groupID.slice(14); - const ancestorDiffBox = commentDiv.closest('.diff-file-box'); + const ancestorDiffBox = commentDiv.closest<HTMLElement>('.diff-file-box'); hideElem(`#show-outdated-${id}`); showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`); @@ -395,39 +279,37 @@ export function initRepoPullRequestReview() { } } - $(document).on('click', '.show-outdated', function (e) { + addDelegatedEventListener(document, 'click', '.show-outdated', (el, e) => { e.preventDefault(); - const id = this.getAttribute('data-comment'); - hideElem(this); + const id = el.getAttribute('data-comment'); + hideElem(el); showElem(`#code-comments-${id}`); showElem(`#code-preview-${id}`); showElem(`#hide-outdated-${id}`); }); - $(document).on('click', '.hide-outdated', function (e) { + addDelegatedEventListener(document, 'click', '.hide-outdated', (el, e) => { e.preventDefault(); - const id = this.getAttribute('data-comment'); - hideElem(this); + const id = el.getAttribute('data-comment'); + hideElem(el); hideElem(`#code-comments-${id}`); hideElem(`#code-preview-${id}`); showElem(`#show-outdated-${id}`); }); - $(document).on('click', 'button.comment-form-reply', async function (e) { + addDelegatedEventListener(document, 'click', 'button.comment-form-reply', (el, e) => { e.preventDefault(); - await handleReply(this); + handleReply(el); }); // The following part is only for diff views - if (!$('.repository.pull.diff').length) return; - - const $reviewBtn = $('.js-btn-review'); - const $panel = $reviewBtn.parent().find('.review-box-panel'); - const $closeBtn = $panel.find('.close'); + if (!document.querySelector('.repository.pull.diff')) return; - if ($reviewBtn.length && $panel.length) { - const tippy = createTippy($reviewBtn[0], { - content: $panel[0], + const elReviewBtn = document.querySelector('.js-btn-review'); + const elReviewPanel = document.querySelector('.review-box-panel.tippy-target'); + if (elReviewBtn && elReviewPanel) { + const tippy = createTippy(elReviewBtn, { + content: elReviewPanel, theme: 'default', placement: 'bottom', trigger: 'click', @@ -435,11 +317,7 @@ export function initRepoPullRequestReview() { interactive: true, hideOnClick: true, }); - - $closeBtn.on('click', (e) => { - e.preventDefault(); - tippy.hide(); - }); + elReviewPanel.querySelector('.close').addEventListener('click', () => tippy.hide()); } addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => { @@ -480,43 +358,79 @@ export function initRepoPullRequestReview() { } export function initRepoIssueReferenceIssue() { + const elDropdown = document.querySelector('.issue_reference_repository_search'); + if (!elDropdown) return; + const form = elDropdown.closest('form'); + fomanticQuery(elDropdown).dropdown({ + fullTextSearch: true, + apiSettings: { + cache: false, + rawResponse: true, + url: `${appSubUrl}/repo/search?q={query}&limit=20`, + onResponse(response: any) { + const filteredResponse = {success: true, results: [] as Array<Record<string, any>>}; + for (const repo of response.data) { + filteredResponse.results.push({ + name: htmlEscape(repo.repository.full_name), + value: repo.repository.full_name, + }); + } + return filteredResponse; + }, + }, + onChange(_value: string, _text: string, _$choice: any) { + form.setAttribute('action', `${appSubUrl}/${_text}/issues/new`); + }, + }); + // Reference issue - $(document).on('click', '.reference-issue', function (e) { - const target = this.getAttribute('data-target'); + addDelegatedEventListener(document, 'click', '.reference-issue', (el, e) => { + e.preventDefault(); + const target = el.getAttribute('data-target'); const content = document.querySelector(`#${target}`)?.textContent ?? ''; - const poster = this.getAttribute('data-poster-username'); - const reference = toAbsoluteUrl(this.getAttribute('data-reference')); - const modalSelector = this.getAttribute('data-modal'); + const poster = el.getAttribute('data-poster-username'); + const reference = toAbsoluteUrl(el.getAttribute('data-reference')); + const modalSelector = el.getAttribute('data-modal'); const modal = document.querySelector(modalSelector); - const textarea = modal.querySelector('textarea[name="content"]'); + const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]'); textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`; - $(modal).modal('show'); - e.preventDefault(); + fomanticQuery(modal).modal('show'); }); } +export function initRepoIssueWipNewTitle() { + // Toggle WIP for new PR + queryElems(document, '.title_wip_desc > a', (el) => el.addEventListener('click', (e) => { + e.preventDefault(); + const wipPrefixes = JSON.parse(el.closest('.title_wip_desc').getAttribute('data-wip-prefixes')); + const titleInput = document.querySelector<HTMLInputElement>('#issue_title'); + const titleValue = titleInput.value; + for (const prefix of wipPrefixes) { + if (titleValue.startsWith(prefix.toUpperCase())) { + return; + } + } + titleInput.value = `${wipPrefixes[0]} ${titleValue}`; + })); +} + export function initRepoIssueWipToggle() { - // Toggle WIP - $('.toggle-wip a, .toggle-wip button').on('click', async (e) => { + // Toggle WIP for existing PR + registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => { e.preventDefault(); - const toggleWip = e.currentTarget.closest('.toggle-wip'); 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(); + })); } export function initRepoIssueTitleEdit() { @@ -589,11 +503,11 @@ export function initRepoIssueBranchSelect() { }); } -async function initSingleCommentEditor($commentForm) { +async function initSingleCommentEditor(commentForm: HTMLFormElement) { // pages: // * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content) // * issue/pr view page: with comment form, has status-button and comment-button - const editor = await initComboMarkdownEditor($commentForm[0].querySelector('.combo-markdown-editor')); + const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor')); const statusButton = document.querySelector<HTMLButtonElement>('#status-button'); const commentButton = document.querySelector<HTMLButtonElement>('#comment-button'); const syncUiState = () => { @@ -611,27 +525,27 @@ async function initSingleCommentEditor($commentForm) { syncUiState(); } -function initIssueTemplateCommentEditors($commentForm) { +function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) { // pages: // * new issue with issue template - const $comboFields = $commentForm.find('.combo-editor-dropzone'); + const comboFields = commentForm.querySelectorAll<HTMLElement>('.combo-editor-dropzone'); const initCombo = async (elCombo: HTMLElement) => { - const $formField = $(elCombo.querySelector('.form-field-real')); + const fieldTextarea = elCombo.querySelector<HTMLTextAreaElement>('.form-field-real'); const dropzoneContainer = elCombo.querySelector<HTMLElement>('.form-field-dropzone'); const markdownEditor = elCombo.querySelector<HTMLElement>('.combo-markdown-editor'); const editor = await initComboMarkdownEditor(markdownEditor); - editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => $formField.val(editor.value())); + editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => fieldTextarea.value = editor.value()); - $formField.on('focus', async () => { + fieldTextarea.addEventListener('focus', async () => { // deactivate all markdown editors - showElem($commentForm.find('.combo-editor-dropzone .form-field-real')); - hideElem($commentForm.find('.combo-editor-dropzone .combo-markdown-editor')); - hideElem($commentForm.find('.combo-editor-dropzone .form-field-dropzone')); + showElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-real')); + hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .combo-markdown-editor')); + hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-dropzone')); // activate this markdown editor - hideElem($formField); + hideElem(fieldTextarea); showElem(markdownEditor); showElem(dropzoneContainer); @@ -640,21 +554,21 @@ function initIssueTemplateCommentEditors($commentForm) { }); }; - for (const el of $comboFields) { + for (const el of comboFields) { initCombo(el); } } export function initRepoCommentFormAndSidebar() { - const $commentForm = $('.comment.form'); - if (!$commentForm.length) return; + const commentForm = document.querySelector<HTMLFormElement>('.comment.form'); + if (!commentForm) return; - if ($commentForm.find('.field.combo-editor-dropzone').length) { + if (commentForm.querySelector('.field.combo-editor-dropzone')) { // at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form - initIssueTemplateCommentEditors($commentForm); - } else if ($commentForm.find('.combo-markdown-editor').length) { + initIssueTemplateCommentEditors(commentForm); + } else if (commentForm.querySelector('.combo-markdown-editor')) { // it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message - initSingleCommentEditor($commentForm); + initSingleCommentEditor(commentForm); } initRepoIssueSidebar(); diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index 33f02be865..249d181b25 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -1,30 +1,28 @@ +import {registerGlobalInitFunc} from '../modules/observer.ts'; import { initRepoCommentFormAndSidebar, initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, - initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue, - initRepoIssueTitleEdit, initRepoIssueWipToggle, - initRepoPullRequestUpdate, + initRepoIssueComments, initRepoIssueReferenceIssue, + initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle, } from './repo-issue.ts'; import {initUnicodeEscapeButton} from './repo-unicode-escape.ts'; import {initRepoCloneButtons} from './repo-common.ts'; import {initCitationFileCopyContent} from './citation.ts'; import {initCompLabelEdit} from './comp/LabelEdit.ts'; -import {initRepoDiffConversationNav} from './repo-diff.ts'; import {initCompReactionSelector} from './comp/ReactionSelector.ts'; import {initRepoSettings} from './repo-settings.ts'; -import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.ts'; -import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.ts'; import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts'; import {initRepoIssueCommentEdit} from './repo-issue-edit.ts'; import {initRepoMilestone} from './repo-milestone.ts'; import {initRepoNew} from './repo-new.ts'; import {createApp} from 'vue'; import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue'; +import {initRepoPullMergeBox} from './repo-issue-pull.ts'; -function initRepoBranchTagSelector(selector: string) { - for (const elRoot of document.querySelectorAll(selector)) { +function initRepoBranchTagSelector() { + registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => { createApp(RepoBranchTagSelector, {elRoot}).mount(elRoot); - } + }); } export function initBranchSelectorTabs() { @@ -43,7 +41,7 @@ export function initRepository() { const pageContent = document.querySelector('.page-content.repository'); if (!pageContent) return; - initRepoBranchTagSelector('.js-branch-tag-selector'); + initRepoBranchTagSelector(); initRepoCommentFormAndSidebar(); // Labels @@ -54,6 +52,7 @@ export function initRepository() { initRepoCloneButtons(); initCitationFileCopyContent(); initRepoSettings(); + initRepoIssueWipNewTitle(); // Issues if (pageContent.matches('.page-content.repository.view.issue')) { @@ -64,17 +63,13 @@ export function initRepository() { initRepoIssueWipToggle(); initRepoIssueComments(); - initRepoDiffConversationNav(); initRepoIssueReferenceIssue(); initRepoIssueCommentDelete(); - initRepoIssueDependencyDelete(); initRepoIssueCodeCommentCancel(); - initRepoPullRequestUpdate(); initCompReactionSelector(); - initRepoPullRequestMergeForm(); - initRepoPullRequestCommitStatus(); + registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox); } initUnicodeEscapeButton(); diff --git a/web_src/js/features/repo-migrate.ts b/web_src/js/features/repo-migrate.ts index b75289feec..0788f83215 100644 --- a/web_src/js/features/repo-migrate.ts +++ b/web_src/js/features/repo-migrate.ts @@ -1,11 +1,11 @@ -import {hideElem, showElem} from '../utils/dom.ts'; +import {hideElem, showElem, type DOMEvent} from '../utils/dom.ts'; import {GET, POST} from '../modules/fetch.ts'; export function initRepoMigrationStatusChecker() { const repoMigrating = document.querySelector('#repo_migrating'); if (!repoMigrating) return; - document.querySelector('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry); + document.querySelector<HTMLButtonElement>('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry); const repoLink = repoMigrating.getAttribute('data-migrating-repo-link'); @@ -55,7 +55,7 @@ export function initRepoMigrationStatusChecker() { syncTaskStatus(); // no await } -async function doMigrationRetry(e) { +async function doMigrationRetry(e: DOMEvent<MouseEvent>) { await POST(e.target.getAttribute('data-migrating-task-retry-url')); window.location.reload(); } diff --git a/web_src/js/features/repo-migration.ts b/web_src/js/features/repo-migration.ts index fb9c822f98..4914e47267 100644 --- a/web_src/js/features/repo-migration.ts +++ b/web_src/js/features/repo-migration.ts @@ -1,4 +1,5 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.ts'; +import {sanitizeRepoName} from './repo-common.ts'; const service = document.querySelector<HTMLInputElement>('#service_type'); const user = document.querySelector<HTMLInputElement>('#auth_username'); @@ -25,13 +26,19 @@ export function initRepoMigration() { }); lfs?.addEventListener('change', setLFSSettingsVisibility); - const cloneAddr = document.querySelector<HTMLInputElement>('#clone_addr'); - cloneAddr?.addEventListener('change', () => { - const repoName = document.querySelector<HTMLInputElement>('#repo_name'); - if (cloneAddr.value && !repoName?.value) { // Only modify if repo_name input is blank - repoName.value = /^(.*\/)?((.+?)(\.git)?)$/.exec(cloneAddr.value)[3]; - } - }); + const elCloneAddr = document.querySelector<HTMLInputElement>('#clone_addr'); + const elRepoName = document.querySelector<HTMLInputElement>('#repo_name'); + if (elCloneAddr && elRepoName) { + let repoNameChanged = false; + elRepoName.addEventListener('input', () => {repoNameChanged = true}); + elCloneAddr.addEventListener('input', () => { + if (repoNameChanged) return; + let repoNameFromUrl = elCloneAddr.value.split(/[?#]/)[0]; + repoNameFromUrl = /^(.*\/)?((.+?)\/?)$/.exec(repoNameFromUrl)[3]; + repoNameFromUrl = repoNameFromUrl.split(/[?#]/)[0]; + elRepoName.value = sanitizeRepoName(repoNameFromUrl); + }); + } } function checkAuth() { diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts index 8a77a77b4a..e2aa13f490 100644 --- a/web_src/js/features/repo-new.ts +++ b/web_src/js/features/repo-new.ts @@ -1,11 +1,14 @@ -import {hideElem, showElem, toggleElem} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts'; +import {htmlEscape} from '../utils/html.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {sanitizeRepoName} from './repo-common.ts'; const {appSubUrl} = window.config; function initRepoNewTemplateSearch(form: HTMLFormElement) { - const inputRepoOwnerUid = form.querySelector<HTMLInputElement>('#uid'); + const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(form, '.ui.primary.button'); + const elCreateRepoErrorMessage = form.querySelector('#create-repo-error-message'); + const elRepoOwnerDropdown = form.querySelector('#repo_owner_dropdown'); const elRepoTemplateDropdown = form.querySelector<HTMLInputElement>('#repo_template_search'); const inputRepoTemplate = form.querySelector<HTMLInputElement>('#repo_template'); const elTemplateUnits = form.querySelector('#template_units'); @@ -18,12 +21,24 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) { inputRepoTemplate.addEventListener('change', checkTemplate); checkTemplate(); - const $dropdown = fomanticQuery(elRepoTemplateDropdown); + const $repoOwnerDropdown = fomanticQuery(elRepoOwnerDropdown); + const $repoTemplateDropdown = fomanticQuery(elRepoTemplateDropdown); const onChangeOwner = function () { - $dropdown.dropdown('setting', { + const ownerId = $repoOwnerDropdown.dropdown('get value'); + const $ownerItem = $repoOwnerDropdown.dropdown('get item', ownerId); + hideElem(elCreateRepoErrorMessage); + elSubmitButton.disabled = false; + if ($ownerItem?.length) { + const elOwnerItem = $ownerItem[0]; + elCreateRepoErrorMessage.textContent = elOwnerItem.getAttribute('data-create-repo-disallowed-prompt') ?? ''; + const hasError = Boolean(elCreateRepoErrorMessage.textContent); + toggleElem(elCreateRepoErrorMessage, hasError); + elSubmitButton.disabled = hasError; + } + $repoTemplateDropdown.dropdown('setting', { apiSettings: { - url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${inputRepoOwnerUid.value}`, - onResponse(response) { + url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${ownerId}`, + onResponse(response: any) { const results = []; results.push({name: '', value: ''}); // empty item means not using template for (const tmplRepo of response.data) { @@ -32,14 +47,14 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) { value: String(tmplRepo.repository.id), }); } - $dropdown.fomanticExt.onResponseKeepSelectedItem($dropdown, inputRepoTemplate.value); + $repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value); return {results}; }, cache: false, }, }); }; - inputRepoOwnerUid.addEventListener('change', onChangeOwner); + $repoOwnerDropdown.dropdown('setting', 'onChange', onChangeOwner); onChangeOwner(); } @@ -66,7 +81,7 @@ export function initRepoNew() { let help = form.querySelector(`.help[data-help-for-repo-name="${CSS.escape(inputRepoName.value)}"]`); if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`); showElem(help); - const repoNamePreferPrivate = {'.profile': false, '.profile-private': true}; + const repoNamePreferPrivate: Record<string, boolean> = {'.profile': false, '.profile-private': true}; const preferPrivate = repoNamePreferPrivate[inputRepoName.value]; // inputPrivate might be disabled because site admin "force private" if (preferPrivate !== undefined && !inputPrivate.closest('.disabled, [disabled]')) { @@ -74,6 +89,10 @@ export function initRepoNew() { } }; inputRepoName.addEventListener('input', updateUiRepoName); + inputRepoName.addEventListener('change', () => { + inputRepoName.value = sanitizeRepoName(inputRepoName.value); + updateUiRepoName(); + }); updateUiRepoName(); initRepoNewTemplateSearch(form); diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts index 11f5c19c8d..ad0feb6101 100644 --- a/web_src/js/features/repo-projects.ts +++ b/web_src/js/features/repo-projects.ts @@ -2,8 +2,9 @@ import {contrastColor} from '../utils/color.ts'; import {createSortable} from '../modules/sortable.ts'; import {POST, request} from '../modules/fetch.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; -import {queryElemChildren, queryElems} from '../utils/dom.ts'; +import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; import type {SortableEvent} from 'sortablejs'; +import {toggleFullScreen} from '../utils.ts'; function updateIssueCount(card: HTMLElement): void { const parent = card.parentElement; @@ -34,8 +35,8 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi } async function initRepoProjectSortable(): Promise<void> { - // the HTML layout is: #project-board > .board > .project-column .cards > .issue-card - const mainBoard = document.querySelector('#project-board > .board.sortable'); + // the HTML layout is: #project-board.board > .project-column .cards > .issue-card + const mainBoard = document.querySelector('#project-board'); let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column'); createSortable(mainBoard, { group: 'project-column', @@ -113,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}"]`); @@ -133,13 +133,32 @@ 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'); } }); } +function initRepoProjectToggleFullScreen(): void { + const enterFullscreenBtn = document.querySelector('.screen-full'); + const exitFullscreenBtn = document.querySelector('.screen-normal'); + if (!enterFullscreenBtn || !exitFullscreenBtn) return; + + const toggleFullscreenState = (isFullScreen: boolean) => { + toggleFullScreen('.projects-view', isFullScreen); + toggleElem(enterFullscreenBtn, !isFullScreen); + toggleElem(exitFullscreenBtn, isFullScreen); + }; + + enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true)); + exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false)); +} + export function initRepoProject(): void { + initRepoProjectToggleFullScreen(); + const writableProjectBoard = document.querySelector('#project-board[data-project-borad-writable="true"]'); if (!writableProjectBoard) return; diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts index 7b3ab504cb..be1821664f 100644 --- a/web_src/js/features/repo-settings.ts +++ b/web_src/js/features/repo-settings.ts @@ -1,9 +1,9 @@ -import $ from 'jquery'; import {minimatch} from 'minimatch'; import {createMonaco} from './codeeditor.ts'; -import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts'; +import {onInputDebounce, queryElems, toggleClass, toggleElem} from '../utils/dom.ts'; import {POST} from '../modules/fetch.ts'; import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts'; +import {fomanticQuery} from '../modules/fomantic/base.ts'; const {appSubUrl, csrfToken} = window.config; @@ -11,11 +11,12 @@ function initRepoSettingsCollaboration() { // Change collaborator access mode for (const dropdownEl of queryElems(document, '.page-content.repository .ui.dropdown.access-mode')) { const textEl = dropdownEl.querySelector(':scope > .text'); - $(dropdownEl).dropdown({ - async action(text, value) { + const $dropdown = fomanticQuery(dropdownEl); + $dropdown.dropdown({ + async action(text: string, value: string) { dropdownEl.classList.add('is-loading', 'loading-icon-2px'); const lastValue = dropdownEl.getAttribute('data-last-value'); - $(dropdownEl).dropdown('hide'); + $dropdown.dropdown('hide'); try { const uid = dropdownEl.getAttribute('data-uid'); await POST(dropdownEl.getAttribute('data-url'), {data: new URLSearchParams({uid, 'mode': value})}); @@ -32,9 +33,9 @@ function initRepoSettingsCollaboration() { // set to the really selected value, defer to next tick to make sure `action` has finished // its work because the calling order might be onHide -> action setTimeout(() => { - const $item = $(dropdownEl).dropdown('get item', dropdownEl.getAttribute('data-last-value')); + const $item = $dropdown.dropdown('get item', dropdownEl.getAttribute('data-last-value')); if ($item) { - $(dropdownEl).dropdown('set selected', dropdownEl.getAttribute('data-last-value')); + $dropdown.dropdown('set selected', dropdownEl.getAttribute('data-last-value')); } else { textEl.textContent = '(none)'; // prevent from misleading users when the access mode is undefined } @@ -48,32 +49,32 @@ function initRepoSettingsSearchTeamBox() { const searchTeamBox = document.querySelector('#search-team-box'); if (!searchTeamBox) return; - $(searchTeamBox).search({ + fomanticQuery(searchTeamBox).search({ minCharacters: 2, + searchFields: ['name', 'description'], + showNoResults: false, + rawResponse: true, apiSettings: { url: `${appSubUrl}/org/${searchTeamBox.getAttribute('data-org-name')}/teams/-/search?q={query}`, headers: {'X-Csrf-Token': csrfToken}, - onResponse(response) { - const items = []; - $.each(response.data, (_i, item) => { + onResponse(response: any) { + const items: Array<Record<string, any>> = []; + for (const item of response.data) { items.push({ title: item.name, description: `${item.permission} access`, // TODO: translate this string }); - }); - + } return {results: items}; }, }, - searchFields: ['name', 'description'], - showNoResults: false, }); } function initRepoSettingsGitHook() { - if (!$('.edit.githook').length) return; + if (!document.querySelector('.page-content.repository.settings.edit.githook')) return; const filename = document.querySelector('.hook-filename').textContent; - createMonaco($('#content')[0] as HTMLTextAreaElement, filename, {language: 'shell'}); + createMonaco(document.querySelector<HTMLTextAreaElement>('#content'), filename, {language: 'shell'}); } function initRepoSettingsBranches() { @@ -120,32 +121,23 @@ function initRepoSettingsBranches() { } function initRepoSettingsOptions() { - if ($('.repository.settings.options').length > 0) { - // Enable or select internal/external wiki system and issue tracker. - $('.enable-system').on('change', function (this: HTMLInputElement) { // eslint-disable-line @typescript-eslint/no-deprecated - if (this.checked) { - $($(this).data('target')).removeClass('disabled'); - if (!$(this).data('context')) $($(this).data('context')).addClass('disabled'); - } else { - $($(this).data('target')).addClass('disabled'); - if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled'); - } - }); - $('.enable-system-radio').on('change', function (this: HTMLInputElement) { // eslint-disable-line @typescript-eslint/no-deprecated - if (this.value === 'false') { - $($(this).data('target')).addClass('disabled'); - if ($(this).data('context') !== undefined) $($(this).data('context')).removeClass('disabled'); - } else if (this.value === 'true') { - $($(this).data('target')).removeClass('disabled'); - if ($(this).data('context') !== undefined) $($(this).data('context')).addClass('disabled'); - } - }); - const $trackerIssueStyleRadios = $('.js-tracker-issue-style'); - $trackerIssueStyleRadios.on('change input', () => { - const checkedVal = $trackerIssueStyleRadios.filter(':checked').val(); - $('#tracker-issue-style-regex-box').toggleClass('disabled', checkedVal !== 'regexp'); - }); - } + const pageContent = document.querySelector('.page-content.repository.settings.options'); + if (!pageContent) return; + + // Enable or select internal/external wiki system and issue tracker. + queryElems<HTMLInputElement>(pageContent, '.enable-system', (el) => el.addEventListener('change', () => { + toggleClass(el.getAttribute('data-target'), 'disabled', !el.checked); + toggleClass(el.getAttribute('data-context'), 'disabled', el.checked); + })); + queryElems<HTMLInputElement>(pageContent, '.enable-system-radio', (el) => el.addEventListener('change', () => { + toggleClass(el.getAttribute('data-target'), 'disabled', el.value === 'false'); + toggleClass(el.getAttribute('data-context'), 'disabled', el.value === 'true'); + })); + + queryElems<HTMLInputElement>(pageContent, '.js-tracker-issue-style', (el) => el.addEventListener('change', () => { + const checkedVal = el.value; + pageContent.querySelector('#tracker-issue-style-regex-box').classList.toggle('disabled', checkedVal !== 'regexp'); + })); } export function initRepoSettings() { diff --git a/web_src/js/features/repo-view-file-tree.ts b/web_src/js/features/repo-view-file-tree.ts new file mode 100644 index 0000000000..f52b64cc51 --- /dev/null +++ b/web_src/js/features/repo-view-file-tree.ts @@ -0,0 +1,37 @@ +import {createApp} from 'vue'; +import {toggleElem} from '../utils/dom.ts'; +import {POST} from '../modules/fetch.ts'; +import ViewFileTree from '../components/ViewFileTree.vue'; +import {registerGlobalEventFunc} from '../modules/observer.ts'; + +const {appSubUrl} = window.config; + +async function toggleSidebar(btn: HTMLElement) { + const elToggleShow = document.querySelector('.repo-view-file-tree-toggle-show'); + const elFileTreeContainer = document.querySelector('.repo-view-file-tree-container'); + const shouldShow = btn.getAttribute('data-toggle-action') === 'show'; + toggleElem(elFileTreeContainer, shouldShow); + toggleElem(elToggleShow, !shouldShow); + + // FIXME: need to remove "full height" style from parent element + + if (!elFileTreeContainer.hasAttribute('data-user-is-signed-in')) return; + await POST(`${appSubUrl}/user/settings/update_preferences`, { + data: {codeViewShowFileTree: shouldShow}, + }); +} + +export async function initRepoViewFileTree() { + const sidebar = document.querySelector<HTMLElement>('.repo-view-file-tree-container'); + const repoViewContent = document.querySelector('.repo-view-content'); + if (!sidebar || !repoViewContent) return; + + registerGlobalEventFunc('click', 'onRepoViewFileTreeToggle', toggleSidebar); + + const fileTree = sidebar.querySelector('#view-file-tree'); + createApp(ViewFileTree, { + repoLink: fileTree.getAttribute('data-repo-link'), + treePath: fileTree.getAttribute('data-tree-path'), + currentRefNameSubURL: fileTree.getAttribute('data-current-ref-name-sub-url'), + }).mount(fileTree); +} diff --git a/web_src/js/features/repo-wiki.ts b/web_src/js/features/repo-wiki.ts index 484c628f9f..6ae0947077 100644 --- a/web_src/js/features/repo-wiki.ts +++ b/web_src/js/features/repo-wiki.ts @@ -1,8 +1,8 @@ -import {initMarkupContent} from '../markup/content.ts'; import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; 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'); @@ -31,8 +31,7 @@ async function initRepoWikiFormEditor() { const response = await POST(editor.previewUrl, {data: formData}); const data = await response.text(); lastContent = newContent; - previewTarget.innerHTML = `<div class="markup ui segment">${data}</div>`; - initMarkupContent(); + previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`; } catch (error) { console.error('Error rendering preview:', error); } finally { @@ -70,7 +69,7 @@ async function initRepoWikiFormEditor() { }); } -function collapseWikiTocForMobile(collapse) { +function collapseWikiTocForMobile(collapse: boolean) { if (collapse) { document.querySelector('.wiki-content-toc details')?.removeAttribute('open'); } diff --git a/web_src/js/features/scoped-access-token.ts b/web_src/js/features/scoped-access-token.ts deleted file mode 100644 index c498d4c011..0000000000 --- a/web_src/js/features/scoped-access-token.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {createApp} from 'vue'; - -export async function initScopedAccessTokenCategories() { - const el = document.querySelector('#scoped-access-token-selector'); - if (!el) return; - - const {default: ScopedAccessTokenSelector} = await import(/* webpackChunkName: "scoped-access-token-selector" */'../components/ScopedAccessTokenSelector.vue'); - try { - const View = createApp(ScopedAccessTokenSelector, { - isAdmin: JSON.parse(el.getAttribute('data-is-admin')), - noAccessLabel: el.getAttribute('data-no-access-label'), - readLabel: el.getAttribute('data-read-label'), - writeLabel: el.getAttribute('data-write-label'), - }); - View.mount(el); - } catch (err) { - console.error('ScopedAccessTokenSelector failed to load', err); - el.textContent = el.getAttribute('data-locale-component-failed-to-load'); - } -} diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts index 46168b2cd7..07f9c435b8 100644 --- a/web_src/js/features/stopwatch.ts +++ b/web_src/js/features/stopwatch.ts @@ -38,7 +38,7 @@ export function initStopwatch() { } let usingPeriodicPoller = false; - const startPeriodicPoller = (timeout) => { + const startPeriodicPoller = (timeout: number) => { if (timeout <= 0 || !Number.isFinite(timeout)) return; usingPeriodicPoller = true; setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout); @@ -103,7 +103,7 @@ export function initStopwatch() { startPeriodicPoller(notificationSettings.MinTimeout); } -async function updateStopwatchWithCallback(callback, timeout) { +async function updateStopwatchWithCallback(callback: (timeout: number) => void, timeout: number) { const isSet = await updateStopwatch(); if (!isSet) { @@ -125,7 +125,7 @@ async function updateStopwatch() { return updateStopwatchData(data); } -function updateStopwatchData(data) { +function updateStopwatchData(data: any) { const watch = data[0]; const btnEls = document.querySelectorAll('.active-stopwatch'); if (!watch) { @@ -134,7 +134,7 @@ function updateStopwatchData(data) { 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/tablesort.ts b/web_src/js/features/tablesort.ts index 15ea358fa3..0648ffd067 100644 --- a/web_src/js/features/tablesort.ts +++ b/web_src/js/features/tablesort.ts @@ -9,7 +9,7 @@ export function initTableSort() { } } -function tableSort(normSort, revSort, isDefault) { +function tableSort(normSort: string, revSort: string, isDefault: string) { if (!normSort) return false; if (!revSort) revSort = ''; diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts index fa65bcbb28..43c21ebe6d 100644 --- a/web_src/js/features/tribute.ts +++ b/web_src/js/features/tribute.ts @@ -1,14 +1,16 @@ import {emojiKeys, emojiHTML, emojiString} from './emoji.ts'; -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; -function makeCollections({mentions, emoji}) { - const collections = []; +type TributeItem = Record<string, any>; - if (emoji) { - collections.push({ +export async function attachTribute(element: HTMLElement) { + const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs'); + + const collections = [ + { // emojis trigger: ':', requireLeadingSpace: true, - values: (query, cb) => { + values: (query: string, cb: (matches: Array<string>) => void) => { const matches = []; for (const name of emojiKeys) { if (name.includes(query)) { @@ -18,39 +20,30 @@ function makeCollections({mentions, emoji}) { } cb(matches); }, - lookup: (item) => item, - selectTemplate: (item) => { + lookup: (item: TributeItem) => item, + selectTemplate: (item: TributeItem) => { if (item === undefined) return null; return emojiString(item.original); }, - menuItemTemplate: (item) => { - return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`; + menuItemTemplate: (item: TributeItem) => { + return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`; }, - }); - } - - if (mentions) { - collections.push({ + }, { // mentions values: window.config.mentionValues ?? [], requireLeadingSpace: true, - menuItemTemplate: (item) => { - return ` + menuItemTemplate: (item: TributeItem) => { + const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : ''; + return html` <div class="tribute-item"> - <img 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> `; }, - }); - } + }, + ]; - return collections; -} - -export async function attachTribute(element, {mentions, emoji}) { - const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs'); - const collections = makeCollections({mentions, emoji}); // @ts-expect-error TS2351: This expression is not constructable (strange, why) const tribute = new Tribute({collection: collections, noMatchTemplate: ''}); tribute.attach(element); diff --git a/web_src/js/features/user-auth-webauthn.ts b/web_src/js/features/user-auth-webauthn.ts index 70516c280d..1f336b9741 100644 --- a/web_src/js/features/user-auth-webauthn.ts +++ b/web_src/js/features/user-auth-webauthn.ts @@ -1,5 +1,5 @@ import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts'; -import {showElem} from '../utils/dom.ts'; +import {hideElem, showElem} from '../utils/dom.ts'; import {GET, POST} from '../modules/fetch.ts'; const {appSubUrl} = window.config; @@ -11,6 +11,12 @@ export async function initUserAuthWebAuthn() { return; } + // webauthn is only supported on secure contexts + if (!window.isSecureContext) { + hideElem(elSignInPasskeyBtn); + return; + } + if (!detectWebAuthnSupport()) { return; } @@ -114,7 +120,7 @@ async function login2FA() { } } -async function verifyAssertion(assertedCredential) { +async function verifyAssertion(assertedCredential: any) { // TODO: Credential type does not work // Move data into Arrays in case it is super long const authData = new Uint8Array(assertedCredential.response.authenticatorData); const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON); @@ -148,7 +154,7 @@ async function verifyAssertion(assertedCredential) { window.location.href = reply?.redirect ?? `${appSubUrl}/`; } -async function webauthnRegistered(newCredential) { +async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work const attestationObject = new Uint8Array(newCredential.response.attestationObject); const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON); const rawId = new Uint8Array(newCredential.rawId); diff --git a/web_src/js/features/user-settings.ts b/web_src/js/features/user-settings.ts index 6312a8b682..6fbb56e540 100644 --- a/web_src/js/features/user-settings.ts +++ b/web_src/js/features/user-settings.ts @@ -1,18 +1,8 @@ import {hideElem, showElem} from '../utils/dom.ts'; -import {initCompCropper} from './comp/Cropper.ts'; - -function initUserSettingsAvatarCropper() { - const fileInput = document.querySelector<HTMLInputElement>('#new-avatar'); - const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel'); - const imageSource = container.querySelector<HTMLImageElement>('.cropper-source'); - initCompCropper({container, fileInput, imageSource}); -} export function initUserSettings() { if (!document.querySelector('.user.settings.profile')) return; - initUserSettingsAvatarCropper(); - const usernameInput = document.querySelector<HTMLInputElement>('#username'); if (!usernameInput) return; usernameInput.addEventListener('input', function () { |