diff options
Diffstat (limited to 'web_src/js')
134 files changed, 2865 insertions, 1940 deletions
diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts index 9e41673b86..96a2759a23 100644 --- a/web_src/js/bootstrap.ts +++ b/web_src/js/bootstrap.ts @@ -2,6 +2,7 @@ // to make sure the error handler always works, we should never import `window.config`, because // some user's custom template breaks it. import type {Intent} from './types.ts'; +import {html} from './utils/html.ts'; // This sets up the URL prefix used in webpack's chunk loading. // This file must be imported before any lazy-loading is being attempted. @@ -23,7 +24,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); if (!msgDiv) { const el = document.createElement('div'); - el.innerHTML = `<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`; + el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`; msgDiv = el.childNodes[0] as HTMLDivElement; } // merge duplicated messages into "the message (count)" format diff --git a/web_src/js/components/ActionRunStatus.vue b/web_src/js/components/ActionRunStatus.vue index 96c6c441be..bc3b99ab89 100644 --- a/web_src/js/components/ActionRunStatus.vue +++ b/web_src/js/components/ActionRunStatus.vue @@ -19,12 +19,12 @@ withDefaults(defineProps<{ <template> <span :data-tooltip-content="localeStatus ?? status" v-if="status"> - <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/> - <SvgIcon name="octicon-skip" class="text grey" :size="size" :class-name="className" v-else-if="status === 'skipped'"/> - <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'cancelled'"/> - <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'waiting'"/> - <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'blocked'"/> - <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class-name="'job-status-rotate ' + className" v-else-if="status === 'running'"/> + <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class="className" v-if="status === 'success'"/> + <SvgIcon name="octicon-skip" class="text grey" :size="size" :class="className" v-else-if="status === 'skipped'"/> + <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class="className" v-else-if="status === 'cancelled'"/> + <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class="className" v-else-if="status === 'waiting'"/> + <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/> + <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'circular-spin ' + className" v-else-if="status === 'running'"/> <SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown --> </span> </template> diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue index eaa9b0ffb1..296cb61cff 100644 --- a/web_src/js/components/ActivityHeatmap.vue +++ b/web_src/js/components/ActivityHeatmap.vue @@ -1,7 +1,7 @@ <script lang="ts" setup> // TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap'; -import {onMounted, ref} from 'vue'; +import {onMounted, shallowRef} from 'vue'; import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap'; defineProps<{ @@ -24,7 +24,7 @@ const colorRange = [ 'var(--color-primary-dark-4)', ]; -const endDate = ref(new Date()); +const endDate = shallowRef(new Date()); onMounted(() => { // work around issue with first legend color being rendered twice and legend cut off diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue index 0aae202d42..5ec4499e48 100644 --- a/web_src/js/components/ContextPopup.vue +++ b/web_src/js/components/ContextPopup.vue @@ -2,16 +2,16 @@ import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {getIssueColor, getIssueIcon} from '../features/issue.ts'; -import {computed, onMounted, ref} from 'vue'; +import {computed, onMounted, shallowRef, useTemplateRef} from 'vue'; import type {IssuePathInfo} from '../types.ts'; const {appSubUrl, i18n} = window.config; -const loading = ref(false); -const issue = ref(null); -const renderedLabels = ref(''); +const loading = shallowRef(false); +const issue = shallowRef(null); +const renderedLabels = shallowRef(''); const i18nErrorOccurred = i18n.error_occurred; -const i18nErrorMessage = ref(null); +const i18nErrorMessage = shallowRef(null); const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'})); const body = computed(() => { @@ -22,7 +22,7 @@ const body = computed(() => { return body; }); -const root = ref<HTMLElement | null>(null); +const root = useTemplateRef('root'); onMounted(() => { root.value.addEventListener('ce-load-context-popup', (e: CustomEventInit<IssuePathInfo>) => { diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index 40ecbba5e3..e938814ec6 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -6,7 +6,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; const {appSubUrl, assetUrlPrefix, pageData} = window.config; -type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning'; +type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped'; type CommitStatusMap = { [status in CommitStatus]: { @@ -22,6 +22,7 @@ const commitStatus: CommitStatusMap = { error: {name: 'gitea-exclamation', color: 'red'}, failure: {name: 'octicon-x', color: 'red'}, warning: {name: 'gitea-exclamation', color: 'yellow'}, + skipped: {name: 'octicon-skip', color: 'grey'}, }; export default defineComponent({ @@ -38,7 +39,7 @@ export default defineComponent({ return { tab, repos: [], - reposTotalCount: 0, + reposTotalCount: null, reposFilter, archivedFilter, privateFilter, @@ -112,9 +113,6 @@ export default defineComponent({ const el = document.querySelector('#dashboard-repo-list'); this.changeReposFilter(this.reposFilter); fomanticQuery(el.querySelector('.ui.dropdown')).dropdown(); - nextTick(() => { - this.$refs.search.focus(); - }); this.textArchivedFilterTitles = { 'archived': this.textShowOnlyArchived, @@ -130,12 +128,12 @@ export default defineComponent({ }, methods: { - changeTab(t) { - this.tab = t; + changeTab(tab: string) { + this.tab = tab; this.updateHistory(); }, - changeReposFilter(filter) { + changeReposFilter(filter: string) { this.reposFilter = filter; this.repos = []; this.page = 1; @@ -218,7 +216,9 @@ export default defineComponent({ this.searchRepos(); }, - changePage(page) { + async changePage(page: number) { + if (this.isLoading) return; + this.page = page; if (this.page > this.finalPage) { this.page = this.finalPage; @@ -228,7 +228,7 @@ export default defineComponent({ } this.repos = []; this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); + await this.searchRepos(); }, async searchRepos() { @@ -240,12 +240,20 @@ export default defineComponent({ let response, json; try { + const firstLoad = this.reposTotalCount === null; if (!this.reposTotalCount) { const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; response = await GET(totalCountSearchURL); - this.reposTotalCount = response.headers.get('X-Total-Count') ?? '?'; + this.reposTotalCount = parseInt(response.headers.get('X-Total-Count') ?? '0'); + } + if (firstLoad && this.reposTotalCount) { + nextTick(() => { + // MDN: If there's no focused element, this is the Document.body or Document.documentElement. + if ((document.activeElement === document.body || document.activeElement === document.documentElement)) { + this.$refs.search.focus({preventScroll: true}); + } + }); } - response = await GET(searchedURL); json = await response.json(); } catch { @@ -256,7 +264,7 @@ export default defineComponent({ } if (searchedURL === this.searchURL) { - this.repos = json.data.map((webSearchRepo) => { + this.repos = json.data.map((webSearchRepo: any) => { return { ...webSearchRepo.repository, latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status @@ -264,7 +272,7 @@ export default defineComponent({ locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status, }; }); - const count = response.headers.get('X-Total-Count'); + const count = Number(response.headers.get('X-Total-Count')); if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') { this.reposTotalCount = count; } @@ -275,7 +283,7 @@ export default defineComponent({ } }, - repoIcon(repo) { + repoIcon(repo: any) { if (repo.fork) { return 'octicon-repo-forked'; } else if (repo.mirror) { @@ -298,7 +306,7 @@ export default defineComponent({ return commitStatus[status].color; }, - reposFilterKeyControl(e) { + async reposFilterKeyControl(e: KeyboardEvent) { switch (e.key) { case 'Enter': document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click(); @@ -307,7 +315,7 @@ export default defineComponent({ if (this.activeIndex > 0) { this.activeIndex--; } else if (this.page > 1) { - this.changePage(this.page - 1); + await this.changePage(this.page - 1); this.activeIndex = this.searchLimit - 1; } break; @@ -316,17 +324,17 @@ export default defineComponent({ this.activeIndex++; } else if (this.page < this.finalPage) { this.activeIndex = 0; - this.changePage(this.page + 1); + await this.changePage(this.page + 1); } break; case 'ArrowRight': if (this.page < this.finalPage) { - this.changePage(this.page + 1); + await this.changePage(this.page + 1); } break; case 'ArrowLeft': if (this.page > 1) { - this.changePage(this.page - 1); + await this.changePage(this.page - 1); } break; } @@ -336,7 +344,6 @@ export default defineComponent({ }, }, }); - </script> <template> <div> @@ -348,13 +355,21 @@ export default defineComponent({ <h4 class="ui top attached header tw-flex tw-items-center"> <div class="tw-flex-1 tw-flex tw-items-center"> {{ textMyRepos }} - <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> + <span v-if="reposTotalCount" class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> </div> <a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo"> <svg-icon name="octicon-plus"/> </a> </h4> - <div class="ui attached segment repos-search"> + <div v-if="!reposTotalCount" class="ui attached segment"> + <div v-if="!isLoading" class="empty-repo-or-org"> + <svg-icon name="octicon-git-branch" :size="24"/> + <p>{{ textNoRepo }}</p> + </div> + <!-- using the loading indicator here will cause more (unnecessary) page flickers, so at the moment, not use the loading indicator --> + <!-- <div v-else class="is-loading loading-icon-2px tw-min-h-16"/> --> + </div> + <div v-else class="ui attached segment repos-search"> <div class="ui small fluid action left icon input"> <input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos"> <i class="icon loading-icon-3px" :class="{'is-loading': isLoading}"><svg-icon name="octicon-search" :size="16"/></i> @@ -367,7 +382,7 @@ export default defineComponent({ otherwise if the "input" handles click event for intermediate status, it breaks the internal state--> <input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxArchivedFilterProps"> <label> - <svg-icon name="octicon-archive" :size="16" class-name="tw-mr-1"/> + <svg-icon name="octicon-archive" :size="16" class="tw-mr-1"/> {{ textShowArchived }} </label> </div> @@ -376,7 +391,7 @@ export default defineComponent({ <div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle"> <input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxPrivateFilterProps"> <label> - <svg-icon name="octicon-lock" :size="16" class-name="tw-mr-1"/> + <svg-icon name="octicon-lock" :size="16" class="tw-mr-1"/> {{ textShowPrivate }} </label> </div> @@ -411,17 +426,17 @@ export default defineComponent({ </div> <div v-if="repos.length" class="ui attached table segment tw-rounded-b"> <ul class="repo-owner-name-list"> - <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id"> + <li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id"> <a class="repo-list-link muted" :href="repo.link"> - <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/> + <svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/> <div class="text truncate">{{ repo.full_name }}</div> <div v-if="repo.archived"> <svg-icon name="octicon-archive" :size="16"/> </div> </a> - <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link" :data-tooltip-content="repo.locale_latest_commit_status_state"> + <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || null" :data-tooltip-content="repo.locale_latest_commit_status_state"> <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl --> - <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class-name="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/> + <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/> </a> </li> </ul> @@ -432,26 +447,26 @@ export default defineComponent({ class="item navigation tw-py-1" :class="{'disabled': page === 1}" @click="changePage(1)" :title="textFirstPage" > - <svg-icon name="gitea-double-chevron-left" :size="16" class-name="tw-mr-1"/> + <svg-icon name="gitea-double-chevron-left" :size="16" class="tw-mr-1"/> </a> <a class="item navigation tw-py-1" :class="{'disabled': page === 1}" @click="changePage(page - 1)" :title="textPreviousPage" > - <svg-icon name="octicon-chevron-left" :size="16" clsas-name="tw-mr-1"/> + <svg-icon name="octicon-chevron-left" :size="16" class="tw-mr-1"/> </a> <a class="active item tw-py-1">{{ page }}</a> <a class="item navigation" :class="{'disabled': page === finalPage}" @click="changePage(page + 1)" :title="textNextPage" > - <svg-icon name="octicon-chevron-right" :size="16" class-name="tw-ml-1"/> + <svg-icon name="octicon-chevron-right" :size="16" class="tw-ml-1"/> </a> <a class="item navigation tw-py-1" :class="{'disabled': page === finalPage}" @click="changePage(finalPage)" :title="textLastPage" > - <svg-icon name="gitea-double-chevron-right" :size="16" class-name="tw-ml-1"/> + <svg-icon name="gitea-double-chevron-right" :size="16" class="tw-ml-1"/> </a> </div> </div> @@ -467,11 +482,17 @@ export default defineComponent({ <svg-icon name="octicon-plus"/> </a> </h4> - <div v-if="organizations.length" class="ui attached table segment tw-rounded-b"> + <div v-if="!organizations.length" class="ui attached segment"> + <div class="empty-repo-or-org"> + <svg-icon name="octicon-organization" :size="24"/> + <p>{{ textNoOrg }}</p> + </div> + </div> + <div v-else class="ui attached table segment tw-rounded-b"> <ul class="repo-owner-name-list"> <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"/> + <svg-icon name="octicon-organization" :size="16" class="repo-list-icon"/> <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'"> @@ -481,7 +502,7 @@ export default defineComponent({ </a> <div class="text light grey tw-flex tw-items-center tw-ml-2"> {{ org.num_repos }} - <svg-icon name="octicon-repo" :size="16" class-name="tw-ml-1 tw-mt-0.5"/> + <svg-icon name="octicon-repo" :size="16" class="tw-ml-1 tw-mt-0.5"/> </div> </li> </ul> @@ -546,4 +567,14 @@ ul li:not(:last-child) { .repo-owner-name-list li.active { background: var(--color-hover); } + +.empty-repo-or-org { + margin-top: 1em; + text-align: center; + color: var(--color-placeholder-text); +} + +.empty-repo-or-org p { + margin: 1em auto; +} </style> diff --git a/web_src/js/components/DiffCommitSelector.vue b/web_src/js/components/DiffCommitSelector.vue index 840acd4b51..a375343979 100644 --- a/web_src/js/components/DiffCommitSelector.vue +++ b/web_src/js/components/DiffCommitSelector.vue @@ -4,6 +4,22 @@ import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {generateAriaId} from '../modules/fomantic/base.ts'; +type Commit = { + id: string, + hovered: boolean, + selected: boolean, + summary: string, + committer_or_author_name: string, + time: string, + short_sha: string, +} + +type CommitListResult = { + commits: Array<Commit>, + last_review_commit_sha: string, + locale: Record<string, string>, +} + export default defineComponent({ components: {SvgIcon}, data: () => { @@ -16,9 +32,9 @@ export default defineComponent({ locale: { filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'), } as Record<string, string>, - commits: [], + commits: [] as Array<Commit>, hoverActivated: false, - lastReviewCommitSha: null, + lastReviewCommitSha: '', uniqueIdMenu: generateAriaId(), uniqueIdShowAll: generateAriaId(), }; @@ -71,7 +87,7 @@ export default defineComponent({ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { const item = document.activeElement; // try to highlight the selected commits const commitIdx = item?.matches('.item') ? item.getAttribute('data-commit-idx') : null; - if (commitIdx) this.highlight(this.commits[commitIdx]); + if (commitIdx) this.highlight(this.commits[Number(commitIdx)]); } }, onKeyUp(event: KeyboardEvent) { @@ -87,7 +103,7 @@ export default defineComponent({ } } }, - highlight(commit) { + highlight(commit: Commit) { if (!this.hoverActivated) return; const indexSelected = this.commits.findIndex((x) => x.selected); const indexCurrentElem = this.commits.findIndex((x) => x.id === commit.id); @@ -125,10 +141,11 @@ export default defineComponent({ } }); }, + /** Load the commits to show in this dropdown */ async fetchCommits() { const resp = await GET(`${this.issueLink}/commits/list`); - const results = await resp.json(); + const results = await resp.json() as CommitListResult; this.commits.push(...results.commits.map((x) => { x.hovered = false; return x; @@ -166,7 +183,7 @@ export default defineComponent({ * the diff from beginning of PR up to the second clicked commit is * opened */ - commitClickedShift(commit) { + commitClickedShift(commit: Commit) { this.hoverActivated = !this.hoverActivated; commit.selected = true; // Second click -> determine our range and open links accordingly @@ -195,7 +212,7 @@ export default defineComponent({ <div class="ui scrolling dropdown custom diff-commit-selector"> <button ref="expandBtn" - class="ui basic button" + class="ui tiny basic button" @click.stop="toggleMenu()" :data-tooltip-content="locale.filter_changes_by_commit" aria-haspopup="true" diff --git a/web_src/js/components/DiffFileList.vue b/web_src/js/components/DiffFileList.vue deleted file mode 100644 index 792a1aefac..0000000000 --- a/web_src/js/components/DiffFileList.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script lang="ts" setup> -import {onMounted, onUnmounted} from 'vue'; -import {loadMoreFiles} from '../features/repo-diff.ts'; -import {diffTreeStore} from '../modules/stores.ts'; - -const store = diffTreeStore(); - -onMounted(() => { - document.querySelector('#show-file-list-btn').addEventListener('click', toggleFileList); -}); - -onUnmounted(() => { - document.querySelector('#show-file-list-btn').removeEventListener('click', toggleFileList); -}); - -function toggleFileList() { - store.fileListIsVisible = !store.fileListIsVisible; -} - -function diffTypeToString(pType: number) { - const diffTypes = { - 1: 'add', - 2: 'modify', - 3: 'del', - 4: 'rename', - 5: 'copy', - }; - return diffTypes[pType]; -} - -function diffStatsWidth(adds: number, dels: number) { - return `${adds / (adds + dels) * 100}%`; -} - -function loadMoreData() { - loadMoreFiles(store.linkLoadMore); -} -</script> - -<template> - <ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible"> - <li v-for="file in store.files" :key="file.NameHash"> - <div class="tw-font-semibold tw-flex tw-items-center pull-right"> - <span v-if="file.IsBin" class="tw-ml-0.5 tw-mr-2">{{ store.binaryFileMessage }}</span> - {{ file.IsBin ? '' : file.Addition + file.Deletion }} - <span v-if="!file.IsBin" class="diff-stats-bar tw-mx-2" :data-tooltip-content="store.statisticsMessage.replace('%d', (file.Addition + file.Deletion)).replace('%d', file.Addition).replace('%d', file.Deletion)"> - <div class="diff-stats-add-bar" :style="{ 'width': diffStatsWidth(file.Addition, file.Deletion) }"/> - </span> - </div> - <!-- todo finish all file status, now modify, add, delete and rename --> - <span :class="['status', diffTypeToString(file.Type)]" :data-tooltip-content="diffTypeToString(file.Type)"> </span> - <a class="file tw-font-mono" :href="'#diff-' + file.NameHash">{{ file.Name }}</a> - </li> - <li v-if="store.isIncomplete" class="tw-pt-1"> - <span class="file tw-flex tw-items-center tw-justify-between">{{ store.tooManyFilesMessage }} - <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a> - </span> - </li> - </ol> -</template> diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue index 8676c4d37f..981d10c1c1 100644 --- a/web_src/js/components/DiffFileTree.vue +++ b/web_src/js/components/DiffFileTree.vue @@ -1,83 +1,14 @@ <script lang="ts" setup> import DiffFileTreeItem from './DiffFileTreeItem.vue'; -import {loadMoreFiles} from '../features/repo-diff.ts'; import {toggleElem} from '../utils/dom.ts'; -import {diffTreeStore} from '../modules/stores.ts'; +import {diffTreeStore} from '../modules/diff-file.ts'; import {setFileFolding} from '../features/file-fold.ts'; -import {computed, onMounted, onUnmounted} from 'vue'; +import {onMounted, onUnmounted} from 'vue'; const LOCAL_STORAGE_KEY = 'diff_file_tree_visible'; const store = diffTreeStore(); -const fileTree = computed(() => { - const result = []; - for (const file of store.files) { - // Split file into directories - const splits = file.Name.split('/'); - let index = 0; - let parent = null; - let isFile = false; - for (const split of splits) { - index += 1; - // reached the end - if (index === splits.length) { - isFile = true; - } - let newParent = { - name: split, - children: [], - isFile, - } as { - name: string, - children: any[], - isFile: boolean, - file?: any, - }; - - if (isFile === true) { - newParent.file = file; - } - - if (parent) { - // check if the folder already exists - const existingFolder = parent.children.find( - (x) => x.name === split, - ); - if (existingFolder) { - newParent = existingFolder; - } else { - parent.children.push(newParent); - } - } else { - const existingFolder = result.find((x) => x.name === split); - if (existingFolder) { - newParent = existingFolder; - } else { - result.push(newParent); - } - } - parent = newParent; - } - } - const mergeChildIfOnlyOneDir = (entries: Array<Record<string, any>>) => { - for (const entry of entries) { - if (entry.children) { - mergeChildIfOnlyOneDir(entry.children); - } - if (entry.children.length === 1 && entry.children[0].isFile === false) { - // Merge it to the parent - entry.name = `${entry.name}/${entry.children[0].name}`; - entry.children = entry.children[0].children; - } - } - }; - // Merge folders with just a folder as children in order to - // reduce the depth of our tree. - mergeChildIfOnlyOneDir(result); - return result; -}); - onMounted(() => { // Default to true if unset store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false'; @@ -112,7 +43,7 @@ function toggleVisibility() { function updateVisibility(visible: boolean) { store.fileTreeIsVisible = visible; - localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible); + localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString()); updateState(store.fileTreeIsVisible); } @@ -126,19 +57,12 @@ function updateState(visible: boolean) { toggleElem(toShow, !visible); toggleElem(toHide, visible); } - -function loadMoreData() { - loadMoreFiles(store.linkLoadMore); -} </script> <template> + <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> <div v-if="store.fileTreeIsVisible" class="diff-file-tree-items"> - <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> - <DiffFileTreeItem v-for="item in fileTree" :key="item.name" :item="item"/> - <div v-if="store.isIncomplete" class="tw-pt-1"> - <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a> - </div> + <DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/> </div> </template> diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue index 9a21a8ac10..f15f093ff8 100644 --- a/web_src/js/components/DiffFileTreeItem.vue +++ b/web_src/js/components/DiffFileTreeItem.vue @@ -1,74 +1,62 @@ <script lang="ts" setup> -import {SvgIcon} from '../svg.ts'; -import {diffTreeStore} from '../modules/stores.ts'; -import {ref} from 'vue'; +import {SvgIcon, type SvgName} from '../svg.ts'; +import {shallowRef} from 'vue'; +import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts'; -type File = { - Name: string; - NameHash: string; - Type: number; - IsViewed: boolean; - IsSubmodule: boolean; -} - -type Item = { - name: string; - isFile: boolean; - file?: File; - children?: Item[]; -}; - -defineProps<{ - item: Item, +const props = defineProps<{ + item: DiffTreeEntry, }>(); const store = diffTreeStore(); -const collapsed = ref(false); +const collapsed = shallowRef(props.item.IsViewed); -function getIconForDiffType(pType: number) { - const diffTypes = { - 1: {name: 'octicon-diff-added', classes: ['text', 'green']}, - 2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']}, - 3: {name: 'octicon-diff-removed', classes: ['text', 'red']}, - 4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']}, - 5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok +function getIconForDiffStatus(pType: DiffStatus) { + const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = { + '': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case + 'added': {name: 'octicon-diff-added', classes: ['text', 'green']}, + 'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']}, + 'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']}, + 'renamed': {name: 'octicon-diff-renamed', classes: ['text', 'teal']}, + 'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']}, + 'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok }; - return diffTypes[pType]; -} - -function fileIcon(file: File) { - if (file.IsSubmodule) { - return 'octicon-file-submodule'; - } - return 'octicon-file'; + return diffTypes[pType] ?? diffTypes['']; } </script> <template> - <!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"--> + <template v-if="item.EntryMode === 'tree'"> + <div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed"> + <!-- directory --> + <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="collapsed ? store.folderIcon : store.folderOpenIcon"/> + <span class="gt-ellipsis">{{ item.DisplayName }}</span> + </div> + + <div v-show="!collapsed" class="sub-items"> + <DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/> + </div> + </template> <a - v-if="item.isFile" class="item-file" - :class="{'selected': store.selectedItem === '#diff-' + item.file.NameHash, 'viewed': item.file.IsViewed}" - :title="item.name" :href="'#diff-' + item.file.NameHash" + v-else + class="item-file" :class="{ 'selected': store.selectedItem === '#diff-' + item.NameHash, 'viewed': item.IsViewed }" + :title="item.DisplayName" :href="'#diff-' + item.NameHash" > <!-- file --> - <SvgIcon :name="fileIcon(item.file)"/> - <span class="gt-ellipsis tw-flex-1">{{ item.name }}</span> - <SvgIcon :name="getIconForDiffType(item.file.Type).name" :class="getIconForDiffType(item.file.Type).classes"/> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="item.FileIcon"/> + <span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span> + <SvgIcon + :name="getIconForDiffStatus(item.DiffStatus).name" + :class="getIconForDiffStatus(item.DiffStatus).classes" + /> </a> - <div v-else class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed"> - <!-- directory --> - <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/> - <SvgIcon class="text primary" :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"/> - <span class="gt-ellipsis">{{ item.name }}</span> - </div> - - <div v-if="item.children?.length" v-show="!collapsed" class="sub-items"> - <DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/> - </div> </template> + <style scoped> -a, a:hover { +a, +a:hover { text-decoration: none; color: var(--color-text); } @@ -91,7 +79,8 @@ a, a:hover { border-radius: 4px; } -.item-file.viewed { +.item-file.viewed, +.item-directory.viewed { color: var(--color-text-light-3); } diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index 1393a7f258..b2c28414c0 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -1,19 +1,19 @@ <script lang="ts" setup> -import {computed, onMounted, onUnmounted, ref, watch} from 'vue'; +import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue'; import {SvgIcon} from '../svg.ts'; import {toggleElem} from '../utils/dom.ts'; const {csrfToken, pageData} = window.config; -const mergeForm = ref(pageData.pullRequestMergeForm); +const mergeForm = pageData.pullRequestMergeForm; -const mergeTitleFieldValue = ref(''); -const mergeMessageFieldValue = ref(''); -const deleteBranchAfterMerge = ref(false); -const autoMergeWhenSucceed = ref(false); +const mergeTitleFieldValue = shallowRef(''); +const mergeMessageFieldValue = shallowRef(''); +const deleteBranchAfterMerge = shallowRef(false); +const autoMergeWhenSucceed = shallowRef(false); -const mergeStyle = ref(''); -const mergeStyleDetail = ref({ +const mergeStyle = shallowRef(''); +const mergeStyleDetail = shallowRef({ hideMergeMessageTexts: false, textDoMerge: '', mergeTitleFieldText: '', @@ -21,33 +21,33 @@ const mergeStyleDetail = ref({ hideAutoMerge: false, }); -const mergeStyleAllowedCount = ref(0); +const mergeStyleAllowedCount = shallowRef(0); -const showMergeStyleMenu = ref(false); -const showActionForm = ref(false); +const showMergeStyleMenu = shallowRef(false); +const showActionForm = shallowRef(false); const mergeButtonStyleClass = computed(() => { - if (mergeForm.value.allOverridableChecksOk) return 'primary'; + if (mergeForm.allOverridableChecksOk) return 'primary'; return autoMergeWhenSucceed.value ? 'primary' : 'red'; }); const forceMerge = computed(() => { - return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk; + return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk; }); watch(mergeStyle, (val) => { - mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e) => e.name === val); + mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val); for (const elem of document.querySelectorAll('[data-pull-merge-style]')) { toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val); } }); onMounted(() => { - mergeStyleAllowedCount.value = mergeForm.value.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0); + mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0); - let mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name; - if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed)?.name; - switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow); + let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name; + if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name; + switchMergeStyle(mergeStyle, !mergeForm.canMergeNow); document.addEventListener('mouseup', hideMergeStyleMenu); }); @@ -63,7 +63,7 @@ function hideMergeStyleMenu() { function toggleActionForm(show: boolean) { showActionForm.value = show; if (!show) return; - deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge; + deleteBranchAfterMerge.value = mergeForm.defaultDeleteBranchAfterMerge; mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText; mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText; } @@ -74,7 +74,7 @@ function switchMergeStyle(name: string, autoMerge = false) { } function clearMergeMessage() { - mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage; + mergeMessageFieldValue.value = mergeForm.defaultMergeMessage; } </script> diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 79b43a3746..2eb2211269 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -6,6 +6,8 @@ import {createElementFromAttrs, toggleElem} from '../utils/dom.ts'; import {formatDatetime} from '../utils/time.ts'; import {renderAnsi} from '../render/ansi.ts'; import {POST, DELETE} from '../modules/fetch.ts'; +import type {IntervalId} from '../types.ts'; +import {toggleFullScreen} from '../utils.ts'; // see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts" type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked'; @@ -24,6 +26,20 @@ type LogLineCommand = { prefix: string, } +type Job = { + id: number; + name: string; + status: RunStatus; + canRerun: boolean; + duration: string; +} + +type Step = { + summary: string, + duration: string, + status: RunStatus, +} + function parseLineCommand(line: LogLine): LogLineCommand | null { for (const prefix of LogLinePrefixesGroup) { if (line.message.startsWith(prefix)) { @@ -77,7 +93,7 @@ export default defineComponent({ default: '', }, locale: { - type: Object as PropType<Record<string, string>>, + type: Object as PropType<Record<string, any>>, default: null, }, }, @@ -86,11 +102,10 @@ export default defineComponent({ const {autoScroll, expandRunning} = getLocaleStorageOptions(); return { // internal state - loadingAbortController: null, - intervalID: null, - currentJobStepsStates: [], - artifacts: [], - onHoverRerunIndex: -1, + loadingAbortController: null as AbortController | null, + intervalID: null as IntervalId | null, + currentJobStepsStates: [] as Array<Record<string, any>>, + artifacts: [] as Array<Record<string, any>>, menuVisible: false, isFullScreen: false, timeVisible: { @@ -105,7 +120,7 @@ export default defineComponent({ link: '', title: '', titleHTML: '', - status: 'unknown' as RunStatus, + status: '' as RunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon canCancel: false, canApprove: false, canRerun: false, @@ -122,7 +137,7 @@ export default defineComponent({ // canRerun: false, // duration: '', // }, - ], + ] as Array<Job>, commit: { localeCommit: '', localePushedBy: '', @@ -148,7 +163,7 @@ export default defineComponent({ // duration: '', // status: '', // } - ], + ] as Array<Step>, }, }; }, @@ -162,7 +177,7 @@ export default defineComponent({ }, }, - async mounted() { // eslint-disable-line @typescript-eslint/no-misused-promises + async mounted() { // load job data and then auto-reload periodically // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener await this.loadJob(); @@ -194,7 +209,7 @@ export default defineComponent({ // get the job step logs container ('.job-step-logs') getJobStepLogsContainer(stepIndex: number): HTMLElement { - return this.$refs.logs[stepIndex]; + return (this.$refs.logs as any)[stepIndex]; }, // get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group` @@ -205,7 +220,7 @@ export default defineComponent({ }, // begin a log group beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) { - const el = this.$refs.logs[stepIndex]; + const el = (this.$refs.logs as any)[stepIndex]; const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'}, this.createLogLine(stepIndex, startTime, { index: line.index, @@ -223,7 +238,7 @@ export default defineComponent({ }, // end a log group endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) { - const el = this.$refs.logs[stepIndex]; + const el = (this.$refs.logs as any)[stepIndex]; el._stepLogsActiveContainer = null; el.append(this.createLogLine(stepIndex, startTime, { index: line.index, @@ -393,7 +408,7 @@ export default defineComponent({ if (this.menuVisible) this.menuVisible = false; }, - toggleTimeDisplay(type: string) { + toggleTimeDisplay(type: 'seconds' | 'stamp') { this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`]; for (const el of (this.$refs.steps as HTMLElement).querySelectorAll(`.log-time-${type}`)) { toggleElem(el, this.timeVisible[`log-time-${type}`]); @@ -402,29 +417,16 @@ export default defineComponent({ toggleFullScreen() { this.isFullScreen = !this.isFullScreen; - const fullScreenEl = document.querySelector('.action-view-right'); - const outerEl = document.querySelector('.full.height'); - const actionBodyEl = document.querySelector('.action-view-body'); - const headerEl = document.querySelector('#navbar'); - const contentEl = document.querySelector('.page-content'); - const footerEl = document.querySelector('.page-footer'); - toggleElem(headerEl, !this.isFullScreen); - toggleElem(contentEl, !this.isFullScreen); - toggleElem(footerEl, !this.isFullScreen); - // move .action-view-right to new parent - if (this.isFullScreen) { - outerEl.append(fullScreenEl); - } else { - actionBodyEl.append(fullScreenEl); - } + toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body'); }, async hashChangeListener() { const selectedLogStep = window.location.hash; if (!selectedLogStep) return; const [_, step, _line] = selectedLogStep.split('-'); - if (!this.currentJobStepsStates[step]) return; - if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) { - this.currentJobStepsStates[step].expanded = true; + const stepNum = Number(step); + if (!this.currentJobStepsStates[stepNum]) return; + if (!this.currentJobStepsStates[stepNum].expanded && this.currentJobStepsStates[stepNum].cursor === null) { + this.currentJobStepsStates[stepNum].expanded = true; // need to await for load job if the step log is loaded for the first time // so logline can be selected by querySelector await this.loadJob(); @@ -437,7 +439,8 @@ export default defineComponent({ }); </script> <template> - <div class="ui container action-view-container"> + <!-- make the view container full width to make users easier to read logs --> + <div class="ui fluid container"> <div class="action-view-header"> <div class="action-info-summary"> <div class="action-info-summary-title"> @@ -476,13 +479,13 @@ export default defineComponent({ <div class="action-view-left"> <div class="job-group-section"> <div class="job-brief-list"> - <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id" @mouseenter="onHoverRerunIndex = job.id" @mouseleave="onHoverRerunIndex = -1"> + <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id"> <div class="job-brief-item-left"> <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/> <span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span> </div> <span class="job-brief-item-right"> - <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun && onHoverRerunIndex === job.id"/> + <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun"/> <span class="step-summary-duration">{{ job.duration }}</span> </span> </a> @@ -493,14 +496,24 @@ export default defineComponent({ {{ locale.artifactsTitle }} </div> <ul class="job-artifacts-list"> - <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name"> - <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> - <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }} - </a> - <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete"> - <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/> - </a> - </li> + <template v-for="artifact in artifacts" :key="artifact.name"> + <li class="job-artifacts-item"> + <template v-if="artifact.status !== 'expired'"> + <a class="flex-text-inline" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> + <SvgIcon name="octicon-file" class="text black"/> + <span class="gt-ellipsis">{{ artifact.name }}</span> + </a> + <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)"> + <SvgIcon name="octicon-trash" class="text black"/> + </a> + </template> + <span v-else class="flex-text-inline text light grey"> + <SvgIcon name="octicon-file"/> + <span class="gt-ellipsis">{{ artifact.name }}</span> + <span class="ui label text light grey tw-flex-shrink-0">{{ locale.artifactExpired }}</span> + </span> + </li> + </template> </ul> </div> </div> @@ -559,7 +572,7 @@ export default defineComponent({ <!-- If the job is done and the job step log is loaded for the first time, show the loading icon currentJobStepsStates[i].cursor === null means the log is loaded for the first time --> - <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 circular-spin"/> <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/> <ActionRunStatus :status="jobStep.status" class="tw-mr-2"/> @@ -662,6 +675,7 @@ export default defineComponent({ padding: 6px; display: flex; justify-content: space-between; + align-items: center; } .job-artifacts-list { @@ -669,10 +683,6 @@ export default defineComponent({ list-style: none; } -.job-artifacts-icon { - padding-right: 3px; -} - .job-brief-list { display: flex; flex-direction: column; @@ -705,11 +715,6 @@ export default defineComponent({ .job-brief-item .job-brief-rerun { cursor: pointer; - transition: transform 0.2s; -} - -.job-brief-item .job-brief-rerun:hover { - transform: scale(130%); } .job-brief-item .job-brief-item-left { @@ -886,16 +891,6 @@ export default defineComponent({ <style> /* eslint-disable-line vue-scoped-css/enforce-style-type */ /* some elements are not managed by vue, so we need to use global style */ -.job-status-rotate { - animation: job-status-rotate-keyframes 1s linear infinite; -} - -@keyframes job-status-rotate-keyframes { - 100% { - transform: rotate(-360deg); - } -} - .job-step-section { margin: 10px; } @@ -945,7 +940,6 @@ export default defineComponent({ .job-step-logs .job-log-line .log-msg { flex: 1; - word-break: break-all; white-space: break-spaces; margin-left: 10px; overflow-wrap: anywhere; diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue index 1295e15582..bbdfda41d0 100644 --- a/web_src/js/components/RepoActivityTopAuthors.vue +++ b/web_src/js/components/RepoActivityTopAuthors.vue @@ -1,8 +1,9 @@ <script lang="ts" setup> +// @ts-expect-error - module exports no types import {VueBarGraph} from 'vue-bar-graph'; -import {computed, onMounted, ref} from 'vue'; +import {computed, onMounted, shallowRef, useTemplateRef} from 'vue'; -const colors = ref({ +const colors = shallowRef({ barColor: 'green', textColor: 'black', textAltColor: 'white', @@ -40,8 +41,8 @@ const graphWidth = computed(() => { return activityTopAuthors.length * 40; }); -const styleElement = ref<HTMLElement | null>(null); -const altStyleElement = ref<HTMLElement | null>(null); +const styleElement = useTemplateRef('styleElement'); +const altStyleElement = useTemplateRef('altStyleElement'); onMounted(() => { const refStyle = window.getComputedStyle(styleElement.value); diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue index 7e35d55b2f..8e3a29a0e0 100644 --- a/web_src/js/components/RepoBranchTagSelector.vue +++ b/web_src/js/components/RepoBranchTagSelector.vue @@ -157,7 +157,7 @@ export default defineComponent({ // @ts-expect-error - el is unknown type return (el && el.length) ? el[0] : null; }, - keydown(e) { + keydown(e: KeyboardEvent) { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); @@ -181,7 +181,7 @@ export default defineComponent({ this.menuVisible = false; } }, - handleTabSwitch(selectedTab) { + handleTabSwitch(selectedTab: SelectedTab) { this.selectedTab = selectedTab; this.focusSearchField(); this.loadTabItems(); @@ -216,17 +216,18 @@ export default defineComponent({ }); </script> <template> - <div class="ui dropdown custom branch-selector-dropdown ellipsis-items-nowrap"> - <div tabindex="0" class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible"> + <div class="ui dropdown custom branch-selector-dropdown ellipsis-text-items"> + <div tabindex="0" class="ui compact button branch-dropdown-button" @click="menuVisible = !menuVisible"> <span class="flex-text-block gt-ellipsis"> <template v-if="dropdownFixedText">{{ dropdownFixedText }}</template> <template v-else> <svg-icon v-if="currentRefType === 'tag'" name="octicon-tag"/> - <svg-icon v-else name="octicon-git-branch"/> - <strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong> + <svg-icon v-else-if="currentRefType === 'branch'" name="octicon-git-branch"/> + <svg-icon v-else name="octicon-git-commit"/> + <strong ref="dropdownRefName" class="tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong> </template> </span> - <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/> + <svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/> </div> <div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak> <div class="ui icon search input"> @@ -235,10 +236,10 @@ export default defineComponent({ </div> <div v-if="showTabBranches" class="branch-tag-tab"> <a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')"> - <svg-icon name="octicon-git-branch" :size="16" class-name="tw-mr-1"/>{{ textBranches }} + <svg-icon name="octicon-git-branch" :size="16" class="tw-mr-1"/>{{ textBranches }} </a> <a v-if="showTabTags" class="branch-tag-item muted" :class="{active: selectedTab === 'tags'}" href="#" @click="handleTabSwitch('tags')"> - <svg-icon name="octicon-tag" :size="16" class-name="tw-mr-1"/>{{ textTags }} + <svg-icon name="octicon-tag" :size="16" class="tw-mr-1"/>{{ textTags }} </a> </div> <div class="branch-tag-divider"/> diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue index 7696996cf6..f331a26fe9 100644 --- a/web_src/js/components/RepoCodeFrequency.vue +++ b/web_src/js/components/RepoCodeFrequency.vue @@ -23,7 +23,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 {onMounted, ref} from 'vue'; +import {onMounted, shallowRef} from 'vue'; const {pageData} = window.config; @@ -47,10 +47,10 @@ defineProps<{ }; }>(); -const isLoading = ref(false); -const errorText = ref(''); -const repoLink = ref(pageData.repoLink || []); -const data = ref<DayData[]>([]); +const isLoading = shallowRef(false); +const errorText = shallowRef(''); +const repoLink = pageData.repoLink; +const data = shallowRef<DayData[]>([]); onMounted(() => { fetchGraphData(); @@ -61,7 +61,7 @@ async function fetchGraphData() { try { let response: Response; do { - response = await GET(`${repoLink.value}/activity/code-frequency/data`); + response = await GET(`${repoLink}/activity/code-frequency/data`); if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying } @@ -150,7 +150,7 @@ const options: ChartOptions<'line'> = { <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue index 5d2c74b6a9..754acb997d 100644 --- a/web_src/js/components/RepoContributors.vue +++ b/web_src/js/components/RepoContributors.vue @@ -80,10 +80,10 @@ export default defineComponent({ sortedContributors: {} as Record<string, any>, type: 'commits', contributorsStats: {} as Record<string, any>, - xAxisStart: null, - xAxisEnd: null, - xAxisMin: null, - xAxisMax: null, + xAxisStart: null as number | null, + xAxisEnd: null as number | null, + xAxisMin: null as number | null, + xAxisMax: null as number | null, }), mounted() { this.fetchGraphData(); @@ -99,7 +99,7 @@ export default defineComponent({ }, methods: { sortContributors() { - const contributors = this.filterContributorWeeksByDateRange(); + const contributors: Record<string, any> = this.filterContributorWeeksByDateRange(); const criteria = `total_${this.type}`; this.sortedContributors = Object.values(contributors) .filter((contributor) => contributor[criteria] !== 0) @@ -158,7 +158,7 @@ export default defineComponent({ }, filterContributorWeeksByDateRange() { - const filteredData = {}; + const filteredData: Record<string, any> = {}; const data = this.contributorsStats; for (const key of Object.keys(data)) { const user = data[key]; @@ -196,7 +196,7 @@ export default defineComponent({ // Normally, chartjs handles this automatically, but it will resize the graph when you // zoom, pan etc. I think resizing the graph makes it harder to compare things visually. const maxValue = Math.max( - ...this.totalStats.weeks.map((o) => o[this.type]), + ...this.totalStats.weeks.map((o: Record<string, any>) => o[this.type]), ); const [coefficient, exp] = maxValue.toExponential().split('e').map(Number); if (coefficient % 1 === 0) return maxValue; @@ -208,7 +208,7 @@ export default defineComponent({ // for contributors' graph. If I let chartjs do this for me, it will choose different // maxY value for each contributors' graph which again makes it harder to compare. const maxValue = Math.max( - ...this.sortedContributors.map((c) => c.max_contribution_type), + ...this.sortedContributors.map((c: Record<string, any>) => c.max_contribution_type), ); const [coefficient, exp] = maxValue.toExponential().split('e').map(Number); if (coefficient % 1 === 0) return maxValue; @@ -232,8 +232,8 @@ export default defineComponent({ }, updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) { - const minVal = chart.options.scales.x.min; - const maxVal = chart.options.scales.x.max; + const minVal = Number(chart.options.scales.x.min); + const maxVal = Number(chart.options.scales.x.max); if (reset) { this.xAxisMin = this.xAxisStart; this.xAxisMax = this.xAxisEnd; @@ -353,12 +353,12 @@ export default defineComponent({ </div> <div> <!-- Contribution type --> - <div class="ui dropdown jump" id="repo-contributors"> + <div class="ui floating dropdown jump" id="repo-contributors"> <div class="ui basic compact button"> <span class="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong> <svg-icon name="octicon-triangle-down" :size="14"/> </div> - <div class="menu"> + <div class="left menu"> <div :class="['item', {'selected': type === 'commits'}]" data-value="commits"> {{ locale.contributionType.commits }} </div> @@ -375,7 +375,7 @@ export default defineComponent({ <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> @@ -397,7 +397,7 @@ export default defineComponent({ <div class="ui top attached header tw-flex tw-flex-1"> <b class="ui right">#{{ index + 1 }}</b> <a :href="contributor.home_link"> - <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt=""> + <img loading="lazy" class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt=""> </a> <div class="tw-ml-2"> <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a> diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue index 10e1fdd70c..27aa27dfc3 100644 --- a/web_src/js/components/RepoRecentCommits.vue +++ b/web_src/js/components/RepoRecentCommits.vue @@ -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 {onMounted, ref} from 'vue'; +import {onMounted, ref, shallowRef} from 'vue'; const {pageData} = window.config; @@ -43,9 +43,9 @@ defineProps<{ }; }>(); -const isLoading = ref(false); -const errorText = ref(''); -const repoLink = ref(pageData.repoLink || []); +const isLoading = shallowRef(false); +const errorText = shallowRef(''); +const repoLink = pageData.repoLink; const data = ref<DayData[]>([]); onMounted(() => { @@ -57,7 +57,7 @@ async function fetchGraphData() { try { let response: Response; do { - response = await GET(`${repoLink.value}/activity/recent-commits/data`); + response = await GET(`${repoLink}/activity/recent-commits/data`); if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying } @@ -128,7 +128,7 @@ const options: ChartOptions<'bar'> = { <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> diff --git a/web_src/js/components/ScopedAccessTokenSelector.vue b/web_src/js/components/ScopedAccessTokenSelector.vue deleted file mode 100644 index 63214d0bf5..0000000000 --- a/web_src/js/components/ScopedAccessTokenSelector.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script lang="ts" setup> -import {computed, onMounted, onUnmounted} from 'vue'; -import {hideElem, showElem} from '../utils/dom.ts'; - -const props = defineProps<{ - isAdmin: boolean; - noAccessLabel: string; - readLabel: string; - writeLabel: string; -}>(); - -const categories = computed(() => { - const categories = [ - 'activitypub', - ]; - if (props.isAdmin) { - categories.push('admin'); - } - categories.push( - 'issue', - 'misc', - 'notification', - 'organization', - 'package', - 'repository', - 'user'); - return categories; -}); - -onMounted(() => { - document.querySelector('#scoped-access-submit').addEventListener('click', onClickSubmit); -}); - -onUnmounted(() => { - document.querySelector('#scoped-access-submit').removeEventListener('click', onClickSubmit); -}); - -function onClickSubmit(e) { - e.preventDefault(); - - const warningEl = document.querySelector('#scoped-access-warning'); - // check that at least one scope has been selected - for (const el of document.querySelectorAll<HTMLInputElement>('.access-token-select')) { - if (el.value) { - // Hide the error if it was visible from previous attempt. - hideElem(warningEl); - // Submit the form. - document.querySelector<HTMLFormElement>('#scoped-access-form').submit(); - // Don't show the warning. - return; - } - } - // no scopes selected, show validation error - showElem(warningEl); -} -</script> - -<template> - <div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category"> - <label class="category-label" :for="'access-token-scope-' + category"> - {{ category }} - </label> - <div class="gitea-select"> - <select - class="ui selection access-token-select" - name="scope" - :id="'access-token-scope-' + category" - > - <option value=""> - {{ noAccessLabel }} - </option> - <option :value="'read:' + category"> - {{ readLabel }} - </option> - <option :value="'write:' + category"> - {{ writeLabel }} - </option> - </select> - </div> - </div> -</template> diff --git a/web_src/js/components/ViewFileTree.vue b/web_src/js/components/ViewFileTree.vue new file mode 100644 index 0000000000..1f90f92586 --- /dev/null +++ b/web_src/js/components/ViewFileTree.vue @@ -0,0 +1,38 @@ +<script lang="ts" setup> +import ViewFileTreeItem from './ViewFileTreeItem.vue'; +import {onMounted, useTemplateRef} from 'vue'; +import {createViewFileTreeStore} from './ViewFileTreeStore.ts'; + +const elRoot = useTemplateRef('elRoot'); + +const props = defineProps({ + repoLink: {type: String, required: true}, + treePath: {type: String, required: true}, + currentRefNameSubURL: {type: String, required: true}, +}); + +const store = createViewFileTreeStore(props); +onMounted(async () => { + store.rootFiles = await store.loadChildren('', props.treePath); + elRoot.value.closest('.is-loading')?.classList?.remove('is-loading'); + window.addEventListener('popstate', (e) => { + store.selectedItem = e.state?.treePath || ''; + if (e.state?.url) store.loadViewContent(e.state.url); + }); +}); +</script> + +<template> + <div class="view-file-tree-items" ref="elRoot"> + <ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/> + </div> +</template> + +<style scoped> +.view-file-tree-items { + display: flex; + flex-direction: column; + gap: 1px; + margin-right: .5rem; +} +</style> diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue new file mode 100644 index 0000000000..5173c7eb46 --- /dev/null +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -0,0 +1,128 @@ +<script lang="ts" setup> +import {SvgIcon} from '../svg.ts'; +import {isPlainClick} from '../utils/dom.ts'; +import {shallowRef} from 'vue'; +import {type createViewFileTreeStore} from './ViewFileTreeStore.ts'; + +type Item = { + entryName: string; + entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown'; + entryIcon: string; + entryIconOpen: string; + fullPath: string; + submoduleUrl?: string; + children?: Item[]; +}; + +const props = defineProps<{ + item: Item, + store: ReturnType<typeof createViewFileTreeStore> +}>(); + +const store = props.store; +const isLoading = shallowRef(false); +const children = shallowRef(props.item.children); +const collapsed = shallowRef(!props.item.children); + +const doLoadChildren = async () => { + collapsed.value = !collapsed.value; + if (!collapsed.value) { + isLoading.value = true; + try { + children.value = await store.loadChildren(props.item.fullPath); + } finally { + isLoading.value = false; + } + } +}; + +const onItemClick = (e: MouseEvent) => { + // only handle the click event with page partial reloading if the user didn't press any special key + // let browsers handle special keys like "Ctrl+Click" + if (!isPlainClick(e)) return; + e.preventDefault(); + if (props.item.entryMode === 'tree') doLoadChildren(); + store.navigateTreeView(props.item.fullPath); +}; + +</script> + +<template> + <a + class="tree-item silenced" + :class="{ + 'selected': store.selectedItem === item.fullPath, + 'type-submodule': item.entryMode === 'commit', + 'type-directory': item.entryMode === 'tree', + 'type-symlink': item.entryMode === 'symlink', + 'type-file': item.entryMode === 'blob' || item.entryMode === 'exec', + }" + :title="item.entryName" + :href="store.buildTreePathWebUrl(item.fullPath)" + @click.stop="onItemClick" + > + <div v-if="item.entryMode === 'tree'" class="item-toggle"> + <SvgIcon v-if="isLoading" name="octicon-sync" class="circular-spin"/> + <SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop.prevent="doLoadChildren"/> + </div> + <div class="item-content"> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/> + <span class="gt-ellipsis">{{ item.entryName }}</span> + </div> + </a> + + <div v-if="children?.length" v-show="!collapsed" class="sub-items"> + <ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :store="store"/> + </div> +</template> + +<style scoped> +.sub-items { + display: flex; + flex-direction: column; + gap: 1px; + margin-left: 14px; + border-left: 1px solid var(--color-secondary); +} + +.tree-item.selected { + color: var(--color-text); + background: var(--color-active); + border-radius: 4px; +} + +.tree-item.type-directory { + user-select: none; +} + +.tree-item { + display: grid; + grid-template-columns: 16px 1fr; + grid-template-areas: "toggle content"; + gap: 0.25em; + padding: 6px; +} + +.tree-item:hover { + color: var(--color-text); + background: var(--color-hover); + border-radius: 4px; + cursor: pointer; +} + +.item-toggle { + grid-area: toggle; + display: flex; + align-items: center; +} + +.item-content { + grid-area: content; + display: flex; + align-items: center; + gap: 0.5em; + text-overflow: ellipsis; + min-width: 0; +} +</style> diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts new file mode 100644 index 0000000000..e2155bd58a --- /dev/null +++ b/web_src/js/components/ViewFileTreeStore.ts @@ -0,0 +1,45 @@ +import {reactive} from 'vue'; +import {GET} from '../modules/fetch.ts'; +import {pathEscapeSegments} from '../utils/url.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; + +export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) { + const store = reactive({ + rootFiles: [], + selectedItem: props.treePath, + + async loadChildren(treePath: string, subPath: string = '') { + const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`); + const json = await response.json(); + const poolSvgs = []; + for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) { + if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent); + } + if (poolSvgs.length) { + const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`); + svgContainer.innerHTML = poolSvgs.join(''); + document.body.append(svgContainer); + } + return json.fileTreeNodes ?? null; + }, + + async loadViewContent(url: string) { + url = url.includes('?') ? url.replace('?', '?only_content=true') : `${url}?only_content=true`; + const response = await GET(url); + document.querySelector('.repo-view-content').innerHTML = await response.text(); + }, + + async navigateTreeView(treePath: string) { + const url = store.buildTreePathWebUrl(treePath); + window.history.pushState({treePath, url}, null, url); + store.selectedItem = treePath; + await store.loadViewContent(url); + }, + + buildTreePathWebUrl(treePath: string) { + return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`; + }, + }); + return store; +} 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 () { diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts index 0c540ac296..e4b540122d 100644 --- a/web_src/js/globals.d.ts +++ b/web_src/js/globals.d.ts @@ -25,11 +25,6 @@ declare module 'htmx.org/dist/htmx.esm.js' { export default value; } -declare module 'uint8-to-base64' { - export function encode(arrayBuffer: Uint8Array): string; - export function decode(base64str: string): Uint8Array; -} - declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' { const value = await import('swagger-ui-dist'); export default value.SwaggerUIBundle; @@ -58,21 +53,24 @@ interface Element { type Writable<T> = { -readonly [K in keyof T]: T[K] }; interface Window { + __webpack_public_path__: string; config: import('./web_src/js/types.ts').Config; $: typeof import('@types/jquery'), jQuery: typeof import('@types/jquery'), htmx: Omit<typeof import('htmx.org/dist/htmx.esm.js').default, 'config'> & { config?: Writable<typeof import('htmx.org').default.config>, + process?: (elt: Element | string) => void, }, - ui?: any, _globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & { _inited: boolean, push: (e: ErrorEvent & PromiseRejectionEvent) => void | number, }, - __webpack_public_path__: string; + codeEditors: any[], // export editor for customization + + // various captcha plugins grecaptcha: any, turnstile: any, hcaptcha: any, - codeEditors: any[], - updateCloneStates: () => void, + + // do not add more properties here unless it is a must } diff --git a/web_src/js/htmx.ts b/web_src/js/htmx.ts index 3f9a5a815c..c23c3a21fa 100644 --- a/web_src/js/htmx.ts +++ b/web_src/js/htmx.ts @@ -1,5 +1,5 @@ import {showErrorToast} from './modules/toast.ts'; -import 'idiomorph/dist/idiomorph-ext.js'; // https://github.com/bigskysoftware/idiomorph#htmx +import 'idiomorph/htmx'; import type {HtmxResponseInfo} from 'htmx.org'; type HtmxEvent = Event & {detail: HtmxResponseInfo}; diff --git a/web_src/js/index.ts b/web_src/js/index.ts index b89e596047..347aad2709 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -11,7 +11,6 @@ import {initImageDiff} from './features/imagediff.ts'; import {initRepoMigration} from './features/repo-migration.ts'; import {initRepoProject} from './features/repo-projects.ts'; import {initTableSort} from './features/tablesort.ts'; -import {initAutoFocusEnd} from './features/autofocus-end.ts'; import {initAdminUserListSearchForm} from './features/admin/users.ts'; import {initAdminConfigs} from './features/admin/config.ts'; import {initMarkupAnchors} from './markup/anchors.ts'; @@ -19,17 +18,10 @@ import {initNotificationCount, initNotificationsTable} from './features/notifica import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; import {initStopwatch} from './features/stopwatch.ts'; import {initFindFileInRepo} from './features/repo-findfile.ts'; -import {initCommentContent, initMarkupContent} from './markup/content.ts'; -import {initPdfViewer} from './render/pdf.ts'; - +import {initMarkupContent} from './markup/content.ts'; +import {initRepoFileView} from './features/file-view.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; -import { - initRepoIssueReferenceRepositorySearch, - initRepoIssueWipTitle, - initRepoPullRequestMergeInstruction, - initRepoPullRequestAllowMaintainerEdit, - initRepoPullRequestReview, initRepoIssueSidebarList, initRepoIssueFilterItemLabel, -} from './features/repo-issue.ts'; +import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; import {initAdminCommon} from './features/admin/common.ts'; @@ -62,63 +54,25 @@ import {initRepoContributors} from './features/contributors.ts'; import {initRepoCodeFrequency} from './features/code-frequency.ts'; import {initRepoRecentCommits} from './features/recent-commits.ts'; import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.ts'; -import {initDirAuto} from './modules/dirauto.ts'; +import {initGlobalSelectorObserver} from './modules/observer.ts'; import {initRepositorySearch} from './features/repo-search.ts'; import {initColorPickers} from './features/colorpicker.ts'; import {initAdminSelfCheck} from './features/admin/selfcheck.ts'; import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts'; import {initGlobalFetchAction} from './features/common-fetch-action.ts'; -import {initScopedAccessTokenCategories} from './features/scoped-access-token.ts'; -import { - initFootLanguageMenu, - initGlobalDropdown, - initGlobalTabularMenu, - initHeadNavbarContentToggle, -} from './features/common-page.ts'; -import { - initGlobalButtonClickOnEnter, - initGlobalButtons, - initGlobalDeleteButton, -} from './features/common-button.ts'; -import { - initGlobalComboMarkdownEditor, - initGlobalEnterQuickSubmit, - initGlobalFormDirtyLeaveConfirm, -} from './features/common-form.ts'; +import {initFootLanguageMenu, initGlobalAvatarUploader, initGlobalDropdown, initGlobalInput, initGlobalTabularMenu, initHeadNavbarContentToggle} from './features/common-page.ts'; +import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts'; +import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; +import {callInitFunctions} from './modules/init.ts'; +import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; initGiteaFomantic(); -initDirAuto(); initSubmitEventPolyfill(); -function callInitFunctions(functions: (() => any)[]) { - // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1" - // It is a quick check, no side effect so no need to do slow URL parsing. - const initStart = performance.now(); - if (window.location.search.includes('_ui_performance_trace=1')) { - let results: {name: string, dur: number}[] = []; - for (const func of functions) { - const start = performance.now(); - func(); - results.push({name: func.name, dur: performance.now() - start}); - } - results = results.sort((a, b) => b.dur - a.dur); - for (let i = 0; i < 20 && i < results.length; i++) { - // eslint-disable-next-line no-console - console.log(`performance trace: ${results[i].name} ${results[i].dur.toFixed(3)}`); - } - } else { - for (const func of functions) { - func(); - } - } - const initDur = performance.now() - initStart; - if (initDur > 500) { - console.error(`slow init functions took ${initDur.toFixed(3)}ms`); - } -} - onDomReady(() => { - callInitFunctions([ + const initStartTime = performance.now(); + const initPerformanceTracer = callInitFunctions([ + initGlobalAvatarUploader, initGlobalDropdown, initGlobalTabularMenu, initGlobalFetchAction, @@ -130,6 +84,7 @@ onDomReady(() => { initGlobalFormDirtyLeaveConfirm, initGlobalComboMarkdownEditor, initGlobalDeleteButton, + initGlobalInput, initCommonOrganization, initCommonIssueListQuickGoto, @@ -142,7 +97,6 @@ onDomReady(() => { initHeadNavbarContentToggle, initFootLanguageMenu, - initCommentContent, initContextPopups, initHeatmap, initImageDiff, @@ -151,7 +105,6 @@ onDomReady(() => { initSshKeyFormParser, initStopwatch, initTableSort, - initAutoFocusEnd, initFindFileInRepo, initCopyContent, @@ -179,18 +132,16 @@ onDomReady(() => { initRepoIssueContentHistory, initRepoIssueList, initRepoIssueFilterItemLabel, - initRepoIssueSidebarList, - initRepoIssueReferenceRepositorySearch, - initRepoIssueWipTitle, + initRepoIssueSidebarDependency, initRepoMigration, initRepoMigrationStatusChecker, initRepoProject, - initRepoPullRequestMergeInstruction, initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoRelease, initRepoReleaseNew, initRepoTopicBar, + initRepoViewFileTree, initRepoWikiForm, initRepository, initRepositoryActionView, @@ -208,10 +159,19 @@ onDomReady(() => { initUserAuthWebAuthnRegister, initUserSettings, initRepoDiffView, - initPdfViewer, - initScopedAccessTokenCategories, initColorPickers, initOAuth2SettingsDisableCheckbox, + + initRepoFileView, ]); + + // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. + initGlobalSelectorObserver(initPerformanceTracer); + if (initPerformanceTracer) initPerformanceTracer.printResults(); + + const initDur = performance.now() - initStartTime; + if (initDur > 500) { + console.error(`slow init functions took ${initDur.toFixed(3)}ms`); + } }); diff --git a/web_src/js/markup/anchors.ts b/web_src/js/markup/anchors.ts index 483d72bd5b..a0d49911fe 100644 --- a/web_src/js/markup/anchors.ts +++ b/web_src/js/markup/anchors.ts @@ -5,21 +5,24 @@ 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: string): void { - if (!encodedId) return; - const id = decodeURIComponent(encodedId); - const prefixedId = addPrefix(id); - let el = document.querySelector(`#${prefixedId}`); +function scrollToAnchor(encodedId?: string): void { + // FIXME: need to rewrite this function with new a better markup anchor generation logic, too many tricks here + let elemId: string; + try { + elemId = decodeURIComponent(encodedId ?? ''); + } catch {} // ignore the errors, since the "encodedId" is from user's input + if (!elemId) return; + + const prefixedId = addPrefix(elemId); + // eslint-disable-next-line unicorn/prefer-query-selector + let el = document.getElementById(prefixedId); // check for matching user-generated `a[name]` - if (!el) { - el = document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`); - } + el = el ?? document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`); // compat for links with old 'user-content-' prefixed hashes - if (!el && hasPrefix(id)) { - return document.querySelector(`#${id}`)?.scrollIntoView(); - } + // eslint-disable-next-line unicorn/prefer-query-selector + el = (!el && hasPrefix(elemId)) ? document.getElementById(elemId) : el; el?.scrollIntoView(); } diff --git a/web_src/js/markup/asciicast.ts b/web_src/js/markup/asciicast.ts index 97b18743a1..125bba447b 100644 --- a/web_src/js/markup/asciicast.ts +++ b/web_src/js/markup/asciicast.ts @@ -1,17 +1,17 @@ -export async function renderAsciicast() { - const els = document.querySelectorAll('.asciinema-player-container'); - if (!els.length) return; +import {queryElems} from '../utils/dom.ts'; - const [player] = await Promise.all([ - import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), - import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), - ]); +export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> { + queryElems(elMarkup, '.asciinema-player-container', async (el) => { + const [player] = await Promise.all([ + // @ts-expect-error: module exports no types + import(/* webpackChunkName: "asciinema-player" */'asciinema-player'), + import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'), + ]); - for (const el of els) { player.create(el.getAttribute('data-asciinema-player-src'), el, { // poster (a preview frame) to display until the playback is started. // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more. poster: 'npt:1:0:0', }); - } + }); } diff --git a/web_src/js/markup/codecopy.ts b/web_src/js/markup/codecopy.ts index f45b7a8e04..b37aa3a236 100644 --- a/web_src/js/markup/codecopy.ts +++ b/web_src/js/markup/codecopy.ts @@ -1,4 +1,5 @@ import {svg} from '../svg.ts'; +import {queryElems} from '../utils/dom.ts'; export function makeCodeCopyButton(): HTMLButtonElement { const button = document.createElement('button'); @@ -7,15 +8,15 @@ export function makeCodeCopyButton(): HTMLButtonElement { return button; } -export function renderCodeCopy(): void { - const els = document.querySelectorAll('.markup .code-block code'); - if (!els.length) return; - - for (const el of els) { - if (!el.textContent) continue; +export function initMarkupCodeCopy(elMarkup: HTMLElement): void { + // .markup .code-block code + queryElems(elMarkup, '.code-block code', (el) => { + if (!el.textContent) return; const btn = makeCodeCopyButton(); // remove final trailing newline introduced during HTML rendering btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, '')); - el.after(btn); - } + // we only want to use `.code-block-container` if it exists, no matter `.code-block` exists or not. + const btnContainer = el.closest('.code-block-container') ?? el.closest('.code-block'); + btnContainer.append(btn); + }); } diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts index b9190b15ce..55db4aa810 100644 --- a/web_src/js/markup/content.ts +++ b/web_src/js/markup/content.ts @@ -1,18 +1,17 @@ -import {renderMermaid} from './mermaid.ts'; -import {renderMath} from './math.ts'; -import {renderCodeCopy} from './codecopy.ts'; -import {renderAsciicast} from './asciicast.ts'; +import {initMarkupCodeMermaid} from './mermaid.ts'; +import {initMarkupCodeMath} from './math.ts'; +import {initMarkupCodeCopy} from './codecopy.ts'; +import {initMarkupRenderAsciicast} from './asciicast.ts'; import {initMarkupTasklist} from './tasklist.ts'; +import {registerGlobalSelectorFunc} from '../modules/observer.ts'; // code that runs for all markup content export function initMarkupContent(): void { - renderMermaid(); - renderMath(); - renderCodeCopy(); - renderAsciicast(); -} - -// code that only runs for comments -export function initCommentContent(): void { - initMarkupTasklist(); + registerGlobalSelectorFunc('.markup', (el: HTMLElement) => { + initMarkupCodeCopy(el); + initMarkupTasklist(el); + initMarkupCodeMermaid(el); + initMarkupCodeMath(el); + initMarkupRenderAsciicast(el); + }); } diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts index fc2083e86d..5866d0d259 100644 --- a/web_src/js/markup/html2markdown.ts +++ b/web_src/js/markup/html2markdown.ts @@ -1,7 +1,9 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; + +type Processor = (el: HTMLElement) => string | HTMLElement | void; type Processors = { - [tagName: string]: (el: HTMLElement) => string | HTMLElement | void; + [tagName: string]: Processor; } type ProcessorContext = { @@ -11,7 +13,7 @@ type ProcessorContext = { } function prepareProcessors(ctx:ProcessorContext): Processors { - const processors = { + const processors: Processors = { H1(el: HTMLElement) { const level = parseInt(el.tagName.slice(1)); el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`; @@ -36,10 +38,10 @@ function prepareProcessors(ctx:ProcessorContext): Processors { IMG(el: HTMLElement) { const alt = el.getAttribute('alt') || 'image'; const src = el.getAttribute('src'); - const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : ''; - const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : ''; + const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : ''; + const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : ''; if (widthAttr || heightAttr) { - return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`; + return html`<img alt="${alt}"${widthAttr}${heightAttr} src="${src}">`; } return ``; }, diff --git a/web_src/js/markup/math.ts b/web_src/js/markup/math.ts index 4777805e3c..bc118137a1 100644 --- a/web_src/js/markup/math.ts +++ b/web_src/js/markup/math.ts @@ -1,4 +1,5 @@ import {displayError} from './common.ts'; +import {queryElems} from '../utils/dom.ts'; function targetElement(el: Element): {target: Element, displayAsBlock: boolean} { // The target element is either the parent "code block with loading indicator", or itself @@ -11,27 +12,25 @@ function targetElement(el: Element): {target: Element, displayAsBlock: boolean} }; } -export async function renderMath(): Promise<void> { - const els = document.querySelectorAll('.markup code.language-math'); - if (!els.length) return; +export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> { + // .markup code.language-math' + queryElems(elMarkup, 'code.language-math', async (el) => { + const [{default: katex}] = await Promise.all([ + import(/* webpackChunkName: "katex" */'katex'), + import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), + ]); - const [{default: katex}] = await Promise.all([ - import(/* webpackChunkName: "katex" */'katex'), - import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), - ]); + const MAX_CHARS = 1000; + const MAX_SIZE = 25; + const MAX_EXPAND = 1000; - const MAX_CHARS = 1000; - const MAX_SIZE = 25; - const MAX_EXPAND = 1000; - - for (const el of els) { const {target, displayAsBlock} = targetElement(el); - if (target.hasAttribute('data-render-done')) continue; + if (target.hasAttribute('data-render-done')) return; const source = el.textContent; if (source.length > MAX_CHARS) { displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`)); - continue; + return; } try { const tempEl = document.createElement(displayAsBlock ? 'p' : 'span'); @@ -44,5 +43,5 @@ export async function renderMath(): Promise<void> { } catch (error) { displayError(target, error); } - } + }); } diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index 2dbed280c2..33d9a1ed9b 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -1,6 +1,8 @@ import {isDarkTheme} from '../utils.ts'; import {makeCodeCopyButton} from './codecopy.ts'; import {displayError} from './common.ts'; +import {queryElems} from '../utils/dom.ts'; +import {html, htmlRaw} from '../utils/html.ts'; const {mermaidMaxSourceCharacters} = window.config; @@ -10,34 +12,32 @@ 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(): Promise<void> { - const els = document.querySelectorAll('.markup code.language-mermaid'); - if (!els.length) return; +export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> { + // .markup code.language-mermaid + queryElems(elMarkup, 'code.language-mermaid', async (el) => { + const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); - const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid'); + mermaid.initialize({ + startOnLoad: false, + theme: isDarkTheme() ? 'dark' : 'neutral', + securityLevel: 'strict', + suppressErrorRendering: true, + }); - mermaid.initialize({ - startOnLoad: false, - theme: isDarkTheme() ? 'dark' : 'neutral', - securityLevel: 'strict', - suppressErrorRendering: true, - }); - - for (const el of els) { const pre = el.closest('pre'); - if (pre.hasAttribute('data-render-done')) continue; + if (pre.hasAttribute('data-render-done')) return; const source = el.textContent; if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) { displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`)); - continue; + return; } try { await mermaid.parse(source); } catch (err) { displayError(pre, err); - continue; + return; } try { @@ -46,8 +46,8 @@ export async function renderMermaid(): Promise<void> { const {svg} = await mermaid.render('mermaid', source); const iframe = document.createElement('iframe'); - iframe.classList.add('markup-render', 'tw-invisible'); - iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`; + iframe.classList.add('markup-content-iframe', 'tw-invisible'); + iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`; const mermaidBlock = document.createElement('div'); mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); @@ -85,5 +85,5 @@ export async function renderMermaid(): Promise<void> { } catch (err) { displayError(pre, err); } - } + }); } diff --git a/web_src/js/markup/tasklist.ts b/web_src/js/markup/tasklist.ts index 95db7fc845..dc4bbd9519 100644 --- a/web_src/js/markup/tasklist.ts +++ b/web_src/js/markup/tasklist.ts @@ -7,80 +7,80 @@ const preventListener = (e: Event) => e.preventDefault(); * Attaches `input` handlers to markdown rendered tasklist checkboxes in comments. * * When a checkbox value changes, the corresponding [ ] or [x] in the markdown string - * is set accordingly and sent to the server. On success it updates the raw-content on + * 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(): void { - for (const el of document.querySelectorAll(`.markup[data-can-edit=true]`) || []) { - const container = el.parentNode; - const checkboxes = el.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`); +export function initMarkupTasklist(elMarkup: HTMLElement): void { + if (!elMarkup.matches('[data-can-edit=true]')) return; - for (const checkbox of checkboxes) { - if (checkbox.hasAttribute('data-editable')) { - return; - } + const container = elMarkup.parentNode; + const checkboxes = elMarkup.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`); - checkbox.setAttribute('data-editable', 'true'); - checkbox.addEventListener('input', async () => { - const checkboxCharacter = checkbox.checked ? 'x' : ' '; - const position = parseInt(checkbox.getAttribute('data-source-position')) + 1; + for (const checkbox of checkboxes) { + if (checkbox.hasAttribute('data-editable')) { + return; + } - const rawContent = container.querySelector('.raw-content'); - const oldContent = rawContent.textContent; + checkbox.setAttribute('data-editable', 'true'); + checkbox.addEventListener('input', async () => { + const checkboxCharacter = checkbox.checked ? 'x' : ' '; + const position = parseInt(checkbox.getAttribute('data-source-position')) + 1; - const encoder = new TextEncoder(); - const buffer = encoder.encode(oldContent); - // Indexes may fall off the ends and return undefined. - if (buffer[position - 1] !== '['.codePointAt(0) || - buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) || - buffer[position + 1] !== ']'.codePointAt(0)) { - // Position is probably wrong. Revert and don't allow change. - checkbox.checked = !checkbox.checked; - throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`); - } - buffer.set(encoder.encode(checkboxCharacter), position); - const newContent = new TextDecoder().decode(buffer); + const rawContent = container.querySelector('.raw-content'); + const oldContent = rawContent.textContent; - if (newContent === oldContent) { - return; - } + const encoder = new TextEncoder(); + const buffer = encoder.encode(oldContent); + // Indexes may fall off the ends and return undefined. + if (buffer[position - 1] !== '['.codePointAt(0) || + buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) || + buffer[position + 1] !== ']'.codePointAt(0)) { + // Position is probably wrong. Revert and don't allow change. + checkbox.checked = !checkbox.checked; + throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`); + } + buffer.set(encoder.encode(checkboxCharacter), position); + const newContent = new TextDecoder().decode(buffer); - // Prevent further inputs until the request is done. This does not use the - // `disabled` attribute because it causes the border to flash on click. - for (const checkbox of checkboxes) { - checkbox.addEventListener('click', preventListener); - } + if (newContent === oldContent) { + return; + } - try { - 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'); + // Prevent further inputs until the request is done. This does not use the + // `disabled` attribute because it causes the border to flash on click. + for (const checkbox of checkboxes) { + checkbox.addEventListener('click', preventListener); + } - const requestBody = new FormData(); - requestBody.append('ignore_attachments', 'true'); - requestBody.append('content', newContent); - requestBody.append('context', context); - requestBody.append('content_version', contentVersion); - const response = await POST(updateUrl, {data: requestBody}); - const data = await response.json(); - if (response.status === 400) { - showErrorToast(data.errorMessage); - return; - } - editContentZone.setAttribute('data-content-version', data.contentVersion); - rawContent.textContent = newContent; - } catch (err) { - checkbox.checked = !checkbox.checked; - console.error(err); - } + try { + 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'); - // Enable input on checkboxes again - for (const checkbox of checkboxes) { - checkbox.removeEventListener('click', preventListener); + const requestBody = new FormData(); + requestBody.append('ignore_attachments', 'true'); + requestBody.append('content', newContent); + requestBody.append('context', context); + requestBody.append('content_version', contentVersion); + const response = await POST(updateUrl, {data: requestBody}); + const data = await response.json(); + if (response.status === 400) { + showErrorToast(data.errorMessage); + return; } - }); - } + editContentZone.setAttribute('data-content-version', data.contentVersion); + rawContent.textContent = newContent; + } catch (err) { + checkbox.checked = !checkbox.checked; + console.error(err); + } + + // Enable input on checkboxes again + for (const checkbox of checkboxes) { + checkbox.removeEventListener('click', preventListener); + } + }); // Enable the checkboxes as they are initially disabled by the markdown renderer for (const checkbox of checkboxes) { diff --git a/web_src/js/modules/diff-file.test.ts b/web_src/js/modules/diff-file.test.ts new file mode 100644 index 0000000000..f0438538a0 --- /dev/null +++ b/web_src/js/modules/diff-file.test.ts @@ -0,0 +1,51 @@ +import {diffTreeStoreSetViewed, reactiveDiffTreeStore} from './diff-file.ts'; + +test('diff-tree', () => { + const store = reactiveDiffTreeStore({ + 'TreeRoot': { + 'FullName': '', + 'DisplayName': '', + 'EntryMode': '', + 'IsViewed': false, + 'NameHash': '....', + 'DiffStatus': '', + 'FileIcon': '', + 'Children': [ + { + 'FullName': 'dir1', + 'DisplayName': 'dir1', + 'EntryMode': 'tree', + 'IsViewed': false, + 'NameHash': '....', + 'DiffStatus': '', + 'FileIcon': '', + 'Children': [ + { + 'FullName': 'dir1/test.txt', + 'DisplayName': 'test.txt', + 'DiffStatus': 'added', + 'NameHash': '....', + 'EntryMode': '', + 'IsViewed': false, + 'FileIcon': '', + 'Children': null, + }, + ], + }, + { + 'FullName': 'other.txt', + 'DisplayName': 'other.txt', + 'NameHash': '........', + 'DiffStatus': 'added', + 'EntryMode': '', + 'IsViewed': false, + 'FileIcon': '', + 'Children': null, + }, + ], + }, + }, '', ''); + diffTreeStoreSetViewed(store, 'dir1/test.txt', true); + expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true); + expect(store.fullNameMap['dir1'].IsViewed).toBe(true); +}); diff --git a/web_src/js/modules/diff-file.ts b/web_src/js/modules/diff-file.ts new file mode 100644 index 0000000000..2cec7bc6b3 --- /dev/null +++ b/web_src/js/modules/diff-file.ts @@ -0,0 +1,82 @@ +import {reactive} from 'vue'; +import type {Reactive} from 'vue'; + +const {pageData} = window.config; + +export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange'; + +export type DiffTreeEntry = { + FullName: string, + DisplayName: string, + NameHash: string, + DiffStatus: DiffStatus, + EntryMode: string, + IsViewed: boolean, + Children: DiffTreeEntry[], + FileIcon: string, + ParentEntry?: DiffTreeEntry, +} + +type DiffFileTreeData = { + TreeRoot: DiffTreeEntry, +}; + +type DiffFileTree = { + folderIcon: string; + folderOpenIcon: string; + diffFileTree: DiffFileTreeData; + fullNameMap?: Record<string, DiffTreeEntry> + fileTreeIsVisible: boolean; + selectedItem: string; +} + +let diffTreeStoreReactive: Reactive<DiffFileTree>; +export function diffTreeStore() { + if (!diffTreeStoreReactive) { + diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree, pageData.FolderIcon, pageData.FolderOpenIcon); + } + return diffTreeStoreReactive; +} + +export function diffTreeStoreSetViewed(store: Reactive<DiffFileTree>, fullName: string, viewed: boolean) { + const entry = store.fullNameMap[fullName]; + if (!entry) return; + entry.IsViewed = viewed; + for (let parent = entry.ParentEntry; parent; parent = parent.ParentEntry) { + parent.IsViewed = isEntryViewed(parent); + } +} + +function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntry) { + map[entry.FullName] = entry; + if (!entry.Children) return; + entry.IsViewed = isEntryViewed(entry); + for (const child of entry.Children) { + child.ParentEntry = entry; + fillFullNameMap(map, child); + } +} + +export function reactiveDiffTreeStore(data: DiffFileTreeData, folderIcon: string, folderOpenIcon: string): Reactive<DiffFileTree> { + const store = reactive({ + diffFileTree: data, + folderIcon, + folderOpenIcon, + fileTreeIsVisible: false, + selectedItem: '', + fullNameMap: {}, + }); + fillFullNameMap(store.fullNameMap, data.TreeRoot); + return store; +} + +function isEntryViewed(entry: DiffTreeEntry): boolean { + if (entry.Children) { + let count = 0; + for (const child of entry.Children) { + if (child.IsViewed) count++; + } + return count === entry.Children.length; + } + return entry.IsViewed; +} diff --git a/web_src/js/modules/dirauto.ts b/web_src/js/modules/dirauto.ts deleted file mode 100644 index 7058a59b09..0000000000 --- a/web_src/js/modules/dirauto.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; - -type DirElement = HTMLInputElement | HTMLTextAreaElement; - -// for performance considerations, it only uses performant syntax -function attachDirAuto(el: DirElement) { - if (el.type !== 'hidden' && - el.type !== 'checkbox' && - el.type !== 'radio' && - el.type !== 'range' && - el.type !== 'color') { - el.dir = 'auto'; - } -} - -export function initDirAuto(): void { - const observer = new MutationObserver((mutationList) => { - const len = mutationList.length; - for (let i = 0; i < len; i++) { - const mutation = mutationList[i]; - const len = mutation.addedNodes.length; - for (let i = 0; i < len; i++) { - const addedNode = mutation.addedNodes[i] as HTMLElement; - if (!isDocumentFragmentOrElementNode(addedNode)) continue; - if (addedNode.nodeName === 'INPUT' || addedNode.nodeName === 'TEXTAREA') { - attachDirAuto(addedNode as DirElement); - } - const children = addedNode.querySelectorAll<DirElement>('input, textarea'); - const len = children.length; - for (let childIdx = 0; childIdx < len; childIdx++) { - attachDirAuto(children[childIdx]); - } - } - } - }); - - const docNodes = document.querySelectorAll<DirElement>('input, textarea'); - const len = docNodes.length; - for (let i = 0; i < len; i++) { - attachDirAuto(docNodes[i]); - } - - observer.observe(document, {subtree: true, childList: true}); -} diff --git a/web_src/js/modules/fomantic.ts b/web_src/js/modules/fomantic.ts index 18a3c18c9c..4b1dbc4f62 100644 --- a/web_src/js/modules/fomantic.ts +++ b/web_src/js/modules/fomantic.ts @@ -1,5 +1,4 @@ import $ from 'jquery'; -import {initFomanticApiPatch} from './fomantic/api.ts'; import {initAriaCheckboxPatch} from './fomantic/checkbox.ts'; import {initAriaFormFieldPatch} from './fomantic/form.ts'; import {initAriaDropdownPatch} from './fomantic/dropdown.ts'; @@ -7,14 +6,13 @@ import {initAriaModalPatch} from './fomantic/modal.ts'; import {initFomanticTransition} from './fomantic/transition.ts'; import {initFomanticDimmer} from './fomantic/dimmer.ts'; import {svg} from '../svg.ts'; +import {initFomanticTab} from './fomantic/tab.ts'; export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)'); export function initGiteaFomantic() { // our extensions $.fn.fomanticExt = {}; - // Silence fomantic's error logging when tabs are used without a target content element - $.fn.tab.settings.silent = true; // By default, use "exact match" for full text search $.fn.dropdown.settings.fullTextSearch = 'exact'; // Do not use "cursor: pointer" for dropdown labels @@ -27,7 +25,7 @@ export function initGiteaFomantic() { initFomanticTransition(); initFomanticDimmer(); - initFomanticApiPatch(); + initFomanticTab(); // Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future. initAriaCheckboxPatch(); diff --git a/web_src/js/modules/fomantic/api.ts b/web_src/js/modules/fomantic/api.ts deleted file mode 100644 index 97430450e2..0000000000 --- a/web_src/js/modules/fomantic/api.ts +++ /dev/null @@ -1,41 +0,0 @@ -import $ from 'jquery'; -import type {FomanticInitFunction} from '../../types.ts'; - -export function initFomanticApiPatch() { - // - // Fomantic API module has some very buggy behaviors: - // - // If encodeParameters=true, it calls `urlEncodedValue` to encode the parameter. - // However, `urlEncodedValue` just tries to "guess" whether the parameter is already encoded, by decoding the parameter and encoding it again. - // - // There are 2 problems: - // 1. It may guess wrong, and skip encoding a parameter which looks like encoded. - // 2. If the parameter can't be decoded, `decodeURIComponent` will throw an error, and the whole request will fail. - // - // This patch only fixes the second error behavior at the moment. - // - const patchKey = '_giteaFomanticApiPatch'; - const oldApi = $.api; - $.api = $.fn.api = function(...args: Parameters<FomanticInitFunction>) { - const apiCall = oldApi.bind(this); - const ret = oldApi.apply(this, args); - - if (typeof args[0] !== 'string') { - const internalGet = apiCall('internal', 'get'); - if (!internalGet.urlEncodedValue[patchKey]) { - const oldUrlEncodedValue = internalGet.urlEncodedValue; - internalGet.urlEncodedValue = function (value: any) { - try { - return oldUrlEncodedValue(value); - } catch { - // if Fomantic API module's `urlEncodedValue` throws an error, we encode it by ourselves. - return encodeURIComponent(value); - } - }; - internalGet.urlEncodedValue[patchKey] = true; - } - } - return ret; - }; - $.api.settings = oldApi.settings; -} diff --git a/web_src/js/modules/fomantic/dropdown.test.ts b/web_src/js/modules/fomantic/dropdown.test.ts index 587e0bca7c..dd3497c8fc 100644 --- a/web_src/js/modules/fomantic/dropdown.test.ts +++ b/web_src/js/modules/fomantic/dropdown.test.ts @@ -23,7 +23,27 @@ test('hideScopedEmptyDividers-simple', () => { `); }); -test('hideScopedEmptyDividers-hidden1', () => { +test('hideScopedEmptyDividers-items-all-filtered', () => { + const container = createElementFromHTML(`<div> +<div class="any"></div> +<div class="divider"></div> +<div class="item filtered">a</div> +<div class="item filtered">b</div> +<div class="divider"></div> +<div class="any"></div> +</div>`); + hideScopedEmptyDividers(container); + expect(container.innerHTML).toEqual(` +<div class="any"></div> +<div class="divider hidden transition"></div> +<div class="item filtered">a</div> +<div class="item filtered">b</div> +<div class="divider"></div> +<div class="any"></div> +`); +}); + +test('hideScopedEmptyDividers-hide-last', () => { const container = createElementFromHTML(`<div> <div class="item">a</div> <div class="divider" data-scope="b"></div> @@ -37,7 +57,7 @@ test('hideScopedEmptyDividers-hidden1', () => { `); }); -test('hideScopedEmptyDividers-hidden2', () => { +test('hideScopedEmptyDividers-scoped-items', () => { const container = createElementFromHTML(`<div> <div class="item" data-scope="">a</div> <div class="divider" data-scope="b"></div> diff --git a/web_src/js/modules/fomantic/dropdown.ts b/web_src/js/modules/fomantic/dropdown.ts index e479c79ee6..ccc22073d7 100644 --- a/web_src/js/modules/fomantic/dropdown.ts +++ b/web_src/js/modules/fomantic/dropdown.ts @@ -11,24 +11,34 @@ export function initAriaDropdownPatch() { if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once'); $.fn.dropdown = ariaDropdownFn; $.fn.fomanticExt.onResponseKeepSelectedItem = onResponseKeepSelectedItem; + $.fn.fomanticExt.onDropdownAfterFiltered = onDropdownAfterFiltered; (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 +// * it does the one-time element event attaching on the first call +// * it delegates the module internal functions like `onLabelCreate` to the patched functions to add more features. function ariaDropdownFn(this: any, ...args: Parameters<FomanticInitFunction>) { const ret = fomanticDropdownFn.apply(this, args); - // if the `$().dropdown()` call is without arguments, or it has non-string (object) argument, - // it means that this call will reset the dropdown internal settings, then we need to re-delegate the callbacks. - const needDelegate = (!args.length || typeof args[0] !== 'string'); - for (const el of this) { + for (let el of this) { + // dropdown will replace '<select class="ui dropdown"/>' to '<div class="ui dropdown"><select (hidden)></select><div class="menu">...</div></div>' + // so we need to correctly find the closest '.ui.dropdown' element, it is the real fomantic dropdown module. + el = el.closest('.ui.dropdown'); if (!el[ariaPatchKey]) { - attachInit(el); + // the elements don't belong to the dropdown "module" and won't be reset + // so we only need to initialize them once. + attachInitElements(el); } - if (needDelegate) { - delegateOne($(el)); + + // if the `$().dropdown()` is called without arguments, or it has non-string (object) argument, + // it means that such call will reset the dropdown "module" including internal settings, + // then we need to re-delegate the callbacks. + const $dropdown = $(el); + const dropdownModule = $dropdown.data('module-dropdown'); + if (!dropdownModule.giteaDelegated) { + dropdownModule.giteaDelegated = true; + delegateDropdownModule($dropdown); } } return ret; @@ -38,7 +48,7 @@ function ariaDropdownFn(this: any, ...args: Parameters<FomanticInitFunction>) { // the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element. function updateMenuItem(dropdown: HTMLElement, item: HTMLElement) { if (!item.id) item.id = generateAriaId(); - item.setAttribute('role', dropdown[ariaPatchKey].listItemRole); + item.setAttribute('role', (dropdown as any)[ariaPatchKey].listItemRole); item.setAttribute('tabindex', '-1'); for (const el of item.querySelectorAll('a, input, button')) el.setAttribute('tabindex', '-1'); } @@ -61,37 +71,17 @@ function updateSelectionLabel(label: HTMLElement) { } } -function processMenuItems($dropdown, dropdownCall) { - const hideEmptyDividers = dropdownCall('setting', 'hideDividers') === 'empty'; +function onDropdownAfterFiltered(this: any) { + const $dropdown = $(this).closest('.ui.dropdown'); // "this" can be the "ui dropdown" or "<select>" + const hideEmptyDividers = $dropdown.dropdown('setting', 'hideDividers') === 'empty'; const itemsMenu = $dropdown[0].querySelector('.scrolling.menu') || $dropdown[0].querySelector('.menu'); - if (hideEmptyDividers) hideScopedEmptyDividers(itemsMenu); + if (hideEmptyDividers && itemsMenu) hideScopedEmptyDividers(itemsMenu); } // delegate the dropdown's template functions and callback functions to add aria attributes. -function delegateOne($dropdown: any) { +function delegateDropdownModule($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. - // Actually, Fomantic UI doesn't support such layout/usage. It needs to patch the "focusSearch" / "blurSearch" functions to make sure it toggles the menu. - const oldFocusSearch = dropdownCall('internal', 'focusSearch'); - const oldBlurSearch = dropdownCall('internal', 'blurSearch'); - // * If the "dropdown icon" is clicked, Fomantic calls "focusSearch", so show the menu - dropdownCall('internal', 'focusSearch', function (this: any) { dropdownCall('show'); oldFocusSearch.call(this) }); - // * If the "dropdown icon" is clicked again when the menu is visible, Fomantic calls "blurSearch", so hide the menu - dropdownCall('internal', 'blurSearch', function (this: any) { oldBlurSearch.call(this); dropdownCall('hide') }); - - const oldFilterItems = dropdownCall('internal', 'filterItems'); - dropdownCall('internal', 'filterItems', function (this: any, ...args: any[]) { - oldFilterItems.call(this, ...args); - processMenuItems($dropdown, dropdownCall); - }); - - const oldShow = dropdownCall('internal', 'show'); - dropdownCall('internal', 'show', function (this: any, ...args: any[]) { - oldShow.call(this, ...args); - processMenuItems($dropdown, dropdownCall); - }); - // the "template" functions are used for dynamic creation (eg: AJAX) const dropdownTemplates = {...dropdownCall('setting', 'templates'), t: performance.now()}; const dropdownTemplatesMenuOld = dropdownTemplates.menu; @@ -143,7 +133,7 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men $(menu).find('> .item').each((_, item) => updateMenuItem(dropdown, item)); // this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash - menu.setAttribute('role', dropdown[ariaPatchKey].listPopupRole); + menu.setAttribute('role', (dropdown as any)[ariaPatchKey].listPopupRole); // prepare selection label items for (const label of dropdown.querySelectorAll<HTMLElement>('.ui.label')) { @@ -151,8 +141,8 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men } // make the primary element (focusable) aria-friendly - focusable.setAttribute('role', focusable.getAttribute('role') ?? dropdown[ariaPatchKey].focusableRole); - focusable.setAttribute('aria-haspopup', dropdown[ariaPatchKey].listPopupRole); + focusable.setAttribute('role', focusable.getAttribute('role') ?? (dropdown as any)[ariaPatchKey].focusableRole); + focusable.setAttribute('aria-haspopup', (dropdown as any)[ariaPatchKey].listPopupRole); focusable.setAttribute('aria-controls', menu.id); focusable.setAttribute('aria-expanded', 'false'); @@ -163,9 +153,8 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men } } -function attachInit(dropdown: HTMLElement) { - dropdown[ariaPatchKey] = {}; - if (dropdown.classList.contains('custom')) return; +function attachInitElements(dropdown: HTMLElement) { + (dropdown as any)[ariaPatchKey] = {}; // Dropdown has 2 different focusing behaviors // * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element. @@ -204,9 +193,9 @@ function attachInit(dropdown: HTMLElement) { // Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before. const isComboBox = dropdown.querySelectorAll('input').length > 0; - dropdown[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu'; - dropdown[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : ''; - dropdown[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem'; + (dropdown as any)[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu'; + (dropdown as any)[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : ''; + (dropdown as any)[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem'; attachDomEvents(dropdown, focusable, menu); attachStaticElements(dropdown, focusable, menu); @@ -229,7 +218,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT // if the popup is visible and has an active/selected item, use its id as aria-activedescendant if (menuVisible) { focusable.setAttribute('aria-activedescendant', active.id); - } else if (dropdown[ariaPatchKey].listPopupRole === 'menu') { + } else if ((dropdown as any)[ariaPatchKey].listPopupRole === 'menu') { // for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item focusable.removeAttribute('aria-activedescendant'); active.classList.remove('active', 'selected'); @@ -239,12 +228,13 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT 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)); - let $item = dropdownCall('get item', dropdownCall('get value')); - if (!$item) $item = $(menu).find('> .item.selected'); // when dropdown filters items by input, there is no "value", so query the "selected" item + const elItem = menu.querySelector<HTMLElement>(':scope > .item.selected, .menu > .item.selected'); // if the selected item is clickable, then trigger the click event. // we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click. - if ($item?.[0]?.matches('a, .js-aria-clickable')) $item[0].click(); + if (elItem?.matches('a, .js-aria-clickable') && !elItem.matches('.tw-hidden, .filtered')) { + e.preventDefault(); + elItem.click(); + } } }); @@ -253,7 +243,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT // when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation // without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation. const deferredRefreshAriaActiveItem = (delay = 0) => { setTimeout(refreshAriaActiveItem, delay) }; - dropdown[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem; + (dropdown as any)[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem; dropdown.addEventListener('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAriaActiveItem(); }); // if the dropdown has been opened by focus, do not trigger the next click event again. @@ -305,9 +295,11 @@ export function hideScopedEmptyDividers(container: Element) { const visibleItems: Element[] = []; const curScopeVisibleItems: Element[] = []; let curScope: string = '', lastVisibleScope: string = ''; - const isScopedDivider = (item: Element) => item.matches('.divider') && item.hasAttribute('data-scope'); + const isDivider = (item: Element) => item.classList.contains('divider'); + const isScopedDivider = (item: Element) => isDivider(item) && item.hasAttribute('data-scope'); const hideDivider = (item: Element) => item.classList.add('hidden', 'transition'); // dropdown has its own classes to hide items - + const showDivider = (item: Element) => item.classList.remove('hidden', 'transition'); + const isHidden = (item: Element) => item.classList.contains('hidden') || item.classList.contains('filtered') || item.classList.contains('tw-hidden'); const handleScopeSwitch = (itemScope: string) => { if (curScopeVisibleItems.length === 1 && isScopedDivider(curScopeVisibleItems[0])) { hideDivider(curScopeVisibleItems[0]); @@ -323,13 +315,16 @@ export function hideScopedEmptyDividers(container: Element) { curScopeVisibleItems.length = 0; }; + // reset hidden dividers + queryElems(container, '.divider', showDivider); + // hide the scope dividers if the scope items are empty for (const item of container.children) { const itemScope = item.getAttribute('data-scope') || ''; if (itemScope !== curScope) { handleScopeSwitch(itemScope); } - if (!item.classList.contains('filtered') && !item.classList.contains('tw-hidden')) { + if (!isHidden(item)) { curScopeVisibleItems.push(item as HTMLElement); } } @@ -337,20 +332,20 @@ export function hideScopedEmptyDividers(container: Element) { // hide all leading and trailing dividers while (visibleItems.length) { - if (!visibleItems[0].matches('.divider')) break; + if (!isDivider(visibleItems[0])) break; hideDivider(visibleItems[0]); visibleItems.shift(); } while (visibleItems.length) { - if (!visibleItems[visibleItems.length - 1].matches('.divider')) break; + if (!isDivider(visibleItems[visibleItems.length - 1])) break; hideDivider(visibleItems[visibleItems.length - 1]); visibleItems.pop(); } // hide all duplicate dividers, hide current divider if next sibling is still divider // no need to update "visibleItems" array since this is the last loop - for (const item of visibleItems) { - if (!item.matches('.divider')) continue; - if (item.nextElementSibling?.matches('.divider')) hideDivider(item); + for (let i = 0; i < visibleItems.length - 1; i++) { + if (!visibleItems[i].matches('.divider')) continue; + if (visibleItems[i + 1].matches('.divider')) hideDivider(visibleItems[i]); } } @@ -363,7 +358,7 @@ function onResponseKeepSelectedItem(dropdown: typeof $|HTMLElement, selectedValu // then the dropdown only shows other items and will select another (wrong) one. // It can't be easily fix by using setTimeout(patch, 0) in `onResponse` because the `onResponse` is called before another `setTimeout(..., timeLeft)` // Fortunately, the "timeLeft" is controlled by "loadingDuration" which is always zero at the moment, so we can use `setTimeout(..., 10)` - const elDropdown = (dropdown instanceof HTMLElement) ? dropdown : dropdown[0]; + const elDropdown = (dropdown instanceof HTMLElement) ? dropdown : (dropdown as any)[0]; setTimeout(() => { queryElems(elDropdown, `.menu .item[data-value="${CSS.escape(selectedValue)}"].filtered`, (el) => el.classList.remove('filtered')); $(elDropdown).dropdown('set selected', selectedValue ?? ''); diff --git a/web_src/js/modules/fomantic/modal.ts b/web_src/js/modules/fomantic/modal.ts index 6a2c558890..a96c7785e1 100644 --- a/web_src/js/modules/fomantic/modal.ts +++ b/web_src/js/modules/fomantic/modal.ts @@ -1,5 +1,7 @@ import $ from 'jquery'; import type {FomanticInitFunction} from '../../types.ts'; +import {queryElems} from '../../utils/dom.ts'; +import {hideToastsFrom} from '../toast.ts'; const fomanticModalFn = $.fn.modal; @@ -8,6 +10,8 @@ export function initAriaModalPatch() { if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once'); $.fn.modal = ariaModalFn; (ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings; + $.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden; + $.fn.modal.settings.onApprove = onModalApproveDefault; } // the patched `$.fn.modal` modal function @@ -27,3 +31,33 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) { } return ret; } + +function onModalBeforeHidden(this: any) { + const $modal = $(this); + const elModal = $modal[0]; + hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body); + + // reset the form after the modal is hidden, after other modal events and handlers (e.g. "onApprove", form submit) + setTimeout(() => { + queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset()); + }, 0); +} + +function onModalApproveDefault(this: any) { + const $modal = $(this); + const selectors = $modal.modal('setting', 'selector'); + const elModal = $modal[0]; + const elApprove = elModal.querySelector(selectors.approve); + const elForm = elApprove?.closest('form'); + if (!elForm) return true; // no form, just allow closing the modal + + // "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 (elForm.matches('.form-fetch-action')) return false; + + // There is an abuse for the "modal" + "form" combination, the "Approve" button is a traditional form submit button in the form. + // Then "approve" and "submit" occur at the same time, the modal will be closed immediately before the form is submitted. + // So here we prevent the modal from closing automatically by returning false, add the "is-loading" class to the form element. + elForm.classList.add('is-loading'); + return false; +} diff --git a/web_src/js/modules/fomantic/tab.ts b/web_src/js/modules/fomantic/tab.ts new file mode 100644 index 0000000000..ceae9dd098 --- /dev/null +++ b/web_src/js/modules/fomantic/tab.ts @@ -0,0 +1,19 @@ +import $ from 'jquery'; +import {queryElemSiblings} from '../../utils/dom.ts'; + +export function initFomanticTab() { + $.fn.tab = function (this: any) { + for (const elBtn of this) { + const tabName = elBtn.getAttribute('data-tab'); + if (!tabName) continue; + elBtn.addEventListener('click', () => { + const elTab = document.querySelector(`.ui.tab[data-tab="${tabName}"]`); + queryElemSiblings(elTab, `.ui.tab`, (el) => el.classList.remove('active')); + queryElemSiblings(elBtn, `[data-tab]`, (el) => el.classList.remove('active')); + elBtn.classList.add('active'); + elTab.classList.add('active'); + }); + } + return this; + }; +} diff --git a/web_src/js/modules/init.ts b/web_src/js/modules/init.ts new file mode 100644 index 0000000000..538fafd83f --- /dev/null +++ b/web_src/js/modules/init.ts @@ -0,0 +1,26 @@ +export class InitPerformanceTracer { + results: {name: string, dur: number}[] = []; + recordCall(name: string, func: ()=>void) { + const start = performance.now(); + func(); + this.results.push({name, dur: performance.now() - start}); + } + printResults() { + this.results = this.results.sort((a, b) => b.dur - a.dur); + for (let i = 0; i < 20 && i < this.results.length; i++) { + console.info(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`); + } + } +} + +export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null { + // Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1" + // It is a quick check, no side effect so no need to do slow URL parsing. + const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer(); + if (perfTracer) { + for (const func of functions) perfTracer.recordCall(func.name, func); + } else { + for (const func of functions) func(); + } + return perfTracer; +} diff --git a/web_src/js/modules/observer.ts b/web_src/js/modules/observer.ts new file mode 100644 index 0000000000..3305c2f29d --- /dev/null +++ b/web_src/js/modules/observer.ts @@ -0,0 +1,112 @@ +import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; +import type {Promisable} from 'type-fest'; +import type {InitPerformanceTracer} from './init.ts'; + +let globalSelectorObserverInited = false; + +type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void}; +const selectorHandlers: SelectorHandler[] = []; + +type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>; +const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {}; + +type GlobalInitFunc<T extends HTMLElement> = (el: T) => Promisable<void>; +const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {}; + +// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements. +export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) { + globalEventFuncs[`${event}:${name}`] = func as GlobalEventFunc<HTMLElement, Event>; +} + +// It handles the global init functions by a selector, for example: +// > registerGlobalSelectorObserver('.ui.dropdown:not(.custom)', (el) => { initDropdown(el, ...) }); +// ATTENTION: For most cases, it's recommended to use registerGlobalInitFunc instead, +// Because this selector-based approach is less efficient and less maintainable. +// But if there are already a lot of elements on many pages, this selector-based approach is more convenient for exiting code. +export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) { + selectorHandlers.push({selector, handler}); + // Then initAddedElementObserver will call this handler for all existing elements after all handlers are added. + // This approach makes the init stage only need to do one "querySelectorAll". + if (!globalSelectorObserverInited) return; + for (const el of document.querySelectorAll<HTMLElement>(selector)) { + handler(el); + } +} + +// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements. +export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) { + globalInitFuncs[name] = handler as GlobalInitFunc<HTMLElement>; + // The "global init" functions are managed internally and called by callGlobalInitFunc + // They must be ready before initGlobalSelectorObserver is called. + if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()'); +} + +function callGlobalInitFunc(el: HTMLElement) { + const initFunc = el.getAttribute('data-global-init'); + const func = globalInitFuncs[initFunc]; + if (!func) throw new Error(`Global init function "${initFunc}" not found`); + + // when an element node is removed and added again, it should not be re-initialized again. + type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean}; + if ((el as GiteaGlobalInitElement)._giteaGlobalInited) return; + (el as GiteaGlobalInitElement)._giteaGlobalInited = true; + + func(el); +} + +function attachGlobalEvents() { + // add global "[data-global-click]" event handler + document.addEventListener('click', (e) => { + const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]'); + if (!elem) return; + const funcName = elem.getAttribute('data-global-click'); + const func = globalEventFuncs[`click:${funcName}`]; + if (!func) throw new Error(`Global event function "click:${funcName}" not found`); + func(elem, e); + }); +} + +export function initGlobalSelectorObserver(perfTracer?: InitPerformanceTracer): void { + if (globalSelectorObserverInited) throw new Error('initGlobalSelectorObserver() already called'); + globalSelectorObserverInited = true; + + attachGlobalEvents(); + + selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc}); + const observer = new MutationObserver((mutationList) => { + const len = mutationList.length; + for (let i = 0; i < len; i++) { + const mutation = mutationList[i]; + const len = mutation.addedNodes.length; + for (let i = 0; i < len; i++) { + const addedNode = mutation.addedNodes[i] as HTMLElement; + if (!isDocumentFragmentOrElementNode(addedNode)) continue; + + for (const {selector, handler} of selectorHandlers) { + if (addedNode.matches(selector)) { + handler(addedNode); + } + for (const el of addedNode.querySelectorAll<HTMLElement>(selector)) { + handler(el); + } + } + } + } + }); + if (perfTracer) { + for (const {selector, handler} of selectorHandlers) { + perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => { + for (const el of document.querySelectorAll<HTMLElement>(selector)) { + handler(el); + } + }); + } + } else { + for (const {selector, handler} of selectorHandlers) { + for (const el of document.querySelectorAll<HTMLElement>(selector)) { + handler(el); + } + } + } + observer.observe(document, {subtree: true, childList: true}); +} diff --git a/web_src/js/modules/stores.ts b/web_src/js/modules/stores.ts deleted file mode 100644 index 942a7bc508..0000000000 --- a/web_src/js/modules/stores.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {reactive} from 'vue'; -import type {Reactive} from 'vue'; - -let diffTreeStoreReactive: Reactive<Record<string, any>>; -export function diffTreeStore() { - if (!diffTreeStoreReactive) { - diffTreeStoreReactive = reactive(window.config.pageData.diffFileInfo); - window.config.pageData.diffFileInfo = diffTreeStoreReactive; - } - return diffTreeStoreReactive; -} diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index bc6d5bfdd6..2a1d998d76 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js'; import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import {formatDatetime} from '../utils/time.ts'; import type {Content, Instance, Placement, Props} from 'tippy.js'; +import {html} from '../utils/html.ts'; type TippyOpts = { role?: string, @@ -9,7 +10,7 @@ type TippyOpts = { } & Partial<Props>; const visibleInstances = new Set<Instance>(); -const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`; +const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`; export function createTippy(target: Element, opts: TippyOpts = {}): Instance { // the callback functions should be destructured from opts, @@ -40,18 +41,20 @@ export function createTippy(target: Element, opts: TippyOpts = {}): Instance { } } visibleInstances.add(instance); + target.setAttribute('aria-controls', instance.popper.id); return onShow?.(instance); }, - arrow: arrow || (theme === 'bare' ? false : arrowSvg), + arrow: arrow ?? (theme === 'bare' ? false : arrowSvg), // HTML role attribute, ideally the default role would be "popover" but it does not exist role: role || 'menu', // CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare" theme: theme || role || 'default', + offset: [0, arrow ? 10 : 6], plugins: [followCursor], ...other, } satisfies Partial<Props>); - if (role === 'menu') { + if (instance.props.role === 'menu') { target.setAttribute('aria-haspopup', 'true'); } @@ -179,13 +182,25 @@ export function initGlobalTooltips(): void { } 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; - const tippy = target._tippy ?? attachTooltip(target, content); - tippy.setContent(content); - if (!tippy.state.isShown) tippy.show(); - tippy.setProps({ + // if the target is inside a dropdown or tippy popup, the menu will be hidden soon + // so display the tooltip on the "aria-controls" element or dropdown instead + let refClientRect: DOMRect; + const popupTippyId = target.closest(`[data-tippy-root]`)?.id; + if (popupTippyId) { + // for example, the "Copy Permalink" button in the "File View" page for the selected lines + target = document.body; + refClientRect = document.querySelector(`[aria-controls="${CSS.escape(popupTippyId)}"]`)?.getBoundingClientRect(); + refClientRect = refClientRect ?? new DOMRect(0, 0, 0, 0); // fallback to empty rect if not found, tippy doesn't accept null + } else { + // for example, the "Copy Link" button in the issue header dropdown menu + target = target.closest('.ui.dropdown') ?? target; + refClientRect = target.getBoundingClientRect(); + } + const tooltipTippy = target._tippy ?? attachTooltip(target, content); + tooltipTippy.setContent(content); + tooltipTippy.setProps({getReferenceClientRect: () => refClientRect}); + if (!tooltipTippy.state.isShown) tooltipTippy.show(); + tooltipTippy.setProps({ onHidden: (tippy) => { // reset the default tooltip content, if no default, then this temporary tooltip could be destroyed if (!attachTooltip(target)) { diff --git a/web_src/js/modules/toast.ts b/web_src/js/modules/toast.ts index 36e2321743..ed807a4977 100644 --- a/web_src/js/modules/toast.ts +++ b/web_src/js/modules/toast.ts @@ -1,6 +1,6 @@ -import {htmlEscape} from 'escape-goat'; +import {htmlEscape} from '../utils/html.ts'; import {svg} from '../svg.ts'; -import {animateOnce, showElem} from '../utils/dom.ts'; +import {animateOnce, queryElems, showElem} from '../utils/dom.ts'; import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown import type {Intent} from '../types.ts'; import type {SvgName} from '../svg.ts'; @@ -37,17 +37,20 @@ const levels: ToastLevels = { type ToastOpts = { useHtmlBody?: boolean, - preventDuplicates?: boolean, + preventDuplicates?: boolean | string, } & Options; +type ToastifyElement = HTMLElement & {_giteaToastifyInstance?: Toast }; + // See https://github.com/apvarun/toastify-js#api for options function showToast(message: string, level: Intent, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other}: ToastOpts = {}): Toast { const body = useHtmlBody ? String(message) : htmlEscape(message); - const key = `${level}-${body}`; + const parent = document.querySelector('.ui.dimmer.active') ?? document.body; + const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${body}` : preventDuplicates) : ''; - // prevent showing duplicate toasts with same level and message, and give a visual feedback for end users + // prevent showing duplicate toasts with the same level and message, and give visual feedback for end users if (preventDuplicates) { - const toastEl = document.querySelector(`.toastify[data-toast-unique-key="${CSS.escape(key)}"]`); + const toastEl = parent.querySelector(`:scope > .toastify.on[data-toast-unique-key="${CSS.escape(duplicateKey)}"]`); if (toastEl) { const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number'); showElem(toastDupNumEl); @@ -59,6 +62,7 @@ function showToast(message: string, level: Intent, {gravity, position, duration, const {icon, background, duration: levelDuration} = levels[level ?? 'info']; const toast = Toastify({ + selector: parent, text: ` <div class='toast-icon'>${svg(icon)}</div> <div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${body}</div> @@ -74,7 +78,8 @@ function showToast(message: string, level: Intent, {gravity, position, duration, toast.showToast(); toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast()); - toast.toastElement.setAttribute('data-toast-unique-key', key); + toast.toastElement.setAttribute('data-toast-unique-key', duplicateKey); + (toast.toastElement as ToastifyElement)._giteaToastifyInstance = toast; return toast; } @@ -89,3 +94,15 @@ export function showWarningToast(message: string, opts?: ToastOpts): Toast { export function showErrorToast(message: string, opts?: ToastOpts): Toast { return showToast(message, 'error', opts); } + +function hideToastByElement(el: Element): void { + (el as ToastifyElement)?._giteaToastifyInstance?.hideToast(); +} + +export function hideToastsFrom(parent: Element): void { + queryElems(parent, ':scope > .toastify.on', hideToastByElement); +} + +export function hideToastsAll(): void { + queryElems(document, '.toastify.on', hideToastByElement); +} diff --git a/web_src/js/render/pdf.ts b/web_src/js/render/pdf.ts deleted file mode 100644 index f31f161e6e..0000000000 --- a/web_src/js/render/pdf.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {htmlEscape} from 'escape-goat'; - -export async function initPdfViewer() { - const els = document.querySelectorAll('.pdf-content'); - if (!els.length) return; - - const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); - - for (const el of els) { - const src = el.getAttribute('data-src'); - const fallbackText = el.getAttribute('data-fallback-button-text'); - pdfobject.embed(src, el, { - fallbackLink: htmlEscape` - <a role="button" class="ui basic button pdf-fallback-button" href="[url]">${fallbackText}</a> - `, - }); - el.classList.remove('is-loading'); - } -} diff --git a/web_src/js/render/plugin.ts b/web_src/js/render/plugin.ts new file mode 100644 index 0000000000..a8dd0a7c05 --- /dev/null +++ b/web_src/js/render/plugin.ts @@ -0,0 +1,10 @@ +export type FileRenderPlugin = { + // unique plugin name + name: string; + + // test if plugin can handle a specified file + canHandle: (filename: string, mimeType: string) => boolean; + + // render file content + render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>; +} diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts new file mode 100644 index 0000000000..2a0929359d --- /dev/null +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -0,0 +1,60 @@ +import type {FileRenderPlugin} from '../plugin.ts'; +import {extname} from '../../utils.ts'; + +// support common 3D model file formats, use online-3d-viewer library for rendering + +// eslint-disable-next-line multiline-comment-style +/* a simple text STL file example: +solid SimpleTriangle + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet +endsolid SimpleTriangle +*/ + +export function newRenderPlugin3DViewer(): FileRenderPlugin { + // Some extensions are text-based formats: + // .3mf .amf .brep: XML + // .fbx: XML or BINARY + // .dae .gltf: JSON + // .ifc, .igs, .iges, .stp, .step are: TEXT + // .stl .ply: TEXT or BINARY + // .obj .off .wrl: TEXT + // So we need to be able to render when the file is recognized as plaintext file by backend. + // + // It needs more logic to make it overall right (render a text 3D model automatically): + // we need to distinguish the ambiguous filename extensions. + // For example: "*.obj, *.off, *.step" might be or not be a 3D model file. + // So when it is a text file, we can't assume that "we only render it by 3D plugin", + // otherwise the end users would be impossible to view its real content when the file is not a 3D model. + const SUPPORTED_EXTENSIONS = [ + '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', + '.dae', '.fbx', '.fcstd', '.glb', '.gltf', + '.ifc', '.igs', '.iges', '.stp', '.step', + '.stl', '.obj', '.off', '.ply', '.wrl', + ]; + + return { + name: '3d-model-viewer', + + canHandle(filename: string, _mimeType: string): boolean { + const ext = extname(filename).toLowerCase(); + return SUPPORTED_EXTENSIONS.includes(ext); + }, + + async render(container: HTMLElement, fileUrl: string): Promise<void> { + // TODO: height and/or max-height? + const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); + const viewer = new OV.EmbeddedViewer(container, { + backgroundColor: new OV.RGBAColor(59, 68, 76, 0), + defaultColor: new OV.RGBColor(65, 131, 196), + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), + }); + viewer.LoadModelFromUrlList([fileUrl]); + }, + }; +} diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts new file mode 100644 index 0000000000..40623be055 --- /dev/null +++ b/web_src/js/render/plugins/pdf-viewer.ts @@ -0,0 +1,20 @@ +import type {FileRenderPlugin} from '../plugin.ts'; + +export function newRenderPluginPdfViewer(): FileRenderPlugin { + return { + name: 'pdf-viewer', + + canHandle(filename: string, _mimeType: string): boolean { + return filename.toLowerCase().endsWith('.pdf'); + }, + + async render(container: HTMLElement, fileUrl: string): Promise<void> { + const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); + // TODO: the PDFObject library does not support dynamic height adjustment, + container.style.height = `${window.innerHeight - 100}px`; + if (!PDFObject.default.embed(fileUrl, container)) { + throw new Error('Unable to render the PDF file'); + } + }, + }; +} diff --git a/web_src/js/standalone/devtest.ts b/web_src/js/standalone/devtest.ts index 3489697a2f..e6baf6c9ce 100644 --- a/web_src/js/standalone/devtest.ts +++ b/web_src/js/standalone/devtest.ts @@ -1,7 +1,7 @@ import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.ts'; function initDevtestToast() { - const levelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast}; + const levelMap: Record<string, any> = {info: showInfoToast, warning: showWarningToast, error: showErrorToast}; for (const el of document.querySelectorAll('.toast-test-button')) { el.addEventListener('click', () => { const level = el.getAttribute('data-toast-level'); diff --git a/web_src/js/standalone/swagger.ts b/web_src/js/standalone/swagger.ts index 63b676b2ea..4b17ba21a8 100644 --- a/web_src/js/standalone/swagger.ts +++ b/web_src/js/standalone/swagger.ts @@ -14,7 +14,7 @@ window.addEventListener('load', async () => { return 0; }); - const ui = SwaggerUI({ + SwaggerUI({ spec, dom_id: '#swagger-ui', deepLinking: true, @@ -27,6 +27,4 @@ window.addEventListener('load', async () => { SwaggerUI.plugins.DownloadUrl, ], }); - - window.ui = ui; }); diff --git a/web_src/js/svg.test.ts b/web_src/js/svg.test.ts index 7f3e0496ec..715b739a82 100644 --- a/web_src/js/svg.test.ts +++ b/web_src/js/svg.test.ts @@ -16,12 +16,11 @@ test('svgParseOuterInner', () => { test('SvgIcon', () => { const root = document.createElement('div'); - createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base', className: 'extra'})}).mount(root); + createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base'})}).mount(root); const node = root.firstChild as Element; expect(node.nodeName).toEqual('svg'); expect(node.getAttribute('width')).toEqual('24'); expect(node.getAttribute('height')).toEqual('24'); expect(node.classList.contains('octicon-link')).toBeTruthy(); expect(node.classList.contains('base')).toBeTruthy(); - expect(node.classList.contains('extra')).toBeTruthy(); }); diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts index 1dd6922abb..50c9536f37 100644 --- a/web_src/js/svg.ts +++ b/web_src/js/svg.ts @@ -1,5 +1,6 @@ import {defineComponent, h, type PropType} from 'vue'; import {parseDom, serializeXml} from './utils.ts'; +import {html, htmlRaw} from './utils/html.ts'; import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg'; import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg'; import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg'; @@ -29,6 +30,7 @@ import octiconFile from '../../public/assets/img/svg/octicon-file.svg'; import octiconFileDirectoryFill from '../../public/assets/img/svg/octicon-file-directory-fill.svg'; import octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-file-directory-open-fill.svg'; import octiconFileSubmodule from '../../public/assets/img/svg/octicon-file-submodule.svg'; +import octiconFileSymlinkFile from '../../public/assets/img/svg/octicon-file-symlink-file.svg'; import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg'; import octiconGear from '../../public/assets/img/svg/octicon-gear.svg'; import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg'; @@ -107,6 +109,7 @@ const svgs = { 'octicon-file-directory-fill': octiconFileDirectoryFill, 'octicon-file-directory-open-fill': octiconFileDirectoryOpenFill, 'octicon-file-submodule': octiconFileSubmodule, + 'octicon-file-symlink-file': octiconFileSymlinkFile, 'octicon-filter': octiconFilter, 'octicon-gear': octiconGear, 'octicon-git-branch': octiconGitBranch, @@ -201,14 +204,13 @@ export const SvgIcon = defineComponent({ props: { name: {type: String as PropType<SvgName>, required: true}, size: {type: Number, default: 16}, - className: {type: String, default: ''}, symbolId: {type: String}, }, render() { let {svgOuter, svgInnerHtml} = svgParseOuterInner(this.name); // https://vuejs.org/guide/extras/render-function.html#creating-vnodes // the `^` is used for attr, set SVG attributes like 'width', `aria-hidden`, `viewBox`, etc - const attrs = {}; + const attrs: Record<string, any> = {}; for (const attr of svgOuter.attributes) { if (attr.name === 'class') continue; attrs[`^${attr.name}`] = attr.value; @@ -216,18 +218,10 @@ export const SvgIcon = defineComponent({ attrs[`^width`] = this.size; attrs[`^height`] = this.size; - // make the <SvgIcon class="foo" class-name="bar"> classes work together - const classes: Array<string> = []; - for (const cls of svgOuter.classList) { - classes.push(cls); - } - // TODO: drop the `className/class-name` prop in the future, only use "class" prop - if (this.className) { - classes.push(...this.className.split(/\s+/).filter(Boolean)); - } + const classes = Array.from(svgOuter.classList); if (this.symbolId) { classes.push('tw-hidden', 'svg-symbol-container'); - svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`; + svgInnerHtml = html`<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${htmlRaw(svgInnerHtml)}</symbol>`; } // create VNode return h('svg', { diff --git a/web_src/js/types.ts b/web_src/js/types.ts index e7c9ac0df4..1b5e652f66 100644 --- a/web_src/js/types.ts +++ b/web_src/js/types.ts @@ -22,6 +22,8 @@ export type Config = { i18n: Record<string, string>, } +export type IntervalId = ReturnType<typeof setInterval>; + export type Intent = 'error' | 'warning' | 'info'; export type RequestData = string | FormData | URLSearchParams | Record<string, any>; @@ -30,6 +32,11 @@ export type RequestOpts = { data?: RequestData, } & RequestInit; +export type RepoOwnerPathInfo = { + ownerName: string, + repoName: string, +} + export type IssuePathInfo = { ownerName: string, repoName: string, diff --git a/web_src/js/utils.test.ts b/web_src/js/utils.test.ts index b527111533..f1025471a4 100644 --- a/web_src/js/utils.test.ts +++ b/web_src/js/utils.test.ts @@ -1,9 +1,15 @@ import { - basename, extname, isObject, stripTags, parseIssueHref, + dirname, basename, extname, isObject, stripTags, parseIssueHref, parseUrl, translateMonth, translateDay, blobToDataURI, - toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseIssueNewHref, + toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo, } from './utils.ts'; +test('dirname', () => { + expect(dirname('/path/to/file.js')).toEqual('/path/to'); + expect(dirname('/path/to')).toEqual('/path'); + expect(dirname('file.js')).toEqual(''); +}); + test('basename', () => { expect(basename('/path/to/file.js')).toEqual('file.js'); expect(basename('/path/to/file')).toEqual('file'); @@ -45,12 +51,14 @@ test('parseIssueHref', () => { expect(parseIssueHref('')).toEqual({ownerName: undefined, repoName: undefined, type: undefined, index: undefined}); }); -test('parseIssueNewHref', () => { - expect(parseIssueNewHref('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'}); - expect(parseIssueNewHref('/owner/repo/issues/new?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'}); - expect(parseIssueNewHref('/sub/owner/repo/issues/new#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'}); - expect(parseIssueNewHref('/sub/owner/repo/compare/feature/branch-1...fix/branch-2')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls'}); - expect(parseIssueNewHref('/other')).toEqual({}); +test('parseRepoOwnerPathInfo', () => { + expect(parseRepoOwnerPathInfo('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'}); + expect(parseRepoOwnerPathInfo('/owner/repo/releases')).toEqual({ownerName: 'owner', repoName: 'repo'}); + expect(parseRepoOwnerPathInfo('/other')).toEqual({}); + window.config.appSubUrl = '/sub'; + expect(parseRepoOwnerPathInfo('/sub/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'}); + expect(parseRepoOwnerPathInfo('/sub/owner/repo/compare/feature/branch-1...fix/branch-2')).toEqual({ownerName: 'owner', repoName: 'repo'}); + window.config.appSubUrl = ''; }); test('parseUrl', () => { diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts index 2a2bdc60f9..e33b1413e8 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -1,5 +1,12 @@ import {decode, encode} from 'uint8-to-base64'; -import type {IssuePageInfo, IssuePathInfo} from './types.ts'; +import type {IssuePageInfo, IssuePathInfo, RepoOwnerPathInfo} from './types.ts'; +import {toggleClass, toggleElem} from './utils/dom.ts'; + +// transform /path/to/file.ext to /path/to +export function dirname(path: string): string { + const lastSlashIndex = path.lastIndexOf('/'); + return lastSlashIndex < 0 ? '' : path.substring(0, lastSlashIndex); +} // transform /path/to/file.ext to file.ext export function basename(path: string): string { @@ -32,16 +39,17 @@ export function stripTags(text: string): string { } export function parseIssueHref(href: string): IssuePathInfo { + // FIXME: it should use pathname and trim the appSubUrl ahead const path = (href || '').replace(/[#?].*$/, ''); const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || []; return {ownerName, repoName, pathType, indexString}; } -export function parseIssueNewHref(href: string): IssuePathInfo { - const path = (href || '').replace(/[#?].*$/, ''); - const [_, ownerName, repoName, pathTypeField] = /([^/]+)\/([^/]+)\/(issues\/new|compare\/.+\.\.\.)/.exec(path) || []; - const pathType = pathTypeField ? (pathTypeField.startsWith('issues/new') ? 'issues' : 'pulls') : undefined; - return {ownerName, repoName, pathType}; +export function parseRepoOwnerPathInfo(pathname: string): RepoOwnerPathInfo { + const appSubUrl = window.config.appSubUrl; + if (appSubUrl && pathname.startsWith(appSubUrl)) pathname = pathname.substring(appSubUrl.length); + const [_, ownerName, repoName] = /([^/]+)\/([^/]+)/.exec(pathname) || []; + return {ownerName, repoName}; } export function parseIssuePageInfo(): IssuePageInfo { @@ -165,10 +173,31 @@ export function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function isImageFile({name, type}: {name: string, type?: string}): boolean { +export function isImageFile({name, type}: {name?: string, type?: string}): boolean { 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 { +export function isVideoFile({name, type}: {name?: string, type?: string}): boolean { return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/'); } + +export function toggleFullScreen(fullscreenElementsSelector: string, isFullScreen: boolean, sourceParentSelector?: string): void { + // hide other elements + const headerEl = document.querySelector('#navbar'); + const contentEl = document.querySelector('.page-content'); + const footerEl = document.querySelector('.page-footer'); + toggleElem(headerEl, !isFullScreen); + toggleElem(contentEl, !isFullScreen); + toggleElem(footerEl, !isFullScreen); + + const sourceParentEl = sourceParentSelector ? document.querySelector(sourceParentSelector) : contentEl; + + const fullScreenEl = document.querySelector(fullscreenElementsSelector); + const outerEl = document.querySelector('.full.height'); + toggleClass(fullscreenElementsSelector, 'fullscreen', isFullScreen); + if (isFullScreen) { + outerEl.append(fullScreenEl); + } else { + sourceParentEl.append(fullScreenEl); + } +} diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index 6e71596850..057ea9808c 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -1,4 +1,10 @@ -import {createElementFromAttrs, createElementFromHTML, queryElemChildren, querySingleVisibleElem} from './dom.ts'; +import { + createElementFromAttrs, + createElementFromHTML, + queryElemChildren, + querySingleVisibleElem, + toggleElem, +} from './dom.ts'; test('createElementFromHTML', () => { expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>'); @@ -19,10 +25,14 @@ test('createElementFromAttrs', () => { }); test('querySingleVisibleElem', () => { - let el = createElementFromHTML('<div><span>foo</span></div>'); + let el = createElementFromHTML('<div></div>'); + expect(querySingleVisibleElem(el, 'span')).toBeNull(); + el = createElementFromHTML('<div><span>foo</span></div>'); expect(querySingleVisibleElem(el, 'span').textContent).toEqual('foo'); el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>'); expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar'); + el = createElementFromHTML('<div><span class="some-class tw-hidden">foo</span><span>bar</span></div>'); + expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar'); el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>'); expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element'); }); @@ -32,3 +42,13 @@ test('queryElemChildren', () => { const children = queryElemChildren(el, '.a'); expect(children.length).toEqual(1); }); + +test('toggleElem', () => { + const el = createElementFromHTML('<p><div>a</div><div class="tw-hidden">b</div></p>'); + toggleElem(el.children); + expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="">b</div></p>'); + toggleElem(el.children, false); + expect(el.outerHTML).toEqual('<p><div class="tw-hidden">a</div><div class="tw-hidden">b</div></p>'); + toggleElem(el.children, true); + expect(el.outerHTML).toEqual('<p><div class="">a</div><div class="">b</div></p>'); +}); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index e24cb29bac..8b540cebb1 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -9,55 +9,50 @@ type ElementsCallback<T extends Element> = (el: T) => Promisable<any>; type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>; export type DOMEvent<E extends Event, T extends Element = HTMLElement> = E & { target: Partial<T>; }; -function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) { +function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]): ArrayLikeIterable<Element> { if (typeof el === 'string' || el instanceof String) { el = document.querySelectorAll(el as string); } if (el instanceof Node) { func(el, ...args); + return [el]; } else if (el.length !== undefined) { // this works for: NodeList, HTMLCollection, Array, jQuery - for (const e of (el as ArrayLikeIterable<Element>)) { - func(e, ...args); - } - } else { - throw new Error('invalid argument to be shown/hidden'); + const elems = el as ArrayLikeIterable<Element>; + for (const elem of elems) func(elem, ...args); + return elems; } + throw new Error('invalid argument to be shown/hidden'); +} + +export function toggleClass(el: ElementArg, className: string, force?: boolean): ArrayLikeIterable<Element> { + return elementsCall(el, (e: Element) => { + if (force === true) { + e.classList.add(className); + } else if (force === false) { + e.classList.remove(className); + } else if (force === undefined) { + e.classList.toggle(className); + } else { + throw new Error('invalid force argument'); + } + }); } /** - * @param el Element + * @param el ElementArg * @param force force=true to show or force=false to hide, undefined to toggle */ -function toggleShown(el: Element, force: boolean) { - if (force === true) { - el.classList.remove('tw-hidden'); - } else if (force === false) { - el.classList.add('tw-hidden'); - } else if (force === undefined) { - el.classList.toggle('tw-hidden'); - } else { - throw new Error('invalid force argument'); - } +export function toggleElem(el: ElementArg, force?: boolean): ArrayLikeIterable<Element> { + return toggleClass(el, 'tw-hidden', force === undefined ? force : !force); } -export function showElem(el: ElementArg) { - elementsCall(el, toggleShown, true); +export function showElem(el: ElementArg): ArrayLikeIterable<Element> { + return toggleElem(el, true); } -export function hideElem(el: ElementArg) { - elementsCall(el, toggleShown, false); -} - -export function toggleElem(el: ElementArg, force?: boolean) { - elementsCall(el, toggleShown, force); -} - -export function isElemHidden(el: ElementArg) { - const res: boolean[] = []; - elementsCall(el, (e) => res.push(e.classList.contains('tw-hidden'))); - if (res.length > 1) throw new Error(`isElemHidden doesn't work for multiple elements`); - return res[0]; +export function hideElem(el: ElementArg): ArrayLikeIterable<Element> { + return toggleElem(el, false); } function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> { @@ -87,7 +82,7 @@ export function queryElemChildren<T extends Element>(parent: Element | ParentNod } // it works like parent.querySelectorAll: all descendants are selected -// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent +// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent if the targets are not for page-level components. export function queryElems<T extends HTMLElement>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback<T>): ArrayLikeIterable<T> { return applyElemsCallback<T>(parent.querySelectorAll(selector), fn); } @@ -166,6 +161,7 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = function resizeToFit() { if (isUserResized) return; if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return; + const previousMargin = textarea.style.marginBottom; try { const {top, bottom} = overflowOffset(); @@ -181,6 +177,9 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = const curHeight = parseFloat(computedStyle.height); const maxHeight = curHeight + bottom - adjustedViewportMarginBottom; + // In Firefox, setting auto height momentarily may cause the page to scroll up + // unexpectedly, prevent this by setting a temporary margin. + textarea.style.marginBottom = `${textarea.clientHeight}px`; textarea.style.height = 'auto'; let newHeight = textarea.scrollHeight + borderAddOn; @@ -201,6 +200,12 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = textarea.style.height = `${newHeight}px`; lastStyleHeight = textarea.style.height; } finally { + // restore previous margin + if (previousMargin) { + textarea.style.marginBottom = previousMargin; + } else { + textarea.style.removeProperty('margin-bottom'); + } // ensure that the textarea is fully scrolled to the end, when the cursor // is at the end during an input event if (textarea.selectionStart === textarea.selectionEnd && @@ -255,12 +260,12 @@ export function loadElem(el: LoadableElement, src: string) { // it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)" const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined'; -export function submitEventSubmitter(e) { +export function submitEventSubmitter(e: any) { e = e.originalEvent ?? e; // if the event is wrapped by jQuery, use "originalEvent", otherwise, use the event itself return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter; } -function submitEventPolyfillListener(e) { +function submitEventPolyfillListener(e: DOMEvent<Event>) { const form = e.target.closest('form'); if (!form) return; form._submitter = e.target.closest('button:not([type]), button[type="submit"], input[type="submit"]'); @@ -273,14 +278,12 @@ export function initSubmitEventPolyfill() { document.body.addEventListener('focus', submitEventPolyfillListener); } -/** - * Check if an element is visible, equivalent to jQuery's `:visible` pseudo. - * Note: This function doesn't account for all possible visibility scenarios. - */ -export function isElemVisible(element: HTMLElement): boolean { - if (!element) return false; - // checking element.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout - return Boolean((element.offsetWidth || element.offsetHeight || element.getClientRects().length) && element.style.display !== 'none'); +export function isElemVisible(el: HTMLElement): boolean { + // Check if an element is visible, equivalent to jQuery's `:visible` pseudo. + // This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem" + if (!el) return false; + // checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout + return !el.classList.contains('tw-hidden') && Boolean((el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none'); } // replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this @@ -311,6 +314,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st 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 + // eslint-disable-next-line github/unescaped-html-literal -- FIXME: maybe we need to use other approaches to create elements from HTML, e.g. using DOMParser if (htmlString.startsWith('<tr')) { const container = document.createElement('table'); container.innerHTML = htmlString; @@ -355,10 +359,19 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s return candidates.length ? candidates[0] as T : null; } -export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => void | Promise<any>, options?: boolean | AddEventListenerOptions) { +export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => Promisable<void>, options?: boolean | AddEventListenerOptions) { parent.addEventListener(type, (e: Event) => { const elem = (e.target as HTMLElement).closest(selector); - if (!elem) return; + // It strictly checks "parent contains the target elem" to avoid side effects of selector running on outside the parent. + // Keep in mind that the elem could have been removed from parent by other event handlers before this event handler is called. + // For example: tippy popup item, the tippy popup could be hidden and removed from DOM before this. + // It is caller's responsibility make sure the elem is still in parent's DOM when this event handler is called. + if (!elem || (parent !== document && !parent.contains(elem))) return; listener(elem as T, e as E); }, options); } + +/** Returns whether a click event is a left-click without any modifiers held */ +export function isPlainClick(e: MouseEvent) { + return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; +} diff --git a/web_src/js/utils/html.test.ts b/web_src/js/utils/html.test.ts new file mode 100644 index 0000000000..3028b7bb0a --- /dev/null +++ b/web_src/js/utils/html.test.ts @@ -0,0 +1,8 @@ +import {html, htmlEscape, htmlRaw} from './html.ts'; + +test('html', async () => { + expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a><>&'"</a>`); + expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`); + expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &></a>`); + expect(htmlEscape(`<a></a>`)).toBe(`<a></a>`); +}); diff --git a/web_src/js/utils/html.ts b/web_src/js/utils/html.ts new file mode 100644 index 0000000000..22e5703c34 --- /dev/null +++ b/web_src/js/utils/html.ts @@ -0,0 +1,32 @@ +export function htmlEscape(s: string, ...args: Array<any>): string { + if (args.length !== 0) throw new Error('use html or htmlRaw instead of htmlEscape'); // check legacy usages + return s.replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +class rawObject { + private readonly value: string; + constructor(v: string) { this.value = v } + toString(): string { return this.value } +} + +export function html(tmpl: TemplateStringsArray, ...parts: Array<any>): string { + let output = tmpl[0]; + for (let i = 0; i < parts.length; i++) { + const value = parts[i]; + const valueEscaped = (value instanceof rawObject) ? value.toString() : htmlEscape(String(parts[i])); + output = output + valueEscaped + tmpl[i + 1]; + } + return output; +} + +export function htmlRaw(s: string|TemplateStringsArray, ...tmplParts: Array<any>): rawObject { + if (typeof s === 'string') { + if (tmplParts.length !== 0) throw new Error("either htmlRaw('str') or htmlRaw`tmpl`"); + return new rawObject(s); + } + return new rawObject(html(s, ...tmplParts)); +} diff --git a/web_src/js/utils/image.test.ts b/web_src/js/utils/image.test.ts index da0605f1d0..49856c891c 100644 --- a/web_src/js/utils/image.test.ts +++ b/web_src/js/utils/image.test.ts @@ -4,7 +4,7 @@ const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A=='; const pngEmpty = 'data:image/png;base64,'; -async function dataUriToBlob(datauri) { +async function dataUriToBlob(datauri: string) { return await (await globalThis.fetch(datauri)).blob(); } diff --git a/web_src/js/utils/time.ts b/web_src/js/utils/time.ts index 6951ebfedb..c63498345f 100644 --- a/web_src/js/utils/time.ts +++ b/web_src/js/utils/time.ts @@ -54,7 +54,7 @@ export type DayDataObject = { } export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataObject): DayData[] { - const result = {}; + const result: Record<string, any> = {}; for (const startDay of startDays) { result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0}; diff --git a/web_src/js/webcomponents/absolute-date.test.ts b/web_src/js/webcomponents/absolute-date.test.ts index a3866829a7..bf591358bd 100644 --- a/web_src/js/webcomponents/absolute-date.test.ts +++ b/web_src/js/webcomponents/absolute-date.test.ts @@ -20,7 +20,7 @@ test('toAbsoluteLocaleDate', () => { // test different timezone const oldTZ = process.env.TZ; process.env.TZ = 'America/New_York'; - expect(new Date('2024-03-15').toLocaleString()).toEqual('3/14/2024, 8:00:00 PM'); - expect(toAbsoluteLocaleDate('2024-03-15')).toEqual('3/15/2024, 12:00:00 AM'); + expect(new Date('2024-03-15').toLocaleString('en-US')).toEqual('3/14/2024, 8:00:00 PM'); + expect(toAbsoluteLocaleDate('2024-03-15', 'en-US')).toEqual('3/15/2024, 12:00:00 AM'); process.env.TZ = oldTZ; }); diff --git a/web_src/js/webcomponents/absolute-date.ts b/web_src/js/webcomponents/absolute-date.ts index 8eb1c3e37e..23a8606673 100644 --- a/web_src/js/webcomponents/absolute-date.ts +++ b/web_src/js/webcomponents/absolute-date.ts @@ -15,7 +15,7 @@ window.customElements.define('absolute-date', class extends HTMLElement { initialized = false; update = () => { - const opt: Intl.DateTimeFormatOptions = {}; + const opt: Record<string, string> = {}; for (const attr of ['year', 'month', 'weekday', 'day']) { if (this.getAttribute(attr)) opt[attr] = this.getAttribute(attr); } diff --git a/web_src/js/webcomponents/overflow-menu.ts b/web_src/js/webcomponents/overflow-menu.ts index 4e729a268a..ae93f2b758 100644 --- a/web_src/js/webcomponents/overflow-menu.ts +++ b/web_src/js/webcomponents/overflow-menu.ts @@ -1,6 +1,6 @@ import {throttle} from 'throttle-debounce'; import {createTippy} from '../modules/tippy.ts'; -import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; +import {addDelegatedEventListener, isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg'; window.customElements.define('overflow-menu', class extends HTMLElement { @@ -12,10 +12,14 @@ window.customElements.define('overflow-menu', class extends HTMLElement { mutationObserver: MutationObserver; lastWidth: number; + updateButtonActivationState() { + if (!this.button || !this.tippyContent) return; + this.button.classList.toggle('active', Boolean(this.tippyContent.querySelector('.item.active'))); + } + updateItems = throttle(100, () => { if (!this.tippyContent) { const div = document.createElement('div'); - div.classList.add('tippy-target'); div.tabIndex = -1; // for initial focus, programmatic focus only div.addEventListener('keydown', (e) => { if (e.key === 'Tab') { @@ -64,9 +68,10 @@ window.customElements.define('overflow-menu', class extends HTMLElement { } } }); - this.append(div); + div.classList.add('tippy-target'); + this.handleItemClick(div, '.tippy-target > .item'); this.tippyContent = div; - } + } // end if: no tippyContent and create a new one const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space'); const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button'); @@ -88,7 +93,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { const menuRight = this.offsetLeft + this.offsetWidth; const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space'); let afterFlexSpace = false; - for (const item of menuItems) { + for (const [idx, item] of menuItems.entries()) { if (item.classList.contains('item-flex-space')) { afterFlexSpace = true; continue; @@ -96,7 +101,10 @@ window.customElements.define('overflow-menu', class extends HTMLElement { if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true'); const itemRight = item.offsetLeft + item.offsetWidth; if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space - this.tippyItems.push(item); + const onlyLastItem = idx === menuItems.length - 1 && this.tippyItems.length === 0; + const lastItemFit = onlyLastItem && menuRight - itemRight > 0; + const moveToPopup = !onlyLastItem || !lastItemFit; + if (moveToPopup) this.tippyItems.push(item); } } itemFlexSpace?.style.removeProperty('display'); @@ -107,6 +115,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { const btn = this.querySelector('.overflow-menu-button'); btn?._tippy?.destroy(); btn?.remove(); + this.button = null; return; } @@ -126,18 +135,17 @@ window.customElements.define('overflow-menu', class extends HTMLElement { // update existing tippy if (this.button?._tippy) { this.button._tippy.setContent(this.tippyContent); + this.updateButtonActivationState(); return; } // create button initially - const btn = document.createElement('button'); - btn.classList.add('overflow-menu-button'); - btn.setAttribute('aria-label', window.config.i18n.more_items); - btn.innerHTML = octiconKebabHorizontal; - this.append(btn); - this.button = btn; - - createTippy(btn, { + this.button = document.createElement('button'); + this.button.classList.add('overflow-menu-button'); + this.button.setAttribute('aria-label', window.config.i18n.more_items); + this.button.innerHTML = octiconKebabHorizontal; + this.append(this.button); + createTippy(this.button, { trigger: 'click', hideOnClick: true, interactive: true, @@ -151,6 +159,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { }, 0); }, }); + this.updateButtonActivationState(); }); init() { @@ -187,6 +196,14 @@ window.customElements.define('overflow-menu', class extends HTMLElement { } }); this.resizeObserver.observe(this); + this.handleItemClick(this, '.overflow-menu-items > .item'); + } + + handleItemClick(el: Element, selector: string) { + addDelegatedEventListener(el, 'click', selector, () => { + this.button?._tippy?.hide(); + this.updateButtonActivationState(); + }); } connectedCallback() { diff --git a/web_src/js/webcomponents/polyfill.test.ts b/web_src/js/webcomponents/polyfill.test.ts new file mode 100644 index 0000000000..4fb4621547 --- /dev/null +++ b/web_src/js/webcomponents/polyfill.test.ts @@ -0,0 +1,7 @@ +import {weakRefClass} from './polyfills.ts'; + +test('polyfillWeakRef', () => { + const WeakRef = weakRefClass(); + const r = new WeakRef(123); + expect(r.deref()).toEqual(123); +}); diff --git a/web_src/js/webcomponents/polyfills.ts b/web_src/js/webcomponents/polyfills.ts index 4a84ee9562..9575324b5a 100644 --- a/web_src/js/webcomponents/polyfills.ts +++ b/web_src/js/webcomponents/polyfills.ts @@ -16,3 +16,19 @@ try { return intlNumberFormat(locales, options); }; } + +export function weakRefClass() { + const weakMap = new WeakMap(); + return class { + constructor(target: any) { + weakMap.set(this, target); + } + deref() { + return weakMap.get(this); + } + }; +} + +if (!window.WeakRef) { + window.WeakRef = weakRefClass() as any; +} |