1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-21 09:48:37 +00:00

Merge branch 'main' into lunny/automerge_support_delete_branch

This commit is contained in:
Lunny Xiao
2024-11-21 21:28:18 -08:00
408 changed files with 8986 additions and 8235 deletions

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import {createApp, nextTick} from 'vue';
import $ from 'jquery';
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
@@ -102,7 +102,7 @@ const sfc = {
mounted() {
const el = document.querySelector('#dashboard-repo-list');
this.changeReposFilter(this.reposFilter);
$(el).find('.dropdown').dropdown();
fomanticQuery(el.querySelector('.ui.dropdown')).dropdown();
nextTick(() => {
this.$refs.search.focus();
});
@@ -471,7 +471,7 @@ export default sfc; // activate the IDE's Vue plugin
<li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name">
<a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)">
<svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/>
<div class="text truncate">{{ org.name }}</div>
<div class="text truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div>
<div><!-- div to prevent underline of label on hover -->
<span class="ui tiny basic label" v-if="org.org_visibility !== 'public'">
{{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }}

View File

@@ -21,7 +21,7 @@ import {
import {chartJsColors} from '../utils/color.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import $ from 'jquery';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const customEventListener = {
id: 'customEventListener',
@@ -77,7 +77,7 @@ export default {
mounted() {
this.fetchGraphData();
$('#repo-contributors').dropdown({
fomanticQuery('#repo-contributors').dropdown({
onChange: (val) => {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;

View File

@@ -5,15 +5,15 @@ import {POST} from '../../modules/fetch.ts';
const {appSubUrl} = window.config;
function onSecurityProtocolChange() {
if (Number(document.querySelector('#security_protocol')?.value) > 0) {
function onSecurityProtocolChange(): void {
if (Number(document.querySelector<HTMLInputElement>('#security_protocol')?.value) > 0) {
showElem('.has-tls');
} else {
hideElem('.has-tls');
}
}
export function initAdminCommon() {
export function initAdminCommon(): void {
if (!document.querySelector('.page-content.admin')) return;
// check whether appUrl(ROOT_URL) is correct, if not, show an error message
@@ -21,34 +21,34 @@ export function initAdminCommon() {
// New user
if ($('.admin.new.user').length > 0 || $('.admin.edit.user').length > 0) {
document.querySelector('#login_type')?.addEventListener('change', function () {
if (this.value?.substring(0, 1) === '0') {
document.querySelector('#user_name')?.removeAttribute('disabled');
document.querySelector('#login_name')?.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('#user_name')?.focus();
document.querySelector<HTMLInputElement>('#user_name')?.focus();
if (this.getAttribute('data-password') === 'required') {
document.querySelector('#password')?.setAttribute('required', 'required');
}
} else {
if (document.querySelector('.admin.edit.user')) {
document.querySelector('#user_name')?.setAttribute('disabled', 'disabled');
if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
}
document.querySelector('#login_name')?.setAttribute('required', 'required');
document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
showElem('.non-local');
hideElem('.local');
document.querySelector('#login_name')?.focus();
document.querySelector<HTMLInputElement>('#login_name')?.focus();
document.querySelector('#password')?.removeAttribute('required');
document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
}
});
}
function onUsePagedSearchChange() {
const searchPageSizeElements = document.querySelectorAll('.search-page-size');
if (document.querySelector('#use_paged_search').checked) {
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
if (document.querySelector<HTMLInputElement>('#use_paged_search').checked) {
showElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.setAttribute('required', 'required');
@@ -61,20 +61,20 @@ export function initAdminCommon() {
}
}
function onOAuth2Change(applyDefaultValues) {
function onOAuth2Change(applyDefaultValues: boolean) {
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
for (const input of document.querySelectorAll('.open_id_connect_auto_discovery_url input[required]')) {
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
input.removeAttribute('required');
}
const provider = document.querySelector('#oauth2_provider').value;
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
switch (provider) {
case 'openidConnect':
document.querySelector('.open_id_connect_auto_discovery_url input').setAttribute('required', 'required');
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input').setAttribute('required', 'required');
showElem('.open_id_connect_auto_discovery_url');
break;
default: {
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
if (!elProviderCustomUrlSettings) break; // some providers do not have custom URL settings
const couldChangeCustomURLs = elProviderCustomUrlSettings.getAttribute('data-available') === 'true';
const mustProvideCustomURLs = elProviderCustomUrlSettings.getAttribute('data-required') === 'true';
@@ -82,7 +82,7 @@ export function initAdminCommon() {
showElem('.oauth2_use_custom_url'); // show the checkbox
}
if (mustProvideCustomURLs) {
document.querySelector('#oauth2_use_custom_url').checked = true; // make the checkbox checked
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url').checked = true; // make the checkbox checked
}
break;
}
@@ -91,17 +91,17 @@ export function initAdminCommon() {
}
function onOAuth2UseCustomURLChange(applyDefaultValues) {
const provider = document.querySelector('#oauth2_provider').value;
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
hideElem('.oauth2_use_custom_url_field');
for (const input of document.querySelectorAll('.oauth2_use_custom_url_field input[required]')) {
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
input.removeAttribute('required');
}
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
if (elProviderCustomUrlSettings && document.querySelector('#oauth2_use_custom_url').checked) {
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url').checked) {
for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
if (applyDefaultValues) {
document.querySelector(`#oauth2_${custom}`).value = document.querySelector(`#${provider}_${custom}`).value;
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`).value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`).value;
}
const customInput = document.querySelector(`#${provider}_${custom}`);
if (customInput && customInput.getAttribute('data-available') === 'true') {
@@ -115,25 +115,26 @@ export function initAdminCommon() {
}
function onEnableLdapGroupsChange() {
toggleElem(document.querySelector('#ldap-group-options'), $('.js-ldap-group-toggle')[0].checked);
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
toggleElem(document.querySelector('#ldap-group-options'), checked);
}
// New authentication
if (document.querySelector('.admin.new.authentication')) {
document.querySelector('#auth_type')?.addEventListener('change', function () {
if (document.querySelector<HTMLDivElement>('.admin.new.authentication')) {
document.querySelector<HTMLInputElement>('#auth_type')?.addEventListener('change', function () {
hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
for (const input of document.querySelectorAll('.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]')) {
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]')) {
input.removeAttribute('required');
}
document.querySelector('.binddnrequired')?.classList.remove('required');
document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
const authType = this.value;
switch (authType) {
case '2': // LDAP
showElem('.ldap');
for (const input of document.querySelectorAll('.binddnrequired input, .ldap div.required:not(.dldap) input')) {
for (const input of document.querySelectorAll<HTMLInputElement>('.binddnrequired input, .ldap div.required:not(.dldap) input')) {
input.setAttribute('required', 'required');
}
document.querySelector('.binddnrequired')?.classList.add('required');
@@ -141,32 +142,32 @@ export function initAdminCommon() {
case '3': // SMTP
showElem('.smtp');
showElem('.has-tls');
for (const input of document.querySelectorAll('.smtp div.required input, .has-tls')) {
for (const input of document.querySelectorAll<HTMLInputElement>('.smtp div.required input, .has-tls')) {
input.setAttribute('required', 'required');
}
break;
case '4': // PAM
showElem('.pam');
for (const input of document.querySelectorAll('.pam input')) {
for (const input of document.querySelectorAll<HTMLInputElement>('.pam input')) {
input.setAttribute('required', 'required');
}
break;
case '5': // LDAP
showElem('.dldap');
for (const input of document.querySelectorAll('.dldap div.required:not(.ldap) input')) {
for (const input of document.querySelectorAll<HTMLInputElement>('.dldap div.required:not(.ldap) input')) {
input.setAttribute('required', 'required');
}
break;
case '6': // OAuth2
showElem('.oauth2');
for (const input of document.querySelectorAll('.oauth2 div.required:not(.oauth2_use_custom_url,.oauth2_use_custom_url_field,.open_id_connect_auto_discovery_url) input')) {
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2 div.required:not(.oauth2_use_custom_url,.oauth2_use_custom_url_field,.open_id_connect_auto_discovery_url) input')) {
input.setAttribute('required', 'required');
}
onOAuth2Change(true);
break;
case '7': // SSPI
showElem('.sspi');
for (const input of document.querySelectorAll('.sspi div.required input')) {
for (const input of document.querySelectorAll<HTMLInputElement>('.sspi div.required input')) {
input.setAttribute('required', 'required');
}
break;
@@ -180,39 +181,39 @@ export function initAdminCommon() {
}
});
$('#auth_type').trigger('change');
document.querySelector('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
document.querySelector('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
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);
}
// Edit authentication
if (document.querySelector('.admin.edit.authentication')) {
const authType = document.querySelector('#auth_type')?.value;
if (document.querySelector<HTMLDivElement>('.admin.edit.authentication')) {
const authType = document.querySelector<HTMLInputElement>('#auth_type')?.value;
if (authType === '2' || authType === '5') {
document.querySelector('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
$('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange);
onEnableLdapGroupsChange();
if (authType === '2') {
document.querySelector('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
}
} else if (authType === '6') {
document.querySelector('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
onOAuth2Change(false);
}
}
if (document.querySelector('.admin.authentication')) {
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.value)}/callback`;
document.querySelector('#oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent((this as HTMLInputElement).value)}/callback`;
}).trigger('input');
}
// Notice
if (document.querySelector('.admin.notice')) {
const detailModal = document.querySelector('#detail-modal');
if (document.querySelector<HTMLDivElement>('.admin.notice')) {
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal');
// Attach view detail modals
$('.view-detail').on('click', function () {
@@ -223,7 +224,7 @@ export function initAdminCommon() {
});
// Select actions
const checkboxes = document.querySelectorAll('.select.table .ui.checkbox input');
const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
$('.select.action').on('click', function () {
switch ($(this).data('action')) {
@@ -244,7 +245,7 @@ export function initAdminCommon() {
break;
}
});
document.querySelector('#delete-selection')?.addEventListener('click', async function (e) {
document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
e.preventDefault();
this.classList.add('is-loading', 'disabled');
const data = new FormData();

View File

@@ -3,17 +3,17 @@ import {POST} from '../../modules/fetch.ts';
const {appSubUrl} = window.config;
export function initAdminConfigs() {
const elAdminConfig = document.querySelector('.page-content.admin.config');
export function initAdminConfigs(): void {
const elAdminConfig = document.querySelector<HTMLDivElement>('.page-content.admin.config');
if (!elAdminConfig) return;
for (const el of elAdminConfig.querySelectorAll('input[type="checkbox"][data-config-dyn-key]')) {
for (const el of elAdminConfig.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-config-dyn-key]')) {
el.addEventListener('change', async () => {
try {
const resp = await POST(`${appSubUrl}/-/admin/config`, {
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key'), value: el.checked}),
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key'), value: String(el.checked)}),
});
const json = await resp.json();
const json: Record<string, any> = await resp.json();
if (json.errorMessage) throw new Error(json.errorMessage);
} catch (ex) {
showTemporaryTooltip(el, ex.toString());

View File

@@ -1,7 +1,7 @@
import $ from 'jquery';
export function initAdminEmails() {
function linkEmailAction(e) {
export function initAdminEmails(): void {
$('.link-email-action').on('click', (e) => {
const $this = $(this);
$('#form-uid').val($this.data('uid'));
$('#form-email').val($this.data('email'));
@@ -9,6 +9,5 @@ export function initAdminEmails() {
$('#form-activate').val($this.data('activate'));
$('#change-email-modal').modal('show');
e.preventDefault();
}
$('.link-email-action').on('click', linkEmailAction);
});
}

View File

@@ -7,16 +7,16 @@ export async function initAdminSelfCheck() {
const elCheckByFrontend = document.querySelector('#self-check-by-frontend');
if (!elCheckByFrontend) return;
const elContent = document.querySelector('.page-content.admin .admin-setting-content');
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content');
// send frontend self-check request
const resp = await POST(`${appSubUrl}/-/admin/self_check`, {
data: new URLSearchParams({
location_origin: window.location.origin,
now: Date.now(), // TODO: check time difference between server and client
now: String(Date.now()), // TODO: check time difference between server and client
}),
});
const json = await resp.json();
const json: Record<string, any> = await resp.json();
toggleElem(elCheckByFrontend, Boolean(json.problems?.length));
for (const problem of json.problems ?? []) {
const elProblem = document.createElement('div');

View File

@@ -1,8 +1,8 @@
export function initAdminUserListSearchForm() {
export function initAdminUserListSearchForm(): void {
const searchForm = window.config.pageData.adminUserListSearchForm;
if (!searchForm) return;
const form = document.querySelector('#user-list-search-form');
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
if (!form) return;
for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) {
@@ -12,23 +12,23 @@ export function initAdminUserListSearchForm() {
if (searchForm.StatusFilterMap) {
for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) {
if (!v) continue;
for (const input of form.querySelectorAll(`input[name="status_filter[${k}]"][value="${v}"]`)) {
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
input.checked = true;
}
}
}
for (const radio of form.querySelectorAll('input[type=radio]')) {
for (const radio of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
radio.addEventListener('click', () => {
form.submit();
});
}
const resetButtons = form.querySelectorAll('.j-reset-status-filter');
const resetButtons = form.querySelectorAll<HTMLAnchorElement>('.j-reset-status-filter');
for (const button of resetButtons) {
button.addEventListener('click', (e) => {
e.preventDefault();
for (const input of form.querySelectorAll('input[type=radio]')) {
for (const input of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
if (input.name.startsWith('status_filter[')) {
input.checked = false;
}

View File

@@ -1,5 +1,5 @@
export function initAutoFocusEnd() {
for (const el of document.querySelectorAll('.js-autofocus-end')) {
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);
}

View File

@@ -35,9 +35,11 @@ export async function initCaptcha() {
}
case 'm-captcha': {
const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
// @ts-expect-error
mCaptcha.INPUT_NAME = 'm-captcha-response';
const instanceURL = captchaEl.getAttribute('data-instance-url');
// @ts-expect-error
mCaptcha.default({
siteKey: {
instanceUrl: new URL(instanceURL),

View File

@@ -1,9 +1,9 @@
import $ from 'jquery';
import {getCurrentLocale} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {pageData} = window.config;
async function initInputCitationValue(citationCopyApa, citationCopyBibtex) {
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
@@ -27,9 +27,9 @@ export async function initCitationFileCopyContent() {
if (!pageData.citationFileContent) return;
const citationCopyApa = document.querySelector('#citation-copy-apa');
const citationCopyBibtex = document.querySelector('#citation-copy-bibtex');
const inputContent = document.querySelector('#citation-copy-content');
const citationCopyApa = document.querySelector<HTMLButtonElement>('#citation-copy-apa');
const citationCopyBibtex = document.querySelector<HTMLButtonElement>('#citation-copy-bibtex');
const inputContent = document.querySelector<HTMLInputElement>('#citation-copy-content');
if ((!citationCopyApa && !citationCopyBibtex) || !inputContent) return;
@@ -41,7 +41,7 @@ export async function initCitationFileCopyContent() {
citationCopyApa.classList.toggle('primary', !isBibtex);
};
document.querySelector('#cite-repo-button')?.addEventListener('click', async (e) => {
document.querySelector('#cite-repo-button')?.addEventListener('click', async (e: MouseEvent & {target: HTMLAnchorElement}) => {
const dropdownBtn = e.target.closest('.ui.dropdown.button');
dropdownBtn.classList.add('is-loading');
@@ -71,6 +71,6 @@ export async function initCitationFileCopyContent() {
dropdownBtn.classList.remove('is-loading');
}
$('#cite-repo-modal').modal('show');
fomanticQuery('#cite-repo-modal').modal('show');
});
}

View File

@@ -9,7 +9,7 @@ const {copy_success, copy_error} = window.config.i18n;
// - data-clipboard-target: Holds a selector for a <input> or <textarea> whose content is copied
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e) => {
document.addEventListener('click', async (e: MouseEvent & {target: HTMLElement}) => {
const target = e.target.closest('[data-clipboard-text], [data-clipboard-target]');
if (!target) return;
@@ -17,7 +17,7 @@ export function initGlobalCopyToClipboardListener() {
let text = target.getAttribute('data-clipboard-text');
if (!text) {
text = document.querySelector(target.getAttribute('data-clipboard-target'))?.value;
text = document.querySelector<HTMLInputElement>(target.getAttribute('data-clipboard-target'))?.value;
}
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {

View File

@@ -21,7 +21,7 @@ const baseOptions = {
automaticLayout: true,
};
function getEditorconfig(input) {
function getEditorconfig(input: HTMLInputElement) {
try {
return JSON.parse(input.getAttribute('data-editorconfig'));
} catch {
@@ -58,7 +58,7 @@ function exportEditor(editor) {
if (!window.codeEditors.includes(editor)) window.codeEditors.push(editor);
}
export async function createMonaco(textarea, filename, editorOpts) {
export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, editorOpts: Record<string, any>) {
const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor');
initLanguages(monaco);
@@ -72,7 +72,7 @@ export async function createMonaco(textarea, filename, editorOpts) {
// https://github.com/microsoft/monaco-editor/issues/2427
// also, monaco can only parse 6-digit hex colors, so we convert the colors to that format
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name) => tinycolor(styles.getPropertyValue(name).trim()).toString('hex6');
const getColor = (name: string) => tinycolor(styles.getPropertyValue(name).trim()).toString('hex6');
monaco.editor.defineTheme('gitea', {
base: isDarkTheme() ? 'vs-dark' : 'vs',
@@ -127,13 +127,13 @@ export async function createMonaco(textarea, filename, editorOpts) {
return {monaco, editor};
}
function getFileBasedOptions(filename, lineWrapExts) {
function getFileBasedOptions(filename: string, lineWrapExts: string[]) {
return {
wordWrap: (lineWrapExts || []).includes(extname(filename)) ? 'on' : 'off',
};
}
function togglePreviewDisplay(previewable) {
function togglePreviewDisplay(previewable: boolean) {
const previewTab = document.querySelector('a[data-tab="preview"]');
if (!previewTab) return;
@@ -152,7 +152,7 @@ function togglePreviewDisplay(previewable) {
}
}
export async function createCodeEditor(textarea, filenameInput) {
export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput: HTMLInputElement) {
const filename = basename(filenameInput.value);
const previewableExts = new Set((textarea.getAttribute('data-previewable-extensions') || '').split(','));
const lineWrapExts = (textarea.getAttribute('data-line-wrap-extensions') || '').split(',');
@@ -177,10 +177,10 @@ export async function createCodeEditor(textarea, filenameInput) {
return editor;
}
function getEditorConfigOptions(ec) {
function getEditorConfigOptions(ec: Record<string, any>): Record<string, any> {
if (!isObject(ec)) return {};
const opts = {};
const opts: Record<string, any> = {};
opts.detectIndentation = !('indent_style' in ec) || !('indent_size' in ec);
if ('indent_size' in ec) opts.indentSize = Number(ec.indent_size);
if ('tab_width' in ec) opts.tabSize = Number(ec.tab_width) || opts.indentSize;

View File

@@ -1,7 +1,7 @@
import {createTippy} from '../modules/tippy.ts';
export async function initColorPickers() {
const els = document.querySelectorAll('.js-color-picker-input');
const els = document.querySelectorAll<HTMLElement>('.js-color-picker-input');
if (!els.length) return;
await Promise.all([
@@ -14,15 +14,15 @@ export async function initColorPickers() {
}
}
function updateSquare(el, newValue) {
function updateSquare(el: HTMLElement, newValue: string): void {
el.style.color = /#[0-9a-f]{6}/i.test(newValue) ? newValue : 'transparent';
}
function updatePicker(el, newValue) {
function updatePicker(el: HTMLElement, newValue: string): void {
el.setAttribute('color', newValue);
}
function initPicker(el) {
function initPicker(el: HTMLElement): void {
const input = el.querySelector('input');
const square = document.createElement('div');
@@ -37,7 +37,7 @@ function initPicker(el) {
updateSquare(square, e.detail.value);
});
input.addEventListener('input', (e) => {
input.addEventListener('input', (e: Event & {target: HTMLInputElement}) => {
updateSquare(square, e.target.value);
updatePicker(picker, e.target.value);
});
@@ -56,7 +56,7 @@ function initPicker(el) {
// init precolors
for (const colorEl of el.querySelectorAll('.precolors .color')) {
colorEl.addEventListener('click', (e) => {
colorEl.addEventListener('click', (e: MouseEvent & {target: HTMLAnchorElement}) => {
const newValue = e.target.getAttribute('data-color-hex');
input.value = newValue;
input.dispatchEvent(new Event('input', {bubbles: true}));

View File

@@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts';
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
import {showErrorToast} from '../modules/toast.ts';
export function initGlobalButtonClickOnEnter() {
export function initGlobalButtonClickOnEnter(): void {
$(document).on('keypress', 'div.ui.button,span.ui.button', (e) => {
if (e.code === ' ' || e.code === 'Enter') {
$(e.target).trigger('click');
@@ -12,13 +12,13 @@ export function initGlobalButtonClickOnEnter() {
});
}
export function initGlobalDeleteButton() {
export function initGlobalDeleteButton(): void {
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
// If there is no form, then the data will be posted to `data-url`.
// TODO: it's not encouraged to use this method. `show-modal` does far better than this.
for (const btn of document.querySelectorAll('.delete-button')) {
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
btn.addEventListener('click', (e) => {
e.preventDefault();
@@ -46,7 +46,7 @@ export function initGlobalDeleteButton() {
// 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(formSelector);
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
form.submit();
}
@@ -73,7 +73,7 @@ export function initGlobalDeleteButton() {
}
}
export function initGlobalButtons() {
export function initGlobalButtons(): void {
// There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
// However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")

View File

@@ -1,13 +1,12 @@
import $ from 'jquery';
import {initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {applyAreYouSure, initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
export function initGlobalFormDirtyLeaveConfirm() {
initAreYouSure(window.jQuery);
// Warn users that try to leave a page after entering data into a form.
// Except on sign-in pages, and for forms marked as 'ignore-dirty'.
if (!$('.user.signin').length) {
$('form:not(.ignore-dirty)').areYouSure();
if (!document.querySelector('.page-content.user.signin')) {
applyAreYouSure('form:not(.ignore-dirty)');
}
}

View File

@@ -74,7 +74,6 @@ export class ComboMarkdownEditor {
previewUrl: string;
previewContext: string;
previewMode: string;
previewWiki: boolean;
constructor(container, options = {}) {
container._giteaComboMarkdownEditor = this;
@@ -213,13 +212,11 @@ export class ComboMarkdownEditor {
this.previewUrl = this.tabPreviewer.getAttribute('data-preview-url');
this.previewContext = this.tabPreviewer.getAttribute('data-preview-context');
this.previewMode = this.options.previewMode ?? 'comment';
this.previewWiki = this.options.previewWiki ?? false;
this.tabPreviewer.addEventListener('click', async () => {
const formData = new FormData();
formData.append('mode', this.previewMode);
formData.append('context', this.previewContext);
formData.append('text', this.value());
formData.append('wiki', String(this.previewWiki));
const response = await POST(this.previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent($(panelPreviewer), data);

View File

@@ -1,7 +1,7 @@
import $ from 'jquery';
import {svg} from '../../svg.ts';
import {htmlEscape} from 'escape-goat';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {i18n} = window.config;
@@ -17,7 +17,7 @@ export function confirmModal(content, {confirmButtonColor = 'primary'} = {}) {
</div>
`);
document.body.append(modal);
const $modal = $(modal);
const $modal = fomanticQuery(modal);
$modal.modal({
onApprove() {
resolve(true);

View File

@@ -1,5 +1,5 @@
import $ from 'jquery';
import {POST} from '../../modules/fetch.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
export function initCompReactionSelector() {
for (const container of document.querySelectorAll('.issue-content, .diff-file-body')) {
@@ -29,7 +29,7 @@ export function initCompReactionSelector() {
if (data.html) {
commentContainer.insertAdjacentHTML('beforeend', data.html);
const bottomReactionsDropdowns = commentContainer.querySelectorAll('.bottom-reactions .dropdown.select-reaction');
$(bottomReactionsDropdowns).dropdown(); // re-init the dropdown
fomanticQuery(bottomReactionsDropdowns).dropdown(); // re-init the dropdown
}
});
}

View File

@@ -1,5 +1,5 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
const looksLikeEmailAddressCheck = /^\S+@\S+$/;
@@ -10,7 +10,7 @@ export function initCompSearchUserBox() {
const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
$(searchUserBox).search({
fomanticQuery(searchUserBox).search({
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}`,

View File

@@ -1,5 +1,5 @@
import $ from 'jquery';
import {toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
export function initRepoBranchButton() {
initRepoCreateBranchButton();
@@ -18,7 +18,7 @@ function initRepoCreateBranchButton() {
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
document.querySelector(fromSpanName).textContent = el.getAttribute('data-branch-from');
$(el.getAttribute('data-modal')).modal('show');
fomanticQuery(el.getAttribute('data-modal')).modal('show');
});
}
}

View File

@@ -7,30 +7,20 @@ 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} from '../utils/dom.ts';
import {
submitEventSubmitter,
queryElemSiblings,
hideElem,
showElem,
animateOnce,
addDelegatedEventListener,
createElementFromHTML,
} from '../utils/dom.ts';
import {POST, GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {pageData, i18n} = window.config;
function initRepoDiffReviewButton() {
const reviewBox = document.querySelector('#review-box');
if (!reviewBox) return;
const counter = reviewBox.querySelector('.review-comments-counter');
if (!counter) return;
$(document).on('click', 'button[name="pending_review"]', (e) => {
const $form = $(e.target).closest('form');
// Watch for the form's submit event.
$form.on('submit', () => {
const num = parseInt(counter.getAttribute('data-pending-comment-number')) + 1 || 1;
counter.setAttribute('data-pending-comment-number', num);
counter.textContent = num;
animateOnce(reviewBox, 'pulse-1p5-200');
});
});
}
function initRepoDiffFileViewToggle() {
$('.file-view-toggle').on('click', function () {
for (const el of queryElemSiblings(this)) {
@@ -47,19 +37,15 @@ function initRepoDiffFileViewToggle() {
}
function initRepoDiffConversationForm() {
$(document).on('submit', '.conversation-holder form', async (e) => {
addDelegatedEventListener<HTMLFormElement>(document, 'submit', '.conversation-holder form', async (form, e) => {
e.preventDefault();
const textArea = form.querySelector<HTMLTextAreaElement>('textarea');
if (!validateTextareaNonEmpty(textArea)) return;
if (form.classList.contains('is-loading')) return;
const $form = $(e.target);
const textArea = e.target.querySelector('textarea');
if (!validateTextareaNonEmpty(textArea)) {
return;
}
if (e.target.classList.contains('is-loading')) return;
try {
e.target.classList.add('is-loading');
const formData = new FormData($form[0]);
form.classList.add('is-loading');
const formData = new FormData(form);
// if the form is submitted by a button, append the button's name and value to the form data
const submitter = submitEventSubmitter(e);
@@ -68,26 +54,42 @@ function initRepoDiffConversationForm() {
formData.append(submitter.name, submitter.value);
}
const response = await POST(e.target.getAttribute('action'), {data: formData});
const $newConversationHolder = $(await response.text());
const {path, side, idx} = $newConversationHolder.data();
const trLineType = form.closest('tr').getAttribute('data-line-type');
const response = await POST(form.getAttribute('action'), {data: formData});
const newConversationHolder = createElementFromHTML(await response.text());
const path = newConversationHolder.getAttribute('data-path');
const side = newConversationHolder.getAttribute('data-side');
const idx = newConversationHolder.getAttribute('data-idx');
form.closest('.conversation-holder').replaceWith(newConversationHolder);
form = null; // prevent further usage of the form because it should have been replaced
$form.closest('.conversation-holder').replaceWith($newConversationHolder);
let selector;
if ($form.closest('tr').data('line-type') === 'same') {
if (trLineType === 'same') {
selector = `[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`;
} else {
selector = `[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`;
}
for (const el of document.querySelectorAll(selector)) {
el.classList.add('tw-invisible');
el.classList.add('tw-invisible'); // TODO need to figure out why
}
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"]')) {
const reviewBox = document.querySelector('#review-box');
const counter = reviewBox?.querySelector('.review-comments-counter');
if (!counter) return;
const num = parseInt(counter.getAttribute('data-pending-comment-number')) + 1 || 1;
counter.setAttribute('data-pending-comment-number', String(num));
counter.textContent = String(num);
animateOnce(reviewBox, 'pulse-1p5-200');
}
$newConversationHolder.find('.dropdown').dropdown();
} catch (error) {
console.error('Error:', error);
showErrorToast(i18n.network_error);
} finally {
e.target.classList.remove('is-loading');
form?.classList.remove('is-loading');
}
});
@@ -219,7 +221,6 @@ export function initRepoDiffView() {
initDiffFileList();
initDiffCommitSelect();
initRepoDiffShowMore();
initRepoDiffReviewButton();
initRepoDiffFileViewToggle();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();

View File

@@ -1,6 +1,6 @@
import $ from 'jquery';
import {hideElem, showElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
export function initRepoGraphGit() {
const graphContainer = document.querySelector('#git-graph-container');
@@ -83,8 +83,8 @@ export function initRepoGraphGit() {
}
const flowSelectRefsDropdown = document.querySelector('#flow-select-refs-dropdown');
$(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected);
$(flowSelectRefsDropdown).dropdown({
fomanticQuery(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected);
fomanticQuery(flowSelectRefsDropdown).dropdown({
clearable: true,
fullTextSeach: 'exact',
onRemove(toRemove) {

View File

@@ -1,8 +1,8 @@
import $ from 'jquery';
import {stripTags} from '../utils.ts';
import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
@@ -73,7 +73,7 @@ export function initRepoTopicBar() {
}
});
$(topicDropdown).dropdown({
fomanticQuery(topicDropdown).dropdown({
allowAdditions: true,
forceSelection: false,
fullTextSearch: 'exact',
@@ -136,7 +136,7 @@ export function initRepoTopicBar() {
onLabelCreate(value) {
value = value.toLowerCase().trim();
this.attr('data-value', value).contents().first().replaceWith(value);
return $(this);
return fomanticQuery(this);
},
onAdd(addedValue, _addedText, $addedChoice) {
addedValue = addedValue.toLowerCase().trim();

View File

@@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
// if there are draft comments, confirm before reloading, to avoid losing comments
export function issueSidebarReloadConfirmDraftComment() {
function issueSidebarReloadConfirmDraftComment() {
const commentTextareas = [
document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'),
document.querySelector<HTMLTextAreaElement>('#comment-form textarea'),
@@ -22,84 +22,138 @@ export function issueSidebarReloadConfirmDraftComment() {
window.location.reload();
}
function collectCheckedValues(elDropdown: HTMLElement) {
return Array.from(elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
}
class IssueSidebarComboList {
updateUrl: string;
updateAlgo: string;
selectionMode: string;
elDropdown: HTMLElement;
elList: HTMLElement;
elComboValue: HTMLInputElement;
initialValues: string[];
export function initIssueSidebarComboList(container: HTMLElement) {
const updateUrl = container.getAttribute('data-update-url');
const elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown');
const elList = container.querySelector<HTMLElement>(':scope > .ui.list');
const elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value');
let initialValues = collectCheckedValues(elDropdown);
constructor(private container: HTMLElement) {
this.updateUrl = this.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}`);
if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`);
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown');
this.elList = container.querySelector<HTMLElement>(':scope > .ui.list');
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value');
}
elDropdown.addEventListener('click', (e) => {
collectCheckedValues() {
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
}
updateUiList(changedValues) {
const elEmptyTip = this.elList.querySelector('.item.empty-list');
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
for (const value of changedValues) {
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
if (!el) continue;
const listItem = el.cloneNode(true) as HTMLElement;
queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
this.elList.append(listItem);
}
const hasItems = Boolean(this.elList.querySelector('.item:not(.empty-list)'));
toggleElem(elEmptyTip, !hasItems);
}
async updateToBackend(changedValues) {
if (this.updateAlgo === 'diff') {
for (const value of this.initialValues) {
if (!changedValues.includes(value)) {
await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
}
}
for (const value of changedValues) {
if (!this.initialValues.includes(value)) {
await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
}
}
} else {
await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
}
issueSidebarReloadConfirmDraftComment();
}
async doUpdate() {
const changedValues = this.collectCheckedValues();
if (this.initialValues.join(',') === changedValues.join(',')) return;
this.updateUiList(changedValues);
if (this.updateUrl) await this.updateToBackend(changedValues);
this.initialValues = changedValues;
}
async onChange() {
if (this.selectionMode === 'single') {
await this.doUpdate();
fomanticQuery(this.elDropdown).dropdown('hide');
}
}
async onItemClick(e) {
const elItem = (e.target as HTMLElement).closest('.item');
if (!elItem) return;
e.preventDefault();
if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
if (elItem.matches('.clear-selection')) {
queryElems(elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
elComboValue.value = '';
queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
this.elComboValue.value = '';
this.onChange();
return;
}
const scope = elItem.getAttribute('data-scope');
if (scope) {
// scoped items could only be checked one at a time
const elSelected = elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
const elSelected = this.elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
if (elSelected === elItem) {
elItem.classList.toggle('checked');
} else {
queryElems(elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
queryElems(this.elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
elItem.classList.toggle('checked', true);
}
} else {
elItem.classList.toggle('checked');
}
elComboValue.value = collectCheckedValues(elDropdown).join(',');
});
const updateToBackend = async (changedValues) => {
let changed = false;
for (const value of initialValues) {
if (!changedValues.includes(value)) {
await POST(updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
changed = true;
if (this.selectionMode === 'multiple') {
elItem.classList.toggle('checked');
} else {
queryElems(this.elDropdown, `.menu > .item.checked`, (el) => el.classList.remove('checked'));
elItem.classList.toggle('checked', true);
}
}
for (const value of changedValues) {
if (!initialValues.includes(value)) {
await POST(updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
changed = true;
this.elComboValue.value = this.collectCheckedValues().join(',');
this.onChange();
}
async onHide() {
if (this.selectionMode === 'multiple') this.doUpdate();
}
init() {
// init the checked items from initial value
if (this.elComboValue.value && this.elComboValue.value !== '0' && !queryElems(this.elDropdown, `.menu > .item.checked`).length) {
const values = this.elComboValue.value.split(',');
for (const value of values) {
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
elItem?.classList.add('checked');
}
this.updateUiList(values);
}
if (changed) issueSidebarReloadConfirmDraftComment();
};
this.initialValues = this.collectCheckedValues();
const syncUiList = (changedValues) => {
const elEmptyTip = elList.querySelector('.item.empty-list');
queryElemChildren(elList, '.item:not(.empty-list)', (el) => el.remove());
for (const value of changedValues) {
const el = elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
const listItem = el.cloneNode(true) as HTMLElement;
queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
elList.append(listItem);
}
const hasItems = Boolean(elList.querySelector('.item:not(.empty-list)'));
toggleElem(elEmptyTip, !hasItems);
};
this.elDropdown.addEventListener('click', (e) => this.onItemClick(e));
fomanticQuery(elDropdown).dropdown('setting', {
action: 'nothing', // do not hide the menu if user presses Enter
fullTextSearch: 'exact',
async onHide() {
// TODO: support "Esc" to cancel the selection. Use partial page loading to avoid losing inputs.
const changedValues = collectCheckedValues(elDropdown);
syncUiList(changedValues);
if (updateUrl) await updateToBackend(changedValues);
initialValues = changedValues;
},
});
fomanticQuery(this.elDropdown).dropdown('setting', {
action: 'nothing', // do not hide the menu if user presses Enter
fullTextSearch: 'exact',
onHide: () => this.onHide(),
});
}
}
export function initIssueSidebarComboList(container: HTMLElement) {
new IssueSidebarComboList(container).init();
}

View File

@@ -1,7 +1,7 @@
A sidebar combo (dropdown+list) is like this:
```html
<div class="issue-sidebar-combo" data-update-url="...">
<div class="issue-sidebar-combo" data-selection-mode="..." data-update-url="...">
<input class="combo-value" name="..." type="hidden" value="...">
<div class="ui dropdown">
<div class="menu">
@@ -25,3 +25,7 @@ If there is `data-update-url`, it also calls backend to attach/detach the change
Also, the changed items will be syncronized 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.

View File

@@ -1,10 +1,7 @@
import $ from 'jquery';
import {POST} from '../modules/fetch.ts';
import {updateIssuesMeta} from './repo-common.ts';
import {svg} from '../svg.ts';
import {htmlEscape} from 'escape-goat';
import {queryElems, toggleElem} from '../utils/dom.ts';
import {initIssueSidebarComboList, issueSidebarReloadConfirmDraftComment} from './repo-issue-sidebar-combolist.ts';
import {initIssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
function initBranchSelector() {
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
@@ -34,212 +31,6 @@ function initBranchSelector() {
});
}
// List submits
function initListSubmits(selector, outerSelector) {
const $list = $(`.ui.${outerSelector}.list`);
const $noSelect = $list.find('.no-select');
const $listMenu = $(`.${selector} .menu`);
let hasUpdateAction = $listMenu.data('action') === 'update';
const items = {};
$(`.${selector}`).dropdown({
'action': 'nothing', // do not hide the menu if user presses Enter
fullTextSearch: 'exact',
async onHide() {
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
if (hasUpdateAction) {
// TODO: Add batch functionality and make this 1 network request.
const itemEntries = Object.entries(items);
for (const [elementId, item] of itemEntries) {
await updateIssuesMeta(
item['update-url'],
item['action'],
item['issue-id'],
elementId,
);
}
if (itemEntries.length) {
issueSidebarReloadConfirmDraftComment();
}
}
},
});
$listMenu.find('.item:not(.no-select)').on('click', function (e) {
e.preventDefault();
if (this.classList.contains('ban-change')) {
return false;
}
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
const clickedItem = this; // eslint-disable-line unicorn/no-this-assignment
const scope = this.getAttribute('data-scope');
$(this).parent().find('.item').each(function () {
if (scope) {
// Enable only clicked item for scoped labels
if (this.getAttribute('data-scope') !== scope) {
return;
}
if (this !== clickedItem && !this.classList.contains('checked')) {
return;
}
} else if (this !== clickedItem) {
// Toggle for other labels
return;
}
if (this.classList.contains('checked')) {
$(this).removeClass('checked');
$(this).find('.octicon-check').addClass('tw-invisible');
if (hasUpdateAction) {
if (!($(this).data('id') in items)) {
items[$(this).data('id')] = {
'update-url': $listMenu.data('update-url'),
action: 'detach',
'issue-id': $listMenu.data('issue-id'),
};
} else {
delete items[$(this).data('id')];
}
}
} else {
$(this).addClass('checked');
$(this).find('.octicon-check').removeClass('tw-invisible');
if (hasUpdateAction) {
if (!($(this).data('id') in items)) {
items[$(this).data('id')] = {
'update-url': $listMenu.data('update-url'),
action: 'attach',
'issue-id': $listMenu.data('issue-id'),
};
} else {
delete items[$(this).data('id')];
}
}
}
});
// TODO: Which thing should be done for choosing review requests
// to make chosen items be shown on time here?
if (selector === 'select-assignees-modify') {
return false;
}
const listIds = [];
$(this).parent().find('.item').each(function () {
if (this.classList.contains('checked')) {
listIds.push($(this).data('id'));
$($(this).data('id-selector')).removeClass('tw-hidden');
} else {
$($(this).data('id-selector')).addClass('tw-hidden');
}
});
if (!listIds.length) {
$noSelect.removeClass('tw-hidden');
} else {
$noSelect.addClass('tw-hidden');
}
$($(this).parent().data('id')).val(listIds.join(','));
return false;
});
$listMenu.find('.no-select.item').on('click', function (e) {
e.preventDefault();
if (hasUpdateAction) {
(async () => {
await updateIssuesMeta(
$listMenu.data('update-url'),
'clear',
$listMenu.data('issue-id'),
'',
);
issueSidebarReloadConfirmDraftComment();
})();
}
$(this).parent().find('.item').each(function () {
$(this).removeClass('checked');
$(this).find('.octicon-check').addClass('tw-invisible');
});
if (selector === 'select-assignees-modify') {
return false;
}
$list.find('.item').each(function () {
$(this).addClass('tw-hidden');
});
$noSelect.removeClass('tw-hidden');
$($(this).parent().data('id')).val('');
});
}
function selectItem(select_id, input_id) {
const $menu = $(`${select_id} .menu`);
const $list = $(`.ui${select_id}.list`);
const hasUpdateAction = $menu.data('action') === 'update';
$menu.find('.item:not(.no-select)').on('click', function () {
$(this).parent().find('.item').each(function () {
$(this).removeClass('selected active');
});
$(this).addClass('selected active');
if (hasUpdateAction) {
(async () => {
await updateIssuesMeta(
$menu.data('update-url'),
'',
$menu.data('issue-id'),
$(this).data('id'),
);
issueSidebarReloadConfirmDraftComment();
})();
}
let icon = '';
if (input_id === '#milestone_id') {
icon = svg('octicon-milestone', 18, 'tw-mr-2');
} else if (input_id === '#project_id') {
icon = svg('octicon-project', 18, 'tw-mr-2');
} else if (input_id === '#assignee_id') {
icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`;
}
$list.find('.selected').html(`
<a class="item muted sidebar-item-link" href="${htmlEscape(this.getAttribute('data-href'))}">
${icon}
${htmlEscape(this.textContent)}
</a>
`);
$(`.ui${select_id}.list .no-select`).addClass('tw-hidden');
$(input_id).val($(this).data('id'));
});
$menu.find('.no-select.item').on('click', function () {
$(this).parent().find('.item:not(.no-select)').each(function () {
$(this).removeClass('selected active');
});
if (hasUpdateAction) {
(async () => {
await updateIssuesMeta(
$menu.data('update-url'),
'',
$menu.data('issue-id'),
$(this).data('id'),
);
issueSidebarReloadConfirmDraftComment();
})();
}
$list.find('.selected').html('');
$list.find('.no-select').removeClass('tw-hidden');
$(input_id).val('');
});
}
function initRepoIssueDue() {
const form = document.querySelector<HTMLFormElement>('.issue-due-form');
if (!form) return;
@@ -257,14 +48,6 @@ export function initRepoIssueSidebar() {
initBranchSelector();
initRepoIssueDue();
// TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList
initListSubmits('select-assignees', 'assignees');
initListSubmits('select-assignees-modify', 'assignees');
selectItem('.select-assignee', '#assignee_id');
selectItem('.select-project', '#project_id');
selectItem('.select-milestone', '#milestone_id');
// 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));
}

View File

@@ -1,7 +1,7 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
import {addDelegatedEventListener, createElementFromHTML, hideElem, showElem, toggleElem} from '../utils/dom.ts';
import {setFileFolding} from './file-fold.ts';
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts';
@@ -443,21 +443,19 @@ export function initRepoPullRequestReview() {
});
}
$(document).on('click', '.add-code-comment', async function (e) {
if (e.target.classList.contains('btn-add-single')) return; // https://github.com/go-gitea/gitea/issues/4745
addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
e.preventDefault();
const isSplit = this.closest('.code-diff')?.classList.contains('code-diff-split');
const side = this.getAttribute('data-side');
const idx = this.getAttribute('data-idx');
const path = this.closest('[data-path]')?.getAttribute('data-path');
const tr = this.closest('tr');
const isSplit = el.closest('.code-diff')?.classList.contains('code-diff-split');
const side = el.getAttribute('data-side');
const idx = el.getAttribute('data-idx');
const path = el.closest('[data-path]')?.getAttribute('data-path');
const tr = el.closest('tr');
const lineType = tr.getAttribute('data-line-type');
const ntr = tr.nextElementSibling;
let $ntr = $(ntr);
let ntr = tr.nextElementSibling;
if (!ntr?.classList.contains('add-comment')) {
$ntr = $(`
ntr = createElementFromHTML(`
<tr class="add-comment" data-line-type="${lineType}">
${isSplit ? `
<td class="add-comment-left" colspan="4"></td>
@@ -466,24 +464,18 @@ export function initRepoPullRequestReview() {
<td class="add-comment-left add-comment-right" colspan="5"></td>
`}
</tr>`);
$(tr).after($ntr);
tr.after(ntr);
}
const $td = $ntr.find(`.add-comment-${side}`);
const $commentCloud = $td.find('.comment-code-cloud');
if (!$commentCloud.length && !$ntr.find('button[name="pending_review"]').length) {
try {
const response = await GET(this.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url'));
const html = await response.text();
$td.html(html);
$td.find("input[name='line']").val(idx);
$td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
$td.find("input[name='path']").val(path);
const editor = await initComboMarkdownEditor($td[0].querySelector('.combo-markdown-editor'));
editor.focus();
} catch (error) {
console.error(error);
}
const td = ntr.querySelector(`.add-comment-${side}`);
const commentCloud = td.querySelector('.comment-code-cloud');
if (!commentCloud && !ntr.querySelector('button[name="pending_review"]')) {
const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url'));
td.innerHTML = await response.text();
td.querySelector<HTMLInputElement>("input[name='line']").value = idx;
td.querySelector<HTMLInputElement>("input[name='side']").value = (side === 'left' ? 'previous' : 'proposed');
td.querySelector<HTMLInputElement>("input[name='path']").value = path;
const editor = await initComboMarkdownEditor(td.querySelector<HTMLElement>('.combo-markdown-editor'));
editor.focus();
}
});
}

View File

@@ -26,7 +26,6 @@ async function initRepoWikiFormEditor() {
formData.append('mode', editor.previewMode);
formData.append('context', editor.previewContext);
formData.append('text', newContent);
formData.append('wiki', editor.previewWiki);
try {
const response = await POST(editor.previewUrl, {data: formData});
const data = await response.text();
@@ -51,8 +50,7 @@ async function initRepoWikiFormEditor() {
// And another benefit is that we only need to write the style once for both editors.
// TODO: Move height style to CSS after EasyMDE removal.
editorHeights: {minHeight: '300px', height: 'calc(100vh - 600px)'},
previewMode: 'gfm',
previewWiki: true,
previewMode: 'wiki',
easyMDEOptions: {
previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
toolbar: ['bold', 'italic', 'strikethrough', '|',

View File

@@ -58,4 +58,8 @@ interface Window {
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
},
__webpack_public_path__: string;
grecaptcha: any,
turnstile: any,
hcaptcha: any,
codeEditors: any[],
}

View File

@@ -1,11 +1,11 @@
import {svg} from '../svg.ts';
const addPrefix = (str) => `user-content-${str}`;
const removePrefix = (str) => str.replace(/^user-content-/, '');
const hasPrefix = (str) => str.startsWith('user-content-');
const addPrefix = (str: string): string => `user-content-${str}`;
const removePrefix = (str: string): string => str.replace(/^user-content-/, '');
const hasPrefix = (str: string): boolean => str.startsWith('user-content-');
// scroll to anchor while respecting the `user-content` prefix that exists on the target
function scrollToAnchor(encodedId) {
function scrollToAnchor(encodedId: string): void {
if (!encodedId) return;
const id = decodeURIComponent(encodedId);
const prefixedId = addPrefix(id);
@@ -24,7 +24,7 @@ function scrollToAnchor(encodedId) {
el?.scrollIntoView();
}
export function initMarkupAnchors() {
export function initMarkupAnchors(): void {
const markupEls = document.querySelectorAll('.markup');
if (!markupEls.length) return;
@@ -39,7 +39,7 @@ export function initMarkupAnchors() {
}
// remove `user-content-` prefix from links so they don't show in url bar when clicked
for (const a of markupEl.querySelectorAll('a[href^="#"]')) {
for (const a of markupEl.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')) {
const href = a.getAttribute('href');
if (!href.startsWith('#user-content-')) continue;
a.setAttribute('href', `#${removePrefix(href.substring(1))}`);
@@ -47,15 +47,15 @@ export function initMarkupAnchors() {
// add `user-content-` prefix to user-generated `a[name]` link targets
// TODO: this prefix should be added in backend instead
for (const a of markupEl.querySelectorAll('a[name]')) {
for (const a of markupEl.querySelectorAll<HTMLAnchorElement>('a[name]')) {
const name = a.getAttribute('name');
if (!name) continue;
a.setAttribute('name', addPrefix(a.name));
a.setAttribute('name', addPrefix(name));
}
for (const a of markupEl.querySelectorAll('a[href^="#"]')) {
for (const a of markupEl.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')) {
a.addEventListener('click', (e) => {
scrollToAnchor(e.currentTarget.getAttribute('href')?.substring(1));
scrollToAnchor((e.currentTarget as HTMLAnchorElement).getAttribute('href')?.substring(1));
});
}
}

View File

@@ -1,13 +1,13 @@
import {svg} from '../svg.ts';
export function makeCodeCopyButton() {
export function makeCodeCopyButton(): HTMLButtonElement {
const button = document.createElement('button');
button.classList.add('code-copy', 'ui', 'button');
button.innerHTML = svg('octicon-copy');
return button;
}
export function renderCodeCopy() {
export function renderCodeCopy(): void {
const els = document.querySelectorAll('.markup .code-block code');
if (!els.length) return;

View File

@@ -1,8 +1,8 @@
export function displayError(el, err) {
export function displayError(el: Element, err: Error): void {
el.classList.remove('is-loading');
const errorNode = document.createElement('pre');
errorNode.setAttribute('class', 'ui message error markup-block-error');
errorNode.textContent = err.str || err.message || String(err);
errorNode.textContent = err.message || String(err);
el.before(errorNode);
el.setAttribute('data-render-done', 'true');
}

View File

@@ -5,7 +5,7 @@ import {renderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
// code that runs for all markup content
export function initMarkupContent() {
export function initMarkupContent(): void {
renderMermaid();
renderMath();
renderCodeCopy();
@@ -13,6 +13,6 @@ export function initMarkupContent() {
}
// code that only runs for comments
export function initCommentContent() {
export function initCommentContent(): void {
initMarkupTasklist();
}

View File

@@ -12,21 +12,20 @@ type ProcessorContext = {
function prepareProcessors(ctx:ProcessorContext): Processors {
const processors = {
H1(el) {
H1(el: HTMLHeadingElement) {
const level = parseInt(el.tagName.slice(1));
el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`;
},
STRONG(el) {
STRONG(el: HTMLElement) {
return `**${el.textContent}**`;
},
EM(el) {
EM(el: HTMLElement) {
return `_${el.textContent}_`;
},
DEL(el) {
DEL(el: HTMLElement) {
return `~~${el.textContent}~~`;
},
A(el) {
A(el: HTMLAnchorElement) {
const text = el.textContent || 'link';
const href = el.getAttribute('href');
if (/^https?:/.test(text) && text === href) {
@@ -34,7 +33,7 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
}
return href ? `[${text}](${href})` : text;
},
IMG(el) {
IMG(el: HTMLImageElement) {
const alt = el.getAttribute('alt') || 'image';
const src = el.getAttribute('src');
const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : '';
@@ -44,32 +43,29 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
}
return `![${alt}](${src})`;
},
P(el) {
P(el: HTMLParagraphElement) {
el.textContent = `${el.textContent}\n`;
},
BLOCKQUOTE(el) {
BLOCKQUOTE(el: HTMLElement) {
el.textContent = `${el.textContent.replace(/^/mg, '> ')}\n`;
},
OL(el) {
OL(el: HTMLElement) {
const preNewLine = ctx.listNestingLevel ? '\n' : '';
el.textContent = `${preNewLine}${el.textContent}\n`;
},
LI(el) {
LI(el: HTMLElement) {
const parent = el.parentNode;
const bullet = parent.tagName === 'OL' ? `1. ` : '* ';
const bullet = (parent as HTMLElement).tagName === 'OL' ? `1. ` : '* ';
const nestingIdentLevel = Math.max(0, ctx.listNestingLevel - 1);
el.textContent = `${' '.repeat(nestingIdentLevel * 4)}${bullet}${el.textContent}${ctx.elementIsLast ? '' : '\n'}`;
return el;
},
INPUT(el) {
INPUT(el: HTMLInputElement) {
return el.checked ? '[x] ' : '[ ] ';
},
CODE(el) {
CODE(el: HTMLElement) {
const text = el.textContent;
if (el.parentNode && el.parentNode.tagName === 'PRE') {
if (el.parentNode && (el.parentNode as HTMLElement).tagName === 'PRE') {
el.textContent = `\`\`\`\n${text}\n\`\`\`\n`;
return el;
}
@@ -86,7 +82,7 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
return processors;
}
function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement) {
function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement): string | void {
if (el.hasAttribute('data-markdown-generated-content')) return el.textContent;
if (el.tagName === 'A' && el.children.length === 1 && el.children[0].tagName === 'IMG') {
return processElement(ctx, processors, el.children[0] as HTMLElement);

View File

@@ -1,12 +1,12 @@
import {displayError} from './common.ts';
function targetElement(el) {
function targetElement(el: Element) {
// The target element is either the current element if it has the
// `is-loading` class or the pre that contains it
return el.classList.contains('is-loading') ? el : el.closest('pre');
}
export async function renderMath() {
export async function renderMath(): Promise<void> {
const els = document.querySelectorAll('.markup code.language-math');
if (!els.length) return;

View File

@@ -10,7 +10,7 @@ body {margin: 0; padding: 0; overflow: hidden}
#mermaid {display: block; margin: 0 auto}
blockquote, dd, dl, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {margin: 0}`;
export async function renderMermaid() {
export async function renderMermaid(): Promise<void> {
const els = document.querySelectorAll('.markup code.language-mermaid');
if (!els.length) return;

View File

@@ -1,7 +1,7 @@
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
const preventListener = (e) => e.preventDefault();
const preventListener = (e: Event) => e.preventDefault();
/**
* Attaches `input` handlers to markdown rendered tasklist checkboxes in comments.
@@ -10,10 +10,10 @@ const preventListener = (e) => e.preventDefault();
* is set accordingly and sent to the server. On success it updates the raw-content on
* error it resets the checkbox to its original value.
*/
export function initMarkupTasklist() {
export function initMarkupTasklist(): void {
for (const el of document.querySelectorAll(`.markup[data-can-edit=true]`) || []) {
const container = el.parentNode;
const checkboxes = el.querySelectorAll(`.task-list-item input[type=checkbox]`);
const checkboxes = el.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`);
for (const checkbox of checkboxes) {
if (checkbox.hasAttribute('data-editable')) {
@@ -52,7 +52,7 @@ export function initMarkupTasklist() {
}
try {
const editContentZone = container.querySelector('.edit-content-zone');
const editContentZone = container.querySelector<HTMLDivElement>('.edit-content-zone');
const updateUrl = editContentZone.getAttribute('data-update-url');
const context = editContentZone.getAttribute('data-context');
const contentVersion = editContentZone.getAttribute('data-content-version');

View File

@@ -13,7 +13,7 @@ function attachDirAuto(el: DirElement) {
}
}
export function initDirAuto() {
export function initDirAuto(): void {
const observer = new MutationObserver((mutationList) => {
const len = mutationList.length;
for (let i = 0; i < len; i++) {

View File

@@ -9,7 +9,7 @@ const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// fetch wrapper, use below method name functions and the `data` option to pass in data
// which will automatically set an appropriate headers. For json content, only object
// and array types are currently supported.
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}) {
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
let body: RequestData;
let contentType: string;
if (data instanceof FormData || data instanceof URLSearchParams) {

View File

@@ -19,7 +19,7 @@ export function initGiteaFomantic() {
// Do not use "cursor: pointer" for dropdown labels
$.fn.dropdown.settings.className.label += ' tw-cursor-default';
// Always use Gitea's SVG icons
$.fn.dropdown.settings.templates.label = function(_value, text, preserveHTML, className) {
$.fn.dropdown.settings.templates.label = function(_value: any, text: any, preserveHTML: any, className: Record<string, string>) {
const escape = $.fn.dropdown.settings.templates.escape;
return escape(text, preserveHTML) + svg('octicon-x', 16, `${className.delete} icon`);
};

View File

@@ -1,4 +1,5 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
export function initFomanticApiPatch() {
//
@@ -15,7 +16,7 @@ export function initFomanticApiPatch() {
//
const patchKey = '_giteaFomanticApiPatch';
const oldApi = $.api;
$.api = $.fn.api = function(...args) {
$.api = $.fn.api = function(...args: Parameters<FomanticInitFunction>) {
const apiCall = oldApi.bind(this);
const ret = oldApi.apply(this, args);
@@ -23,7 +24,7 @@ export function initFomanticApiPatch() {
const internalGet = apiCall('internal', 'get');
if (!internalGet.urlEncodedValue[patchKey]) {
const oldUrlEncodedValue = internalGet.urlEncodedValue;
internalGet.urlEncodedValue = function (value) {
internalGet.urlEncodedValue = function (value: any) {
try {
return oldUrlEncodedValue(value);
} catch {

View File

@@ -5,7 +5,7 @@ export function generateAriaId() {
return `_aria_auto_id_${ariaIdCounter++}`;
}
export function linkLabelAndInput(label, input) {
export function linkLabelAndInput(label: Element, input: Element) {
const labelFor = label.getAttribute('for');
const inputId = input.getAttribute('id');

View File

@@ -3,7 +3,7 @@ import {queryElemChildren} from '../../utils/dom.ts';
export function initFomanticDimmer() {
// stand-in for removed dimmer module
$.fn.dimmer = function (arg0, arg1) {
$.fn.dimmer = function (arg0: string, arg1: any) {
if (arg0 === 'add content') {
const $el = arg1;
const existingDimmer = document.querySelector('body > .ui.dimmer');

View File

@@ -1,5 +1,6 @@
import $ from 'jquery';
import {generateAriaId} from './base.ts';
import type {FomanticInitFunction} from '../../types.ts';
const ariaPatchKey = '_giteaAriaPatchDropdown';
const fomanticDropdownFn = $.fn.dropdown;
@@ -8,13 +9,13 @@ const fomanticDropdownFn = $.fn.dropdown;
export function initAriaDropdownPatch() {
if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once');
$.fn.dropdown = ariaDropdownFn;
ariaDropdownFn.settings = fomanticDropdownFn.settings;
(ariaDropdownFn as FomanticInitFunction).settings = fomanticDropdownFn.settings;
}
// the patched `$.fn.dropdown` function, it passes the arguments to Fomantic's `$.fn.dropdown` function, and:
// * it does the one-time attaching on the first call
// * it delegates the `onLabelCreate` to the patched `onLabelCreate` to add necessary aria attributes
function ariaDropdownFn(...args) {
function ariaDropdownFn(...args: Parameters<FomanticInitFunction>) {
const ret = fomanticDropdownFn.apply(this, args);
// if the `$().dropdown()` call is without arguments, or it has non-string (object) argument,
@@ -33,7 +34,7 @@ function ariaDropdownFn(...args) {
// make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable
// the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
function updateMenuItem(dropdown, item) {
function updateMenuItem(dropdown: HTMLElement, item: HTMLElement) {
if (!item.id) item.id = generateAriaId();
item.setAttribute('role', dropdown[ariaPatchKey].listItemRole);
item.setAttribute('tabindex', '-1');
@@ -43,7 +44,7 @@ function updateMenuItem(dropdown, item) {
* make the label item and its "delete icon" have correct aria attributes
* @param {HTMLElement} label
*/
function updateSelectionLabel(label) {
function updateSelectionLabel(label: HTMLElement) {
// the "label" is like this: "<a|div class="ui label" data-value="1">the-label-name <i|svg class="delete icon"/></a>"
if (!label.id) {
label.id = generateAriaId();
@@ -59,7 +60,7 @@ function updateSelectionLabel(label) {
}
// delegate the dropdown's template functions and callback functions to add aria attributes.
function delegateOne($dropdown) {
function delegateOne($dropdown: any) {
const dropdownCall = fomanticDropdownFn.bind($dropdown);
// If there is a "search input" in the "menu", Fomantic will only "focus the input" but not "toggle the menu" when the "dropdown icon" is clicked.
@@ -74,7 +75,7 @@ function delegateOne($dropdown) {
// the "template" functions are used for dynamic creation (eg: AJAX)
const dropdownTemplates = {...dropdownCall('setting', 'templates'), t: performance.now()};
const dropdownTemplatesMenuOld = dropdownTemplates.menu;
dropdownTemplates.menu = function(response, fields, preserveHTML, className) {
dropdownTemplates.menu = function(response: any, fields: any, preserveHTML: any, className: Record<string, string>) {
// when the dropdown menu items are loaded from AJAX requests, the items are created dynamically
const menuItems = dropdownTemplatesMenuOld(response, fields, preserveHTML, className);
const div = document.createElement('div');
@@ -89,7 +90,7 @@ function delegateOne($dropdown) {
// the `onLabelCreate` is used to add necessary aria attributes for dynamically created selection labels
const dropdownOnLabelCreateOld = dropdownCall('setting', 'onLabelCreate');
dropdownCall('setting', 'onLabelCreate', function(value, text) {
dropdownCall('setting', 'onLabelCreate', function(value: any, text: string) {
const $label = dropdownOnLabelCreateOld.call(this, value, text);
updateSelectionLabel($label[0]);
return $label;
@@ -97,7 +98,7 @@ function delegateOne($dropdown) {
const oldSet = dropdownCall('internal', 'set');
const oldSetDirection = oldSet.direction;
oldSet.direction = function($menu) {
oldSet.direction = function($menu: any) {
oldSetDirection.call(this, $menu);
const classNames = dropdownCall('setting', 'className');
$menu = $menu || $dropdown.find('> .menu');
@@ -113,7 +114,7 @@ function delegateOne($dropdown) {
}
// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes
function attachStaticElements(dropdown, focusable, menu) {
function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, menu: HTMLElement) {
// prepare static dropdown menu list popup
if (!menu.id) {
menu.id = generateAriaId();
@@ -125,7 +126,7 @@ function attachStaticElements(dropdown, focusable, menu) {
menu.setAttribute('role', dropdown[ariaPatchKey].listPopupRole);
// prepare selection label items
for (const label of dropdown.querySelectorAll('.ui.label')) {
for (const label of dropdown.querySelectorAll<HTMLElement>('.ui.label')) {
updateSelectionLabel(label);
}
@@ -142,7 +143,7 @@ function attachStaticElements(dropdown, focusable, menu) {
}
}
function attachInit(dropdown) {
function attachInit(dropdown: HTMLElement) {
dropdown[ariaPatchKey] = {};
if (dropdown.classList.contains('custom')) return;
@@ -161,7 +162,7 @@ function attachInit(dropdown) {
// TODO: multiple selection is only partially supported. Check and test them one by one in the future.
const textSearch = dropdown.querySelector('input.search');
const textSearch = dropdown.querySelector<HTMLElement>('input.search');
const focusable = textSearch || dropdown; // the primary element for focus, see comment above
if (!focusable) return;
@@ -191,7 +192,7 @@ function attachInit(dropdown) {
attachStaticElements(dropdown, focusable, menu);
}
function attachDomEvents(dropdown, focusable, menu) {
function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HTMLElement) {
// when showing, it has class: ".animating.in"
// when hiding, it has class: ".visible.animating.out"
const isMenuVisible = () => (menu.classList.contains('visible') && !menu.classList.contains('out')) || menu.classList.contains('in');
@@ -215,7 +216,7 @@ function attachDomEvents(dropdown, focusable, menu) {
}
};
dropdown.addEventListener('keydown', (e) => {
dropdown.addEventListener('keydown', (e: KeyboardEvent) => {
// here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
if (e.key === 'Enter') {
const dropdownCall = fomanticDropdownFn.bind($(dropdown));
@@ -260,7 +261,7 @@ function attachDomEvents(dropdown, focusable, menu) {
deferredRefreshAriaActiveItem(100);
}, 0);
}, true);
dropdown.addEventListener('click', (e) => {
dropdown.addEventListener('click', (e: MouseEvent) => {
if (isMenuVisible() &&
ignoreClickPreVisible !== 2 && // dropdown is switch from invisible to visible
ignoreClickPreEvents === 2 // the click event is related to mousedown+focus

View File

@@ -1,4 +1,5 @@
import $ from 'jquery';
import type {FomanticInitFunction} from '../../types.ts';
const fomanticModalFn = $.fn.modal;
@@ -6,12 +7,12 @@ const fomanticModalFn = $.fn.modal;
export function initAriaModalPatch() {
if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
$.fn.modal = ariaModalFn;
ariaModalFn.settings = fomanticModalFn.settings;
(ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings;
}
// the patched `$.fn.modal` modal function
// * it does the one-time attaching on the first call
function ariaModalFn(...args) {
function ariaModalFn(...args: Parameters<FomanticInitFunction>) {
const ret = fomanticModalFn.apply(this, args);
if (args[0] === 'show' || args[0]?.autoShow) {
for (const el of this) {

View File

@@ -8,13 +8,13 @@ export function initFomanticTransition() {
'set duration', 'save conditions', 'restore conditions',
]);
// stand-in for removed transition module
$.fn.transition = function (arg0, arg1, arg2) {
$.fn.transition = function (arg0: any, arg1: any, arg2: any) {
if (arg0 === 'is supported') return true;
if (arg0 === 'is animating') return false;
if (arg0 === 'is inward') return false;
if (arg0 === 'is outward') return false;
let argObj;
let argObj: Record<string, any>;
if (typeof arg0 === 'string') {
// many behaviors are no-op now. https://fomantic-ui.com/modules/transition.html#/usage
if (transitionNopBehaviors.has(arg0)) return this;

View File

@@ -1,17 +1,18 @@
import type {SortableOptions} from 'sortablejs';
import type {SortableOptions, SortableEvent} from 'sortablejs';
export async function createSortable(el, opts: {handle?: string} & SortableOptions = {}) {
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}) {
// @ts-expect-error: wrong type derived by typescript
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');
return new Sortable(el, {
animation: 150,
ghostClass: 'card-ghost',
onChoose: (e) => {
onChoose: (e: SortableEvent) => {
const handle = opts.handle ? e.item.querySelector(opts.handle) : e.item;
handle.classList.add('tw-cursor-grabbing');
opts.onChoose?.(e);
},
onUnchoose: (e) => {
onUnchoose: (e: SortableEvent) => {
const handle = opts.handle ? e.item.querySelector(opts.handle) : e.item;
handle.classList.remove('tw-cursor-grabbing');
opts.onUnchoose?.(e);

View File

@@ -1,7 +1,7 @@
import tippy, {followCursor} from 'tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import type {Content, Instance, Props} from 'tippy.js';
import type {Content, Instance, Placement, Props} from 'tippy.js';
type TippyOpts = {
role?: string,
@@ -16,6 +16,7 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
// because we should use our own wrapper functions to handle them, do not let the user override them
const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
// @ts-expect-error: wrong type derived by typescript
const instance: Instance = tippy(target, {
appendTo: document.body,
animation: false,
@@ -65,7 +66,7 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
*
* Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation.
*/
function attachTooltip(target: Element, content: Content = null) {
function attachTooltip(target: Element, content: Content = null): Instance {
switchTitleToTooltip(target);
content = content ?? target.getAttribute('data-tooltip-content');
@@ -77,16 +78,16 @@ function attachTooltip(target: Element, content: Content = null) {
const hasClipboardTarget = target.hasAttribute('data-clipboard-target');
const hideOnClick = !hasClipboardTarget;
const props = {
const props: TippyOpts = {
content,
delay: 100,
role: 'tooltip',
theme: 'tooltip',
hideOnClick,
placement: target.getAttribute('data-tooltip-placement') || 'top-start',
followCursor: target.getAttribute('data-tooltip-follow-cursor') || false,
placement: target.getAttribute('data-tooltip-placement') as Placement || 'top-start',
followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false,
...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
} as TippyOpts;
};
if (!target._tippy) {
createTippy(target, props);
@@ -96,7 +97,7 @@ function attachTooltip(target: Element, content: Content = null) {
return target._tippy;
}
function switchTitleToTooltip(target: Element) {
function switchTitleToTooltip(target: Element): void {
let title = target.getAttribute('title');
if (title) {
// apply custom formatting to relative-time's tooltips
@@ -121,14 +122,14 @@ function switchTitleToTooltip(target: Element) {
* Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)"
* The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy
*/
function lazyTooltipOnMouseHover(e: MouseEvent) {
function lazyTooltipOnMouseHover(e: MouseEvent): void {
e.target.removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
attachTooltip(this);
}
// Activate the tooltip for current element.
// If the element has no aria-label, use the tooltip content as aria-label.
function attachLazyTooltip(el: Element) {
function attachLazyTooltip(el: Element): void {
el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true});
// meanwhile, if the element has no aria-label, use the tooltip content as aria-label
@@ -141,13 +142,13 @@ function attachLazyTooltip(el: Element) {
}
// Activate the tooltip for all children elements.
function attachChildrenLazyTooltip(target: Element) {
function attachChildrenLazyTooltip(target: Element): void {
for (const el of target.querySelectorAll<Element>('[data-tooltip-content]')) {
attachLazyTooltip(el);
}
}
export function initGlobalTooltips() {
export function initGlobalTooltips(): void {
// use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed
const observerConnect = (observer: MutationObserver) => observer.observe(document, {
subtree: true,
@@ -178,7 +179,7 @@ export function initGlobalTooltips() {
attachChildrenLazyTooltip(document.documentElement);
}
export function showTemporaryTooltip(target: Element, content: Content) {
export function showTemporaryTooltip(target: Element, content: Content): void {
// if the target is inside a dropdown, the menu will be hidden soon
// so display the tooltip on the dropdown instead
target = target.closest('.ui.dropdown') || target;

View File

@@ -2,7 +2,7 @@ import {sleep} from '../utils.ts';
const {appSubUrl} = window.config;
export async function logoutFromWorker() {
export async function logoutFromWorker(): Promise<void> {
// wait for a while because other requests (eg: logout) may be in the flight
await sleep(5000);
window.location.href = `${appSubUrl}/`;

View File

@@ -153,7 +153,7 @@ export type SvgName = keyof typeof svgs;
// most of the SVG icons in assets couldn't be used directly.
// retrieve an HTML string for given SVG icon name, size and additional classes
export function svg(name: SvgName, size = 16, classNames: string|string[]): string {
export function svg(name: SvgName, size = 16, classNames?: string|string[]): string {
const className = Array.isArray(classNames) ? classNames.join(' ') : classNames;
if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`);
if (size === 16 && !className) return svgs[name];

View File

@@ -54,3 +54,8 @@ export type Issue = {
merged: boolean;
};
};
export type FomanticInitFunction = {
settings?: Record<string, any>,
(...args: any[]): any,
}

View File

@@ -118,7 +118,7 @@ test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => {
});
test('file detection', () => {
for (const name of ['a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) {
for (const name of ['a.avif', 'a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) {
expect(isImageFile({name})).toBeTruthy();
}
for (const name of ['', 'a.jpg.x', '/path.png/x', 'webp']) {

View File

@@ -165,7 +165,7 @@ export function sleep(ms: number): Promise<void> {
}
export function isImageFile({name, type}: {name: string, type?: string}): boolean {
return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/');
return /\.(avif|jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/');
}
export function isVideoFile({name, type}: {name: string, type?: string}): boolean {

View File

@@ -2,6 +2,7 @@ import {createElementFromAttrs, createElementFromHTML, querySingleVisibleElem} f
test('createElementFromHTML', () => {
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
expect(createElementFromHTML('<tr data-x="1"><td>foo</td></tr>').outerHTML).toEqual('<tr data-x="1"><td>foo</td></tr>');
});
test('createElementFromAttrs', () => {

View File

@@ -301,10 +301,17 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
}
// Warning: Do not enter any unsanitized variables here
export function createElementFromHTML(htmlString: string): HTMLElement {
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
htmlString = htmlString.trim();
// some tags like "tr" are special, it must use a correct parent container to create
if (htmlString.startsWith('<tr')) {
const container = document.createElement('table');
container.innerHTML = htmlString;
return container.querySelector<T>('tr');
}
const div = document.createElement('div');
div.innerHTML = htmlString.trim();
return div.firstChild as HTMLElement;
div.innerHTML = htmlString;
return div.firstChild as T;
}
export function createElementFromAttrs(tagName: string, attrs: Record<string, any>, ...children: (Node|string)[]): HTMLElement {
@@ -340,3 +347,11 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s
if (candidates.length > 1) throw new Error(`Expected exactly one visible element matching selector "${selector}", but found ${candidates.length}`);
return candidates.length ? candidates[0] as T : null;
}
export function addDelegatedEventListener<T extends HTMLElement>(parent: Node, type: string, selector: string, listener: (elem: T, e: Event) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
parent.addEventListener(type, (e: Event) => {
const elem = (e.target as HTMLElement).closest(selector);
if (!elem) return;
listener(elem as T, e);
}, options);
}

View File

@@ -1,6 +1,6 @@
import emojis from '../../../assets/emoji.json';
import {GET} from '../modules/fetch.ts';
import type {Issue} from '../features/issue.ts';
import type {Issue} from '../types.ts';
const maxMatches = 6;

View File

@@ -195,3 +195,7 @@ export function initAreYouSure($) {
});
};
}
export function applyAreYouSure(selector: string) {
$(selector).areYouSure();
}