このコミットが含まれているのは:
@@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||
import { prefetchForURL } from '@/lib/prefetchers'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
|
||||
import type { AnchorHTMLAttributes, KeyboardEvent, MouseEvent, TouchEvent } from 'react'
|
||||
import type { To } from 'react-router-dom'
|
||||
|
||||
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
@@ -25,6 +25,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
state,
|
||||
onMouseEnter,
|
||||
onTouchStart,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
cancelOnError = false,
|
||||
...rest }, ref) => {
|
||||
@@ -97,11 +98,22 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent<HTMLAnchorElement>) => {
|
||||
onKeyDown?.(ev)
|
||||
|
||||
if (ev.defaultPrevented)
|
||||
return
|
||||
|
||||
if (ev.key === ' ' || ev.key === 'Spacebar')
|
||||
ev.preventDefault ()
|
||||
}
|
||||
|
||||
return (
|
||||
<a ref={ref}
|
||||
href={typeof to === 'string' ? to : createPath (to)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onTouchStart={handleTouchStart}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={cn ('cursor-pointer', className)}
|
||||
{...rest}/>)
|
||||
|
||||
@@ -249,16 +249,16 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
|
||||
<TopNavUser user={user}/>
|
||||
|
||||
<a href="#"
|
||||
className="md:hidden ml-auto pr-4
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden ml-auto border-0 bg-transparent pr-4
|
||||
text-pink-600 hover:text-pink-400
|
||||
dark:text-pink-300 dark:hover:text-pink-100"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
onClick={() => {
|
||||
setMenuOpen (!(menuOpen))
|
||||
}}>
|
||||
{menuOpen ? '×' : 'Menu'}
|
||||
</a>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
|
||||
@@ -28,15 +28,15 @@ const TabGroup: FC<Props> = ({ children, rightAddon }) => {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 gap-4">
|
||||
{tabs.map ((tab, i) => (
|
||||
<a key={i}
|
||||
href="#"
|
||||
className={cn (i === current && 'font-bold')}
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={cn ('bg-transparent p-0 text-left', i === current && 'font-bold')}
|
||||
onClick={() => {
|
||||
setCurrent (i)
|
||||
}}>
|
||||
{tab.props.name}
|
||||
</a>))}
|
||||
</button>))}
|
||||
</div>
|
||||
{rightAddon && (
|
||||
<div className="shrink-0">
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
formatKeyBinding,
|
||||
isSpaceBinding,
|
||||
keyBindingFromKeyboardEvent,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
SHORTCUT_SCOPE_LABELS,
|
||||
} from '@/lib/keyboardShortcuts'
|
||||
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
||||
|
||||
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
|
||||
import type { FC } from 'react'
|
||||
|
||||
type Props = {
|
||||
sectionClassName: string
|
||||
}
|
||||
|
||||
|
||||
const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
|
||||
const {
|
||||
conflictActionIds,
|
||||
effectiveBindings,
|
||||
keyboardSettings,
|
||||
resetAllBindings,
|
||||
resetBinding,
|
||||
setBinding,
|
||||
setEnabled,
|
||||
} = useKeyboardShortcutSettings ()
|
||||
const [capturingActionId, setCapturingActionId] =
|
||||
useState<ShortcutActionId | null> (null)
|
||||
|
||||
useEffect (() => {
|
||||
if (capturingActionId == null)
|
||||
return
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing || event.key === 'Process')
|
||||
return
|
||||
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
|
||||
if (event.key === 'Escape')
|
||||
{
|
||||
setCapturingActionId (null)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' || event.key === 'Delete')
|
||||
{
|
||||
setBinding (capturingActionId, null)
|
||||
setCapturingActionId (null)
|
||||
return
|
||||
}
|
||||
|
||||
const binding = keyBindingFromKeyboardEvent (event)
|
||||
if (binding == null)
|
||||
return
|
||||
|
||||
if (isSpaceBinding (binding))
|
||||
{
|
||||
toast ({ title: 'Space は今は割り当てられません.' })
|
||||
return
|
||||
}
|
||||
|
||||
setBinding (capturingActionId, binding)
|
||||
setCapturingActionId (null)
|
||||
}
|
||||
|
||||
document.addEventListener ('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener ('keydown', onKeyDown, true)
|
||||
}, [capturingActionId, setBinding])
|
||||
|
||||
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
|
||||
...definition,
|
||||
currentBinding: effectiveBindings[definition.id],
|
||||
hasConflict: conflictActionIds.has (definition.id),
|
||||
hasCustomBinding: Object.prototype.hasOwnProperty.call (
|
||||
keyboardSettings.bindings ?? { },
|
||||
definition.id,
|
||||
),
|
||||
})), [conflictActionIds, effectiveBindings, keyboardSettings.bindings])
|
||||
|
||||
return (
|
||||
<section className={sectionClassName}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-bold">キーボード</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
この設定は現在のブラウザに自動保存されます.競合した割当は保存されますが,
|
||||
dispatcher では発火しません.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" onClick={resetAllBindings}>
|
||||
すべて既定に戻す
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keyboardSettings.enabled !== false}
|
||||
onChange={event => {
|
||||
setEnabled (event.target.checked)
|
||||
}}/>
|
||||
<span>キーボード・ショートカットを有効にする</span>
|
||||
</label>
|
||||
|
||||
{capturingActionId != null && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます.
|
||||
</p>)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
|
||||
<thead className="border-b border-border">
|
||||
<tr>
|
||||
<th className="p-2 text-left">操作</th>
|
||||
<th className="p-2 text-left">適用範囲</th>
|
||||
<th className="p-2 text-left">現在のキー</th>
|
||||
<th className="p-2 text-left">変更</th>
|
||||
<th className="p-2 text-left">既定に戻す</th>
|
||||
<th className="p-2 text-left">競合状態</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map (row => (
|
||||
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
|
||||
<td className="p-2">{row.label}</td>
|
||||
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
|
||||
<td className="p-2 font-mono">
|
||||
{formatKeyBinding (row.currentBinding)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={capturingActionId === row.id ? 'default' : 'outline'}
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setCapturingActionId (
|
||||
capturingActionId === row.id ? null : row.id,
|
||||
)
|
||||
}}>
|
||||
{capturingActionId === row.id ? '入力待ち…' : '変更'}
|
||||
</Button>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-8"
|
||||
disabled={!(row.hasCustomBinding)}
|
||||
onClick={() => resetBinding (row.id)}>
|
||||
既定に戻す
|
||||
</Button>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{row.hasConflict
|
||||
? <span className="text-red-600 dark:text-red-400">競合あり</span>
|
||||
: 'なし'}
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>)
|
||||
}
|
||||
|
||||
export default KeyboardSettingsSection
|
||||
新しい課題から参照
ユーザをブロックする