mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-07 23:09:32 +02:00
feat: global shortcut support (#39604)
* Fix #5796 Global shortcuts can be easily introduced by adding "data-shortcut-keys" to the elements --------- Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
1 parent
9b1d6f47f1
commit
4cba1e18ed
10 files changed
+374
-67
No files matched your search
@@ -37,7 +37,7 @@
|
||||
{{end}}
|
||||
|
||||
{{$notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
|
||||
<a class="item" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}">
|
||||
<a class="item" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" data-shortcut-keys="g n">
|
||||
<div class="tw-relative flex-text-block">
|
||||
{{svg "octicon-bell"}}
|
||||
<span class="notification_count{{if not $notificationUnreadCount}} tw-hidden{{end}}">{{$notificationUnreadCount}}</span>
|
||||
|
||||
@@ -64,8 +64,7 @@
|
||||
</div>
|
||||
|
||||
<div class="ui g-modal-confirm modal" id="test-modal-danger">
|
||||
{{svg "octicon-x" 16 "inside close"}}
|
||||
<div class="header">dangerous action dialog</div>
|
||||
<div class="header">dangerous action dialog {{svg "octicon-x" 16 "close-modal"}}</div>
|
||||
<div class="content">hello, this is the modal dialog content, this is a dangerous operation</div>
|
||||
{{template "base/modal_actions_confirm" (dict "ModalButtonDangerText" "I know and must do this is dangerous operation")}}
|
||||
</div>
|
||||
|
||||
+16
-14
@@ -97,13 +97,14 @@
|
||||
{{if not (or .Repository.IsBeingCreated .Repository.IsBroken)}}
|
||||
<div class="overflow-menu-items">
|
||||
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeCode}}
|
||||
<a class="{{if .PageIsViewCode}}active {{end}}item" href="{{.RepoLink}}{{if and (ne .BranchName .Repository.DefaultBranch) (not $.PageIsWiki)}}/src/{{.RefTypeNameSubURL}}{{end}}">
|
||||
{{svg "octicon-code"}} {{ctx.Locale.Tr "repo.code"}}
|
||||
</a>
|
||||
{{$codeLinkUseCurrentRef := and (ne .BranchName .Repository.DefaultBranch) (not $.PageIsWiki)}}
|
||||
<a class="{{if .PageIsViewCode}}active{{end}} item" href="{{.RepoLink}}{{if $codeLinkUseCurrentRef}}/src/{{.RefTypeNameSubURL}}{{end}}" data-shortcut-keys="g c">
|
||||
{{svg "octicon-code"}} {{ctx.Locale.Tr "repo.code"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
|
||||
<a class="{{if .PageIsIssueList}}active {{end}}item" href="{{.RepoLink}}/issues">
|
||||
<a class="{{if .PageIsIssueList}}active{{end}} item" href="{{.RepoLink}}/issues" data-shortcut-keys="g i">
|
||||
{{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}}
|
||||
{{if .Repository.NumOpenIssues}}
|
||||
<span class="ui small label">{{CountFmt .Repository.NumOpenIssues}}</span>
|
||||
@@ -111,14 +112,15 @@
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalTracker}}
|
||||
<a class="{{if .PageIsIssueList}}active {{end}}item" href="{{.RepoExternalIssuesLink}}" target="_blank">
|
||||
{{if and (.Permission.CanRead ctx.Consts.RepoUnitTypeExternalTracker) .RepoExternalIssuesLink}}
|
||||
{{/* Internal and external issue trackers can be enabled at the same time, only show the external link if it exists */}}
|
||||
<a class="item" href="{{.RepoExternalIssuesLink}}" target="_blank">
|
||||
{{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.issues"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if and .Repository.CanEnablePulls (.Permission.CanRead ctx.Consts.RepoUnitTypePullRequests)}}
|
||||
<a class="{{if .PageIsPullList}}active {{end}}item" href="{{.RepoLink}}/pulls">
|
||||
<a class="{{if .PageIsPullList}}active{{end}} item" href="{{.RepoLink}}/pulls" data-shortcut-keys="g p">
|
||||
{{svg "octicon-git-pull-request"}} {{ctx.Locale.Tr "repo.pulls"}}
|
||||
{{if .Repository.NumOpenPulls}}
|
||||
<span class="ui small label">{{CountFmt .Repository.NumOpenPulls}}</span>
|
||||
@@ -127,7 +129,7 @@
|
||||
{{end}}
|
||||
|
||||
{{if and .EnableActions (.Permission.CanRead ctx.Consts.RepoUnitTypeActions) (not .IsEmptyRepo)}}
|
||||
<a class="{{if .PageIsActions}}active {{end}}item" href="{{.RepoLink}}/actions">
|
||||
<a class="{{if .PageIsActions}}active{{end}} item" href="{{.RepoLink}}/actions" data-shortcut-keys="g a">
|
||||
{{svg "octicon-play"}} {{ctx.Locale.Tr "actions.actions"}}
|
||||
{{if .Repository.NumOpenActionRuns}}
|
||||
<span class="ui small label">{{CountFmt .Repository.NumOpenActionRuns}}</span>
|
||||
@@ -136,14 +138,14 @@
|
||||
{{end}}
|
||||
|
||||
{{if .Permission.CanRead ctx.Consts.RepoUnitTypePackages}}
|
||||
<a href="{{.RepoLink}}/packages" class="{{if .IsPackagesPage}}active {{end}}item">
|
||||
<a href="{{.RepoLink}}/packages" class="{{if .IsPackagesPage}}active{{end}} item">
|
||||
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
|
||||
{{if and (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
|
||||
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
|
||||
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active{{end}} item" data-shortcut-keys="g b">
|
||||
{{svg "octicon-project"}} {{ctx.Locale.Tr "repo.projects"}}
|
||||
{{if .Repository.NumOpenProjects}}
|
||||
<span class="ui small label">{{CountFmt .Repository.NumOpenProjects}}</span>
|
||||
@@ -152,7 +154,7 @@
|
||||
{{end}}
|
||||
|
||||
{{if and (.Permission.CanRead ctx.Consts.RepoUnitTypeReleases) (not .IsEmptyRepo)}}
|
||||
<a class="{{if or .PageIsReleaseList .PageIsTagList}}active {{end}}item" href="{{.RepoLink}}/releases">
|
||||
<a class="{{if or .PageIsReleaseList .PageIsTagList}}active{{end}} item" href="{{.RepoLink}}/releases" data-shortcut-keys="g r">
|
||||
{{svg "octicon-tag"}} {{ctx.Locale.Tr "repo.releases"}}
|
||||
{{if .NumReleases}}
|
||||
<span class="ui small label">{{CountFmt .NumReleases}}</span>
|
||||
@@ -161,19 +163,19 @@
|
||||
{{end}}
|
||||
|
||||
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeWiki}}
|
||||
<a class="{{if .PageIsWiki}}active {{end}}item" href="{{.RepoLink}}/wiki">
|
||||
<a class="{{if .PageIsWiki}}active{{end}} item" href="{{.RepoLink}}/wiki" data-shortcut-keys="g w">
|
||||
{{svg "octicon-book"}} {{ctx.Locale.Tr "repo.wiki"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeExternalWiki}}
|
||||
<a class="item" href="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}" target="_blank">
|
||||
<a class="item" href="{{(.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeExternalWiki).ExternalWikiConfig.ExternalWikiURL}}" target="_blank" data-shortcut-keys="g w">
|
||||
{{svg "octicon-link-external"}} {{ctx.Locale.Tr "repo.wiki"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if and (.Permission.CanReadAny ctx.Consts.RepoUnitTypePullRequests ctx.Consts.RepoUnitTypeIssues ctx.Consts.RepoUnitTypeReleases ctx.Consts.RepoUnitTypeCode) (not .IsEmptyRepo)}}
|
||||
<a class="{{if .PageIsActivity}}active {{end}}item" href="{{.RepoLink}}/activity">
|
||||
<a class="{{if .PageIsActivity}}active{{end}} item" href="{{.RepoLink}}/activity">
|
||||
{{svg "octicon-pulse"}} {{ctx.Locale.Tr "repo.activity"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
@@ -25,26 +25,30 @@
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.ui.modal > .close {
|
||||
/* TODO: this "close" button was inherited and modified from Fomantic UI, actually it is not good enough:
|
||||
* * the layout depends on absolute position, which is not good for responsive design
|
||||
* * it is not good for accessibility, ideally it should use a "button" element */
|
||||
.ui.modal .close-modal {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: -2.5rem;
|
||||
right: -2.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 1rem;
|
||||
z-index: 1;
|
||||
opacity: 0.8;
|
||||
font-size: 1.25em;
|
||||
color: var(--color-white);
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0.625rem 0 0;
|
||||
color: var(--color-text);
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.ui.modal > .close:hover {
|
||||
.ui.modal .close-modal:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ui.modal > .header {
|
||||
display: block;
|
||||
position: relative;
|
||||
font-family: var(--fonts-regular);
|
||||
background: var(--color-body);
|
||||
margin: 0;
|
||||
@@ -62,11 +66,6 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.ui.modal > .header .svg {
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ui.modal > .content,
|
||||
.ui.modal form > .content {
|
||||
display: block;
|
||||
@@ -126,11 +125,6 @@
|
||||
.ui.modal > .header {
|
||||
padding-right: 2.25rem;
|
||||
}
|
||||
.ui.modal > .close {
|
||||
top: 1.0535rem;
|
||||
right: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 767.98px) {
|
||||
@@ -142,10 +136,6 @@
|
||||
display: block;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
.ui.modal > .close {
|
||||
top: 0.5rem !important;
|
||||
right: 0.5rem !important;
|
||||
}
|
||||
.ui.modal > .actions {
|
||||
padding: 1rem 1rem 0 !important;
|
||||
}
|
||||
@@ -158,21 +148,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui.modal > .close.inside + .header {
|
||||
padding-right: 2.25rem;
|
||||
}
|
||||
|
||||
.ui.modal > .close.inside {
|
||||
top: 1.0535rem;
|
||||
right: 1rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ui.modal > .close.icon[height="16"] {
|
||||
top: 0.7em;
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.ui.mini.modal > .header:not(.ui) {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
@@ -1180,7 +1180,7 @@ $.fn.modal.settings = {
|
||||
onDeny : function(){ return true; },
|
||||
|
||||
selector : {
|
||||
close : '> .close',
|
||||
close : '.close-modal',
|
||||
approve : '.actions .positive, .actions .approve, .actions .ok',
|
||||
deny : '.actions .negative, .actions .deny, .actions .cancel',
|
||||
modal : '.ui.modal',
|
||||
|
||||
@@ -16,7 +16,6 @@ let i18nTextDeleteFromHistoryConfirm: string;
|
||||
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
|
||||
const elDetailDialog = createElementFromHTML(html`
|
||||
<div class="ui modal content-history-detail-dialog">
|
||||
${svgRaw('octicon-x', 16, 'close icon inside')}
|
||||
<div class="header flex-left-right">
|
||||
<div>${htmlRaw(itemTitleHtml)}</div>
|
||||
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
|
||||
@@ -26,6 +25,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
|
||||
</div>
|
||||
</div>
|
||||
${svgRaw('octicon-x', 16, 'close-modal')}
|
||||
</div>
|
||||
<div class="comment-diff-data is-loading"></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {tryShortcutQuoteReply} from './repo-issue-edit.ts';
|
||||
|
||||
test('tryShortcutQuoteReply', () => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `
|
||||
<div class="comment-container">
|
||||
<button class="quote-reply">Quote reply</button>
|
||||
<div class="render-content markup">Comment text</div>
|
||||
<div class="raw-content">Raw text</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.append(container);
|
||||
const selection = window.getSelection()!;
|
||||
onTestFinished(() => {
|
||||
selection.removeAllRanges();
|
||||
container.remove();
|
||||
});
|
||||
const button = container.querySelector('button')!;
|
||||
const onClick = vi.fn();
|
||||
button.addEventListener('click', onClick);
|
||||
|
||||
selection.removeAllRanges();
|
||||
expect(tryShortcutQuoteReply()).toBe(false);
|
||||
|
||||
selection.selectAllChildren(container.querySelector('.raw-content')!);
|
||||
expect(tryShortcutQuoteReply()).toBe(false);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(container.querySelector('.render-content')!.firstChild!);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
selection.collapseToStart();
|
||||
expect(tryShortcutQuoteReply()).toBe(false);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
expect(tryShortcutQuoteReply()).toBe(true);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
button.remove();
|
||||
expect(tryShortcutQuoteReply()).toBe(false);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {errorMessage} from '../modules/errors.ts';
|
||||
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
|
||||
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
|
||||
import {applyAreYouSure} from '../modules/are-you-sure.ts';
|
||||
import {registerShortcutHandler} from '../modules/shortcut.ts';
|
||||
|
||||
async function tryOnEditContent(e: Event) {
|
||||
const clickTarget = (e.target as HTMLElement).closest('.edit-content');
|
||||
@@ -150,9 +151,32 @@ async function tryOnQuoteReply(e: Event) {
|
||||
editor.moveCursorToEnd();
|
||||
}
|
||||
|
||||
export function tryShortcutQuoteReply(): boolean {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount || selection.isCollapsed) return false;
|
||||
const node = selection.getRangeAt(0).commonAncestorContainer;
|
||||
const el = node instanceof HTMLElement ? node : node.parentElement;
|
||||
|
||||
const commentContainer = el?.closest<HTMLElement>('.render-content.markup')?.closest('.comment-container');
|
||||
if (!commentContainer) return false;
|
||||
|
||||
const quoteReplyBtn = commentContainer.querySelector<HTMLElement>('.quote-reply')!;
|
||||
if (!quoteReplyBtn) return false;
|
||||
|
||||
quoteReplyBtn.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function initRepoIssueCommentEdit() {
|
||||
document.addEventListener('click', (e) => {
|
||||
tryOnEditContent(e); // Edit issue or comment content
|
||||
tryOnQuoteReply(e); // Quote reply to the comment editor
|
||||
});
|
||||
// TODO: it is not list in the shortcut help modal at the moment, there are many details:
|
||||
// 1. not every page supports such "r"
|
||||
// 2. "r" only works when there is a ".quote-reply" menu item, but it is not for a single one
|
||||
// To make the framework fully support such mechanism correctly, we might also need to consider about dynamically added elements.
|
||||
// The shortcut "r" was chosen by https://github.com/go-gitea/gitea/issues/5796#issuecomment-5528657082
|
||||
// and GitLab: https://gitlab.com/gitlab-org/gitlab-foss/-/commit/ca5d0c82509cedb94f9bfa4a40e77706a58faafe
|
||||
registerShortcutHandler('r', tryShortcutQuoteReply);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import '../../fomantic/build/fomantic.js';
|
||||
import {userEvent} from 'vitest/browser';
|
||||
import {initGlobalShortcut, makeFullKeyFromEvent} from './shortcut.ts';
|
||||
import {initGlobalSelectorObserver} from './observer.ts';
|
||||
import {initGiteaFomantic} from './fomantic.ts';
|
||||
|
||||
function press(key: string, options: KeyboardEventInit = {}, target: HTMLElement = document.body) {
|
||||
const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true, ...options});
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
document.body.innerHTML = `
|
||||
<button data-shortcut-keys="g i"> <span> </span><span>Issues</span><span>42</span></button>
|
||||
<div class="global-shortcut-wrapper">
|
||||
<input placeholder="Search code">
|
||||
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="s">S</kbd>
|
||||
</div>
|
||||
<textarea></textarea>
|
||||
<select><option>Value</option></select>
|
||||
<div contenteditable="true"><span>Editable</span></div>
|
||||
`;
|
||||
initGiteaFomantic();
|
||||
initGlobalShortcut();
|
||||
initGlobalSelectorObserver(null);
|
||||
});
|
||||
|
||||
beforeEach(() => press('Escape'));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterAll(() => document.body.replaceChildren());
|
||||
|
||||
test('clicks matching sequences and clears completed or expired presses', () => {
|
||||
const click = vi.spyOn(document.querySelector('button')!, 'click');
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1000);
|
||||
press('x');
|
||||
press('g');
|
||||
expect(press('i').defaultPrevented).toBe(false);
|
||||
press('Escape');
|
||||
press('G');
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
expect(press('I').defaultPrevented).toBe(true);
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
press('i');
|
||||
press('g');
|
||||
now.mockReturnValue(2001);
|
||||
expect(press('i').defaultPrevented).toBe(false);
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
press('g');
|
||||
press('x');
|
||||
press('i');
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('ignores editing, modifiers, composition and repeated keydown events', () => {
|
||||
const click = vi.spyOn(document.querySelector('button')!, 'click');
|
||||
for (const target of document.querySelectorAll<HTMLElement>('input, textarea, select, [contenteditable] span')) {
|
||||
press('g');
|
||||
press('x', {}, target);
|
||||
press('i');
|
||||
}
|
||||
for (const options of [{ctrlKey: true}, {metaKey: true}, {altKey: true}, {isComposing: true}]) {
|
||||
press('g');
|
||||
press('i', options);
|
||||
press('i');
|
||||
}
|
||||
press('g');
|
||||
press('Escape');
|
||||
press('i');
|
||||
press('Escape');
|
||||
press('g');
|
||||
press('i', {repeat: true});
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
press('i');
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps input focus, hint visibility and Escape behavior', () => {
|
||||
const input = document.querySelector('input')!;
|
||||
const kbd = document.querySelector('kbd')!;
|
||||
expect(press('S').defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(kbd.classList.contains('tw-hidden')).toBe(true);
|
||||
input.value = 'search';
|
||||
press('Escape', {}, input);
|
||||
expect(input.value).toBe('');
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
expect(kbd.classList.contains('tw-hidden')).toBe(false);
|
||||
});
|
||||
|
||||
test('normalizes keys and sorts modifiers alphabetically', () => {
|
||||
for (const [options, expected] of [
|
||||
[{key: 'V'}, 'V'],
|
||||
[{key: 'v'}, 'V'],
|
||||
[{key: 'V', ctrlKey: true, shiftKey: true}, 'Control+Shift+V'],
|
||||
[{key: 'a', ctrlKey: true, altKey: true, metaKey: true, shiftKey: true}, 'Alt+Control+Meta+Shift+A'],
|
||||
[{key: ' ', shiftKey: true}, 'Shift+Space'],
|
||||
[{key: '+', altKey: true}, 'Alt+Plus'],
|
||||
[{key: 'ArrowUp', metaKey: true}, 'Meta+ArrowUp'],
|
||||
[{key: 'PageDown'}, 'PageDown'],
|
||||
[{key: 'F12'}, 'F12'],
|
||||
[{key: 'Escape'}, 'Escape'],
|
||||
...['Control', 'Alt', 'Meta', 'Shift', ''].map((key) => [{key}, '']),
|
||||
] as [KeyboardEventInit, string][]) {
|
||||
expect(makeFullKeyFromEvent(new KeyboardEvent('keydown', options))).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows current shortcuts with ? and closes with Escape or the close button', async () => {
|
||||
const input = document.querySelector('input')!;
|
||||
const click = vi.spyOn(document.querySelector('button')!, 'click');
|
||||
press('?', {shiftKey: true}, input);
|
||||
expect(document.querySelector('#global-shortcut-help')).toBeNull();
|
||||
press('?', {ctrlKey: true});
|
||||
expect(document.querySelector('#global-shortcut-help')).toBeNull();
|
||||
press('g');
|
||||
expect(press('?', {shiftKey: true}).defaultPrevented).toBe(true);
|
||||
const modal = document.querySelector<HTMLElement>('#global-shortcut-help')!;
|
||||
expect(modal.classList.contains('active')).toBe(true);
|
||||
expect(modal.querySelector('.shortcut-list tr:nth-child(2) td:last-child')!.textContent).toBe('Issues');
|
||||
expect(modal.querySelector('.shortcut-list')!.textContent).toContain('Search code');
|
||||
expect(modal.querySelectorAll('.shortcut-list tr')).toHaveLength(3);
|
||||
press('g');
|
||||
press('i');
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
expect(modal.classList.contains('active')).toBe(false);
|
||||
|
||||
input.setAttribute('placeholder', '<b>Updated search</b>');
|
||||
press('?');
|
||||
expect(document.querySelectorAll('.ui.modal')).toHaveLength(1);
|
||||
expect(modal.querySelector('.shortcut-list')!.textContent).toContain('<b>Updated search</b>');
|
||||
expect(modal.querySelector('.shortcut-list b')).toBeNull();
|
||||
expect(modal.querySelectorAll('.shortcut-list tr')).toHaveLength(3);
|
||||
});
|
||||
+140
-11
@@ -1,5 +1,19 @@
|
||||
import {registerGlobalInitFunc} from './observer.ts';
|
||||
import {hideElem, toggleElem} from '../utils/dom.ts';
|
||||
import {createElementFromHTML, hideElem, toggleElem} from '../utils/dom.ts';
|
||||
import {showFomanticModal} from './fomantic/modal.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
|
||||
type ShortcutHandler = (el: HTMLElement) => boolean;
|
||||
|
||||
const shortcutHandlers: Record<string, ShortcutHandler[]> = {};
|
||||
|
||||
type ShortcutPress = {
|
||||
fullKey: string,
|
||||
pressTime: number,
|
||||
};
|
||||
|
||||
const shortcutPresses: ShortcutPress[] = [];
|
||||
|
||||
function initShortcutKbd(kbd: HTMLElement) {
|
||||
// Handle initial state: hide the kbd hint if the associated input already has a value
|
||||
@@ -8,6 +22,7 @@ function initShortcutKbd(kbd: HTMLElement) {
|
||||
if (elem?.value) hideElem(kbd);
|
||||
kbd.setAttribute('aria-hidden', 'true');
|
||||
kbd.setAttribute('aria-keyshortcuts', kbd.getAttribute('data-shortcut-keys')!);
|
||||
kbd.addEventListener('click', () => elemFromKbd(kbd)?.focus());
|
||||
}
|
||||
|
||||
function shortcutWrapper(el: HTMLElement): HTMLElement | null {
|
||||
@@ -23,6 +38,79 @@ function kbdFromElem(input: HTMLElement): HTMLElement | null {
|
||||
return shortcutWrapper(input)?.querySelector<HTMLElement>('kbd') || null;
|
||||
}
|
||||
|
||||
export function makeFullKeyFromEvent(e: KeyboardEvent): string {
|
||||
// follow the MDN standard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts
|
||||
// always use PascalCase (upper case for single letter), and sort the modifiers in the order in alphabetical order
|
||||
const modifierKeys = ['Alt', 'Control', 'Meta', 'Shift'];
|
||||
let key = e.key;
|
||||
if (!key || modifierKeys.includes(key)) return '';
|
||||
if (key.length === 1) key = key.toUpperCase();
|
||||
if (key === ' ') key = 'Space';
|
||||
if (key === '+') key = 'Plus';
|
||||
|
||||
const modifiers: string[] = [];
|
||||
for (const mod of modifierKeys) {
|
||||
if (e.getModifierState(mod)) modifiers.push(mod);
|
||||
}
|
||||
return [...modifiers, key].join('+');
|
||||
}
|
||||
|
||||
function getShortcutTextContent(elem: Element): string {
|
||||
for (const child of elem.childNodes) {
|
||||
let text = '';
|
||||
if (child instanceof Text) {
|
||||
text = child.data.trim();
|
||||
} else if (child instanceof Element) {
|
||||
// in the future, we can also exclude elements that should not contribute to shortcut labels.
|
||||
text = getShortcutTextContent(child);
|
||||
}
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function showShortcutHelp() {
|
||||
let modal = document.querySelector<HTMLElement>('#global-shortcut-help');
|
||||
if (!modal) {
|
||||
modal = createElementFromHTML<HTMLElement>(html`
|
||||
<div id="global-shortcut-help" class="ui small modal" role="dialog" aria-modal="true">
|
||||
<div class="header">
|
||||
Keyboard Shortcuts
|
||||
${svgRaw('octicon-x', 16, 'close-modal')}
|
||||
</div>
|
||||
<div class="scrolling content">
|
||||
<table class="ui very basic compact unstackable table">
|
||||
<tbody class="shortcut-list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
document.body.append(modal);
|
||||
}
|
||||
|
||||
const list = modal.querySelector<HTMLElement>('.shortcut-list')!;
|
||||
list.replaceChildren(createElementFromHTML(html`<tr><td><kbd>?</kbd></td><td>Keyboard Shortcuts</td></tr>`));
|
||||
for (const elem of document.querySelectorAll<HTMLElement>('[data-shortcut-keys]')) {
|
||||
const target = elem.matches('kbd') ? elemFromKbd(elem) : elem;
|
||||
|
||||
// TODO: which element should be triggered by shortcut: can be fine-tuned in the future
|
||||
if (!target || target.matches(':disabled, .disabled')) continue;
|
||||
|
||||
const label = target.getAttribute('aria-label') ||
|
||||
target.getAttribute('placeholder') ||
|
||||
target.getAttribute('title') ||
|
||||
getShortcutTextContent(target);
|
||||
list.append(createElementFromHTML(html`<tr><td><kbd>${elem.getAttribute('data-shortcut-keys')}</kbd></td><td>${label}</td></tr>`));
|
||||
}
|
||||
showFomanticModal(modal);
|
||||
}
|
||||
|
||||
export function registerShortcutHandler(fullKey: string, handler: ShortcutHandler) {
|
||||
fullKey = fullKey.toLowerCase();
|
||||
shortcutHandlers[fullKey] ||= [];
|
||||
shortcutHandlers[fullKey].push(handler);
|
||||
}
|
||||
|
||||
export function initGlobalShortcut() {
|
||||
registerGlobalInitFunc('onGlobalShortcut', initShortcutKbd);
|
||||
|
||||
@@ -31,13 +119,18 @@ export function initGlobalShortcut() {
|
||||
// When Escape is pressed inside such an input, the input is cleared and blurred.
|
||||
// The <kbd> element is shown/hidden automatically based on input focus and value.
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
// Modifier keys are not supported yet
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.isComposing || e.defaultPrevented) {
|
||||
shortcutPresses.length = 0;
|
||||
return;
|
||||
}
|
||||
if (e.repeat) return;
|
||||
|
||||
const isPlainKey = !e.ctrlKey && !e.altKey && !e.metaKey;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Handle Escape: clear and blur inputs that have an associated keyboard shortcut
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === 'Escape' && isPlainKey) {
|
||||
shortcutPresses.length = 0;
|
||||
const kbd = kbdFromElem(target);
|
||||
if (kbd) {
|
||||
(target as HTMLInputElement).value = '';
|
||||
@@ -47,17 +140,53 @@ export function initGlobalShortcut() {
|
||||
}
|
||||
|
||||
// Don't trigger shortcuts when typing in input fields or contenteditable areas
|
||||
if (target.matches('input, textarea, select') || target.isContentEditable) {
|
||||
let shouldSkip = target.matches('input, textarea, select') || target.isContentEditable;
|
||||
// Skip shortcuts when a modal or dropdown is open (active), to avoid interfering with their own keyboard handling
|
||||
shouldSkip = shouldSkip || Boolean(document.querySelector('.ui.modal.active') || document.querySelector('.ui.dropdown.active'));
|
||||
if (shouldSkip) {
|
||||
shortcutPresses.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find kbd element with matching shortcut (case-insensitive), then focus its sibling input
|
||||
const key = e.key.toLowerCase();
|
||||
// At the moment, only a simple match. In the future, it can be extended to support modifiers and key combinations
|
||||
const kbd = document.querySelector<HTMLElement>(`.global-shortcut-wrapper > kbd[data-shortcut-keys="${CSS.escape(key)}"]`);
|
||||
if (!kbd) return;
|
||||
// Show the keyboard shortcut help modal when '?' is pressed without modifiers
|
||||
if (e.key === '?' && isPlainKey) {
|
||||
e.preventDefault();
|
||||
shortcutPresses.length = 0;
|
||||
showShortcutHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const fullKey = makeFullKeyFromEvent(e);
|
||||
// we don't support multiple modifier keys as a single shortcut (e.g., press Ctrl twice) at the moment
|
||||
if (!fullKey) return;
|
||||
|
||||
// just a simple algorithm to detect multi-key shortcuts, so far so good, for daily usage
|
||||
// if any bad case happens, it can be fine-tuned in the future.
|
||||
const keyPressDelay = 1000;
|
||||
const pressTime = Date.now();
|
||||
while (shortcutPresses.length && pressTime - shortcutPresses[0].pressTime > keyPressDelay) {
|
||||
shortcutPresses.shift();
|
||||
}
|
||||
shortcutPresses.push({fullKey, pressTime});
|
||||
|
||||
const fullKeys = shortcutPresses.map((p) => p.fullKey).join(' ');
|
||||
for (const handler of shortcutHandlers[fullKeys.toLowerCase()] || []) {
|
||||
if (handler(target)) {
|
||||
e.preventDefault();
|
||||
shortcutPresses.length = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// It's said that "spaces in aria-keyshortcuts separate alternative shortcuts", but we want to support multi-key shortcuts
|
||||
// So here we use our own data attribute to store the full shortcut keys, and match it case-insensitively.
|
||||
const matchedElems = document.querySelectorAll<HTMLElement>(`[data-shortcut-keys="${CSS.escape(fullKeys)}" i]`);
|
||||
if (!matchedElems.length) return;
|
||||
// TODO: if there are multiple matches, maybe we could introduce some priority rules to determine which one to trigger
|
||||
const matchedElem = matchedElems[0];
|
||||
e.preventDefault();
|
||||
elemFromKbd(kbd)!.focus();
|
||||
shortcutPresses.length = 0;
|
||||
matchedElem.click();
|
||||
});
|
||||
|
||||
// Toggle kbd shortcut hint visibility on input focus/blur
|
||||
|
||||
Reference in new issue
Block a user