feat(shortcut): Keyboard Navigation (#168)

* chore: remove sliding menu

* feat(ui): sheet

* feat: shortcut component

* chore: register new shortcut component to layout

* fix: react attr naming

* fix: set default to false for shortcut

* feat(store): keydown-manager

* feat(hooks): keyboard manager

* chore: use util from base for la-editor

* chore: use util from base for minimal-tiptap-editor

* chore(utils): keyboard

* chore: use new keyboard manager

* fix: uniqueness of certain component

* feat: global key handler

* chore: implement new key handler
This commit is contained in:
Aslam
2024-09-19 21:17:11 +07:00
committed by GitHub
parent 0df105f186
commit 8eed3f8cc2
23 changed files with 686 additions and 515 deletions

View File

@@ -1,33 +1,33 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { getShortcutKey } from '../utils'
import * as React from "react"
import { cn } from "@/lib/utils"
import { getShortcutKey } from "@/lib/utils"
export interface ShortcutKeyProps extends React.HTMLAttributes<HTMLSpanElement> {
keys: string[]
keys: string[]
}
export const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, keys, ...props }, ref) => {
const modifiedKeys = keys.map(key => getShortcutKey(key))
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ')
const modifiedKeys = keys.map(key => getShortcutKey(key))
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ")
return (
<span aria-label={ariaLabel} className={cn('inline-flex items-center gap-0.5', className)} {...props} ref={ref}>
{modifiedKeys.map(shortcut => (
<kbd
key={shortcut.symbol}
className={cn(
'inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]',
return (
<span aria-label={ariaLabel} className={cn("inline-flex items-center gap-0.5", className)} {...props} ref={ref}>
{modifiedKeys.map(shortcut => (
<kbd
key={shortcut.symbol}
className={cn(
"inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]",
className
)}
{...props}
ref={ref}
>
{shortcut.symbol}
</kbd>
))}
</span>
)
className
)}
{...props}
ref={ref}
>
{shortcut.symbol}
</kbd>
))}
</span>
)
})
ShortcutKey.displayName = 'ShortcutKey'
ShortcutKey.displayName = "ShortcutKey"

View File

@@ -1,112 +1,112 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import { cn } from '@/lib/utils'
import { CaretDownIcon } from '@radix-ui/react-icons'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ToolbarButton } from './toolbar-button'
import { ShortcutKey } from './shortcut-key'
import { getShortcutKey } from '../utils'
import type { FormatAction } from '../types'
import type { VariantProps } from 'class-variance-authority'
import type { toggleVariants } from '@/components/ui/toggle'
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { cn } from "@/lib/utils"
import { CaretDownIcon } from "@radix-ui/react-icons"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { ToolbarButton } from "./toolbar-button"
import { ShortcutKey } from "./shortcut-key"
import { getShortcutKey } from "@/lib/utils"
import type { FormatAction } from "../types"
import type { VariantProps } from "class-variance-authority"
import type { toggleVariants } from "@/components/ui/toggle"
interface ToolbarSectionProps extends VariantProps<typeof toggleVariants> {
editor: Editor
actions: FormatAction[]
activeActions?: string[]
mainActionCount?: number
dropdownIcon?: React.ReactNode
dropdownTooltip?: string
dropdownClassName?: string
editor: Editor
actions: FormatAction[]
activeActions?: string[]
mainActionCount?: number
dropdownIcon?: React.ReactNode
dropdownTooltip?: string
dropdownClassName?: string
}
export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
editor,
actions,
activeActions = actions.map(action => action.value),
mainActionCount = 0,
dropdownIcon,
dropdownTooltip = 'More options',
dropdownClassName = 'w-12',
size,
variant
editor,
actions,
activeActions = actions.map(action => action.value),
mainActionCount = 0,
dropdownIcon,
dropdownTooltip = "More options",
dropdownClassName = "w-12",
size,
variant
}) => {
const { mainActions, dropdownActions } = React.useMemo(() => {
const sortedActions = actions
.filter(action => activeActions.includes(action.value))
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
const { mainActions, dropdownActions } = React.useMemo(() => {
const sortedActions = actions
.filter(action => activeActions.includes(action.value))
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
return {
mainActions: sortedActions.slice(0, mainActionCount),
dropdownActions: sortedActions.slice(mainActionCount)
}
}, [actions, activeActions, mainActionCount])
return {
mainActions: sortedActions.slice(0, mainActionCount),
dropdownActions: sortedActions.slice(mainActionCount)
}
}, [actions, activeActions, mainActionCount])
const renderToolbarButton = React.useCallback(
(action: FormatAction) => (
<ToolbarButton
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
isActive={action.isActive(editor)}
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(' ')}`}
aria-label={action.label}
size={size}
variant={variant}
>
{action.icon}
</ToolbarButton>
),
[editor, size, variant]
)
const renderToolbarButton = React.useCallback(
(action: FormatAction) => (
<ToolbarButton
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
isActive={action.isActive(editor)}
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`}
aria-label={action.label}
size={size}
variant={variant}
>
{action.icon}
</ToolbarButton>
),
[editor, size, variant]
)
const renderDropdownMenuItem = React.useCallback(
(action: FormatAction) => (
<DropdownMenuItem
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
className={cn('flex flex-row items-center justify-between gap-4', {
'bg-accent': action.isActive(editor)
})}
aria-label={action.label}
>
<span className="grow">{action.label}</span>
<ShortcutKey keys={action.shortcuts} />
</DropdownMenuItem>
),
[editor]
)
const renderDropdownMenuItem = React.useCallback(
(action: FormatAction) => (
<DropdownMenuItem
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
className={cn("flex flex-row items-center justify-between gap-4", {
"bg-accent": action.isActive(editor)
})}
aria-label={action.label}
>
<span className="grow">{action.label}</span>
<ShortcutKey keys={action.shortcuts} />
</DropdownMenuItem>
),
[editor]
)
const isDropdownActive = React.useMemo(
() => dropdownActions.some(action => action.isActive(editor)),
[dropdownActions, editor]
)
const isDropdownActive = React.useMemo(
() => dropdownActions.some(action => action.isActive(editor)),
[dropdownActions, editor]
)
return (
<>
{mainActions.map(renderToolbarButton)}
{dropdownActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
isActive={isDropdownActive}
tooltip={dropdownTooltip}
aria-label={dropdownTooltip}
className={cn(dropdownClassName)}
size={size}
variant={variant}
>
{dropdownIcon || <CaretDownIcon className="size-5" />}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
{dropdownActions.map(renderDropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)
return (
<>
{mainActions.map(renderToolbarButton)}
{dropdownActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
isActive={isDropdownActive}
tooltip={dropdownTooltip}
aria-label={dropdownTooltip}
className={cn(dropdownClassName)}
size={size}
variant={variant}
>
{dropdownIcon || <CaretDownIcon className="size-5" />}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
{dropdownActions.map(renderDropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)
}
export default ToolbarSection

View File

@@ -1,81 +1,14 @@
import type { Editor } from '@tiptap/core'
import type { MinimalTiptapProps } from './minimal-tiptap'
import type { Editor } from "@tiptap/core"
import type { MinimalTiptapProps } from "./minimal-tiptap"
let isMac: boolean | undefined
export function getOutput(editor: Editor, format: MinimalTiptapProps["output"]) {
if (format === "json") {
return editor.getJSON()
}
interface Navigator {
userAgentData?: {
brands: { brand: string; version: string }[]
mobile: boolean
platform: string
getHighEntropyValues: (hints: string[]) => Promise<{
platform: string
platformVersion: string
uaFullVersion: string
}>
}
}
function getPlatform(): string {
const nav = navigator as Navigator
if (nav.userAgentData) {
if (nav.userAgentData.platform) {
return nav.userAgentData.platform
}
nav.userAgentData.getHighEntropyValues(['platform']).then(highEntropyValues => {
if (highEntropyValues.platform) {
return highEntropyValues.platform
}
})
}
if (typeof navigator.platform === 'string') {
return navigator.platform
}
return ''
}
export function isMacOS() {
if (isMac === undefined) {
isMac = getPlatform().toLowerCase().includes('mac')
}
return isMac
}
interface ShortcutKeyResult {
symbol: string
readable: string
}
export function getShortcutKey(key: string): ShortcutKeyResult {
const lowercaseKey = key.toLowerCase()
if (lowercaseKey === 'mod') {
return isMacOS() ? { symbol: '⌘', readable: 'Command' } : { symbol: 'Ctrl', readable: 'Control' }
} else if (lowercaseKey === 'alt') {
return isMacOS() ? { symbol: '⌥', readable: 'Option' } : { symbol: 'Alt', readable: 'Alt' }
} else if (lowercaseKey === 'shift') {
return isMacOS() ? { symbol: '⇧', readable: 'Shift' } : { symbol: 'Shift', readable: 'Shift' }
} else {
return { symbol: key, readable: key }
}
}
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
return keys.map(key => getShortcutKey(key))
}
export function getOutput(editor: Editor, format: MinimalTiptapProps['output']) {
if (format === 'json') {
return editor.getJSON()
}
if (format === 'html') {
return editor.getText() ? editor.getHTML() : ''
}
return editor.getText()
if (format === "html") {
return editor.getText() ? editor.getHTML() : ""
}
return editor.getText()
}