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

View File

@@ -3,11 +3,13 @@ import { ensureUrlProtocol } from "@/lib/utils"
import { useTheme } from "next-themes"
import { toast } from "sonner"
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 = () => {
const { setTheme } = useTheme()
const router = useRouter()
const { newPage } = usePageActions()
const changeTheme = React.useCallback(
(theme: string) => {
@@ -35,19 +37,10 @@ export const useCommandActions = () => {
const createNewPage = React.useCallback(
(me: LaAccount) => {
try {
const newPersonalPage = PersonalPage.create(
{ 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")
}
const page = newPage(me)
router.push(`/pages/${page.id}`)
},
[router]
[router, newPage]
)
return {

View File

@@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils"
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon"
import { toast } from "sonner"
import Link from "next/link"
import {
DropdownMenu,
@@ -21,6 +20,7 @@ import {
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { icons } from "lucide-react"
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
type SortOption = "title" | "recent"
type ShowOption = 5 | 10 | 15 | 20 | 0
@@ -101,20 +101,13 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount, isActi
const NewPageButton: React.FC = () => {
const { me } = useAccount()
const router = useRouter()
const { newPage } = usePageActions()
if (!me) return null
const handleClick = () => {
try {
const newPersonalPage = PersonalPage.create(
{ 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")
}
const page = newPage(me)
router.push(`/pages/${page.id}`)
}
return (

View File

@@ -23,11 +23,11 @@ import { usePageActions } from "../hooks/use-page-actions"
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)
}
export const DeleteEmptyPage = (currentPageId: string | null) => {
const useDeleteEmptyPage = (currentPageId: string | null) => {
const router = useRouter()
const { me } = useAccount({
root: {
@@ -36,21 +36,17 @@ export const DeleteEmptyPage = (currentPageId: string | null) => {
})
useEffect(() => {
const handleRouteChange = () => {
return () => {
if (!currentPageId || !me?.root?.personalPages) return
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)
if (index !== -1) {
me.root.personalPages.splice(index, 1)
}
}
}
return () => {
handleRouteChange()
}
}, [currentPageId, me, router])
}
@@ -62,9 +58,9 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
const { deletePage } = usePageActions()
const confirm = useConfirm()
DeleteEmptyPage(pageId)
// useDeleteEmptyPage(pageId)
const handleDelete = async () => {
const handleDelete = useCallback(async () => {
const result = await confirm({
title: "Delete 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>)
router.push("/pages")
}
}
}, [confirm, deletePage, me, pageId, router])
if (!page) return null
@@ -132,29 +128,32 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const isContentInitialMount = useRef(true)
const isInitialFocusApplied = useRef(false)
const updatePageContent = (content: Content, model: PersonalPage) => {
const updatePageContent = useCallback((content: Content, model: PersonalPage) => {
if (isContentInitialMount.current) {
isContentInitialMount.current = false
return
}
model.content = content
model.updatedAt = new Date()
}
}, [])
const handleUpdateTitle = (editor: Editor) => {
if (isTitleInitialMount.current) {
isTitleInitialMount.current = false
return
}
const handleUpdateTitle = useCallback(
(editor: Editor) => {
if (isTitleInitialMount.current) {
isTitleInitialMount.current = false
return
}
const newTitle = editor.getText()
if (newTitle !== page.title) {
const slug = generateUniqueSlug(page.title?.toString() || "")
page.title = newTitle
page.slug = slug
page.updatedAt = new Date()
}
}
const newTitle = editor.getText()
if (newTitle !== page.title) {
const slug = generateUniqueSlug(page.title?.toString() || "")
page.title = newTitle
page.slug = slug
page.updatedAt = new Date()
}
},
[page]
)
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
const editor = titleEditorRef.current
@@ -254,7 +253,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
contentEditorRef.current.editor.commands.focus()
}
}
}, [page.title, titleEditor, contentEditorRef])
}, [page.title, titleEditor])
return (
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">

View File

@@ -1,54 +1,58 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { LaIcon } from "@/components/custom/la-icon"
import { useAccount } from "@/lib/providers/jazz-provider"
import { useRouter } from "next/navigation"
import { PersonalPage } from "@/lib/schema"
import { toast } from "sonner"
import { usePageActions } from "./hooks/use-page-actions"
export const PageHeader = React.memo(() => {
interface PageHeaderProps {}
export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
const { me } = useAccount()
const router = useRouter()
const { newPage } = usePageActions()
if (!me) return null
const handleClick = () => {
try {
const newPersonalPage = PersonalPage.create(
{ 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")
}
const handleNewPageClick = () => {
const page = newPage(me)
router.push(`/pages/${page.id}`)
}
return (
<ContentHeader className="px-6 py-5 max-lg:px-4">
<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>
<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 className="px-6 py-4 max-lg:px-4">
<HeaderTitle />
<div className="flex flex-auto" />
<NewPageButton onClick={handleNewPageClick} />
</ContentHeader>
)
})
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>
)

View File

@@ -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 }
}

View File

@@ -4,6 +4,15 @@ import { LaAccount, PersonalPage } from "@/lib/schema"
import { ID } from "jazz-tools"
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 => {
if (!me.root?.personalPages) return
@@ -32,5 +41,5 @@ export const usePageActions = () => {
}
}, [])
return { deletePage }
return { newPage, deletePage }
}

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 { useAccount } from "@/lib/providers/jazz-provider"
import { useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { PageItem } from "./partials/page-item"
import { useKeyboardNavigation } from "./hooks/use-keyboard-navigation"
import { useMedia } from "react-use"
import { Column } from "./partials/column"
import { useColumnStyles } from "./hooks/use-column-styles"
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
import { useRouter } from "next/navigation"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
interface PageListProps {
activeItemIndex: number | null
@@ -23,6 +23,7 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
const { me } = useAccount({ root: { personalPages: [] } })
const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages])
const router = useRouter()
const itemCount = personalPages?.length || 0
const handleEnter = useCallback(
(selectedPage: PersonalPage) => {
@@ -31,24 +32,35 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
[router]
)
const { listRef, setItemRef } = useKeyboardNavigation({
personalPages,
activeItemIndex,
setActiveItemIndex,
isCommandPaletteOpen,
disableEnterKey,
onEnter: handleEnter
})
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) handleEnter?.(selectedPage)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, handleEnter]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
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 />}
<PageListItems
listRef={listRef}
setItemRef={setItemRef}
personalPages={personalPages}
activeItemIndex={activeItemIndex}
/>
<PageListItems personalPages={personalPages} activeItemIndex={activeItemIndex} />
</div>
)
}
@@ -72,29 +84,30 @@ export const ColumnHeader: React.FC = () => {
}
interface PageListItemsProps {
listRef: React.RefObject<HTMLDivElement>
setItemRef: (el: HTMLAnchorElement | null, index: number) => void
personalPages?: PersonalPageLists | null
activeItemIndex: number | null
}
const PageListItems: React.FC<PageListItemsProps> = ({ listRef, setItemRef, personalPages, activeItemIndex }) => (
<Primitive.div
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]"
tabIndex={-1}
role="list"
>
{personalPages?.map(
(page, index) =>
page?.id && (
<PageItem
key={page.id}
ref={(el: HTMLAnchorElement | null) => setItemRef(el, index)}
page={page}
isActive={index === activeItemIndex}
/>
)
)}
</Primitive.div>
)
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
return (
<Primitive.div
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={-1}
role="list"
>
{personalPages?.map(
(page, index) =>
page?.id && (
<PageItem
key={page.id}
ref={el => setElementRef(el, index)}
page={page}
isActive={index === activeItemIndex}
/>
)
)}
</Primitive.div>
)
}

View File

@@ -21,14 +21,10 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
<Link
ref={ref}
tabIndex={isActive ? 0 : -1}
className={cn(
"relative block cursor-default rounded-lg outline-none",
"h-12 items-center gap-x-2 py-2 max-lg:px-4 sm:px-6",
{
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive
}
)}
className={cn("relative block cursor-default outline-none", "min-h-12 py-2 max-lg:px-4 sm:px-6", {
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
})}
href={`/pages/${page.id}`}
role="listitem"
>
@@ -38,14 +34,9 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
</Column.Wrapper>
{!isTablet && (
<>
{/* <Column.Wrapper style={columnStyles.content}>
<Column.Text className="text-[13px]">{page.slug}</Column.Text>
</Column.Wrapper> */}
<Column.Wrapper style={columnStyles.topic}>
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
</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">