diff --git a/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx b/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx index 56899f9f4..6c4d03a08 100644 --- a/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx @@ -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" diff --git a/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx b/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx index 14532ae82..afa8d7b64 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx @@ -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" diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index dc5264fb7..da71ab50d 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -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", diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx index 309d3811f..00e3e6a64 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx index f9e883cd3..3f6d9e809 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index 2d0119c89..f8f710790 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -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 }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index c3d03232e..744a0acfb 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -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 ( -
)} -
+ ) } diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 2e6a0f1db..392258aa5 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -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 = ({ onFocus={() => setIsUserInteraction(true)} > @@ -299,10 +303,11 @@ const Renderer: React.FC<{ const RegisterCommands = ({ scrollerRef, isUserInteraction, + setIsUserInteraction, }: { - entryId: string scrollerRef: React.RefObject 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 } diff --git a/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx b/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx index fb578f721..5d0c9d3b9 100644 --- a/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx +++ b/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx @@ -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" diff --git a/packages/internal/components/src/common/Focusable.tsx b/packages/internal/components/src/common/Focusable/Focusable.tsx similarity index 50% rename from packages/internal/components/src/common/Focusable.tsx rename to packages/internal/components/src/common/Focusable/Focusable.tsx index 50fbca1b6..e04ba6bd5 100644 --- a/packages/internal/components/src/common/Focusable.tsx +++ b/packages/internal/components/src/common/Focusable/Focusable.tsx @@ -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>(null!) +import { FocusableContext, FocusActionsContext, FocusTargetRefContext } from "./context" +import { highlightElement } from "./utils" + export const Focusable: Component< React.DetailedHTMLProps, HTMLDivElement> > = ({ ref, ...props }) => { @@ -13,33 +13,35 @@ export const Focusable: Component< const containerRef = useRef(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 ( -
+ ({ highlightBoundary }), [highlightBoundary])}> +
+ ) } - -export const useFocusable = () => { - return use(FocusableContext) -} - -export const useFocusTargetRef = () => { - return use(FocusTargetRefContext) -} diff --git a/packages/internal/components/src/common/Focusable/context.ts b/packages/internal/components/src/common/Focusable/context.ts new file mode 100644 index 000000000..c50aa988d --- /dev/null +++ b/packages/internal/components/src/common/Focusable/context.ts @@ -0,0 +1,7 @@ +import { createContext } from "react" + +export const FocusableContext = createContext(false) +export const FocusTargetRefContext = createContext>(null!) +export const FocusActionsContext = createContext<{ + highlightBoundary: () => void +}>(null!) diff --git a/packages/internal/components/src/common/Focusable/hooks.ts b/packages/internal/components/src/common/Focusable/hooks.ts new file mode 100644 index 000000000..4a349a1e5 --- /dev/null +++ b/packages/internal/components/src/common/Focusable/hooks.ts @@ -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) +} diff --git a/packages/internal/components/src/common/Focusable/index.ts b/packages/internal/components/src/common/Focusable/index.ts new file mode 100644 index 000000000..b96ce5b87 --- /dev/null +++ b/packages/internal/components/src/common/Focusable/index.ts @@ -0,0 +1,2 @@ +export * from "./Focusable" +export * from "./hooks" diff --git a/packages/internal/components/src/common/Focusable/utils.ts b/packages/internal/components/src/common/Focusable/utils.ts new file mode 100644 index 000000000..fd95891f4 --- /dev/null +++ b/packages/internal/components/src/common/Focusable/utils.ts @@ -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) +} diff --git a/packages/internal/components/src/ui/button/action-button.tsx b/packages/internal/components/src/ui/button/action-button.tsx index 853f21a32..7ae2f4faf 100644 --- a/packages/internal/components/src/ui/button/action-button.tsx +++ b/packages/internal/components/src/ui/button/action-button.tsx @@ -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"