feat: support vim keybinding navigation between entry content and timeline
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
262a7ce55e
commit
c2d8ffaa19
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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!)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./Focusable"
|
||||
export * from "./hooks"
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue