Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 6 additions & 36 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ catalog:
ahooks: 3.9.7
autoprefixer: 10.4.27
class-variance-authority: 0.7.1
client-only: 0.0.1
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.5.1
Expand All @@ -154,7 +155,6 @@ catalog:
eslint-plugin-sonarjs: 4.0.2
eslint-plugin-storybook: 10.3.5
fast-deep-equal: 3.1.3
foxact: 0.3.0
happy-dom: 20.8.9
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.12
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const mockCopy = vi.fn()
const mockReset = vi.fn()
let mockCopied = false

vi.mock('foxact/use-clipboard', () => ({
vi.mock('@/hooks/use-clipboard', () => ({
useClipboard: () => ({
copy: mockCopy,
reset: mockReset,
Expand Down
2 changes: 1 addition & 1 deletion web/app/components/base/copy-feedback/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import {
RiClipboardFill,
RiClipboardLine,
} from '@remixicon/react'
import { useClipboard } from 'foxact/use-clipboard'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import { useClipboard } from '@/hooks/use-clipboard'
import copyStyle from './style.module.css'

type Props = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const copy = vi.fn()
const reset = vi.fn()
let copied = false

vi.mock('foxact/use-clipboard', () => ({
vi.mock('@/hooks/use-clipboard', () => ({
useClipboard: () => ({
copy,
reset,
Expand Down
2 changes: 1 addition & 1 deletion web/app/components/base/copy-icon/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'
import { useClipboard } from 'foxact/use-clipboard'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useClipboard } from '@/hooks/use-clipboard'
import Tooltip from '../tooltip'

type Props = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const mockCopy = vi.fn()
let mockCopied = false
const mockReset = vi.fn()

vi.mock('foxact/use-clipboard', () => ({
vi.mock('@/hooks/use-clipboard', () => ({
useClipboard: () => ({
copy: mockCopy,
copied: mockCopied,
Expand Down
2 changes: 1 addition & 1 deletion web/app/components/base/input-with-copy/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client'
import type { InputProps } from '../input'
import { useClipboard } from 'foxact/use-clipboard'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useClipboard } from '@/hooks/use-clipboard'
import { cn } from '@/utils/classnames'
import ActionButton from '../action-button'
import Tooltip from '../tooltip'
Expand Down
7 changes: 7 additions & 0 deletions web/hooks/noop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type Noop = {
// eslint-disable-next-line ts/no-explicit-any
(...args: any[]): any
}

/** @see https://foxact.skk.moe/noop */
export const noop: Noop = () => { /* noop */ }
72 changes: 72 additions & 0 deletions web/hooks/use-clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useRef, useState } from 'react'
import { writeTextToClipboard } from '@/utils/clipboard'
import { noop } from './noop'
import { useStableHandler } from './use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired'
import { useCallback } from './use-typescript-happy-callback'
import 'client-only'

type UseClipboardOption = {
timeout?: number
usePromptAsFallback?: boolean
promptFallbackText?: string
onCopyError?: (error: Error) => void
}

/** @see https://foxact.skk.moe/use-clipboard */
export function useClipboard({
timeout = 1000,
usePromptAsFallback = false,
promptFallbackText = 'Failed to copy to clipboard automatically, please manually copy the text below.',
onCopyError,
}: UseClipboardOption = {}) {
const [error, setError] = useState<Error | null>(null)
const [copied, setCopied] = useState(false)
const copyTimeoutRef = useRef<number | null>(null)

const stablizedOnCopyError = useStableHandler<[e: Error], void>(onCopyError || noop)

const handleCopyResult = useCallback((isCopied: boolean) => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current)
}
if (isCopied) {
copyTimeoutRef.current = window.setTimeout(() => setCopied(false), timeout)
}
setCopied(isCopied)
}, [timeout])

const handleCopyError = useCallback((e: Error) => {
setError(e)
stablizedOnCopyError(e)
}, [stablizedOnCopyError])

const copy = useCallback(async (valueToCopy: string) => {
try {
await writeTextToClipboard(valueToCopy)
}
catch (e) {
if (usePromptAsFallback) {
try {
// eslint-disable-next-line no-alert -- prompt as fallback in case of copy error
window.prompt(promptFallbackText, valueToCopy)
}
catch (e2) {
handleCopyError(e2 as Error)
}
}
else {
handleCopyError(e as Error)
}
}
}, [handleCopyResult, promptFallbackText, handleCopyError, usePromptAsFallback])

const reset = useCallback(() => {
setCopied(false)
setError(null)
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current)
}
}, [])

return { copy, reset, error, copied }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as reactExports from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'

// useIsomorphicInsertionEffect
const useInsertionEffect
= typeof window === 'undefined'
// useInsertionEffect is only available in React 18+

? useEffect
: reactExports.useInsertionEffect || useLayoutEffect

/**
* @see https://foxact.skk.moe/use-stable-handler-only-when-you-know-what-you-are-doing-or-you-will-be-fired
* Similar to useCallback, with a few subtle differences:
* - The returned function is a stable reference, and will always be the same between renders
* - No dependency lists required
* - Properties or state accessed within the callback will always be "current"
*/
// eslint-disable-next-line ts/no-explicit-any
export function useStableHandler<Args extends any[], Result>(
callback: (...args: Args) => Result,
): typeof callback {
// Keep track of the latest callback:
// eslint-disable-next-line ts/no-explicit-any
const latestRef = useRef<typeof callback>(shouldNotBeInvokedBeforeMount as any)
useInsertionEffect(() => {
latestRef.current = callback
}, [callback])

return useCallback<typeof callback>((...args) => {
const fn = latestRef.current
return fn(...args)
}, [])
}

/**
* Render methods should be pure, especially when concurrency is used,
* so we will throw this error if the callback is called while rendering.
*/
function shouldNotBeInvokedBeforeMount() {
throw new Error(
'foxact: the stablized handler cannot be invoked before the component has mounted.',
)
}
10 changes: 10 additions & 0 deletions web/hooks/use-typescript-happy-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useCallback as useCallbackFromReact } from 'react'

/** @see https://foxact.skk.moe/use-typescript-happy-callback */
const useTypeScriptHappyCallback: <Args extends unknown[], R>(
fn: (...args: Args) => R,
deps: React.DependencyList,
) => (...args: Args) => R = useCallbackFromReact

/** @see https://foxact.skk.moe/use-typescript-happy-callback */
export const useCallback = useTypeScriptHappyCallback
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"abcjs": "catalog:",
"ahooks": "catalog:",
"class-variance-authority": "catalog:",
"client-only": "catalog:",
"clsx": "catalog:",
"cmdk": "catalog:",
"copy-to-clipboard": "catalog:",
Expand All @@ -100,7 +101,6 @@
"emoji-mart": "catalog:",
"es-toolkit": "catalog:",
"fast-deep-equal": "catalog:",
"foxact": "catalog:",
"hast-util-to-jsx-runtime": "catalog:",
"html-entities": "catalog:",
"html-to-image": "catalog:",
Expand Down
5 changes: 3 additions & 2 deletions web/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,12 @@ afterEach(async () => {
})
})

// mock foxact/use-clipboard - not available in test environment
vi.mock('foxact/use-clipboard', () => ({
// mock custom clipboard hook - wraps writeTextToClipboard with fallback
vi.mock('@/hooks/use-clipboard', () => ({
useClipboard: () => ({
copy: vi.fn(),
copied: false,
reset: vi.fn(),
}),
}))

Expand Down
Loading