mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
fix(link): Navigate between item and fix Enter keybind (#165)
* feat: add item scroll to active * fix: reset enterkey and scroll to view * fix: link item displayName
This commit is contained in:
@@ -8,11 +8,12 @@ import { parseAsBoolean, useQueryState } from "nuqs"
|
|||||||
import { atom, useAtom } from "jotai"
|
import { atom, useAtom } from "jotai"
|
||||||
import { LinkBottomBar } from "./bottom-bar"
|
import { LinkBottomBar } from "./bottom-bar"
|
||||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||||
|
import { useKey } from "react-use"
|
||||||
|
|
||||||
export const isDeleteConfirmShownAtom = atom(false)
|
export const isDeleteConfirmShownAtom = atom(false)
|
||||||
|
|
||||||
export function LinkRoute(): React.ReactElement {
|
export function LinkRoute(): React.ReactElement {
|
||||||
const [nuqsEditId] = useQueryState("editId")
|
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
|
||||||
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
||||||
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
|
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
|
||||||
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
||||||
@@ -50,6 +51,11 @@ export function LinkRoute(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
|
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
|
||||||
|
|
||||||
|
useKey("Escape", () => {
|
||||||
|
setDisableEnterKey(false)
|
||||||
|
setNuqsEditId(null)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LinkHeader />
|
<LinkHeader />
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const LinkHeader = React.memo(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContentHeader className="px-6 max-lg:px-4 lg:py-5">
|
<ContentHeader className="px-6 max-lg:px-4 lg:py-4">
|
||||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||||
<SidebarToggleButton />
|
<SidebarToggleButton />
|
||||||
<div className="flex min-h-0 items-center">
|
<div className="flex min-h-0 items-center">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { commandPaletteOpenAtom } from "@/components/custom/command-palette/comm
|
|||||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||||
import { useLinkActions } from "./hooks/use-link-actions"
|
import { useLinkActions } from "./hooks/use-link-actions"
|
||||||
import { isDeleteConfirmShownAtom } from "./LinkRoute"
|
import { isDeleteConfirmShownAtom } from "./LinkRoute"
|
||||||
|
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||||
|
|
||||||
interface LinkListProps {
|
interface LinkListProps {
|
||||||
activeItemIndex: number | null
|
activeItemIndex: number | null
|
||||||
@@ -77,12 +78,6 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
useKey("Escape", () => {
|
|
||||||
if (editId) {
|
|
||||||
setEditId(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
useKey(
|
useKey(
|
||||||
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
|
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
|
||||||
async () => {
|
async () => {
|
||||||
@@ -245,6 +240,8 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
setDraggingId(null)
|
setDraggingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Primitive.div
|
<Primitive.div
|
||||||
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
|
||||||
@@ -271,6 +268,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
|
|||||||
isActive={activeItemIndex === index}
|
isActive={activeItemIndex === index}
|
||||||
setActiveItemIndex={setActiveItemIndex}
|
setActiveItemIndex={setActiveItemIndex}
|
||||||
index={index}
|
index={index}
|
||||||
|
ref={el => setElementRef(el, index)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { cn, ensureUrlProtocol } from "@/lib/utils"
|
|||||||
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||||
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
import { linkOpenPopoverForIdAtom } from "@/store/link"
|
||||||
|
|
||||||
interface LinkItemProps {
|
interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||||
personalLink: PersonalLink
|
personalLink: PersonalLink
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
isEditing: boolean
|
isEditing: boolean
|
||||||
@@ -26,134 +26,138 @@ interface LinkItemProps {
|
|||||||
index: number
|
index: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkItem: React.FC<LinkItemProps> = ({
|
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
|
||||||
isEditing,
|
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => {
|
||||||
setEditId,
|
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||||
personalLink,
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||||
disabled = false,
|
|
||||||
isDragging,
|
|
||||||
isActive,
|
|
||||||
setActiveItemIndex,
|
|
||||||
index
|
|
||||||
}) => {
|
|
||||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
|
||||||
|
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
pointerEvents: isDragging ? "none" : "auto"
|
pointerEvents: isDragging ? "none" : "auto"
|
||||||
}),
|
}),
|
||||||
[transform, transition, isDragging]
|
[transform, transition, isDragging]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
||||||
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
||||||
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
|
||||||
|
|
||||||
const selectedLearningState = useMemo(
|
const selectedLearningState = useMemo(
|
||||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||||
[personalLink.learningState]
|
[personalLink.learningState]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleLearningStateSelect = useCallback(
|
const handleLearningStateSelect = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
const learningState = value as LearningStateValue
|
const learningState = value as LearningStateValue
|
||||||
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
||||||
setOpenPopoverForId(null)
|
setOpenPopoverForId(null)
|
||||||
},
|
},
|
||||||
[personalLink, setOpenPopoverForId]
|
[personalLink, setOpenPopoverForId]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
return (
|
||||||
}
|
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
ref={setNodeRef}
|
ref={node => {
|
||||||
style={style as React.CSSProperties}
|
setNodeRef(node)
|
||||||
{...attributes}
|
if (typeof ref === "function") {
|
||||||
{...listeners}
|
ref(node)
|
||||||
tabIndex={0}
|
} else if (ref) {
|
||||||
onFocus={() => setActiveItemIndex(index)}
|
ref.current = node
|
||||||
onBlur={() => setActiveItemIndex(null)}
|
}
|
||||||
className={cn(
|
}}
|
||||||
"relative cursor-default outline-none",
|
style={style as React.CSSProperties}
|
||||||
"mx-auto grid w-[98%] grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-lg p-2",
|
{...attributes}
|
||||||
{
|
{...listeners}
|
||||||
"bg-muted-foreground/5": isActive,
|
tabIndex={0}
|
||||||
"hover:bg-muted/50": !isActive
|
onFocus={() => setActiveItemIndex(index)}
|
||||||
}
|
onBlur={() => setActiveItemIndex(null)}
|
||||||
)}
|
className={cn(
|
||||||
onDoubleClick={handleRowDoubleClick}
|
"relative cursor-default outline-none",
|
||||||
>
|
"grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2",
|
||||||
<Popover
|
{
|
||||||
open={openPopoverForId === personalLink.id}
|
"bg-muted-foreground/5": isActive,
|
||||||
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
|
"hover:bg-muted/50": !isActive
|
||||||
>
|
}
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
|
|
||||||
{selectedLearningState?.icon ? (
|
|
||||||
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
|
||||||
) : (
|
|
||||||
<LaIcon name="Circle" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-52 rounded-lg p-0"
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
onCloseAutoFocus={e => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<LearningStateSelectorContent
|
|
||||||
showSearch={false}
|
|
||||||
searchPlaceholder="Search state..."
|
|
||||||
value={personalLink.learningState}
|
|
||||||
onSelect={handleLearningStateSelect}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
|
||||||
{personalLink.icon && (
|
|
||||||
<Image
|
|
||||||
src={personalLink.icon}
|
|
||||||
alt={personalLink.title}
|
|
||||||
className="size-5 shrink-0 rounded-full"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
onDoubleClick={handleRowDoubleClick}
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
open={openPopoverForId === personalLink.id}
|
||||||
|
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
|
||||||
|
{selectedLearningState?.icon ? (
|
||||||
|
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
||||||
|
) : (
|
||||||
|
<LaIcon name="Circle" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-52 rounded-lg p-0"
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
onCloseAutoFocus={e => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<LearningStateSelectorContent
|
||||||
|
showSearch={false}
|
||||||
|
searchPlaceholder="Search state..."
|
||||||
|
value={personalLink.learningState}
|
||||||
|
onSelect={handleLearningStateSelect}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||||
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
|
{personalLink.icon && (
|
||||||
{personalLink.url && (
|
<Image
|
||||||
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
src={personalLink.icon}
|
||||||
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
|
alt={personalLink.title}
|
||||||
<Link
|
className="size-5 shrink-0 rounded-full"
|
||||||
href={ensureUrlProtocol(personalLink.url)}
|
width={16}
|
||||||
passHref
|
height={16}
|
||||||
prefetch={false}
|
/>
|
||||||
target="_blank"
|
)}
|
||||||
onClick={e => e.stopPropagation()}
|
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
|
||||||
className="hover:text-primary truncate text-xs"
|
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
|
||||||
>
|
{personalLink.url && (
|
||||||
{personalLink.url}
|
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
|
||||||
</Link>
|
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
|
||||||
</div>
|
<Link
|
||||||
|
href={ensureUrlProtocol(personalLink.url)}
|
||||||
|
passHref
|
||||||
|
prefetch={false}
|
||||||
|
target="_blank"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="hover:text-primary truncate text-xs"
|
||||||
|
>
|
||||||
|
{personalLink.url}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center justify-end">
|
||||||
|
{personalLink.topic && (
|
||||||
|
<Badge variant="secondary" className="border-muted-foreground/25">
|
||||||
|
{personalLink.topic.prettyName}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center justify-end">
|
LinkItem.displayName = "LinkItem"
|
||||||
{personalLink.topic && (
|
|
||||||
<Badge variant="secondary" className="border-muted-foreground/25">
|
|
||||||
{personalLink.topic.prettyName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
30
web/hooks/use-active-item-scroll.ts
Normal file
30
web/hooks/use-active-item-scroll.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react"
|
||||||
|
|
||||||
|
type ElementRef<T extends HTMLElement> = T | null
|
||||||
|
type ElementRefs<T extends HTMLElement> = ElementRef<T>[]
|
||||||
|
|
||||||
|
interface ActiveItemScrollOptions {
|
||||||
|
activeIndex: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemScrollOptions) {
|
||||||
|
const { activeIndex } = options
|
||||||
|
const elementRefs = useRef<ElementRefs<T>>([])
|
||||||
|
|
||||||
|
const scrollActiveElementIntoView = useCallback((index: number) => {
|
||||||
|
const activeElement = elementRefs.current[index]
|
||||||
|
activeElement?.scrollIntoView({ block: "nearest" })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeIndex !== null) {
|
||||||
|
scrollActiveElementIntoView(activeIndex)
|
||||||
|
}
|
||||||
|
}, [activeIndex, scrollActiveElementIntoView])
|
||||||
|
|
||||||
|
const setElementRef = useCallback((element: ElementRef<T>, index: number) => {
|
||||||
|
elementRefs.current[index] = element
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return setElementRef
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user