186 lines
4.9 KiB
TypeScript
186 lines
4.9 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { Suspense } from "react"
|
|
import { useSearchParams } from "next/navigation"
|
|
|
|
import {
|
|
useDesignSystemSearchParams,
|
|
type DesignSystemSearchParams,
|
|
} from "@/app/(app)/(create)/lib/search-params"
|
|
|
|
type HistoryContextValue = {
|
|
canGoBack: boolean
|
|
canGoForward: boolean
|
|
goBack: () => void
|
|
goForward: () => void
|
|
}
|
|
|
|
const HistoryContext = React.createContext<HistoryContextValue | null>(null)
|
|
|
|
// Reads useSearchParams() in its own Suspense boundary so the
|
|
// provider never blanks out children while search params resolve.
|
|
// useSearchParams reflects the *settled* preset, which coalesces the
|
|
// transient values nuqs emits mid-update — reading nuqs state directly
|
|
// here would record those transients as phantom history entries.
|
|
function PresetSync({
|
|
onPresetChange,
|
|
}: {
|
|
onPresetChange: (preset: string) => void
|
|
}) {
|
|
const searchParams = useSearchParams()
|
|
const preset = searchParams.get("preset") ?? ""
|
|
|
|
React.useEffect(() => {
|
|
onPresetChange(preset)
|
|
}, [preset, onPresetChange])
|
|
|
|
return null
|
|
}
|
|
|
|
export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
|
// Write through the shared search-params hook (shallow) instead of
|
|
// router.replace. router.replace triggers a full server navigation that
|
|
// resets the preset on prod — the same failure the customizer avoids by
|
|
// going shallow (#11060).
|
|
const [, setParams] = useDesignSystemSearchParams()
|
|
|
|
const [preset, setPreset] = React.useState("")
|
|
|
|
const entriesRef = React.useRef<string[]>([preset])
|
|
const indexRef = React.useRef(0)
|
|
const maxIndexRef = React.useRef(0)
|
|
const isNavigatingRef = React.useRef(false)
|
|
|
|
const [index, setIndex] = React.useState(0)
|
|
const [maxIndex, setMaxIndex] = React.useState(0)
|
|
|
|
const onPresetChange = React.useCallback((nextPreset: string) => {
|
|
setPreset(nextPreset)
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
if (isNavigatingRef.current) {
|
|
isNavigatingRef.current = false
|
|
return
|
|
}
|
|
|
|
if (preset === entriesRef.current[indexRef.current]) {
|
|
return
|
|
}
|
|
|
|
const nextEntries = entriesRef.current.slice(0, indexRef.current + 1)
|
|
nextEntries.push(preset)
|
|
entriesRef.current = nextEntries
|
|
|
|
const nextIndex = nextEntries.length - 1
|
|
indexRef.current = nextIndex
|
|
maxIndexRef.current = nextIndex
|
|
setIndex(nextIndex)
|
|
setMaxIndex(nextIndex)
|
|
}, [preset])
|
|
|
|
const canGoBack = index > 0
|
|
const canGoForward = index < maxIndex
|
|
|
|
const goBack = React.useCallback(() => {
|
|
if (indexRef.current <= 0) {
|
|
return
|
|
}
|
|
|
|
isNavigatingRef.current = true
|
|
const nextIndex = indexRef.current - 1
|
|
indexRef.current = nextIndex
|
|
setIndex(nextIndex)
|
|
|
|
// The first history entry is "" (no preset in the URL); null clears the
|
|
// param to restore that state. nuqs accepts null to clear a key, which the
|
|
// wrapper's input type does not model.
|
|
setParams(
|
|
{
|
|
preset: entriesRef.current[nextIndex] || null,
|
|
} as Partial<DesignSystemSearchParams>,
|
|
{ history: "replace" }
|
|
)
|
|
}, [setParams])
|
|
|
|
const goForward = React.useCallback(() => {
|
|
if (indexRef.current >= maxIndexRef.current) {
|
|
return
|
|
}
|
|
|
|
isNavigatingRef.current = true
|
|
const nextIndex = indexRef.current + 1
|
|
indexRef.current = nextIndex
|
|
setIndex(nextIndex)
|
|
|
|
// The first history entry is "" (no preset in the URL); null clears the
|
|
// param to restore that state. nuqs accepts null to clear a key, which the
|
|
// wrapper's input type does not model.
|
|
setParams(
|
|
{
|
|
preset: entriesRef.current[nextIndex] || null,
|
|
} as Partial<DesignSystemSearchParams>,
|
|
{ history: "replace" }
|
|
)
|
|
}, [setParams])
|
|
|
|
React.useEffect(() => {
|
|
const down = (e: KeyboardEvent) => {
|
|
if (!e.metaKey && !e.ctrlKey) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
|
e.target instanceof HTMLInputElement ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
e.target instanceof HTMLSelectElement
|
|
) {
|
|
return
|
|
}
|
|
|
|
const key = e.key.toLowerCase()
|
|
|
|
if ((key === "z" && e.shiftKey) || (key === "y" && e.ctrlKey)) {
|
|
e.preventDefault()
|
|
goForward()
|
|
return
|
|
}
|
|
|
|
if (key === "z") {
|
|
e.preventDefault()
|
|
goBack()
|
|
}
|
|
}
|
|
|
|
document.addEventListener("keydown", down)
|
|
|
|
return () => {
|
|
document.removeEventListener("keydown", down)
|
|
}
|
|
}, [goBack, goForward])
|
|
|
|
const value = React.useMemo(
|
|
() => ({ canGoBack, canGoForward, goBack, goForward }),
|
|
[canGoBack, canGoForward, goBack, goForward]
|
|
)
|
|
|
|
return (
|
|
<HistoryContext value={value}>
|
|
<Suspense>
|
|
<PresetSync onPresetChange={onPresetChange} />
|
|
</Suspense>
|
|
{children}
|
|
</HistoryContext>
|
|
)
|
|
}
|
|
|
|
export function useHistory() {
|
|
const context = React.useContext(HistoryContext)
|
|
if (!context) {
|
|
throw new Error("useHistory must be used within HistoryProvider")
|
|
}
|
|
return context
|
|
}
|