2025.11.28.22.40

This commit is contained in:
RUI
2025-11-28 22:44:54 +08:00
parent 0d73d0c63b
commit 21da21925e
71 changed files with 2924 additions and 1834 deletions

View File

@@ -0,0 +1,47 @@
"use client"
import { useCallback, useRef } from "react"
// basically Exclude<React.ClassAttributes<T>["ref"], string>
type UserRef<T> =
| ((instance: T | null) => void)
| React.RefObject<T | null>
| null
| undefined
const updateRef = <T>(ref: NonNullable<UserRef<T>>, value: T | null) => {
if (typeof ref === "function") {
ref(value)
} else if (ref && typeof ref === "object" && "current" in ref) {
// Safe assignment without MutableRefObject
;(ref as { current: T | null }).current = value
}
}
export const useComposedRef = <T extends HTMLElement>(
libRef: React.RefObject<T | null>,
userRef: UserRef<T>
) => {
const prevUserRef = useRef<UserRef<T>>(null)
return useCallback(
(instance: T | null) => {
if (libRef && "current" in libRef) {
;(libRef as { current: T | null }).current = instance
}
if (prevUserRef.current) {
updateRef(prevUserRef.current, null)
}
prevUserRef.current = userRef
if (userRef) {
updateRef(userRef, instance)
}
},
[libRef, userRef]
)
}
export default useComposedRef

View File

@@ -0,0 +1,69 @@
import type { Editor } from "@tiptap/react"
import { useWindowSize } from "@/hooks/use-window-size"
import { useBodyRect } from "@/hooks/use-element-rect"
import { useEffect } from "react"
export interface CursorVisibilityOptions {
/**
* The Tiptap editor instance
*/
editor?: Editor | null
/**
* Reference to the toolbar element that may obscure the cursor
*/
overlayHeight?: number
}
/**
* Custom hook that ensures the cursor remains visible when typing in a Tiptap editor.
* Automatically scrolls the window when the cursor would be hidden by the toolbar.
*
* @param options.editor The Tiptap editor instance
* @param options.overlayHeight Toolbar height to account for
* @returns The bounding rect of the body
*/
export function useCursorVisibility({
editor,
overlayHeight = 0,
}: CursorVisibilityOptions) {
const { height: windowHeight } = useWindowSize()
const rect = useBodyRect({
enabled: true,
throttleMs: 100,
useResizeObserver: true,
})
useEffect(() => {
const ensureCursorVisibility = () => {
if (!editor) return
const { state, view } = editor
if (!view.hasFocus()) return
// Get current cursor position coordinates
const { from } = state.selection
const cursorCoords = view.coordsAtPos(from)
if (windowHeight < rect.height && cursorCoords) {
const availableSpace = windowHeight - cursorCoords.top
// If the cursor is hidden behind the overlay or offscreen, scroll it into view
if (availableSpace < overlayHeight) {
const targetCursorY = Math.max(windowHeight / 2, overlayHeight)
const currentScrollY = window.scrollY
const cursorAbsoluteY = cursorCoords.top + currentScrollY
const newScrollY = cursorAbsoluteY - targetCursorY
window.scrollTo({
top: Math.max(0, newScrollY),
behavior: "smooth",
})
}
}
}
ensureCursorVisibility()
}, [editor, overlayHeight, windowHeight, rect.height])
return rect
}

View File

@@ -0,0 +1,166 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useThrottledCallback } from "@/hooks/use-throttled-callback"
export type RectState = Omit<DOMRect, "toJSON">
export interface ElementRectOptions {
/**
* The element to track. Can be an Element, ref, or selector string.
* Defaults to document.body if not provided.
*/
element?: Element | React.RefObject<Element> | string | null
/**
* Whether to enable rect tracking
*/
enabled?: boolean
/**
* Throttle delay in milliseconds for rect updates
*/
throttleMs?: number
/**
* Whether to use ResizeObserver for more accurate tracking
*/
useResizeObserver?: boolean
}
const initialRect: RectState = {
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
}
const isSSR = typeof window === "undefined"
const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined"
/**
* Helper function to check if code is running on client side
*/
const isClientSide = (): boolean => !isSSR
/**
* Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc.
*
* @param options Configuration options for element rect tracking
* @returns The current bounding rectangle of the element
*/
export function useElementRect({
element,
enabled = true,
throttleMs = 100,
useResizeObserver = true,
}: ElementRectOptions = {}): RectState {
const [rect, setRect] = useState<RectState>(initialRect)
const getTargetElement = useCallback((): Element | null => {
if (!enabled || !isClientSide()) return null
if (!element) {
return document.body
}
if (typeof element === "string") {
return document.querySelector(element)
}
if ("current" in element) {
return element.current
}
return element
}, [element, enabled])
const updateRect = useThrottledCallback(
() => {
if (!enabled || !isClientSide()) return
const targetElement = getTargetElement()
if (!targetElement) {
setRect(initialRect)
return
}
const newRect = targetElement.getBoundingClientRect()
setRect({
x: newRect.x,
y: newRect.y,
width: newRect.width,
height: newRect.height,
top: newRect.top,
right: newRect.right,
bottom: newRect.bottom,
left: newRect.left,
})
},
throttleMs,
[enabled, getTargetElement],
{ leading: true, trailing: true }
)
useEffect(() => {
if (!enabled || !isClientSide()) {
setRect(initialRect)
return
}
const targetElement = getTargetElement()
if (!targetElement) return
updateRect()
const cleanup: (() => void)[] = []
if (useResizeObserver && hasResizeObserver) {
const resizeObserver = new ResizeObserver(() => {
window.requestAnimationFrame(updateRect)
})
resizeObserver.observe(targetElement)
cleanup.push(() => resizeObserver.disconnect())
}
const handleUpdate = () => updateRect()
window.addEventListener("scroll", handleUpdate, true)
window.addEventListener("resize", handleUpdate, true)
cleanup.push(() => {
window.removeEventListener("scroll", handleUpdate)
window.removeEventListener("resize", handleUpdate)
})
return () => {
cleanup.forEach((fn) => fn())
setRect(initialRect)
}
}, [enabled, getTargetElement, updateRect, useResizeObserver])
return rect
}
/**
* Convenience hook for tracking document.body rect
*/
export function useBodyRect(
options: Omit<ElementRectOptions, "element"> = {}
): RectState {
return useElementRect({
...options,
element: isClientSide() ? document.body : null,
})
}
/**
* Convenience hook for tracking a ref element's rect
*/
export function useRefRect<T extends Element>(
ref: React.RefObject<T>,
options: Omit<ElementRectOptions, "element"> = {}
): RectState {
return useElementRect({ ...options, element: ref })
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from "react"
type BreakpointMode = "min" | "max"
/**
* Hook to detect whether the current viewport matches a given breakpoint rule.
* Example:
* useIsBreakpoint("max", 768) // true when width < 768
* useIsBreakpoint("min", 1024) // true when width >= 1024
*/
export function useIsBreakpoint(
mode: BreakpointMode = "max",
breakpoint = 768
) {
const [matches, setMatches] = useState<boolean | undefined>(undefined)
useEffect(() => {
const query =
mode === "min"
? `(min-width: ${breakpoint}px)`
: `(max-width: ${breakpoint - 1}px)`
const mql = window.matchMedia(query)
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches)
// Set initial value
setMatches(mql.matches)
// Add listener
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [mode, breakpoint])
return !!matches
}

View File

@@ -0,0 +1,194 @@
import type { Editor } from "@tiptap/react"
import { useEffect, useState } from "react"
type Orientation = "horizontal" | "vertical" | "both"
interface MenuNavigationOptions<T> {
/**
* The Tiptap editor instance, if using with a Tiptap editor.
*/
editor?: Editor | null
/**
* Reference to the container element for handling keyboard events.
*/
containerRef?: React.RefObject<HTMLElement | null>
/**
* Search query that affects the selected item.
*/
query?: string
/**
* Array of items to navigate through.
*/
items: T[]
/**
* Callback fired when an item is selected.
*/
onSelect?: (item: T) => void
/**
* Callback fired when the menu should close.
*/
onClose?: () => void
/**
* The navigation orientation of the menu.
* @default "vertical"
*/
orientation?: Orientation
/**
* Whether to automatically select the first item when the menu opens.
* @default true
*/
autoSelectFirstItem?: boolean
}
/**
* Hook that implements keyboard navigation for dropdown menus and command palettes.
*
* Handles arrow keys, tab, home/end, enter for selection, and escape to close.
* Works with both Tiptap editors and regular DOM elements.
*
* @param options - Configuration options for the menu navigation
* @returns Object containing the selected index and a setter function
*/
export function useMenuNavigation<T>({
editor,
containerRef,
query,
items,
onSelect,
onClose,
orientation = "vertical",
autoSelectFirstItem = true,
}: MenuNavigationOptions<T>) {
const [selectedIndex, setSelectedIndex] = useState<number>(
autoSelectFirstItem ? 0 : -1
)
useEffect(() => {
const handleKeyboardNavigation = (event: KeyboardEvent) => {
if (!items.length) return false
const moveNext = () =>
setSelectedIndex((currentIndex) => {
if (currentIndex === -1) return 0
return (currentIndex + 1) % items.length
})
const movePrev = () =>
setSelectedIndex((currentIndex) => {
if (currentIndex === -1) return items.length - 1
return (currentIndex - 1 + items.length) % items.length
})
switch (event.key) {
case "ArrowUp": {
if (orientation === "horizontal") return false
event.preventDefault()
movePrev()
return true
}
case "ArrowDown": {
if (orientation === "horizontal") return false
event.preventDefault()
moveNext()
return true
}
case "ArrowLeft": {
if (orientation === "vertical") return false
event.preventDefault()
movePrev()
return true
}
case "ArrowRight": {
if (orientation === "vertical") return false
event.preventDefault()
moveNext()
return true
}
case "Tab": {
event.preventDefault()
if (event.shiftKey) {
movePrev()
} else {
moveNext()
}
return true
}
case "Home": {
event.preventDefault()
setSelectedIndex(0)
return true
}
case "End": {
event.preventDefault()
setSelectedIndex(items.length - 1)
return true
}
case "Enter": {
if (event.isComposing) return false
event.preventDefault()
if (selectedIndex !== -1 && items[selectedIndex]) {
onSelect?.(items[selectedIndex])
}
return true
}
case "Escape": {
event.preventDefault()
onClose?.()
return true
}
default:
return false
}
}
let targetElement: HTMLElement | null = null
if (editor) {
targetElement = editor.view.dom
} else if (containerRef?.current) {
targetElement = containerRef.current
}
if (targetElement) {
targetElement.addEventListener("keydown", handleKeyboardNavigation, true)
return () => {
targetElement?.removeEventListener(
"keydown",
handleKeyboardNavigation,
true
)
}
}
return undefined
}, [
editor,
containerRef,
items,
selectedIndex,
onSelect,
onClose,
orientation,
])
useEffect(() => {
if (query) {
setSelectedIndex(autoSelectFirstItem ? 0 : -1)
}
}, [query, autoSelectFirstItem])
return {
selectedIndex: items.length ? selectedIndex : undefined,
setSelectedIndex,
}
}

View File

@@ -0,0 +1,75 @@
import type { RefObject } from "react"
import { useEffect, useState } from "react"
type ScrollTarget = RefObject<HTMLElement> | Window | null | undefined
type EventTargetWithScroll = Window | HTMLElement | Document
interface UseScrollingOptions {
debounce?: number
fallbackToDocument?: boolean
}
export function useScrolling(
target?: ScrollTarget,
options: UseScrollingOptions = {}
): boolean {
const { debounce = 150, fallbackToDocument = true } = options
const [isScrolling, setIsScrolling] = useState(false)
useEffect(() => {
// Resolve element or window
const element: EventTargetWithScroll =
target && typeof Window !== "undefined" && target instanceof Window
? target
: ((target as RefObject<HTMLElement>)?.current ?? window)
// Mobile: fallback to document when using window
const eventTarget: EventTargetWithScroll =
fallbackToDocument &&
element === window &&
typeof document !== "undefined"
? document
: element
const on = (
el: EventTargetWithScroll,
event: string,
handler: EventListener
) => el.addEventListener(event, handler, true)
const off = (
el: EventTargetWithScroll,
event: string,
handler: EventListener
) => el.removeEventListener(event, handler)
let timeout: ReturnType<typeof setTimeout>
const supportsScrollEnd = element === window && "onscrollend" in window
const handleScroll: EventListener = () => {
if (!isScrolling) setIsScrolling(true)
if (!supportsScrollEnd) {
clearTimeout(timeout)
timeout = setTimeout(() => setIsScrolling(false), debounce)
}
}
const handleScrollEnd: EventListener = () => setIsScrolling(false)
on(eventTarget, "scroll", handleScroll)
if (supportsScrollEnd) {
on(eventTarget, "scrollend", handleScrollEnd)
}
return () => {
off(eventTarget, "scroll", handleScroll)
if (supportsScrollEnd) {
off(eventTarget, "scrollend", handleScrollEnd)
}
clearTimeout(timeout)
}
}, [target, debounce, fallbackToDocument, isScrolling])
return isScrolling
}

View File

@@ -0,0 +1,48 @@
import throttle from "lodash.throttle"
import { useUnmount } from "@/hooks/use-unmount"
import { useMemo } from "react"
interface ThrottleSettings {
leading?: boolean | undefined
trailing?: boolean | undefined
}
const defaultOptions: ThrottleSettings = {
leading: false,
trailing: true,
}
/**
* A hook that returns a throttled callback function.
*
* @param fn The function to throttle
* @param wait The time in ms to wait before calling the function
* @param dependencies The dependencies to watch for changes
* @param options The throttle options
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useThrottledCallback<T extends (...args: any[]) => any>(
fn: T,
wait = 250,
dependencies: React.DependencyList = [],
options: ThrottleSettings = defaultOptions
): {
(this: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T>
cancel: () => void
flush: () => void
} {
const handler = useMemo(
() => throttle<T>(fn, wait, options),
// eslint-disable-next-line react-hooks/exhaustive-deps
dependencies
)
useUnmount(() => {
handler.cancel()
})
return handler
}
export default useThrottledCallback

21
src/hooks/use-unmount.ts Normal file
View File

@@ -0,0 +1,21 @@
import { useRef, useEffect } from "react"
/**
* Hook that executes a callback when the component unmounts.
*
* @param callback Function to be called on component unmount
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useUnmount = (callback: (...args: Array<any>) => any) => {
const ref = useRef(callback)
ref.current = callback
useEffect(
() => () => {
ref.current()
},
[]
)
}
export default useUnmount

View File

@@ -0,0 +1,93 @@
"use client"
import { useEffect, useState } from "react"
import { useThrottledCallback } from "@/hooks/use-throttled-callback"
export interface WindowSizeState {
/**
* The width of the window's visual viewport in pixels.
*/
width: number
/**
* The height of the window's visual viewport in pixels.
*/
height: number
/**
* The distance from the top of the visual viewport to the top of the layout viewport.
* Particularly useful for handling mobile keyboard appearance.
*/
offsetTop: number
/**
* The distance from the left of the visual viewport to the left of the layout viewport.
*/
offsetLeft: number
/**
* The scale factor of the visual viewport.
* This is useful for scaling elements based on the current zoom level.
*/
scale: number
}
/**
* Hook that tracks the window's visual viewport dimensions, position, and provides
* a CSS transform for positioning elements.
*
* Uses the Visual Viewport API to get accurate measurements, especially important
* for mobile devices where virtual keyboards can change the visible area.
* Only updates state when values actually change to optimize performance.
*
* @returns An object containing viewport properties and a CSS transform string
*/
export function useWindowSize(): WindowSizeState {
const [windowSize, setWindowSize] = useState<WindowSizeState>({
width: 0,
height: 0,
offsetTop: 0,
offsetLeft: 0,
scale: 0,
})
const handleViewportChange = useThrottledCallback(() => {
if (typeof window === "undefined") return
const vp = window.visualViewport
if (!vp) return
const {
width = 0,
height = 0,
offsetTop = 0,
offsetLeft = 0,
scale = 0,
} = vp
setWindowSize((prevState) => {
if (
width === prevState.width &&
height === prevState.height &&
offsetTop === prevState.offsetTop &&
offsetLeft === prevState.offsetLeft &&
scale === prevState.scale
) {
return prevState
}
return { width, height, offsetTop, offsetLeft, scale }
})
}, 200)
useEffect(() => {
const visualViewport = window.visualViewport
if (!visualViewport) return
visualViewport.addEventListener("resize", handleViewportChange)
handleViewportChange()
return () => {
visualViewport.removeEventListener("resize", handleViewportChange)
}
}, [handleViewportChange])
return windowSize
}

View File

@@ -18,6 +18,22 @@ interface User {
};
过期时间?: string;
};
AI配置?: {
使: 'system' | 'custom';
: {
名称: string;
: 'chat' | 'text' | 'image' | 'video';
API_Endpoint?: string;
API_Key?: string;
Model?: string;
系统提示词?: string;
}[];
当前助手ID?: string;
?: {
今日调用次数: number;
剩余免费额度: number;
};
};
}
interface AuthContextType {