feat: Make it possible to close inline reply with the escape key. (#2273)
Fixes #915. Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
This commit is contained in:
parent
8fc9d5c728
commit
815438172e
|
@ -153,6 +153,7 @@ export default {
|
||||||
<li><kbd>f</kbd> to favorite</li>
|
<li><kbd>f</kbd> to favorite</li>
|
||||||
<li><kbd>b</kbd> to boost</li>
|
<li><kbd>b</kbd> to boost</li>
|
||||||
<li><kbd>r</kbd> to reply</li>
|
<li><kbd>r</kbd> to reply</li>
|
||||||
|
<li><kbd>Escape</kbd> to close reply</li>
|
||||||
<li><kbd>i</kbd> to open images, video, or audio</li>
|
<li><kbd>i</kbd> to open images, video, or audio</li>
|
||||||
<li><kbd>y</kbd> to show or hide sensitive media</li>
|
<li><kbd>y</kbd> to show or hide sensitive media</li>
|
||||||
<li><kbd>m</kbd> to mention the author</li>
|
<li><kbd>m</kbd> to mention the author</li>
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
{#if isStatusInOwnThread}
|
{#if isStatusInOwnThread}
|
||||||
<StatusDetails {...params} {...timestampParams} />
|
<StatusDetails {...params} {...timestampParams} />
|
||||||
{/if}
|
{/if}
|
||||||
<StatusToolbar {...params} {replyShown} on:recalculateHeight />
|
<StatusToolbar {...params} {replyShown} on:recalculateHeight on:focusArticle="focusArticle()" />
|
||||||
{#if replyShown}
|
{#if replyShown}
|
||||||
<StatusComposeBox {...params} on:recalculateHeight />
|
<StatusComposeBox {...params} on:recalculateHeight />
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -133,6 +133,7 @@
|
||||||
import { composeNewStatusMentioning } from '../../_actions/mention.js'
|
import { composeNewStatusMentioning } from '../../_actions/mention.js'
|
||||||
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid.js'
|
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid.js'
|
||||||
import { addEmojiTooltips } from '../../_utils/addEmojiTooltips.js'
|
import { addEmojiTooltips } from '../../_utils/addEmojiTooltips.js'
|
||||||
|
import { tryToFocusElement } from '../../_utils/tryToFocusElement.js'
|
||||||
|
|
||||||
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea', 'label'])
|
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea', 'label'])
|
||||||
const isUserInputElement = node => INPUT_TAGS.has(node.localName)
|
const isUserInputElement = node => INPUT_TAGS.has(node.localName)
|
||||||
|
@ -213,6 +214,10 @@
|
||||||
async mentionAuthor () {
|
async mentionAuthor () {
|
||||||
const { accountForShortcut } = this.get()
|
const { accountForShortcut } = this.get()
|
||||||
await composeNewStatusMentioning(accountForShortcut)
|
await composeNewStatusMentioning(accountForShortcut)
|
||||||
|
},
|
||||||
|
focusArticle () {
|
||||||
|
const { elementId } = this.get()
|
||||||
|
tryToFocusElement(elementId, /* scroll */ true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
{#if enableShortcuts}
|
{#if enableShortcuts}
|
||||||
<Shortcut scope={shortcutScope} key="f" on:pressed="toggleFavorite(true)"/>
|
<Shortcut scope={shortcutScope} key="f" on:pressed="toggleFavorite(true)"/>
|
||||||
<Shortcut scope={shortcutScope} key="r" on:pressed="reply()"/>
|
<Shortcut scope={shortcutScope} key="r" on:pressed="reply()"/>
|
||||||
|
<Shortcut scope={shortcutScope} key="escape" on:pressed="dismiss()"/>
|
||||||
<Shortcut scope={shortcutScope} key="b" on:pressed="reblog(true)"/>
|
<Shortcut scope={shortcutScope} key="b" on:pressed="reblog(true)"/>
|
||||||
{/if}
|
{/if}
|
||||||
<style>
|
<style>
|
||||||
|
@ -146,6 +147,13 @@
|
||||||
this.fire('recalculateHeight')
|
this.fire('recalculateHeight')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
dismiss () {
|
||||||
|
const { replyShown } = this.get()
|
||||||
|
if (replyShown) {
|
||||||
|
this.reply()
|
||||||
|
this.fire('focusArticle')
|
||||||
|
}
|
||||||
|
},
|
||||||
async onOptionsClick () {
|
async onOptionsClick () {
|
||||||
const { originalStatus, originalAccountId } = this.get()
|
const { originalStatus, originalAccountId } = this.get()
|
||||||
const updateRelationshipPromise = updateProfileAndRelationship(originalAccountId)
|
const updateRelationshipPromise = updateProfileAndRelationship(originalAccountId)
|
||||||
|
|
|
@ -172,15 +172,22 @@ function unmapKeys (keyMap, keys, component) {
|
||||||
|
|
||||||
function acceptShortcutEvent (event) {
|
function acceptShortcutEvent (event) {
|
||||||
const { target } = event
|
const { target } = event
|
||||||
return !(
|
if (
|
||||||
event.altKey ||
|
event.altKey ||
|
||||||
event.metaKey ||
|
event.metaKey ||
|
||||||
event.ctrlKey ||
|
event.ctrlKey ||
|
||||||
(event.shiftKey && event.key !== '?') || // '?' is a special case - it is allowed
|
(event.shiftKey && event.key !== '?') // '?' is a special case - it is allowed
|
||||||
(target && (
|
) {
|
||||||
target.isContentEditable ||
|
return false
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
// Allow escape everywhere.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Don't allow other keys in text boxes.
|
||||||
|
return !(target && (
|
||||||
|
target.isContentEditable ||
|
||||||
['TEXTAREA', 'SELECT'].includes(target.tagName) ||
|
['TEXTAREA', 'SELECT'].includes(target.tagName) ||
|
||||||
(target.tagName === 'INPUT' && !['radio', 'checkbox'].includes(target.getAttribute('type')))
|
(target.tagName === 'INPUT' && !['radio', 'checkbox'].includes(target.getAttribute('type')))
|
||||||
))
|
))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { scheduleIdleTask } from './scheduleIdleTask.js'
|
||||||
const RETRIES = 5
|
const RETRIES = 5
|
||||||
const TIMEOUT = 50
|
const TIMEOUT = 50
|
||||||
|
|
||||||
export async function tryToFocusElement (id) {
|
export async function tryToFocusElement (id, scroll) {
|
||||||
for (let i = 0; i < RETRIES; i++) {
|
for (let i = 0; i < RETRIES; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
await new Promise(resolve => setTimeout(resolve, TIMEOUT))
|
await new Promise(resolve => setTimeout(resolve, TIMEOUT))
|
||||||
|
@ -13,7 +13,7 @@ export async function tryToFocusElement (id) {
|
||||||
const element = document.getElementById(id)
|
const element = document.getElementById(id)
|
||||||
if (element) {
|
if (element) {
|
||||||
try {
|
try {
|
||||||
element.focus({ preventScroll: true })
|
element.focus({ preventScroll: !scroll })
|
||||||
console.log('focused element', id)
|
console.log('focused element', id)
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
getFirstModalMedia,
|
getFirstModalMedia,
|
||||||
getNthStatusAccountLink,
|
getNthStatusAccountLink,
|
||||||
getNthStatusAccountLinkSelector,
|
getNthStatusAccountLinkSelector,
|
||||||
focus
|
focus, getNthComposeReplyInput, getActiveElementId, getActiveElementClassList
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { homeTimeline } from '../fixtures'
|
import { homeTimeline } from '../fixtures'
|
||||||
import { loginAsFoobar } from '../roles'
|
import { loginAsFoobar } from '../roles'
|
||||||
|
@ -234,3 +234,19 @@ test('Shortcut down makes next status active when focused inside of a status', a
|
||||||
.pressKey('down')
|
.pressKey('down')
|
||||||
.expect(isNthStatusActive(2)()).ok()
|
.expect(isNthStatusActive(2)()).ok()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Press r to reply, press Esc to close reply', async t => {
|
||||||
|
await loginAsFoobar(t)
|
||||||
|
await t
|
||||||
|
.expect(getNthStatus(1).exists).ok()
|
||||||
|
await activateStatus(t, 0)
|
||||||
|
const id = await getActiveElementId()
|
||||||
|
await t
|
||||||
|
.expect(getNthComposeReplyInput(1).exists).notOk()
|
||||||
|
.pressKey('r')
|
||||||
|
.expect(getNthComposeReplyInput(1).exists).ok()
|
||||||
|
.expect(getActiveElementClassList()).contains('compose-box-input')
|
||||||
|
.pressKey('esc')
|
||||||
|
.expect(getNthComposeReplyInput(1).exists).notOk()
|
||||||
|
.expect(getActiveElementId()).eql(id)
|
||||||
|
})
|
||||||
|
|
Loading…
Reference in a new issue