feat: support vim keybinding navigation between entry content and timeline

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-05-13 22:35:26 +08:00
parent 262a7ce55e
commit c2d8ffaa19
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
15 changed files with 328 additions and 75 deletions

View File

@ -1,4 +1,4 @@
import { Focusable } from "@follow/components/common/Focusable.js"
import { Focusable } from "@follow/components/common/Focusable/index.js"
import { SYSTEM_CAN_UNDER_BLUR_WINDOW } from "@follow/shared/constants"
import { cn } from "@follow/utils/utils"

View File

@ -1,4 +1,4 @@
import { Focusable } from "@follow/components/common/Focusable.js"
import { Focusable } from "@follow/components/common/Focusable/index.js"
import { Spring } from "@follow/components/constants/spring.js"
import { ActionButton, MotionButtonBase } from "@follow/components/ui/button/index.js"
import type { HTMLMediaState } from "@follow/hooks"

View File

@ -46,11 +46,13 @@ export const COMMAND_ID = {
},
layout: {
toggleTimelineColumn: "layout:toggle-timeline-column",
focusToTimeline: "layout:focus-to-timeline",
},
timeline: {
switchToNext: "timeline:switch-to-next",
switchToPrevious: "timeline:switch-to-previous",
refetch: "timeline:refetch",
enter: "timeline:enter",
},
entryRender: {
scrollDown: "entry-render:scroll-down",

View File

@ -1,9 +1,17 @@
import { EventBus } from "@follow/utils/event-bus"
import { setTimelineColumnShow } from "~/atoms/sidebar"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
interface EventBusMap {
"layout:focus-to-timeline": never
}
}
export const useRegisterLayoutCommands = () => {
useRegisterCommandEffect([
{
@ -13,6 +21,13 @@ export const useRegisterLayoutCommands = () => {
setTimelineColumnShow((show) => !show)
},
},
{
id: COMMAND_ID.layout.focusToTimeline,
label: "Focus to timeline",
run: () => {
EventBus.dispatch(COMMAND_ID.layout.focusToTimeline)
},
},
])
}
@ -21,4 +36,9 @@ export type ToggleTimelineColumnCommand = Command<{
fn: () => void
}>
export type LayoutCommand = ToggleTimelineColumnCommand
export type FocusToTimelineCommand = Command<{
id: typeof COMMAND_ID.layout.focusToTimeline
fn: () => void
}>
export type LayoutCommand = ToggleTimelineColumnCommand | FocusToTimelineCommand

View File

@ -9,6 +9,7 @@ declare module "@follow/utils/event-bus" {
"timeline:switch-to-next": never
"timeline:switch-to-previous": never
"timeline:refetch": never
"timeline:enter": never
}
}
export const useRegisterTimelineCommand = () => {
@ -35,6 +36,13 @@ export const useRegisterTimelineCommand = () => {
EventBus.dispatch("timeline:refetch")
},
},
{
id: COMMAND_ID.timeline.enter,
label: "Enter Selected Entry",
run: () => {
EventBus.dispatch("timeline:enter")
},
},
])
}
@ -53,7 +61,13 @@ export type RefetchTimelineCommand = Command<{
fn: () => void
}>
export type EnterTimelineCommand = Command<{
id: typeof COMMAND_ID.timeline.enter
fn: () => void
}>
export type TimelineCommand =
| SwitchToNextTimelineCommand
| SwitchToPreviousTimelineCommand
| RefetchTimelineCommand
| EnterTimelineCommand

View File

@ -1,9 +1,11 @@
import { useFocusable, useFocusActions } from "@follow/components/common/Focusable/hooks.js"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { useRefValue } from "@follow/hooks"
import { nextFrame } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import type { FC } from "react"
import { memo, useEffect, useLayoutEffect, useState } from "react"
import { memo, useEffect } from "react"
import { useMainContainerElement } from "~/atoms/dom"
import { HotkeyScope } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteEntryId } from "~/hooks/biz/useRouteParams"
@ -11,7 +13,7 @@ import { useConditionalHotkeyScope } from "~/hooks/common"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding } from "../command/hooks/use-register-hotkey"
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
export const EntryColumnShortcutHandler: FC<{
refetch: () => void
@ -40,6 +42,12 @@ export const EntryColumnShortcutHandler: FC<{
when,
})
useCommandHotkey({
commandId: COMMAND_ID.timeline.enter,
shortcut: "Enter",
when,
})
const currentEntryIdRef = useRefValue(useRouteEntryId())
const navigate = useNavigateEntry()
@ -82,32 +90,18 @@ export const EntryColumnShortcutHandler: FC<{
})
}, [refetch])
const $mainContainer = useMainContainerElement()
const [isFocusIn, setIsFocusIn] = useState(false)
const $scrollArea = useScrollViewElement()
const { highlightBoundary } = useFocusActions()
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.layout.focusToTimeline, () => {
$scrollArea?.focus()
nextFrame(highlightBoundary)
})
}, [$scrollArea, highlightBoundary])
const isFocusIn = useFocusable()
useConditionalHotkeyScope(HotkeyScope.Timeline, isFocusIn, true)
// Enable arrow key navigation shortcuts only when focus is on entryContent or entryList,
// entryList shortcuts should not be triggered in the feed col
useLayoutEffect(() => {
if (!$mainContainer) return
const handler = () => {
const target = document.activeElement
const isFocusIn = $mainContainer.contains(target) || $mainContainer === target
setIsFocusIn(isFocusIn)
}
handler()
// NOTE: focusin event will bubble to the document
document.addEventListener("focusin", handler)
document.addEventListener("focusout", handler)
return () => {
document.removeEventListener("focusin", handler)
document.removeEventListener("focusout", handler)
}
}, [$mainContainer])
return null
})

View File

@ -1,3 +1,4 @@
import { Focusable } from "@follow/components/common/Focusable/index.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { FeedViewType, views } from "@follow/constants"
import { useTitle } from "@follow/hooks"
@ -116,7 +117,7 @@ function EntryColumnImpl() {
const ListComponent = views[view]!.gridMode ? EntryColumnGrid : EntryList
return (
<div
<Focusable
data-hide-in-print
className="@container relative flex h-full flex-1 flex-col"
onClick={
@ -164,7 +165,7 @@ function EntryColumnImpl() {
/>
)}
</EntryColumnWrapper>
</div>
</Focusable>
)
}

View File

@ -1,10 +1,14 @@
import { Focusable, useFocusable } from "@follow/components/common/Focusable.js"
import {
Focusable,
useFocusable,
useFocusActions,
} from "@follow/components/common/Focusable/index.js"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import type { FeedViewType } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import type { FeedModel, InboxModel } from "@follow/models/types"
import { stopPropagation } from "@follow/utils/dom"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn, combineCleanupFunctions } from "@follow/utils/utils"
@ -35,7 +39,7 @@ import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding } from "../command/hooks/use-register-hotkey"
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { AISummary } from "./AISummary"
import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar"
@ -136,9 +140,9 @@ export const EntryContent: Component<EntryContentProps> = ({
onFocus={() => setIsUserInteraction(true)}
>
<RegisterCommands
entryId={entry.entries.id}
scrollerRef={scrollerRef}
isUserInteraction={isUserInteraction}
setIsUserInteraction={setIsUserInteraction}
/>
<EntryTimelineSidebar entryId={entry.entries.id} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
@ -299,10 +303,11 @@ const Renderer: React.FC<{
const RegisterCommands = ({
scrollerRef,
isUserInteraction,
setIsUserInteraction,
}: {
entryId: string
scrollerRef: React.RefObject<HTMLDivElement | null>
isUserInteraction: boolean
setIsUserInteraction: (isUserInteraction: boolean) => void
}) => {
const containerFocused = useFocusable()
useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true)
@ -330,26 +335,43 @@ const RegisterCommands = ({
when,
})
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.entryRender.scrollUp, () => {
const currentScroll = scrollerRef.current?.scrollTop
const delta = window.innerHeight
useCommandHotkey({
commandId: COMMAND_ID.layout.focusToTimeline,
when,
shortcut: "Backspace",
})
if (typeof currentScroll === "number" && delta) {
springScrollTo(currentScroll - delta, scrollerRef.current!)
}
})
}, [scrollerRef])
const { highlightBoundary } = useFocusActions()
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.entryRender.scrollDown, () => {
const currentScroll = scrollerRef.current?.scrollTop
const delta = window.innerHeight
if (typeof currentScroll === "number" && delta) {
springScrollTo(currentScroll + delta, scrollerRef.current!)
}
})
}, [scrollerRef])
return combineCleanupFunctions(
EventBus.subscribe(COMMAND_ID.entryRender.scrollUp, () => {
const currentScroll = scrollerRef.current?.scrollTop
const delta = window.innerHeight
if (typeof currentScroll === "number" && delta) {
springScrollTo(currentScroll - delta, scrollerRef.current!)
}
}),
EventBus.subscribe(COMMAND_ID.entryRender.scrollDown, () => {
const currentScroll = scrollerRef.current?.scrollTop
const delta = window.innerHeight
if (typeof currentScroll === "number" && delta) {
springScrollTo(currentScroll + delta, scrollerRef.current!)
}
}),
EventBus.subscribe(COMMAND_ID.timeline.enter, () => {
const $scroller = scrollerRef.current
if ($scroller) {
springScrollTo(0, $scroller)
$scroller.focus()
nextFrame(highlightBoundary)
setIsUserInteraction(true)
}
}),
)
}, [highlightBoundary, scrollerRef, setIsUserInteraction])
return null
}

View File

@ -1,4 +1,4 @@
import { useFocusable } from "@follow/components/common/Focusable.js"
import { useFocusable } from "@follow/components/common/Focusable/index.js"
import { Spring } from "@follow/components/constants/spring.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx"

View File

@ -1,9 +1,9 @@
import { createContext, use, useImperativeHandle, useRef, useState } from "react"
import { useCallback, useImperativeHandle, useMemo, useRef, useState } from "react"
import { useEventListener } from "usehooks-ts"
// const
const FocusableContext = createContext(false)
const FocusTargetRefContext = createContext<React.RefObject<HTMLElement | undefined>>(null!)
import { FocusableContext, FocusActionsContext, FocusTargetRefContext } from "./context"
import { highlightElement } from "./utils"
export const Focusable: Component<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
> = ({ ref, ...props }) => {
@ -13,33 +13,35 @@ export const Focusable: Component<
const containerRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => containerRef.current!)
const highlightBoundary = useCallback(() => {
if (!isFocusWithIn) return
const element = containerRef.current
if (!element) return
highlightElement(element)
}, [isFocusWithIn])
useEventListener("focusin", (e) => {
if (containerRef.current?.contains(e.target as Node)) {
setIsFocusWithIn(true)
focusTargetRef.current = e.target as HTMLElement
if (import.meta.env.DEV) {
highlightElement(containerRef.current!)
}
} else {
setIsFocusWithIn(false)
focusTargetRef.current = undefined
}
})
// useEventListener("focusout", (e) => {
// if (!containerRef.current?.contains(e.target as Node)) {
// setIsFocusWithIn(false)
// }
// })
return (
<FocusableContext value={isFocusWithIn}>
<FocusTargetRefContext value={focusTargetRef}>
<div tabIndex={-1} role="region" ref={containerRef} {...rest} />
<FocusActionsContext value={useMemo(() => ({ highlightBoundary }), [highlightBoundary])}>
<div tabIndex={-1} role="region" ref={containerRef} {...rest} />
</FocusActionsContext>
</FocusTargetRefContext>
</FocusableContext>
)
}
export const useFocusable = () => {
return use(FocusableContext)
}
export const useFocusTargetRef = () => {
return use(FocusTargetRefContext)
}

View File

@ -0,0 +1,7 @@
import { createContext } from "react"
export const FocusableContext = createContext(false)
export const FocusTargetRefContext = createContext<React.RefObject<HTMLElement | undefined>>(null!)
export const FocusActionsContext = createContext<{
highlightBoundary: () => void
}>(null!)

View File

@ -0,0 +1,15 @@
import { use } from "react"
import { FocusableContext, FocusActionsContext, FocusTargetRefContext } from "./context"
export const useFocusable = () => {
return use(FocusableContext)
}
export const useFocusTargetRef = () => {
return use(FocusTargetRefContext)
}
export const useFocusActions = () => {
return use(FocusActionsContext)
}

View File

@ -0,0 +1,2 @@
export * from "./Focusable"
export * from "./hooks"

View File

@ -0,0 +1,174 @@
// Grok AI
// Interface for keyframe properties
interface HighlightKeyframe {
shadowWidth: number
shadowOpacity: number
outlineWidth: number
outlineOpacity: number
}
// Cubic-bezier easing function (0.4, 0, 0.2, 1)
function cubicBezier(t: number, p1x: number, p1y: number, p2x: number, p2y: number): number {
const cx: number = 3 * p1x
const bx: number = 3 * (p2x - p1x) - cx
const ax: number = 1 - cx - bx
const cy: number = 3 * p1y
const by: number = 3 * (p2y - p1y) - cy
const ay: number = 1 - cy - by
function sampleCurveX(t: number): number {
return ((ax * t + bx) * t + cx) * t
}
function sampleCurveY(t: number): number {
return ((ay * t + by) * t + cy) * t
}
function solveCurveX(x: number): number {
let t2: number = x
for (let i = 0; i < 8; i++) {
const x2: number = sampleCurveX(t2) - x
if (Math.abs(x2) < 1e-6) return t2
const d2: number = (3 * ax * t2 + 2 * bx) * t2 + cx
if (Math.abs(d2) < 1e-6) break
t2 -= x2 / d2
}
return t2
}
return sampleCurveY(solveCurveX(t))
}
export function highlightElement(element: HTMLElement | null): void {
if (!element) return
// Get element's bounding rectangle
const rect: DOMRect = element.getBoundingClientRect()
const padding = 10 // Extra space for shadow effect
// Create canvas
const canvas: HTMLCanvasElement = document.createElement("canvas")
canvas.style.position = "absolute"
canvas.style.top = `${rect.top + window.scrollY - padding}px`
canvas.style.left = `${rect.left + window.scrollX - padding}px`
canvas.width = rect.width + padding * 2
canvas.height = rect.height + padding * 2
canvas.style.pointerEvents = "none"
document.body.append(canvas)
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!
if (!ctx) {
canvas.remove()
return
}
const duration = 1000 // 1000ms
const startTime: number = performance.now()
const borderRadius = 8 // Border radius for rounded corners
// Keyframes (mimicking CSS)
const keyframes: HighlightKeyframe[] = [
{
shadowWidth: 4,
shadowOpacity: 0.8,
outlineWidth: 1,
outlineOpacity: 0.5,
},
{
shadowWidth: 2,
shadowOpacity: 0.4,
outlineWidth: 1,
outlineOpacity: 0.3,
},
{
shadowWidth: 0,
shadowOpacity: 0,
outlineWidth: 0,
outlineOpacity: 0,
},
]
function interpolate(start: number, end: number, factor: number): number {
return start + (end - start) * factor
}
function animate(): void {
const elapsed: number = performance.now() - startTime
let t: number = elapsed / duration
if (t > 1) t = 1
// Apply cubic-bezier easing
t = cubicBezier(t, 0.4, 0, 0.2, 1)
// Determine which keyframe segment we're in
const segmentDuration: number = 1 / (keyframes.length - 1)
const segmentIndex: number = Math.floor(t / segmentDuration)
const segmentT: number = (t - segmentIndex * segmentDuration) / segmentDuration
if (segmentIndex >= keyframes.length - 1) {
// Animation complete, remove canvas
canvas.remove()
return
}
const startFrame: HighlightKeyframe = keyframes[segmentIndex]!
const endFrame: HighlightKeyframe = keyframes[segmentIndex + 1]!
// Interpolate values
const shadowWidth: number = interpolate(startFrame.shadowWidth, endFrame.shadowWidth, segmentT)
const shadowOpacity: number = interpolate(
startFrame.shadowOpacity,
endFrame.shadowOpacity,
segmentT,
)
const outlineWidth: number = interpolate(
startFrame.outlineWidth,
endFrame.outlineWidth,
segmentT,
)
const outlineOpacity: number = interpolate(
startFrame.outlineOpacity,
endFrame.outlineOpacity,
segmentT,
)
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Draw shadow (approximated as thick border with rounded corners)
if (shadowWidth > 0) {
ctx.strokeStyle = `rgba(255, 165, 0, ${shadowOpacity})` // Orange color
ctx.lineWidth = shadowWidth
ctx.beginPath()
ctx.roundRect(
padding - shadowWidth / 2,
padding - shadowWidth / 2,
rect.width + shadowWidth,
rect.height + shadowWidth,
borderRadius,
)
ctx.stroke()
}
// Draw outline (with rounded corners)
if (outlineWidth > 0) {
ctx.strokeStyle = `rgba(255, 165, 0, ${outlineOpacity})`
ctx.lineWidth = outlineWidth
ctx.beginPath()
ctx.roundRect(
padding - outlineWidth / 2,
padding - outlineWidth / 2,
rect.width + outlineWidth,
rect.height + outlineWidth,
borderRadius,
)
ctx.stroke()
}
if (t < 1) {
requestAnimationFrame(animate)
} else {
canvas.remove()
}
}
requestAnimationFrame(animate)
}

View File

@ -1,4 +1,4 @@
import { useFocusable } from "@follow/components/common/Focusable.jsx"
import { useFocusable } from "@follow/components/common/Focusable/index.js"
import { stopPropagation } from "@follow/utils/dom"
import { cn, getOS } from "@follow/utils/utils"
import * as React from "react"