fix(link): Keybind, scroll behaviour, restrict drag to vertical (#176)

* chore: expose scrollActiveElementIntoView

* feat(utils): editable element

* fix: memoize exceptionRefs, use animation frame and check editable element

* fix: improve btn on mobile

* chore(drps): bump framer motion version

* fix(link): big fix

* chore: remove comment code

* feat: touch device
This commit is contained in:
Aslam
2024-09-21 19:37:29 +07:00
committed by GitHub
parent bf5ae100ab
commit 21084cd3f3
14 changed files with 453 additions and 386 deletions
+11
View File
@@ -0,0 +1,11 @@
:root {
--link-background-muted: hsl(0, 0%, 97.3%);
--link-border-after: hsl(0, 0%, 91%);
--link-shadow: hsl(240, 5.6%, 82.5%);
}
.dark {
--link-background-muted: hsl(220, 6.7%, 8.8%);
--link-border-after: hsl(230, 10%, 11.8%);
--link-shadow: hsl(234.9, 27.1%, 25.3%);
}
+1
View File
@@ -73,3 +73,4 @@
} }
@import "./command-palette.css"; @import "./command-palette.css";
@import "./custom.css";
+6 -41
View File
@@ -1,13 +1,12 @@
"use client" "use client"
import React, { useEffect, useState, useCallback, useRef } from "react" import React, { useState } from "react"
import { LinkHeader } from "@/components/routes/link/header" import { LinkHeader } from "@/components/routes/link/header"
import { LinkList } from "@/components/routes/link/list" import { LinkList } from "@/components/routes/link/list"
import { LinkManage } from "@/components/routes/link/manage" import { LinkManage } from "@/components/routes/link/manage"
import { parseAsBoolean, useQueryState } from "nuqs" import { useQueryState } from "nuqs"
import { atom, useAtom } from "jotai" import { atom } from "jotai"
import { LinkBottomBar } from "./bottom-bar" import { LinkBottomBar } from "./bottom-bar"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { useKey } from "react-use" import { useKey } from "react-use"
export const isDeleteConfirmShownAtom = atom(false) export const isDeleteConfirmShownAtom = atom(false)
@@ -15,44 +14,9 @@ export const isDeleteConfirmShownAtom = atom(false)
export function LinkRoute(): React.ReactElement { export function LinkRoute(): React.ReactElement {
const [nuqsEditId, setNuqsEditId] = 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 [keyboardActiveIndex, setKeyboardActiveIndex] = useState<number | null>(null)
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
const [isDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
const [disableEnterKey, setDisableEnterKey] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleCommandPaletteClose = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setDisableEnterKey(true)
timeoutRef.current = setTimeout(() => {
setDisableEnterKey(false)
timeoutRef.current = null
}, 100)
}, [])
useEffect(() => {
if (isDeleteConfirmShown || isCommandPaletteOpen || isInCreateMode) {
setDisableEnterKey(true)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
} else if (!isCommandPaletteOpen) {
handleCommandPaletteClose()
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
useKey("Escape", () => { useKey("Escape", () => {
setDisableEnterKey(false)
setNuqsEditId(null) setNuqsEditId(null)
}) })
@@ -64,7 +28,8 @@ export function LinkRoute(): React.ReactElement {
key={nuqsEditId} key={nuqsEditId}
activeItemIndex={activeItemIndex} activeItemIndex={activeItemIndex}
setActiveItemIndex={setActiveItemIndex} setActiveItemIndex={setActiveItemIndex}
disableEnterKey={disableEnterKey} keyboardActiveIndex={keyboardActiveIndex}
setKeyboardActiveIndex={setKeyboardActiveIndex}
/> />
<LinkBottomBar /> <LinkBottomBar />
</> </>
+23 -19
View File
@@ -1,11 +1,11 @@
"use client" "use client"
import React, { useCallback, useEffect, useRef } from "react" import React, { useCallback, useEffect, useMemo, useRef } from "react"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
import type { icons } from "lucide-react" import type { icons } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { cn, getShortcutKeys } from "@/lib/utils" import { cn, getShortcutKeys, isEditableElement } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon" import { LaIcon } from "@/components/custom/la-icon"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { parseAsBoolean, useQueryState } from "nuqs" import { parseAsBoolean, useQueryState } from "nuqs"
@@ -70,13 +70,13 @@ export const LinkBottomBar: React.FC = () => {
const handleCreateMode = useCallback(() => { const handleCreateMode = useCallback(() => {
setEditId(null) setEditId(null)
setTimeout(() => { requestAnimationFrame(() => {
setCreateMode(prev => !prev) setCreateMode(prev => !prev)
}, 100) })
}, [setEditId, setCreateMode]) }, [setEditId, setCreateMode])
useEffect(() => { const exceptionRefs = useMemo(
setGlobalLinkFormExceptionRefsAtom([ () => [
overlayRef, overlayRef,
contentRef, contentRef,
deleteBtnRef, deleteBtnRef,
@@ -85,8 +85,13 @@ export const LinkBottomBar: React.FC = () => {
confirmBtnRef, confirmBtnRef,
plusBtnRef, plusBtnRef,
plusMoreBtnRef plusMoreBtnRef
]) ],
}, [setGlobalLinkFormExceptionRefsAtom]) []
)
useEffect(() => {
setGlobalLinkFormExceptionRefsAtom(exceptionRefs)
}, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs])
const handleDelete = async (e: React.MouseEvent) => { const handleDelete = async (e: React.MouseEvent) => {
if (!personalLink || !me) return if (!personalLink || !me) return
@@ -122,8 +127,9 @@ export const LinkBottomBar: React.FC = () => {
const handleKeydown = useCallback( const handleKeydown = useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
const isCreateShortcut = event.key === "c" const isCreateShortcut = event.key === "c"
const target = event.target as HTMLElement
if (isCreateShortcut) { if (isCreateShortcut && !isEditableElement(target)) {
event.preventDefault() event.preventDefault()
handleCreateMode() handleCreateMode()
} }
@@ -136,29 +142,26 @@ export const LinkBottomBar: React.FC = () => {
const shortcutText = getShortcutKeys(["c"]) const shortcutText = getShortcutKeys(["c"])
return ( return (
<motion.div <div className="bg-background min-h-11 border-t">
className="bg-background absolute bottom-0 left-0 right-0 h-11 border-t"
animate={{ y: 0 }}
initial={{ y: "100%" }}
>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{editId && ( {editId && (
<motion.div <motion.div
key="expanded" key="expanded"
className="flex h-full items-center justify-center gap-1 px-2" className="flex h-full items-center justify-center gap-1 border-t px-2"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }} exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
> >
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} /> <ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} aria-label="Go back" />
<ToolbarButton <ToolbarButton
icon={"Trash"} icon={"Trash"}
onClick={handleDelete} onClick={handleDelete}
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
ref={deleteBtnRef} ref={deleteBtnRef}
aria-label="Delete link"
/> />
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} /> <ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} aria-label="More options" />
</motion.div> </motion.div>
)} )}
@@ -171,19 +174,20 @@ export const LinkBottomBar: React.FC = () => {
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
> >
{createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} />} {createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} aria-label="Go back" />}
{!createMode && ( {!createMode && (
<ToolbarButton <ToolbarButton
icon={"Plus"} icon={"Plus"}
onClick={handleCreateMode} onClick={handleCreateMode}
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`} tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
ref={plusBtnRef} ref={plusBtnRef}
aria-label="New link"
/> />
)} )}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</motion.div> </div>
) )
} }
+2 -2
View File
@@ -42,7 +42,7 @@ export const LinkHeader = React.memo(() => {
</ContentHeader> </ContentHeader>
{isTablet && ( {isTablet && (
<div className="flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4"> <div className="flex min-h-10 flex-row items-start justify-between border-b px-6 pb-4 pt-2 max-lg:pl-4">
<LearningTab /> <LearningTab />
</div> </div>
)} )}
@@ -115,7 +115,7 @@ const FilterAndSort = React.memo(() => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Popover open={sortOpen} onOpenChange={setSortOpen}> <Popover open={sortOpen} onOpenChange={setSortOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button size="sm" type="button" variant="secondary" className="gap-x-2 text-sm"> <Button size="sm" type="button" variant="secondary" className="min-w-8 gap-x-2 text-sm max-sm:p-0">
<ListFilterIcon size={16} className="text-primary/60" /> <ListFilterIcon size={16} className="text-primary/60" />
<span className="hidden md:block">Filter: {getFilterText()}</span> <span className="hidden md:block">Filter: {getFilterText()}</span>
</Button> </Button>
@@ -9,18 +9,20 @@ export const useLinkActions = () => {
try { try {
const index = me.root.personalLinks.findIndex(item => item?.id === link.id) const index = me.root.personalLinks.findIndex(item => item?.id === link.id)
if (index === -1) { if (index === -1) {
console.error("Delete operation fail", { index, link }) throw new Error(`Link with id ${link.id} not found`)
return
} }
me.root.personalLinks.splice(index, 1)
toast.success("Link deleted.", { toast.success("Link deleted.", {
position: "bottom-right", position: "bottom-right",
description: `${link.title} has been deleted.` description: `${link.title} has been deleted.`
}) })
me.root.personalLinks.splice(index, 1)
} catch (error) { } catch (error) {
toast.error("Failed to delete link") console.error("Failed to delete link:", error)
toast.error("Failed to delete link", {
description: error instanceof Error ? error.message : "An unknown error occurred"
})
} }
}, []) }, [])
+110 -83
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from "react" import React, { useCallback, useMemo } from "react"
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -8,17 +8,20 @@ import {
useSensors, useSensors,
DragEndEvent, DragEndEvent,
DragStartEvent, DragStartEvent,
UniqueIdentifier UniqueIdentifier,
MeasuringStrategy,
TouchSensor
} from "@dnd-kit/core" } from "@dnd-kit/core"
import { Primitive } from "@radix-ui/react-primitive"
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable" import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
import type { MeasuringConfiguration } from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import { useAccount } from "@/lib/providers/jazz-provider" import { useAccount } from "@/lib/providers/jazz-provider"
import { PersonalLinkLists } from "@/lib/schema/personal-link" import { PersonalLinkLists } from "@/lib/schema/personal-link"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { linkSortAtom } from "@/store/link" import { linkSortAtom } from "@/store/link"
import { useKey } from "react-use" import { useKey } from "react-use"
import { LinkItem } from "./partials/link-item" import { LinkItem } from "./partials/link-item"
import { useQueryState } from "nuqs" import { parseAsBoolean, useQueryState } from "nuqs"
import { learningStateAtom } from "./header" import { learningStateAtom } from "./header"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { useConfirm } from "@omit/react-confirm-dialog" import { useConfirm } from "@omit/react-confirm-dialog"
@@ -27,30 +30,43 @@ import { isDeleteConfirmShownAtom } from "./LinkRoute"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { useKeyboardManager } from "@/hooks/use-keyboard-manager" import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
import { useKeydownListener } from "@/hooks/use-keydown-listener" import { useKeydownListener } from "@/hooks/use-keydown-listener"
import { useTouchSensor } from "@/hooks/use-touch-sensor"
interface LinkListProps { interface LinkListProps {
activeItemIndex: number | null activeItemIndex: number | null
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>> setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
disableEnterKey: boolean keyboardActiveIndex: number | null
setKeyboardActiveIndex: React.Dispatch<React.SetStateAction<number | null>>
} }
const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => { const measuring: MeasuringConfiguration = {
const [isCommandPalettePpen] = useAtom(commandPaletteOpenAtom) droppable: {
strategy: MeasuringStrategy.Always
}
}
const LinkList: React.FC<LinkListProps> = ({
activeItemIndex,
setActiveItemIndex,
keyboardActiveIndex,
setKeyboardActiveIndex
}) => {
const isTouchDevice = useTouchSensor()
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom) const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
const [editId, setEditId] = useQueryState("editId") const [editId, setEditId] = useQueryState("editId")
const [createMode] = useQueryState("create", parseAsBoolean)
const [activeLearningState] = useAtom(learningStateAtom) const [activeLearningState] = useAtom(learningStateAtom)
const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null) const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null)
const [sort] = useAtom(linkSortAtom)
const { deleteLink } = useLinkActions() const { deleteLink } = useLinkActions()
const confirm = useConfirm() const confirm = useConfirm()
const { me } = useAccount({ root: { personalLinks: [] } })
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
const { me } = useAccount({
root: { personalLinks: [] }
})
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks]) const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
const [sort] = useAtom(linkSortAtom)
const filteredLinks = useMemo( const filteredLinks = useMemo(
() => () =>
personalLinks.filter(link => { personalLinks.filter(link => {
@@ -70,9 +86,9 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
) )
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(isTouchDevice ? TouchSensor : PointerSensor, {
activationConstraint: { activationConstraint: {
distance: 8 distance: 5
} }
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
@@ -80,51 +96,6 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
}) })
) )
useKey(
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
async () => {
if (activeItemIndex !== null) {
setIsDeleteConfirmShown(true)
const activeLink = sortedLinks[activeItemIndex]
if (activeLink) {
const result = await confirm({
title: `Delete "${activeLink.title}"?`,
description: "This action cannot be undone.",
alertDialogTitle: {
className: "text-base"
},
cancelButton: {
variant: "outline"
},
confirmButton: {
variant: "destructive"
}
})
if (result) {
if (!me) return
deleteLink(me, activeLink)
setIsDeleteConfirmShown(false)
} else {
setIsDeleteConfirmShown(false)
}
}
}
},
{ event: "keydown" }
)
// on mounted, if editId is set, set activeItemIndex to the index of the item with the editId
useEffect(() => {
if (editId) {
const index = sortedLinks.findIndex(link => link?.id === editId)
if (index !== -1) {
setActiveItemIndex(index)
}
}
}, [editId, sortedLinks, setActiveItemIndex])
const updateSequences = useCallback((links: PersonalLinkLists) => { const updateSequences = useCallback((links: PersonalLinkLists) => {
links.forEach((link, index) => { links.forEach((link, index) => {
if (link) { if (link) {
@@ -133,22 +104,46 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
}) })
}, []) }, [])
const { isKeyboardDisabled } = useKeyboardManager("XComponent") const handleDeleteLink = useCallback(async () => {
if (activeItemIndex === null) return
setIsDeleteConfirmShown(true)
const activeLink = sortedLinks[activeItemIndex]
if (!activeLink || !me) return
const result = await confirm({
title: `Delete "${activeLink.title}"?`,
description: "This action cannot be undone.",
alertDialogTitle: { className: "text-base" },
cancelButton: { variant: "outline" },
confirmButton: { variant: "destructive" }
})
if (result) {
deleteLink(me, activeLink)
}
setIsDeleteConfirmShown(false)
}, [activeItemIndex, sortedLinks, me, confirm, deleteLink, setIsDeleteConfirmShown])
useKey(event => (event.metaKey || event.ctrlKey) && event.key === "Backspace", handleDeleteLink, { event: "keydown" })
useKeydownListener((e: KeyboardEvent) => { useKeydownListener((e: KeyboardEvent) => {
if ( if (
isKeyboardDisabled || isKeyboardDisabled ||
isCommandPalettePpen || isCommandPaletteOpen ||
!me?.root?.personalLinks || !me?.root?.personalLinks ||
sortedLinks.length === 0 || sortedLinks.length === 0 ||
editId !== null editId !== null ||
e.defaultPrevented
) )
return return
if (e.key === "ArrowUp" || e.key === "ArrowDown") { switch (e.key) {
case "ArrowUp":
case "ArrowDown":
e.preventDefault() e.preventDefault()
setActiveItemIndex(prevIndex => { setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0 if (prevIndex === null) return 0
const newIndex = const newIndex =
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1) e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
@@ -169,26 +164,45 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
updateSequences(me.root.personalLinks) updateSequences(me.root.personalLinks)
} }
setKeyboardActiveIndex(newIndex)
return newIndex return newIndex
}) })
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) { break
case "Home":
e.preventDefault() e.preventDefault()
const activeLink = sortedLinks[activeItemIndex] setActiveItemIndex(0)
if (activeLink) { break
setEditId(activeLink.id) case "End":
} e.preventDefault()
setActiveItemIndex(sortedLinks.length - 1)
break
} }
}) })
const handleDragStart = useCallback( const handleDragStart = useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
if (sort !== "manual") return if (sort !== "manual") return
if (!me) return
const { active } = event const { active } = event
const activeIndex = me?.root.personalLinks.findIndex(item => item?.id === active.id)
if (activeIndex === -1) {
console.error("Drag operation fail", { activeIndex, activeId: active.id })
return
}
setActiveItemIndex(activeIndex)
setDraggingId(active.id) setDraggingId(active.id)
}, },
[sort] [sort, me, setActiveItemIndex]
) )
const handleDragCancel = useCallback(() => {
setDraggingId(null)
}, [])
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event const { active, over } = event
@@ -226,51 +240,64 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
}) })
updateSequences(me.root.personalLinks) updateSequences(me.root.personalLinks)
setActiveItemIndex(newIndex)
} catch (error) { } catch (error) {
console.error("Error during link reordering:", error) console.error("Error during link reordering:", error)
} }
} }
setActiveItemIndex(null)
setDraggingId(null) setDraggingId(null)
} }
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex }) const { setElementRef } = useActiveItemScroll<HTMLDivElement>({ activeIndex: keyboardActiveIndex })
return ( return (
<Primitive.div
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={0}
>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
measuring={measuring}
modifiers={[restrictToVerticalAxis]}
> >
<div className="relative flex h-full grow items-stretch overflow-hidden">
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}> <SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
<ul role="list" className="divide-primary/5 divide-y"> <div className="relative flex h-full grow flex-col items-stretch overflow-hidden">
<div className="flex h-full w-[calc(100%+0px)] flex-col overflow-hidden pr-0">
<div className="relative overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
{sortedLinks.map( {sortedLinks.map(
(linkItem, index) => (linkItem, index) =>
linkItem && ( linkItem && (
<LinkItem <LinkItem
key={linkItem.id} key={linkItem.id}
isEditing={editId === linkItem.id}
setEditId={setEditId}
personalLink={linkItem}
disabled={sort !== "manual" || editId !== null}
isDragging={draggingId === linkItem.id}
isActive={activeItemIndex === index} isActive={activeItemIndex === index}
personalLink={linkItem}
editId={editId}
setEditId={setEditId}
disabled={sort !== "manual" || editId !== null}
setActiveItemIndex={setActiveItemIndex} setActiveItemIndex={setActiveItemIndex}
onPointerMove={() => {
if (editId !== null || draggingId !== null || createMode) {
return undefined
}
setKeyboardActiveIndex(null)
setActiveItemIndex(index)
}}
index={index} index={index}
onItemSelected={link => setEditId(link.id)}
data-keyboard-active={keyboardActiveIndex === index}
ref={el => setElementRef(el, index)} ref={el => setElementRef(el, index)}
/> />
) )
)} )}
</ul> </div>
</div>
</div>
</SortableContext> </SortableContext>
</div>
</DndContext> </DndContext>
</Primitive.div>
) )
} }
@@ -15,34 +15,35 @@ 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 extends React.HTMLAttributes<HTMLLIElement> { interface LinkItemProps extends React.HTMLAttributes<HTMLDivElement> {
personalLink: PersonalLink personalLink: PersonalLink
disabled?: boolean disabled?: boolean
isEditing: boolean editId: string | null
setEditId: (id: string | null) => void setEditId: (id: string | null) => void
isDragging: boolean
isActive: boolean isActive: boolean
setActiveItemIndex: (index: number | null) => void setActiveItemIndex: (index: number | null) => void
index: number index: number
onItemSelected?: (personalLink: PersonalLink) => void
} }
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>( export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => { (
{ personalLink, disabled, editId, setEditId, isActive, setActiveItemIndex, index, onItemSelected, ...props },
ref
) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom) const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) 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"
}), }),
[transform, transition, isDragging] [transform, transition]
) )
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 selectedLearningState = useMemo( const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState), () => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
@@ -58,14 +59,14 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
[personalLink, setOpenPopoverForId] [personalLink, setOpenPopoverForId]
) )
if (isEditing) { if (editId === personalLink.id) {
return ( return (
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} /> <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
) )
} }
return ( return (
<li <div
ref={node => { ref={node => {
setNodeRef(node) setNodeRef(node)
if (typeof ref === "function") { if (typeof ref === "function") {
@@ -75,27 +76,43 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
} }
}} }}
style={style as React.CSSProperties} style={style as React.CSSProperties}
{...props}
{...attributes} {...attributes}
{...listeners} {...listeners}
tabIndex={0} tabIndex={0}
onFocus={() => setActiveItemIndex(index)} onDoubleClick={() => onItemSelected?.(personalLink)}
onBlur={() => setActiveItemIndex(null)} aria-disabled={disabled}
className={cn( aria-selected={isActive}
"relative cursor-default outline-none", data-disabled={disabled}
"grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2", data-active={isActive}
{ className="w-full overflow-visible border-b-[0.5px] border-transparent outline-none data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]"
"bg-muted-foreground/5": isActive, onKeyDown={e => {
"hover:bg-muted/50": !isActive if (e.key === "Enter") {
e.preventDefault()
onItemSelected?.(personalLink)
} }
}}
>
<div
className={cn(
"w-full grow overflow-visible outline-none",
"flex items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2"
)} )}
onDoubleClick={handleRowDoubleClick}
> >
<Popover <Popover
open={openPopoverForId === personalLink.id} open={openPopoverForId === personalLink.id}
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)} onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0"> <Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className="size-7 shrink-0 p-0"
onClick={e => e.stopPropagation()}
onDoubleClick={e => e.stopPropagation()}
>
{selectedLearningState?.icon ? ( {selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} /> <LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
) : ( ) : (
@@ -103,12 +120,7 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="start">
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent <LearningStateSelectorContent
showSearch={false} showSearch={false}
searchPlaceholder="Search state..." searchPlaceholder="Search state..."
@@ -118,7 +130,8 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
</PopoverContent> </PopoverContent>
</Popover> </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.5 overflow-hidden md:flex-row md:items-center md:gap-x-2">
<div className="flex items-center gap-x-1">
{personalLink.icon && ( {personalLink.icon && (
<Image <Image
src={personalLink.icon} src={personalLink.icon}
@@ -128,8 +141,8 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
height={16} height={16}
/> />
)} )}
<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 line-clamp-1 text-sm font-medium">{personalLink.title}</p>
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p> </div>
{personalLink.url && ( {personalLink.url && (
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1"> <div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" /> <LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
@@ -146,7 +159,8 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
</div> </div>
)} )}
</div> </div>
</div>
<div className="flex-1"></div>
<div className="flex shrink-0 items-center justify-end"> <div className="flex shrink-0 items-center justify-end">
{personalLink.topic && ( {personalLink.topic && (
@@ -155,7 +169,10 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
</Badge> </Badge>
)} )}
</div> </div>
</li> </div>
<div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--link-border-after)]"></div>
</div>
) )
} }
) )
+1 -1
View File
@@ -89,7 +89,7 @@ interface PageListItemsProps {
} }
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => { const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex }) const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
return ( return (
<Primitive.div <Primitive.div
+1 -1
View File
@@ -132,7 +132,7 @@ interface TopicListItemsProps {
} }
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => { const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex }) const { setElementRef } = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
return ( return (
<Primitive.div <Primitive.div
+3 -2
View File
@@ -13,7 +13,8 @@ export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemSc
const scrollActiveElementIntoView = useCallback((index: number) => { const scrollActiveElementIntoView = useCallback((index: number) => {
const activeElement = elementRefs.current[index] const activeElement = elementRefs.current[index]
activeElement?.scrollIntoView({ block: "nearest" }) activeElement?.focus()
// activeElement?.scrollIntoView({ block: "nearest" })
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -26,5 +27,5 @@ export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemSc
elementRefs.current[index] = element elementRefs.current[index] = element
}, []) }, [])
return setElementRef return { setElementRef, scrollActiveElementIntoView }
} }
+20
View File
@@ -0,0 +1,20 @@
import { useState, useEffect } from "react"
export function useTouchSensor() {
const [isTouchDevice, setIsTouchDevice] = useState(false)
useEffect(() => {
const detectTouch = () => {
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0)
}
detectTouch()
window.addEventListener("touchstart", detectTouch, false)
return () => {
window.removeEventListener("touchstart", detectTouch)
}
}, [])
return isTouchDevice
}
+18
View File
@@ -34,6 +34,24 @@ export function shuffleArray<T>(array: T[]): T[] {
return shuffled return shuffled
} }
export const isEditableElement = (element: HTMLElement): boolean => {
if (element.isContentEditable) {
return true
}
const tagName = element.tagName.toLowerCase()
const editableTags = ["input", "textarea", "select", "option"]
if (editableTags.includes(tagName)) {
return true
}
const role = element.getAttribute("role")
const editableRoles = ["textbox", "combobox", "listbox"]
return role ? editableRoles.includes(role) : false
}
export * from "./urls" export * from "./urls"
export * from "./slug" export * from "./slug"
export * from "./keyboard" export * from "./keyboard"
+6 -5
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@clerk/nextjs": "^5.6.0", "@clerk/nextjs": "^5.6.0",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@nothing-but/force-graph": "^0.9.5", "@nothing-but/force-graph": "^0.9.5",
@@ -75,13 +76,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"framer-motion": "^11.5.5", "framer-motion": "^11.5.6",
"geist": "^1.3.1", "geist": "^1.3.1",
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5", "jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
"jazz-react": "0.7.35-guest-auth.5", "jazz-react": "0.7.35-guest-auth.5",
"jazz-react-auth-clerk": "0.7.35-guest-auth.5", "jazz-react-auth-clerk": "0.7.35-guest-auth.5",
"jazz-tools": "0.7.35-guest-auth.5", "jazz-tools": "0.7.35-guest-auth.5",
"jotai": "^2.9.3", "jotai": "^2.10.0",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"lucide-react": "^0.429.0", "lucide-react": "^0.429.0",
"next": "14.2.10", "next": "14.2.10",
@@ -102,15 +103,15 @@
"vaul": "^0.9.4", "vaul": "^0.9.4",
"zod": "^3.23.8", "zod": "^3.23.8",
"zsa": "^0.6.0", "zsa": "^0.6.0",
"zsa-react": "^0.2.2" "zsa-react": "^0.2.3"
}, },
"devDependencies": { "devDependencies": {
"@ronin/learn-anything": "^0.0.0-3452357373461", "@ronin/learn-anything": "0.0.0-3452357373461",
"@testing-library/jest-dom": "^6.5.0", "@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^22.5.5", "@types/node": "^22.5.5",
"@types/react": "^18.3.7", "@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"eslint": "^8.57.1", "eslint": "^8.57.1",