Merge pull request #1061 from Retrospring/feature/hotkey

Keyboard shortcuts
This commit is contained in:
Karina Kwiatek 2023-04-29 16:26:55 +02:00 committed by GitHub
commit b801af9465
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 280 additions and 41 deletions

View file

@ -100,26 +100,27 @@ $unicodeRangeValues in Lexend.$unicodeMap {
*/
@import "components/announcements",
"components/answerbox",
"components/avatars",
"components/buttons",
"components/collapse",
"components/comments",
"components/container",
"components/entry",
"components/icons",
"components/inbox-actions",
"components/inbox-entry",
"components/mobile-nav",
"components/navbar",
"components/notifications",
"components/profile",
"components/push-settings",
"components/question",
"components/smiles",
"components/themes",
"components/totp-setup",
"components/userbox";
"components/answerbox",
"components/avatars",
"components/buttons",
"components/collapse",
"components/comments",
"components/container",
"components/entry",
"components/hotkey",
"components/icons",
"components/inbox-actions",
"components/inbox-entry",
"components/mobile-nav",
"components/navbar",
"components/notifications",
"components/profile",
"components/push-settings",
"components/question",
"components/smiles",
"components/themes",
"components/totp-setup",
"components/userbox";
/**
UTILITIES

View file

@ -0,0 +1,6 @@
.js-hotkey-navigating {
.js-hotkey-current-selection {
outline: var(--primary) solid 4px;
}
}

View file

@ -7,7 +7,8 @@ module BootstrapHelper
badge_color: nil,
badge_attr: {},
icon: nil,
class: ""
class: "",
hotkey: nil,
}.merge(options)
classes = [
@ -33,7 +34,7 @@ module BootstrapHelper
body += " #{content_tag(:span, options[:badge], class: badge_class, **options[:badge_attr])}".html_safe
end
content_tag(:li, link_to(body.html_safe, path, class: "nav-link"), class: classes)
content_tag(:li, link_to(body.html_safe, path, class: "nav-link", data: { hotkey: options[:hotkey] }), class: classes)
end
def list_group_item(body, path, options = {})

View file

@ -1,11 +1,13 @@
import '@hotwired/turbo-rails';
import initializeBootstrap from './initializers/bootstrap';
import initializeHotkey from './initializers/hotkey';
import initializeServiceWorker from './initializers/serviceWorker';
import initializeStimulus from './initializers/stimulus';
export default function start(): void {
try {
initializeBootstrap();
initializeHotkey();
initializeServiceWorker();
initializeStimulus();
} catch (e) {

View file

@ -0,0 +1,12 @@
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
export default class extends Controller<HTMLElement> {
connect(): void {
install(this.element);
}
disconnect(): void {
uninstall(this.element);
}
}

View file

@ -0,0 +1,62 @@
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
export default class extends Controller {
static classes = ["current"];
static targets = ["current", "traversable"];
declare readonly hasCurrentTarget: boolean;
declare readonly currentTarget: HTMLElement;
declare readonly traversableTargets: HTMLElement[];
traversableTargetConnected(target: HTMLElement): void {
if (!("navigationIndex" in target.dataset)) {
target.dataset.navigationIndex = this.traversableTargets.indexOf(target).toString();
}
if (!this.hasCurrentTarget) {
const first = this.traversableTargets[0];
first.dataset.navigationTarget += " current";
}
}
currentTargetConnected(target: HTMLElement): void {
target.classList.add("js-hotkey-current-selection");
target.querySelectorAll<HTMLElement>("[data-selection-hotkey]")
.forEach(el => install(el, el.dataset.selectionHotkey));
}
currentTargetDisconnected(target: HTMLElement): void {
target.classList.remove("js-hotkey-current-selection");
target.querySelectorAll<HTMLElement>("[data-selection-hotkey]")
.forEach(el => uninstall(el));
}
up(): void {
const prevIndex = this.traversableTargets.indexOf(this.currentTarget) - 1;
if (prevIndex == -1) return;
this.navigate(this.traversableTargets[prevIndex]);
}
down(): void {
const nextIndex = this.traversableTargets.indexOf(this.currentTarget) + 1;
if (nextIndex == this.traversableTargets.length) return;
this.navigate(this.traversableTargets[nextIndex]);
}
navigate(target: HTMLElement): void {
if (!document.body.classList.contains("js-hotkey-navigating")) {
document.body.classList.add("js-hotkey-navigating");
}
if (target.dataset.navigationTarget == "traversable") {
this.currentTarget.dataset.navigationTarget = "traversable";
target.dataset.navigationTarget = "traversable current";
target.scrollIntoView({ block: "center", inline: "center" });
}
}
}

View file

@ -0,0 +1,7 @@
export function commentHotkeyHandler(event: Event): void {
const button = event.target as HTMLButtonElement;
const id = button.dataset.aId;
document.querySelector(`#ab-comments-section-${id}`).classList.remove('d-none');
document.querySelector<HTMLElement>(`[name="ab-comment-new"][data-a-id="${id}"]`).focus();
}

View file

@ -4,10 +4,12 @@ import { commentComposeEnd, commentComposeStart, commentCreateClickHandler, comm
import { commentReportHandler } from "./report";
import { commentSmileHandler } from "./smile";
import { commentToggleHandler } from "./toggle";
import { commentHotkeyHandler } from "retrospring/features/answerbox/comment/hotkey";
export default (): void => {
registerEvents([
{ type: 'click', target: '[name=ab-comments]', handler: commentToggleHandler, global: true },
{ type: 'click', target: '[name=ab-open-and-comment]', handler: commentHotkeyHandler, global: true },
{ type: 'click', target: '[name=ab-smile-comment]', handler: commentSmileHandler, global: true },
{ type: 'click', target: '[data-action=ab-comment-report]', handler: commentReportHandler, global: true },
{ type: 'click', target: '[data-action=ab-comment-destroy]', handler: commentDestroyHandler, global: true },

View file

@ -0,0 +1,7 @@
import { install } from '@github/hotkey'
export default function (): void {
document.addEventListener('turbo:load', () => {
document.querySelectorAll('[data-hotkey]').forEach(el => install(el as HTMLElement));
});
}

View file

@ -8,9 +8,11 @@ import CollapseController from "retrospring/controllers/collapse_controller";
import ThemeController from "retrospring/controllers/theme_controller";
import CapabilitiesController from "retrospring/controllers/capabilities_controller";
import CropperController from "retrospring/controllers/cropper_controller";
import HotkeyController from "retrospring/controllers/hotkey_controller";
import InboxSharingController from "retrospring/controllers/inbox_sharing_controller";
import ToastController from "retrospring/controllers/toast_controller";
import PwaBadgeController from "retrospring/controllers/pwa_badge_controller";
import NavigationController from "retrospring/controllers/navigation_controller";
/**
* This module sets up Stimulus and our controllers
@ -29,8 +31,10 @@ export default function (): void {
window['Stimulus'].register('collapse', CollapseController);
window['Stimulus'].register('cropper', CropperController);
window['Stimulus'].register('format-popup', FormatPopupController);
window['Stimulus'].register('hotkey', HotkeyController);
window['Stimulus'].register('inbox-sharing', InboxSharingController);
window['Stimulus'].register('pwa-badge', PwaBadgeController);
window['Stimulus'].register('navigation', NavigationController);
window['Stimulus'].register('theme', ThemeController);
window['Stimulus'].register('toast', ToastController);
}

View file

@ -1,8 +1,8 @@
%button.btn.btn-link.answerbox__action{ type: :button, name: "ab-smile", data: { a_id: a.id, action: current_user&.smiled?(a) ? :unsmile : :smile }, disabled: !user_signed_in? }
%button.btn.btn-link.answerbox__action{ type: :button, name: "ab-smile", data: { a_id: a.id, action: current_user&.smiled?(a) ? :unsmile : :smile, selection_hotkey: "s" }, disabled: !user_signed_in? }
%i.fa.fa-fw.fa-smile-o
%span{ id: "ab-smile-count-#{a.id}" }= a.smiles.count
- unless display_all
%button.btn.btn-link.answerbox__action{ type: :button, name: "ab-comments", data: { a_id: a.id, state: :hidden } }
%button.btn.btn-link.answerbox__action{ type: :button, name: "ab-comments", data: { a_id: a.id, state: :hidden, selection_hotkey: "x" } }
%i.fa.fa-fw.fa-comments
%span{ id: "ab-comment-count-#{a.id}" }= a.comment_count
.btn-group

View file

@ -25,6 +25,7 @@
%span.caret
= render "actions/comment", comment: comment, answer: a
- if user_signed_in?
%button.d-none{ name: "ab-open-and-comment", data: { a_id: a.id, selection_hotkey: "c" } }
.comment__compose-wrapper{
name: "ab-comment-new-group",
data: { a_id: a.id, controller: "character-count", character_count_max_value: 512 }

View file

@ -11,7 +11,7 @@
= t(".asked_html", user: user_screen_name(a.question.user, context_user: a.user, author_identifier: a.question.author_is_anonymous ? a.question.author_identifier: nil), time: time_tooltip(a.question))
- if !a.question.author_is_anonymous && !a.question.direct
·
%a{ href: question_path(a.question.user.screen_name, a.question.id) }
%a{ href: question_path(a.question.user.screen_name, a.question.id), data: { selection_hotkey: "a" } }
= t(".answers", count: a.question.answer_count)
.answerbox__question-body{ data: { controller: a.question.long? ? "collapse" : nil } }
.answerbox__question-text{ class: a.question.long? && !display_all ? "collapsed" : "", data: { collapse_target: "content" } }

View file

@ -1,5 +1,5 @@
- display_all ||= nil
.card.answerbox{ data: { id: a.id, q_id: a.question.id } }
.card.answerbox{ data: { id: a.id, q_id: a.question.id, navigation_target: "traversable" } }
- if @question.nil?
= render "answerbox/header", a: a, display_all: display_all
.card-body
@ -19,7 +19,7 @@
%h6.answerbox__answer-user
= raw t(".answered", hide: hidespan(t(".hide"), "d-none d-sm-inline"), user: user_screen_name(a.user))
.answerbox__answer-date
= link_to(raw(t("time.distance_ago", time: time_tooltip(a))), answer_path(a.user.screen_name, a.id))
= link_to(raw(t("time.distance_ago", time: time_tooltip(a))), answer_path(a.user.screen_name, a.id), data: { selection_hotkey: "l" })
.col-md-6.d-flex.d-md-block.answerbox__actions
= render "answerbox/actions", a: a, display_all: display_all
- else

View file

@ -11,4 +11,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @inbox_last_id, author: @author }.compact,
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @inbox_last_id, author: @author }.compact,
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -31,6 +31,7 @@
= render 'shared/announcements'
= yield
= render "shared/formatting"
= render "shared/hotkeys"
.d-none#toasts
- if Rails.env.development?
#debug

View file

@ -14,4 +14,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @inbox_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @inbox_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -8,6 +8,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @reports_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- parent_layout "moderation"

View file

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @reports_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -9,17 +9,17 @@
%span.badge.rounded-pill.bg-warning.text-bg-warning.fs-7
DEV
%ul.nav.navbar-nav.me-auto
= nav_entry t("navigation.timeline"), root_path, icon: 'home'
= nav_entry t("navigation.inbox"), "/inbox", icon: "inbox", badge: inbox_count, badge_attr: { data: { controller: "pwa-badge" } }
= nav_entry t("navigation.timeline"), root_path, icon: "home", hotkey: "g t"
= nav_entry t("navigation.inbox"), "/inbox", icon: "inbox", badge: inbox_count, badge_attr: { data: { controller: "pwa-badge" } }, hotkey: "g i"
- if APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod?
= nav_entry t("navigation.discover"), discover_path, icon: 'compass'
= nav_entry t("navigation.discover"), discover_path, icon: "compass", hotkey: "g d"
%ul.nav.navbar-nav
- if @user.present? && @user != current_user
%li.nav-item.d-none.d-sm-block{ data: { bs_toggle: 'tooltip', bs_placement: 'bottom' }, title: t(".list") }
%a.nav-link{ href: '#', data: { bs_target: '#modal-list-memberships', bs_toggle: :modal } }
%i.fa.fa-list.hidden-xs
%span.d-none.d-sm-inline.d-md-none= t(".list")
= nav_entry t("navigation.notifications"), notifications_path, badge: notification_count, class: 'd-block d-sm-none'
= nav_entry t("navigation.notifications"), notifications_path, badge: notification_count, class: "d-block d-sm-none", hotkey: "g n"
%li.nav-item.dropdown.d-none.d-sm-block
%a.nav-link.dropdown-toggle{ href: '#', data: { bs_toggle: :dropdown } }
- if notification_count.nil?
@ -30,7 +30,7 @@
%span.badge= notification_count
= render 'navigation/dropdown/notifications', notifications: notifications, size: "desktop"
%li.nav-item.d-none.d-sm-block{ data: { bs_toggle: 'tooltip', bs_placement: 'bottom' }, title: t('.ask_question') }
%a.nav-link{ href: '#', name: 'toggle-all-ask', data: { bs_target: '#modal-ask-followers', bs_toggle: :modal } }
%a.nav-link{ href: "#", name: "toggle-all-ask", data: { bs_target: "#modal-ask-followers", bs_toggle: :modal, hotkey: "n" } }
%i.fa.fa-pencil-square-o
%li.nav-item.dropdown.profile--image-dropdown
%a.nav-link.dropdown-toggle.p-sm-0{ href: "#", data: { bs_toggle: :dropdown } }

View file

@ -1,6 +1,6 @@
.dropdown-menu.dropdown-menu-end.profile-dropdown{ id: "rs-#{size}-nav-profile" }
%h6.dropdown-header.d-none.d-sm-block= current_user.screen_name
%a.dropdown-item{ href: user_path(current_user) }
%a.dropdown-item{ href: user_path(current_user), data: { hotkey: "g p" } }
%i.fa.fa-fw.fa-user
= t(".profile")
%a.dropdown-item{ href: edit_user_registration_path }
@ -34,6 +34,10 @@
%i.fa.fa-fw.fa-flask
= t(".feedback.features")
.dropdown-divider
%a.dropdown-item{ href: "#", data: { bs_target: "#modal-hotkeys", bs_toggle: "modal", hotkey: "Shift+?,?,Shift+ß" } }
%i.fa.fa-keyboard
= t(".hotkeys")
.dropdown-divider
= link_to destroy_user_session_path, data: { turbo_method: :delete }, class: "dropdown-item" do
%i.fa.fa-fw.fa-sign-out
= t("voc.logout")

View file

@ -22,6 +22,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @notifications_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- provide(:title, generate_title(t(".title")))

View file

@ -11,4 +11,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @notifications_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -2,7 +2,9 @@
= render "question", question: @question, hidden: false
= render "question", question: @question, hidden: true
.container.question-page
#answers
#answers{ data: { controller: "navigation" } }
%button.d-none{ data: { hotkey: "j", action: "navigation#down" } }
%button.d-none{ data: { hotkey: "k", action: "navigation#up" } }
- @answers.each do |a|
= render "answerbox", a: a, show_question: false
@ -12,6 +14,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @answers_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- if user_signed_in? && !current_user.answered?(@question) && current_user != @question.user && @question.user&.privacy_allow_stranger_answers

View file

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @answers_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -0,0 +1,80 @@
.modal.fade#modal-hotkeys{ aria: { hidden: true, labelledby: "modal-hotkeys-label" }, role: :dialog, tabindex: -1 }
.modal-dialog
.modal-content
.modal-header
%h5.modal-title#modal-hotkeys-label= t(".title")
%button.btn-close{ data: { bs_dismiss: :modal }, type: :button }
%span.visually-hidden= t("voc.close")
.modal-body
.row
.col-6
.card
.card-header
%h5.card-title= t(".navigation.title")
%ul.list-group.list-group-flush
%li.list-group-item
= t("navigation.timeline")
%kbd
%kbd g
%kbd t
%li.list-group-item
= t("navigation.inbox")
%kbd
%kbd g
%kbd i
- if APP_CONFIG.dig(:features, :discover, :enabled) || current_user.mod?
%li.list-group-item
= t("navigation.discover")
%kbd
%kbd g
%kbd d
%li.list-group-item
= t("navigation.notifications")
%kbd
%kbd g
%kbd n
%li.list-group-item
= t("navigation.dropdown.profile.profile")
%kbd
%kbd g
%kbd p
.col-6
.card
.card-header
%h5.card-title= t(".global.title")
%ul.list-group.list-group-flush
%li.list-group-item
= t(".global.navigation.up")
%kbd k
%li.list-group-item
= t(".global.navigation.down")
%kbd j
%li.list-group-item
= t("navigation.desktop.ask_question")
%kbd n
%li.list-group-item
= t("voc.load")
%kbd .
%li.list-group-item
= t(".show_dialog")
%kbd ?
.card
.card-header
%h5.card-title= t(".answer.title")
%ul.list-group.list-group-flush
%li.list-group-item
= t("voc.smile")
%kbd s
%li.list-group-item
= t(".answer.view_comments")
%kbd x
%li.list-group-item
= t(".answer.comment")
%kbd c
%li.list-group-item
= t(".answer.all_answers")
%kbd a
%li.list-group-item
= t(".answer.view_answer")
%kbd l

View file

@ -1,4 +1,6 @@
#timeline
#timeline{ data: { controller: "navigation" } }
%button.d-none{ data: { hotkey: "j", action: "navigation#down" } }
%button.d-none{ data: { hotkey: "k", action: "navigation#up" } }
- @timeline.each do |answer|
= render "answerbox", a: answer
@ -8,6 +10,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @timeline_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- provide(:title, @title || APP_CONFIG["site_name"])

View file

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @timeline_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -8,6 +8,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @questions_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- provide(:title, questions_title(@user))

View file

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @questions_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -1,11 +1,14 @@
- unless @user.banned?
#pinned-answers
- @pinned_answers.each do |a|
= render "answerbox", a:
%div{ data: { controller: "navigation" } }
%button.d-none{ data: { hotkey: "j", action: "navigation#down" } }
%button.d-none{ data: { hotkey: "k", action: "navigation#up" } }
#pinned-answers
- @pinned_answers.each do |a|
= render "answerbox", a:
#answers
- @answers.each do |a|
= render "answerbox", a:
#answers
- @answers.each do |a|
= render "answerbox", a:
- if @more_data_available
.d-flex.justify-content-center.justify-content-sm-start#paginator
@ -13,6 +16,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @answers_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
:ruby

View file

@ -8,4 +8,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @answers_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -9,6 +9,7 @@
class: "btn btn-light",
method: :get,
params: { last_id: @relationships_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }
- provide(:title, t(".title.#{type}", user: @user.profile.safe_name))

View file

@ -9,4 +9,5 @@
class: "btn btn-light",
method: :get,
params: { last_id: @relationships_last_id },
data: { controller: :hotkey, hotkey: "." },
form: { data: { turbo_stream: true } }

View file

@ -330,6 +330,7 @@ en:
heading: "Feedback"
bugs: "Bugs"
features: "Feature Requests"
hotkeys: "Keyboard Shortcuts"
desktop:
ask_question: "Ask a question"
list: :user.actions.list
@ -583,6 +584,22 @@ en:
question:
visible_to_you: "Only visible to you as it was asked directly"
visible_mod_mode: "You can see this because you are in moderation view"
hotkeys:
navigation:
title: "Navigation"
title: "Keyboard Shortcuts"
show_dialog: "Show this dialog"
answer:
title: "Answer actions"
view_comments: "View comments"
comment: "Write a comment"
all_answers: "View all answers"
view_answer: "View answer page"
global:
navigation:
up: "Move selection up"
down: "Move selection down"
title: "Site-wide"
tabs:
admin:
announcements: "Announcements"

View file

@ -17,6 +17,7 @@ en:
mute: "Mute"
save: "Save changes"
show_anonymous_questions: "Show all questions from this user"
smile: "Smile"
subscribe: "Subscribe"
unsubscribe: "Unsubscribe"
register: "Sign up"

View file

@ -9,6 +9,7 @@
"dependencies": {
"@fontsource/lexend": "^4.5.15",
"@fortawesome/fontawesome-free": "^6.4.0",
"@github/hotkey": "^2.0.1",
"@hotwired/stimulus": "^3.2.1",
"@hotwired/turbo-rails": "^7.3.0",
"@melloware/coloris": "^0.19.1",

View file

@ -184,6 +184,11 @@
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.4.0.tgz#1ee0c174e472c84b23cb46c995154dc383e3b4fe"
integrity sha512-0NyytTlPJwB/BF5LtRV8rrABDbe3TdTXqNB3PdZ+UUUZAEIrdOJdmABqKjt4AXwIoJNaRVVZEXxpNrqvE1GAYQ==
"@github/hotkey@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@github/hotkey/-/hotkey-2.0.1.tgz#24ad6b49313cee5b98368174eab16a4b53a08ec7"
integrity sha512-qKXjAJjtheJbf4ie3hi8IwrHWJZHB5qdojR6JGo6jvQNPpsdUbk/NIdU8sxu4PW41CjW80vfciDMu3MAP3j2Fg==
"@hotwired/stimulus@^3.2.1":
version "3.2.1"
resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.1.tgz#e3de23623b0c52c247aba4cd5d530d257008676b"