mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-28 11:17:20 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user