fix(page): Add item scroll, fix display issues, refactor nav, and improve perf (#166)

* feat: add item scroll to active

* fix: reset enterkey and scroll to view

* fix: link item displayName

* refactor: remove keyboard page nav

* chore: fix scrolling, perf, keys, highlight active item etc

* chore: use new hook for create a page

* chore: disabled auto delete page
This commit is contained in:
Aslam
2024-09-19 21:12:05 +07:00
committed by GitHub
parent afaef5d3c5
commit c003711905
8 changed files with 142 additions and 209 deletions
@@ -3,11 +3,13 @@ import { ensureUrlProtocol } from "@/lib/utils"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { toast } from "sonner" import { toast } from "sonner"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { LaAccount, PersonalPage } from "@/lib/schema" import { LaAccount } from "@/lib/schema"
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
export const useCommandActions = () => { export const useCommandActions = () => {
const { setTheme } = useTheme() const { setTheme } = useTheme()
const router = useRouter() const router = useRouter()
const { newPage } = usePageActions()
const changeTheme = React.useCallback( const changeTheme = React.useCallback(
(theme: string) => { (theme: string) => {
@@ -35,19 +37,10 @@ export const useCommandActions = () => {
const createNewPage = React.useCallback( const createNewPage = React.useCallback(
(me: LaAccount) => { (me: LaAccount) => {
try { const page = newPage(me)
const newPersonalPage = PersonalPage.create( router.push(`/pages/${page.id}`)
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
toast.error("Failed to create page")
}
}, },
[router] [router, newPage]
) )
return { return {
@@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils"
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page" import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon" import { LaIcon } from "@/components/custom/la-icon"
import { toast } from "sonner"
import Link from "next/link" import Link from "next/link"
import { import {
DropdownMenu, DropdownMenu,
@@ -21,6 +20,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { icons } from "lucide-react" import { icons } from "lucide-react"
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
type SortOption = "title" | "recent" type SortOption = "title" | "recent"
type ShowOption = 5 | 10 | 15 | 20 | 0 type ShowOption = 5 | 10 | 15 | 20 | 0
@@ -101,20 +101,13 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount, isActi
const NewPageButton: React.FC = () => { const NewPageButton: React.FC = () => {
const { me } = useAccount() const { me } = useAccount()
const router = useRouter() const router = useRouter()
const { newPage } = usePageActions()
if (!me) return null if (!me) return null
const handleClick = () => { const handleClick = () => {
try { const page = newPage(me)
const newPersonalPage = PersonalPage.create( router.push(`/pages/${page.id}`)
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
toast.error("Failed to create page")
}
} }
return ( return (
@@ -23,11 +23,11 @@ import { usePageActions } from "../hooks/use-page-actions"
const TITLE_PLACEHOLDER = "Untitled" const TITLE_PLACEHOLDER = "Untitled"
const emptyPage = (page: PersonalPage): boolean => { const isPageEmpty = (page: PersonalPage): boolean => {
return (!page.title || page.title.trim() === "") && (!page.content || Object.keys(page.content).length === 0) return (!page.title || page.title.trim() === "") && (!page.content || Object.keys(page.content).length === 0)
} }
export const DeleteEmptyPage = (currentPageId: string | null) => { const useDeleteEmptyPage = (currentPageId: string | null) => {
const router = useRouter() const router = useRouter()
const { me } = useAccount({ const { me } = useAccount({
root: { root: {
@@ -36,21 +36,17 @@ export const DeleteEmptyPage = (currentPageId: string | null) => {
}) })
useEffect(() => { useEffect(() => {
const handleRouteChange = () => { return () => {
if (!currentPageId || !me?.root?.personalPages) return if (!currentPageId || !me?.root?.personalPages) return
const currentPage = me.root.personalPages.find(page => page?.id === currentPageId) const currentPage = me.root.personalPages.find(page => page?.id === currentPageId)
if (currentPage && emptyPage(currentPage)) { if (currentPage && isPageEmpty(currentPage)) {
const index = me.root.personalPages.findIndex(page => page?.id === currentPageId) const index = me.root.personalPages.findIndex(page => page?.id === currentPageId)
if (index !== -1) { if (index !== -1) {
me.root.personalPages.splice(index, 1) me.root.personalPages.splice(index, 1)
} }
} }
} }
return () => {
handleRouteChange()
}
}, [currentPageId, me, router]) }, [currentPageId, me, router])
} }
@@ -62,9 +58,9 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
const { deletePage } = usePageActions() const { deletePage } = usePageActions()
const confirm = useConfirm() const confirm = useConfirm()
DeleteEmptyPage(pageId) // useDeleteEmptyPage(pageId)
const handleDelete = async () => { const handleDelete = useCallback(async () => {
const result = await confirm({ const result = await confirm({
title: "Delete page", title: "Delete page",
description: "Are you sure you want to delete this page?", description: "Are you sure you want to delete this page?",
@@ -78,7 +74,7 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
deletePage(me, pageId as ID<PersonalPage>) deletePage(me, pageId as ID<PersonalPage>)
router.push("/pages") router.push("/pages")
} }
} }, [confirm, deletePage, me, pageId, router])
if (!page) return null if (!page) return null
@@ -132,29 +128,32 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const isContentInitialMount = useRef(true) const isContentInitialMount = useRef(true)
const isInitialFocusApplied = useRef(false) const isInitialFocusApplied = useRef(false)
const updatePageContent = (content: Content, model: PersonalPage) => { const updatePageContent = useCallback((content: Content, model: PersonalPage) => {
if (isContentInitialMount.current) { if (isContentInitialMount.current) {
isContentInitialMount.current = false isContentInitialMount.current = false
return return
} }
model.content = content model.content = content
model.updatedAt = new Date() model.updatedAt = new Date()
} }, [])
const handleUpdateTitle = (editor: Editor) => { const handleUpdateTitle = useCallback(
if (isTitleInitialMount.current) { (editor: Editor) => {
isTitleInitialMount.current = false if (isTitleInitialMount.current) {
return isTitleInitialMount.current = false
} return
}
const newTitle = editor.getText() const newTitle = editor.getText()
if (newTitle !== page.title) { if (newTitle !== page.title) {
const slug = generateUniqueSlug(page.title?.toString() || "") const slug = generateUniqueSlug(page.title?.toString() || "")
page.title = newTitle page.title = newTitle
page.slug = slug page.slug = slug
page.updatedAt = new Date() page.updatedAt = new Date()
} }
} },
[page]
)
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => { const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
const editor = titleEditorRef.current const editor = titleEditorRef.current
@@ -254,7 +253,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
contentEditorRef.current.editor.commands.focus() contentEditorRef.current.editor.commands.focus()
} }
} }
}, [page.title, titleEditor, contentEditorRef]) }, [page.title, titleEditor])
return ( return (
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]"> <div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">
+37 -33
View File
@@ -1,54 +1,58 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { LaIcon } from "@/components/custom/la-icon" import { LaIcon } from "@/components/custom/la-icon"
import { useAccount } from "@/lib/providers/jazz-provider" import { useAccount } from "@/lib/providers/jazz-provider"
import { useRouter } from "next/navigation" import { usePageActions } from "./hooks/use-page-actions"
import { PersonalPage } from "@/lib/schema"
import { toast } from "sonner"
export const PageHeader = React.memo(() => { interface PageHeaderProps {}
export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
const { me } = useAccount() const { me } = useAccount()
const router = useRouter() const router = useRouter()
const { newPage } = usePageActions()
if (!me) return null if (!me) return null
const handleClick = () => { const handleNewPageClick = () => {
try { const page = newPage(me)
const newPersonalPage = PersonalPage.create( router.push(`/pages/${page.id}`)
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
toast.error("Failed to create page")
}
} }
return ( return (
<ContentHeader className="px-6 py-5 max-lg:px-4"> <ContentHeader className="px-6 py-4 max-lg:px-4">
<div className="flex min-w-0 shrink-0 items-center gap-1.5"> <HeaderTitle />
<SidebarToggleButton /> <div className="flex flex-auto" />
<div className="flex min-h-0 items-center"> <NewPageButton onClick={handleNewPageClick} />
<span className="truncate text-left font-bold lg:text-xl">Pages</span>
</div>
</div>
<div className="flex flex-auto"></div>
<div className="flex w-auto items-center justify-end">
<div className="flex items-center gap-2">
<Button size="sm" type="button" variant="secondary" className="gap-x-2" onClick={handleClick}>
<LaIcon name="Plus" />
<span className="hidden md:block">New page</span>
</Button>
</div>
</div>
</ContentHeader> </ContentHeader>
) )
}) })
PageHeader.displayName = "PageHeader" PageHeader.displayName = "PageHeader"
const HeaderTitle: React.FC = () => (
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
<span className="truncate text-left font-bold lg:text-xl">Pages</span>
</div>
</div>
)
interface NewPageButtonProps {
onClick: () => void
}
const NewPageButton: React.FC<NewPageButtonProps> = ({ onClick }) => (
<div className="flex w-auto items-center justify-end">
<div className="flex items-center gap-2">
<Button size="sm" type="button" variant="secondary" className="gap-x-2" onClick={onClick}>
<LaIcon name="Plus" />
<span className="hidden md:block">New page</span>
</Button>
</div>
</div>
)
@@ -1,69 +0,0 @@
import React, { useEffect, useRef, useCallback } from "react"
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
interface UseKeyboardNavigationProps {
personalPages?: PersonalPageLists | null
activeItemIndex: number | null
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
isCommandPaletteOpen: boolean
disableEnterKey: boolean
onEnter?: (selectedPage: PersonalPage) => void
}
export const useKeyboardNavigation = ({
personalPages,
activeItemIndex,
setActiveItemIndex,
isCommandPaletteOpen,
disableEnterKey,
onEnter
}: UseKeyboardNavigationProps) => {
const listRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLAnchorElement | null)[]>([])
const itemCount = personalPages?.length || 0
const scrollIntoView = useCallback((index: number) => {
if (itemRefs.current[index]) {
itemRefs.current[index]?.scrollIntoView({
block: "nearest"
})
}
}, [])
useEffect(() => {
if (activeItemIndex !== null) {
scrollIntoView(activeItemIndex)
}
}, [activeItemIndex, scrollIntoView])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (isCommandPaletteOpen) return
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) {
e.preventDefault()
const selectedPage = personalPages[activeItemIndex]
if (selectedPage) onEnter?.(selectedPage)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, onEnter]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
const setItemRef = useCallback((el: HTMLAnchorElement | null, index: number) => {
itemRefs.current[index] = el
}, [])
return { listRef, setItemRef }
}
@@ -4,6 +4,15 @@ import { LaAccount, PersonalPage } from "@/lib/schema"
import { ID } from "jazz-tools" import { ID } from "jazz-tools"
export const usePageActions = () => { export const usePageActions = () => {
const newPage = useCallback((me: LaAccount): PersonalPage => {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
return newPersonalPage
}, [])
const deletePage = useCallback((me: LaAccount, pageId: ID<PersonalPage>): void => { const deletePage = useCallback((me: LaAccount, pageId: ID<PersonalPage>): void => {
if (!me.root?.personalPages) return if (!me.root?.personalPages) return
@@ -32,5 +41,5 @@ export const usePageActions = () => {
} }
}, []) }, [])
return { deletePage } return { newPage, deletePage }
} }
+52 -39
View File
@@ -1,15 +1,15 @@
import React, { useMemo, useCallback } from "react" import React, { useMemo, useCallback, useEffect } from "react"
import { Primitive } from "@radix-ui/react-primitive" import { Primitive } from "@radix-ui/react-primitive"
import { useAccount } from "@/lib/providers/jazz-provider" import { useAccount } from "@/lib/providers/jazz-provider"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { PageItem } from "./partials/page-item" import { PageItem } from "./partials/page-item"
import { useKeyboardNavigation } from "./hooks/use-keyboard-navigation"
import { useMedia } from "react-use" import { useMedia } from "react-use"
import { Column } from "./partials/column" import { Column } from "./partials/column"
import { useColumnStyles } from "./hooks/use-column-styles" import { useColumnStyles } from "./hooks/use-column-styles"
import { PersonalPage, PersonalPageLists } from "@/lib/schema" import { PersonalPage, PersonalPageLists } from "@/lib/schema"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
interface PageListProps { interface PageListProps {
activeItemIndex: number | null activeItemIndex: number | null
@@ -23,6 +23,7 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
const { me } = useAccount({ root: { personalPages: [] } }) const { me } = useAccount({ root: { personalPages: [] } })
const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages]) const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages])
const router = useRouter() const router = useRouter()
const itemCount = personalPages?.length || 0
const handleEnter = useCallback( const handleEnter = useCallback(
(selectedPage: PersonalPage) => { (selectedPage: PersonalPage) => {
@@ -31,24 +32,35 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
[router] [router]
) )
const { listRef, setItemRef } = useKeyboardNavigation({ const handleKeyDown = useCallback(
personalPages, (e: KeyboardEvent) => {
activeItemIndex, if (isCommandPaletteOpen) return
setActiveItemIndex,
isCommandPaletteOpen, if (e.key === "ArrowUp" || e.key === "ArrowDown") {
disableEnterKey, e.preventDefault()
onEnter: handleEnter setActiveItemIndex(prevIndex => {
}) if (prevIndex === null) return 0
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) {
e.preventDefault()
const selectedPage = personalPages[activeItemIndex]
if (selectedPage) handleEnter?.(selectedPage)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, handleEnter]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
return ( return (
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col overflow-hidden border-t">
{!isTablet && <ColumnHeader />} {!isTablet && <ColumnHeader />}
<PageListItems <PageListItems personalPages={personalPages} activeItemIndex={activeItemIndex} />
listRef={listRef}
setItemRef={setItemRef}
personalPages={personalPages}
activeItemIndex={activeItemIndex}
/>
</div> </div>
) )
} }
@@ -72,29 +84,30 @@ export const ColumnHeader: React.FC = () => {
} }
interface PageListItemsProps { interface PageListItemsProps {
listRef: React.RefObject<HTMLDivElement>
setItemRef: (el: HTMLAnchorElement | null, index: number) => void
personalPages?: PersonalPageLists | null personalPages?: PersonalPageLists | null
activeItemIndex: number | null activeItemIndex: number | null
} }
const PageListItems: React.FC<PageListItemsProps> = ({ listRef, setItemRef, personalPages, activeItemIndex }) => ( const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
<Primitive.div const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
ref={listRef}
className="divide-primary/5 mx-auto my-2 flex w-[99%] flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]" return (
tabIndex={-1} <Primitive.div
role="list" className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
> tabIndex={-1}
{personalPages?.map( role="list"
(page, index) => >
page?.id && ( {personalPages?.map(
<PageItem (page, index) =>
key={page.id} page?.id && (
ref={(el: HTMLAnchorElement | null) => setItemRef(el, index)} <PageItem
page={page} key={page.id}
isActive={index === activeItemIndex} ref={el => setElementRef(el, index)}
/> page={page}
) isActive={index === activeItemIndex}
)} />
</Primitive.div> )
) )}
</Primitive.div>
)
}
@@ -21,14 +21,10 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
<Link <Link
ref={ref} ref={ref}
tabIndex={isActive ? 0 : -1} tabIndex={isActive ? 0 : -1}
className={cn( className={cn("relative block cursor-default outline-none", "min-h-12 py-2 max-lg:px-4 sm:px-6", {
"relative block cursor-default rounded-lg outline-none", "bg-muted-foreground/5": isActive,
"h-12 items-center gap-x-2 py-2 max-lg:px-4 sm:px-6", "hover:bg-muted/50": !isActive
{ })}
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive
}
)}
href={`/pages/${page.id}`} href={`/pages/${page.id}`}
role="listitem" role="listitem"
> >
@@ -38,14 +34,9 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
</Column.Wrapper> </Column.Wrapper>
{!isTablet && ( {!isTablet && (
<> <Column.Wrapper style={columnStyles.topic}>
{/* <Column.Wrapper style={columnStyles.content}> {page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
<Column.Text className="text-[13px]">{page.slug}</Column.Text> </Column.Wrapper>
</Column.Wrapper> */}
<Column.Wrapper style={columnStyles.topic}>
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
</Column.Wrapper>
</>
)} )}
<Column.Wrapper style={columnStyles.updated} className="flex justify-end"> <Column.Wrapper style={columnStyles.updated} className="flex justify-end">