From 867478d55cf0ac30512430591671ffabee67cae0 Mon Sep 17 00:00:00 2001 From: Aslam Date: Mon, 23 Sep 2024 23:16:02 +0700 Subject: [PATCH] fix: Link, Pages, Topic, Hook and Others (#178) * chore: remove useKeyDownListener * chore: remove react-use, update jazz version and add query string * chore: update jazz version * chore: use simple mac or win utils code * feat(util): add isTextInput * feat(hooks): all needed hooks * fix: link bunch stuff * fix: page bunch stuff * chore: bunch update for custom component * chore: use throttle from internal hook * chore: topic bunch stuff * chore: update layout * fix: truncate content header of topic detail --- package.json | 5 +- web/app/(pages)/layout.tsx | 5 +- web/components/custom/Shortcut/shortcut.tsx | 8 ++ .../command-palette/command-palette.tsx | 13 -- web/components/custom/content-header.tsx | 10 +- .../custom/global-keyboard-handler.tsx | 130 +++++++++++++++++ .../custom/global-keydown-handler.tsx | 63 -------- .../custom/learning-state-selector.tsx | 32 +++-- .../custom/sidebar/partial/page-section.tsx | 5 +- .../sidebar/partial/profile-section.tsx | 6 +- web/components/custom/sidebar/sidebar.tsx | 10 +- web/components/custom/topic-selector.tsx | 7 +- web/components/la-editor/la-editor.tsx | 40 +----- web/components/routes/link/LinkRoute.tsx | 20 +-- web/components/routes/link/bottom-bar.tsx | 42 ++---- web/components/routes/link/header.tsx | 6 +- web/components/routes/link/list.tsx | 135 +++++++----------- web/components/routes/link/manage.tsx | 6 +- .../routes/link/partials/form/link-form.tsx | 40 +++--- .../routes/link/partials/link-item.tsx | 41 +++--- .../routes/page/detail/PageDetailRoute.tsx | 2 +- .../routes/page/hooks/use-column-styles.ts | 2 +- web/components/routes/page/list.tsx | 2 +- .../routes/page/partials/page-item.tsx | 2 +- web/components/routes/public/Autocomplete.tsx | 41 +++--- .../routes/topics/detail/Header.tsx | 13 +- .../topics/detail/partials/link-item.tsx | 7 +- .../routes/topics/hooks/use-column-styles.ts | 2 +- web/components/routes/topics/list.tsx | 2 +- .../routes/topics/partials/topic-item.tsx | 1 - web/hooks/use-active-item-scroll.ts | 4 +- web/hooks/use-event-listener.ts | 33 +++++ web/hooks/use-is-mounted.ts | 19 +++ web/hooks/use-key-down.ts | 79 ++++++++++ web/hooks/use-keyboard-manager.ts | 13 +- web/hooks/use-keydown-listener.ts | 21 --- web/hooks/use-media.ts | 23 +++ web/hooks/use-on-click-outside.ts | 28 ++++ web/hooks/use-throttle.ts | 34 +++++ web/hooks/use-touch-sensor.ts | 9 +- web/lib/utils/index.ts | 24 ++-- web/lib/utils/keyboard.ts | 87 ++++++----- web/package.json | 10 +- 43 files changed, 616 insertions(+), 466 deletions(-) create mode 100644 web/components/custom/global-keyboard-handler.tsx delete mode 100644 web/components/custom/global-keydown-handler.tsx create mode 100644 web/hooks/use-event-listener.ts create mode 100644 web/hooks/use-is-mounted.ts create mode 100644 web/hooks/use-key-down.ts delete mode 100644 web/hooks/use-keydown-listener.ts create mode 100644 web/hooks/use-media.ts create mode 100644 web/hooks/use-on-click-outside.ts create mode 100644 web/hooks/use-throttle.ts diff --git a/package.json b/package.json index 3d94d0f8..42acf914 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,10 @@ "@clerk/themes": "^2.1.30", "@tauri-apps/cli": "^2.0.0-rc.16", "@tauri-apps/plugin-fs": "^2.0.0-rc.2", - "jazz-nodejs": "0.7.35-guest-auth.5", - "react-icons": "^5.3.0" + "jazz-nodejs": "0.8.0" }, "devDependencies": { - "bun-types": "^1.1.28" + "bun-types": "^1.1.29" }, "prettier": { "plugins": [ diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 3b897096..6f3decf8 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -3,7 +3,7 @@ import { Sidebar } from "@/components/custom/sidebar/sidebar" import { CommandPalette } from "@/components/custom/command-palette/command-palette" import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" import { Shortcut } from "@/components/custom/Shortcut/shortcut" -import { GlobalKeydownHandler } from "@/components/custom/global-keydown-handler" +import { GlobalKeyboardHandler } from "@/components/custom/global-keyboard-handler" export const viewport: Viewport = { width: "device-width, shrink-to-fit=no", @@ -16,8 +16,7 @@ export default function PageLayout({ children }: { children: React.ReactNode })
- - + diff --git a/web/components/custom/Shortcut/shortcut.tsx b/web/components/custom/Shortcut/shortcut.tsx index a8918077..070d446d 100644 --- a/web/components/custom/Shortcut/shortcut.tsx +++ b/web/components/custom/Shortcut/shortcut.tsx @@ -38,6 +38,14 @@ const SHORTCUTS: ShortcutSection[] = [ { label: "Go to page", keys: ["G"], then: ["P"] }, { label: "Go to topic", keys: ["G"], then: ["T"] } ] + }, + { + title: "Links", + shortcuts: [{ label: "Create new link", keys: ["c"] }] + }, + { + title: "Pages", + shortcuts: [{ label: "Create new page", keys: ["p"] }] } ] diff --git a/web/components/custom/command-palette/command-palette.tsx b/web/components/custom/command-palette/command-palette.tsx index 11feb4dd..db76b28b 100644 --- a/web/components/custom/command-palette/command-palette.tsx +++ b/web/components/custom/command-palette/command-palette.tsx @@ -11,7 +11,6 @@ import { searchSafeRegExp } from "@/lib/utils" import { GraphNode } from "@/components/routes/public/PublicHomeRoute" import { useCommandActions } from "./hooks/use-command-actions" import { atom, useAtom } from "jotai" -import { useKeydownListener } from "@/hooks/use-keydown-listener" const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default) @@ -40,18 +39,6 @@ export function RealCommandPalette() { const raw_graph_data = React.use(graph_data_promise) as GraphNode[] - const handleKeydown = React.useCallback( - (e: KeyboardEvent) => { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - setOpen(prev => !prev) - } - }, - [setOpen] - ) - - useKeydownListener(handleKeydown) - const bounce = React.useCallback(() => { if (dialogRef.current) { dialogRef.current.style.transform = "scale(0.99) translateX(-50%)" diff --git a/web/components/custom/content-header.tsx b/web/components/custom/content-header.tsx index 500e0254..550f3459 100644 --- a/web/components/custom/content-header.tsx +++ b/web/components/custom/content-header.tsx @@ -1,12 +1,12 @@ "use client" -import React from "react" +import * as React from "react" import { Button } from "../ui/button" -import { PanelLeftIcon } from "lucide-react" import { useAtom } from "jotai" import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar" -import { useMedia } from "react-use" +import { useMedia } from "@/hooks/use-media" import { cn } from "@/lib/utils" +import { LaIcon } from "./la-icon" type ContentHeaderProps = Omit, "title"> @@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef { className="text-primary/60" onClick={handleClick} > - +
) diff --git a/web/components/custom/global-keyboard-handler.tsx b/web/components/custom/global-keyboard-handler.tsx new file mode 100644 index 00000000..4c7aa6ba --- /dev/null +++ b/web/components/custom/global-keyboard-handler.tsx @@ -0,0 +1,130 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { useRouter } from "next/navigation" +import queryString from "query-string" +import { usePageActions } from "../routes/page/hooks/use-page-actions" +import { useAuth } from "@clerk/nextjs" +import { isModKey } from "@/lib/utils" +import { useAtom } from "jotai" +import { commandPaletteOpenAtom } from "./command-palette/command-palette" + +type RegisterKeyDownProps = { + trigger: KeyFilter + handler: (event: KeyboardEvent) => void + options?: Options +} + +function RegisterKeyDown({ trigger, handler, options }: RegisterKeyDownProps) { + useKeyDown(trigger, handler, options) + return null +} + +type Sequence = { + [key: string]: string +} + +const SEQUENCES: Sequence = { + GL: "/links", + GP: "/pages", + GT: "/topics" +} + +const MAX_SEQUENCE_TIME = 1000 + +export function GlobalKeyboardHandler() { + const [openCommandPalette, setOpenCommandPalette] = useAtom(commandPaletteOpenAtom) + const [sequence, setSequence] = useState([]) + const { signOut } = useAuth() + const router = useRouter() + const { me } = useAccountOrGuest() + const { newPage } = usePageActions() + + const resetSequence = useCallback(() => { + setSequence([]) + }, []) + + const checkSequence = useCallback(() => { + const sequenceStr = sequence.join("") + const route = SEQUENCES[sequenceStr] + + if (route) { + console.log(`Navigating to ${route}...`) + router.push(route) + resetSequence() + } + }, [sequence, router, resetSequence]) + + const goToNewLink = useCallback( + (event: KeyboardEvent) => { + if (event.metaKey || event.altKey) { + return + } + + router.push(`/links?${queryString.stringify({ create: true })}`) + }, + [router] + ) + + const goToNewPage = useCallback( + (event: KeyboardEvent) => { + if (event.metaKey || event.altKey) { + return + } + + if (!me || me._type === "Anonymous") { + return + } + + const page = newPage(me) + + router.push(`/pages/${page.id}`) + }, + [me, newPage, router] + ) + + useKeyDown( + e => e.altKey && e.shiftKey && e.code === "KeyQ", + () => { + signOut() + } + ) + + useKeyDown( + () => true, + e => { + const key = e.key.toUpperCase() + setSequence(prev => [...prev, key]) + } + ) + + useKeyDown( + e => isModKey(e) && e.code === "KeyK", + e => { + e.preventDefault() + setOpenCommandPalette(prev => !prev) + } + ) + + useEffect(() => { + checkSequence() + + const timeoutId = setTimeout(() => { + resetSequence() + }, MAX_SEQUENCE_TIME) + + return () => clearTimeout(timeoutId) + }, [sequence, checkSequence, resetSequence]) + + return ( + me && + me._type !== "Anonymous" && ( + <> + + + + ) + ) +} diff --git a/web/components/custom/global-keydown-handler.tsx b/web/components/custom/global-keydown-handler.tsx deleted file mode 100644 index 527162cd..00000000 --- a/web/components/custom/global-keydown-handler.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client" - -import { useState, useEffect, useCallback } from "react" -import { useKeydownListener } from "@/hooks/use-keydown-listener" -import { useAuth } from "@clerk/nextjs" -import { useRouter } from "next/navigation" - -type Sequence = { - [key: string]: string -} - -const SEQUENCES: Sequence = { - GL: "/links", - GP: "/pages", - GT: "/topics" -} - -const MAX_SEQUENCE_TIME = 1000 - -export function GlobalKeydownHandler() { - const [sequence, setSequence] = useState([]) - const { signOut } = useAuth() - const router = useRouter() - - const resetSequence = useCallback(() => { - setSequence([]) - }, []) - - const checkSequence = useCallback(() => { - const sequenceStr = sequence.join("") - const route = SEQUENCES[sequenceStr] - - if (route) { - console.log(`Navigating to ${route}...`) - router.push(route) - resetSequence() - } - }, [sequence, router, resetSequence]) - - useKeydownListener((e: KeyboardEvent) => { - // Check for logout shortcut - if (e.altKey && e.shiftKey && e.code === "KeyQ") { - signOut() - return - } - - // Key sequence handling - const key = e.key.toUpperCase() - setSequence(prev => [...prev, key]) - }) - - useEffect(() => { - checkSequence() - - const timeoutId = setTimeout(() => { - resetSequence() - }, MAX_SEQUENCE_TIME) - - return () => clearTimeout(timeoutId) - }, [sequence, checkSequence, resetSequence]) - - return null -} diff --git a/web/components/custom/learning-state-selector.tsx b/web/components/custom/learning-state-selector.tsx index 5b467b86..6f00af5c 100644 --- a/web/components/custom/learning-state-selector.tsx +++ b/web/components/custom/learning-state-selector.tsx @@ -8,6 +8,7 @@ import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { linkLearningStateSelectorAtom } from "@/store/link" import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command" import { ScrollArea } from "@/components/ui/scroll-area" +import type { icons } from "lucide-react" interface LearningStateSelectorProps { showSearch?: boolean @@ -16,6 +17,7 @@ interface LearningStateSelectorProps { value?: string onChange: (value: LearningStateValue) => void className?: string + defaultIcon?: keyof typeof icons } export const LearningStateSelector: React.FC = ({ @@ -24,7 +26,8 @@ export const LearningStateSelector: React.FC = ({ searchPlaceholder = "Search state...", value, onChange, - className + className, + defaultIcon }) => { const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom) const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === value), [value]) @@ -44,21 +47,24 @@ export const LearningStateSelector: React.FC = ({ variant="secondary" className={cn("gap-x-2 text-sm", className)} > - {selectedLearningState?.icon && ( - - )} - - {selectedLearningState?.label || defaultLabel} - + {selectedLearningState?.icon || + (defaultIcon && ( + + ))} + + {selectedLearningState?.label || + (defaultLabel && ( + + {selectedLearningState?.label || defaultLabel} + + ))} - e.preventDefault()} - > + = ({ pageCount, isActi isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground" )} > - +

Pages {pageCount > 0 && {pageCount}} diff --git a/web/components/custom/sidebar/partial/profile-section.tsx b/web/components/custom/sidebar/partial/profile-section.tsx index 2396c7ac..09c73624 100644 --- a/web/components/custom/sidebar/partial/profile-section.tsx +++ b/web/components/custom/sidebar/partial/profile-section.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import * as React from "react" import { SignInButton, useAuth, useUser } from "@clerk/nextjs" import { useAtom } from "jotai" import Link from "next/link" @@ -27,13 +27,13 @@ import { useKeyboardManager } from "@/hooks/use-keyboard-manager" export const ProfileSection: React.FC = () => { const { user, isSignedIn } = useUser() const { signOut } = useAuth() - const [menuOpen, setMenuOpen] = useState(false) + const [menuOpen, setMenuOpen] = React.useState(false) const pathname = usePathname() const [, setShowShortcut] = useAtom(showShortcutAtom) const { disableKeydown } = useKeyboardManager("profileSection") - useEffect(() => { + React.useEffect(() => { disableKeydown(menuOpen) }, [menuOpen, disableKeydown]) diff --git a/web/components/custom/sidebar/sidebar.tsx b/web/components/custom/sidebar/sidebar.tsx index 3679128a..64a4f9ae 100644 --- a/web/components/custom/sidebar/sidebar.tsx +++ b/web/components/custom/sidebar/sidebar.tsx @@ -3,9 +3,8 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" -import { useMedia } from "react-use" +import { useMedia } from "@/hooks/use-media" import { useAtom } from "jotai" -import { SearchIcon } from "lucide-react" import { Logo } from "@/components/custom/logo" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" @@ -15,6 +14,7 @@ import { PageSection } from "./partial/page-section" import { TopicSection } from "./partial/topic-section" import { ProfileSection } from "./partial/profile-section" import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { LaIcon } from "../la-icon" interface SidebarContextType { isCollapsed: boolean @@ -98,7 +98,7 @@ const LogoAndSearch: React.FC = React.memo(() => { type="button" className="text-primary/60 flex w-20 items-center justify-start py-4 pl-2" > - + )} @@ -119,11 +119,11 @@ const SidebarContent: React.FC = React.memo(() => {

-
+
{me._type === "Account" && } - {me._type === "Account" && } {me._type === "Account" && } + {me._type === "Account" && }
diff --git a/web/components/custom/topic-selector.tsx b/web/components/custom/topic-selector.tsx index 8a1f6691..2807b1fc 100644 --- a/web/components/custom/topic-selector.tsx +++ b/web/components/custom/topic-selector.tsx @@ -79,12 +79,7 @@ export const TopicSelector = forwardRef( - e.preventDefault()} - > + {group?.root.topics && ( , "value"> { output?: "html" | "json" | "text" @@ -25,10 +25,6 @@ export interface LAEditorRef { editor: Editor | null } -interface CustomEditor extends Editor { - previousBlockCount?: number -} - export const LAEditor = React.forwardRef( ( { @@ -46,32 +42,13 @@ export const LAEditor = React.forwardRef( }, ref ) => { - const [content, setContent] = React.useState(value) - const throttledContent = useThrottleFn(defaultContent => defaultContent, throttleDelay, [content]) - const [lastThrottledContent, setLastThrottledContent] = React.useState(throttledContent) + const throttledSetValue = useThrottle((value: Content) => onUpdate?.(value), throttleDelay) const handleUpdate = React.useCallback( (editor: Editor) => { - const newContent = getOutput(editor, output) - setContent(newContent) - - const customEditor = editor as CustomEditor - const json = customEditor.getJSON() - - if (json.content && Array.isArray(json.content)) { - const currentBlockCount = json.content.length - - if ( - typeof customEditor.previousBlockCount === "number" && - currentBlockCount > customEditor.previousBlockCount - ) { - onNewBlock?.(newContent) - } - - customEditor.previousBlockCount = currentBlockCount - } + throttledSetValue(getOutput(editor, output)) }, - [output, onNewBlock] + [output, throttledSetValue] ) const editor = useEditor({ @@ -96,13 +73,6 @@ export const LAEditor = React.forwardRef( } }) - React.useEffect(() => { - if (lastThrottledContent !== throttledContent) { - setLastThrottledContent(throttledContent) - onUpdate?.(throttledContent!) - } - }, [throttledContent, lastThrottledContent, onUpdate]) - React.useImperativeHandle( ref, () => ({ diff --git a/web/components/routes/link/LinkRoute.tsx b/web/components/routes/link/LinkRoute.tsx index e6cb4cef..8b31f071 100644 --- a/web/components/routes/link/LinkRoute.tsx +++ b/web/components/routes/link/LinkRoute.tsx @@ -1,36 +1,20 @@ "use client" -import React, { useState } from "react" +import * as React from "react" import { LinkHeader } from "@/components/routes/link/header" import { LinkList } from "@/components/routes/link/list" import { LinkManage } from "@/components/routes/link/manage" -import { useQueryState } from "nuqs" import { atom } from "jotai" import { LinkBottomBar } from "./bottom-bar" -import { useKey } from "react-use" export const isDeleteConfirmShownAtom = atom(false) export function LinkRoute(): React.ReactElement { - const [nuqsEditId, setNuqsEditId] = useQueryState("editId") - const [activeItemIndex, setActiveItemIndex] = useState(null) - const [keyboardActiveIndex, setKeyboardActiveIndex] = useState(null) - - useKey("Escape", () => { - setNuqsEditId(null) - }) - return ( <> - + ) diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index 2dbf44c4..aa4a812b 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -1,11 +1,11 @@ "use client" -import React, { useCallback, useEffect, useMemo, useRef } from "react" +import * as React from "react" import { motion, AnimatePresence } from "framer-motion" import type { icons } from "lucide-react" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { cn, getShortcutKeys, isEditableElement } from "@/lib/utils" +import { cn, getShortcutKeys } from "@/lib/utils" import { LaIcon } from "@/components/custom/la-icon" import { useAtom } from "jotai" import { parseAsBoolean, useQueryState } from "nuqs" @@ -15,7 +15,6 @@ import { PersonalLink } from "@/lib/schema" import { ID } from "jazz-tools" import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form" import { useLinkActions } from "./hooks/use-link-actions" -import { useKeydownListener } from "@/hooks/use-keydown-listener" interface ToolbarButtonProps extends React.ComponentPropsWithoutRef { icon: keyof typeof icons @@ -55,27 +54,27 @@ export const LinkBottomBar: React.FC = () => { const { me } = useAccount({ root: { personalLinks: [] } }) const personalLink = useCoState(PersonalLink, editId as ID) - const cancelBtnRef = useRef(null) - const confirmBtnRef = useRef(null) - const overlayRef = useRef(null) - const contentRef = useRef(null) + const cancelBtnRef = React.useRef(null) + const confirmBtnRef = React.useRef(null) + const overlayRef = React.useRef(null) + const contentRef = React.useRef(null) - const deleteBtnRef = useRef(null) - const editMoreBtnRef = useRef(null) - const plusBtnRef = useRef(null) - const plusMoreBtnRef = useRef(null) + const deleteBtnRef = React.useRef(null) + const editMoreBtnRef = React.useRef(null) + const plusBtnRef = React.useRef(null) + const plusMoreBtnRef = React.useRef(null) const { deleteLink } = useLinkActions() const confirm = useConfirm() - const handleCreateMode = useCallback(() => { + const handleCreateMode = React.useCallback(() => { setEditId(null) requestAnimationFrame(() => { setCreateMode(prev => !prev) }) }, [setEditId, setCreateMode]) - const exceptionRefs = useMemo( + const exceptionRefs = React.useMemo( () => [ overlayRef, contentRef, @@ -89,7 +88,7 @@ export const LinkBottomBar: React.FC = () => { [] ) - useEffect(() => { + React.useEffect(() => { setGlobalLinkFormExceptionRefsAtom(exceptionRefs) }, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs]) @@ -124,21 +123,6 @@ export const LinkBottomBar: React.FC = () => { } } - const handleKeydown = useCallback( - (event: KeyboardEvent) => { - const isCreateShortcut = event.key === "c" - const target = event.target as HTMLElement - - if (isCreateShortcut && !isEditableElement(target)) { - event.preventDefault() - handleCreateMode() - } - }, - [handleCreateMode] - ) - - useKeydownListener(handleKeydown) - const shortcutText = getShortcutKeys(["c"]) return ( diff --git a/web/components/routes/link/header.tsx b/web/components/routes/link/header.tsx index 9215fc27..bc6ecbe1 100644 --- a/web/components/routes/link/header.tsx +++ b/web/components/routes/link/header.tsx @@ -1,10 +1,9 @@ "use client" import * as React from "react" -import { ListFilterIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" -import { useMedia } from "react-use" +import { useMedia } from "@/hooks/use-media" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -15,6 +14,7 @@ import { LEARNING_STATES } from "@/lib/constants" import { useQueryState, parseAsStringLiteral } from "nuqs" import { FancySwitch } from "@omit/react-fancy-switch" import { cn } from "@/lib/utils" +import { LaIcon } from "@/components/custom/la-icon" const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES] const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value) @@ -116,7 +116,7 @@ const FilterAndSort = React.memo(() => { diff --git a/web/components/routes/link/list.tsx b/web/components/routes/link/list.tsx index 46342985..9710c83b 100644 --- a/web/components/routes/link/list.tsx +++ b/web/components/routes/link/list.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react" +import * as React from "react" import { DndContext, closestCenter, @@ -19,25 +19,18 @@ import { useAccount } from "@/lib/providers/jazz-provider" import { PersonalLinkLists } from "@/lib/schema/personal-link" import { useAtom } from "jotai" import { linkSortAtom } from "@/store/link" -import { useKey } from "react-use" import { LinkItem } from "./partials/link-item" import { parseAsBoolean, useQueryState } from "nuqs" import { learningStateAtom } from "./header" -import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { useConfirm } from "@omit/react-confirm-dialog" import { useLinkActions } from "./hooks/use-link-actions" import { isDeleteConfirmShownAtom } from "./LinkRoute" import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" -import { useKeyboardManager } from "@/hooks/use-keyboard-manager" -import { useKeydownListener } from "@/hooks/use-keydown-listener" import { useTouchSensor } from "@/hooks/use-touch-sensor" +import { useKeyDown } from "@/hooks/use-key-down" +import { isModKey } from "@/lib/utils" -interface LinkListProps { - activeItemIndex: number | null - setActiveItemIndex: React.Dispatch> - keyboardActiveIndex: number | null - setKeyboardActiveIndex: React.Dispatch> -} +interface LinkListProps {} const measuring: MeasuringConfiguration = { droppable: { @@ -45,14 +38,11 @@ const measuring: MeasuringConfiguration = { } } -const LinkList: React.FC = ({ - activeItemIndex, - setActiveItemIndex, - keyboardActiveIndex, - setKeyboardActiveIndex -}) => { +const LinkList: React.FC = () => { const isTouchDevice = useTouchSensor() - const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom) + const lastActiveIndexRef = React.useRef(null) + const [activeItemIndex, setActiveItemIndex] = React.useState(null) + const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState(null) const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom) const [editId, setEditId] = useQueryState("editId") const [createMode] = useQueryState("create", parseAsBoolean) @@ -63,11 +53,10 @@ const LinkList: React.FC = ({ const { deleteLink } = useLinkActions() const confirm = useConfirm() const { me } = useAccount({ root: { personalLinks: [] } }) - const { isKeyboardDisabled } = useKeyboardManager("XComponent") - const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks]) + const personalLinks = React.useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks]) - const filteredLinks = useMemo( + const filteredLinks = React.useMemo( () => personalLinks.filter(link => { if (activeLearningState === "all") return true @@ -77,7 +66,7 @@ const LinkList: React.FC = ({ [personalLinks, activeLearningState] ) - const sortedLinks = useMemo( + const sortedLinks = React.useMemo( () => sort === "title" ? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || "")) @@ -85,9 +74,21 @@ const LinkList: React.FC = ({ [filteredLinks, sort] ) + React.useEffect(() => { + if (editId !== null) { + const index = sortedLinks.findIndex(link => link?.id === editId) + if (index !== -1) { + lastActiveIndexRef.current = index + setActiveItemIndex(index) + setKeyboardActiveIndex(index) + } + } + }, [editId, setActiveItemIndex, setKeyboardActiveIndex, sortedLinks]) + const sensors = useSensors( useSensor(isTouchDevice ? TouchSensor : PointerSensor, { activationConstraint: { + ...(isTouchDevice ? { delay: 100, tolerance: 5 } : {}), distance: 5 } }), @@ -96,7 +97,7 @@ const LinkList: React.FC = ({ }) ) - const updateSequences = useCallback((links: PersonalLinkLists) => { + const updateSequences = React.useCallback((links: PersonalLinkLists) => { links.forEach((link, index) => { if (link) { link.sequence = index @@ -104,7 +105,7 @@ const LinkList: React.FC = ({ }) }, []) - const handleDeleteLink = useCallback(async () => { + const handleDeleteLink = React.useCallback(async () => { if (activeItemIndex === null) return setIsDeleteConfirmShown(true) const activeLink = sortedLinks[activeItemIndex] @@ -124,63 +125,31 @@ const LinkList: React.FC = ({ setIsDeleteConfirmShown(false) }, [activeItemIndex, sortedLinks, me, confirm, deleteLink, setIsDeleteConfirmShown]) - useKey(event => (event.metaKey || event.ctrlKey) && event.key === "Backspace", handleDeleteLink, { event: "keydown" }) + useKeyDown(e => isModKey(e) && e.key === "Backspace", handleDeleteLink) - useKeydownListener((e: KeyboardEvent) => { - if ( - isKeyboardDisabled || - isCommandPaletteOpen || - !me?.root?.personalLinks || - sortedLinks.length === 0 || - editId !== null || - e.defaultPrevented - ) - return + const next = () => Math.min((activeItemIndex ?? 0) + 1, sortedLinks.length - 1) - switch (e.key) { - case "ArrowUp": + const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0) + + const handleKeyDown = (ev: KeyboardEvent) => { + switch (ev.key) { case "ArrowDown": - e.preventDefault() - setActiveItemIndex(prevIndex => { - if (prevIndex === null) return 0 - - const newIndex = - e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1) - - if (e.metaKey && sort === "manual") { - const linksArray = [...me.root.personalLinks] - const newLinks = arrayMove(linksArray, prevIndex, newIndex) - - while (me.root.personalLinks.length > 0) { - me.root.personalLinks.pop() - } - - newLinks.forEach(link => { - if (link) { - me.root.personalLinks.push(link) - } - }) - - updateSequences(me.root.personalLinks) - } - - setKeyboardActiveIndex(newIndex) - - return newIndex - }) - break - case "Home": - e.preventDefault() - setActiveItemIndex(0) - break - case "End": - e.preventDefault() - setActiveItemIndex(sortedLinks.length - 1) + ev.preventDefault() + ev.stopPropagation() + setActiveItemIndex(next()) + setKeyboardActiveIndex(next()) break + case "ArrowUp": + ev.preventDefault() + ev.stopPropagation() + setActiveItemIndex(prev()) + setKeyboardActiveIndex(prev()) } - }) + } - const handleDragStart = useCallback( + useKeyDown(() => true, handleKeyDown) + + const handleDragStart = React.useCallback( (event: DragStartEvent) => { if (sort !== "manual") return if (!me) return @@ -199,7 +168,7 @@ const LinkList: React.FC = ({ [sort, me, setActiveItemIndex] ) - const handleDragCancel = useCallback(() => { + const handleDragCancel = React.useCallback(() => { setDraggingId(null) }, []) @@ -249,7 +218,9 @@ const LinkList: React.FC = ({ setDraggingId(null) } - const { setElementRef } = useActiveItemScroll({ activeIndex: keyboardActiveIndex }) + const { setElementRef } = useActiveItemScroll({ + activeIndex: keyboardActiveIndex + }) return ( = ({ measuring={measuring} modifiers={[restrictToVerticalAxis]} > -
+
item?.id || "") || []} strategy={verticalListSortingStrategy}>
@@ -274,9 +245,7 @@ const LinkList: React.FC = ({ isActive={activeItemIndex === index} personalLink={linkItem} editId={editId} - setEditId={setEditId} disabled={sort !== "manual" || editId !== null} - setActiveItemIndex={setActiveItemIndex} onPointerMove={() => { if (editId !== null || draggingId !== null || createMode) { return undefined @@ -285,6 +254,12 @@ const LinkList: React.FC = ({ setKeyboardActiveIndex(null) setActiveItemIndex(index) }} + onFormClose={() => { + setEditId(null) + setActiveItemIndex(lastActiveIndexRef.current) + setKeyboardActiveIndex(lastActiveIndexRef.current) + console.log(keyboardActiveIndex) + }} index={index} onItemSelected={link => setEditId(link.id)} data-keyboard-active={keyboardActiveIndex === index} diff --git a/web/components/routes/link/manage.tsx b/web/components/routes/link/manage.tsx index 438ea906..4884ef1c 100644 --- a/web/components/routes/link/manage.tsx +++ b/web/components/routes/link/manage.tsx @@ -1,7 +1,6 @@ "use client" import React from "react" -import { useKey } from "react-use" import { LinkForm } from "./partials/form/link-form" import { motion, AnimatePresence } from "framer-motion" import { parseAsBoolean, useQueryState } from "nuqs" @@ -12,9 +11,6 @@ const LinkManage: React.FC = () => { const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean) const handleFormClose = () => setCreateMode(false) - const handleFormFail = () => {} - - useKey("Escape", handleFormClose) return ( @@ -25,7 +21,7 @@ const LinkManage: React.FC = () => { exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.1 }} > - + )} diff --git a/web/components/routes/link/partials/form/link-form.tsx b/web/components/routes/link/partials/form/link-form.tsx index 3fbce3d7..a74b610e 100644 --- a/web/components/routes/link/partials/form/link-form.tsx +++ b/web/components/routes/link/partials/form/link-form.tsx @@ -19,6 +19,7 @@ import { FormField, FormItem, FormLabel } from "@/components/ui/form" import { LearningStateSelector } from "@/components/custom/learning-state-selector" import { TopicSelector, topicSelectorAtom } from "@/components/custom/topic-selector" import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" +import { useOnClickOutside } from "@/hooks/use-on-click-outside" export const globalLinkFormExceptionRefsAtom = atom[]>([]) @@ -78,26 +79,16 @@ export const LinkForm: React.FC = ({ [exceptionsRefs, globalExceptionRefs] ) - React.useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const isClickInsideForm = formRef.current && formRef.current.contains(event.target as Node) - - const isClickInsideExceptions = allExceptionRefs.some((ref, index) => { - const isInside = ref.current && ref.current.contains(event.target as Node) - return isInside - }) - - if (!isClickInsideForm && !istopicSelectorOpen && !islearningStateSelectorOpen && !isClickInsideExceptions) { - onClose?.() - } + useOnClickOutside(formRef, event => { + if ( + !istopicSelectorOpen && + !islearningStateSelectorOpen && + !allExceptionRefs.some(ref => ref.current?.contains(event.target as Node)) + ) { + console.log("clicking outside") + onClose?.() } - - document.addEventListener("mousedown", handleClickOutside) - - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - }, [islearningStateSelectorOpen, istopicSelectorOpen, allExceptionRefs, onClose]) + }) React.useEffect(() => { if (selectedLink) { @@ -193,7 +184,15 @@ export const LinkForm: React.FC = ({ const canSubmit = form.formState.isValid && !form.formState.isSubmitting return ( -
+
{ + if (e.key === "Escape") { + handleCancel() + } + }} + >
@@ -213,7 +212,6 @@ export const LinkForm: React.FC = ({ { - // toggle, if already selected set undefined form.setValue("learningState", field.value === value ? undefined : value) }} showSearch={false} diff --git a/web/components/routes/link/partials/link-item.tsx b/web/components/routes/link/partials/link-item.tsx index 1b5c4816..8340aac4 100644 --- a/web/components/routes/link/partials/link-item.tsx +++ b/web/components/routes/link/partials/link-item.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react" +import * as React from "react" import Image from "next/image" import Link from "next/link" import { useAtom } from "jotai" @@ -19,22 +19,18 @@ interface LinkItemProps extends React.HTMLAttributes { personalLink: PersonalLink disabled?: boolean editId: string | null - setEditId: (id: string | null) => void isActive: boolean - setActiveItemIndex: (index: number | null) => void index: number onItemSelected?: (personalLink: PersonalLink) => void + onFormClose?: () => void } export const LinkItem = React.forwardRef( - ( - { personalLink, disabled, editId, setEditId, isActive, setActiveItemIndex, index, onItemSelected, ...props }, - ref - ) => { + ({ personalLink, disabled, editId, isActive, index, onItemSelected, onFormClose, ...props }, ref) => { const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom) const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) - const style = useMemo( + const style = React.useMemo( () => ({ transform: CSS.Transform.toString(transform), transition @@ -42,15 +38,12 @@ export const LinkItem = React.forwardRef( [transform, transition] ) - const handleSuccess = useCallback(() => setEditId(null), [setEditId]) - const handleOnClose = useCallback(() => setEditId(null), [setEditId]) - - const selectedLearningState = useMemo( + const selectedLearningState = React.useMemo( () => LEARNING_STATES.find(ls => ls.value === personalLink.learningState), [personalLink.learningState] ) - const handleLearningStateSelect = useCallback( + const handleLearningStateSelect = React.useCallback( (value: string) => { const learningState = value as LearningStateValue personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState @@ -59,10 +52,19 @@ export const LinkItem = React.forwardRef( [personalLink, setOpenPopoverForId] ) + const handleKeyDown = React.useCallback( + (ev: React.KeyboardEvent) => { + if (ev.key === "Enter") { + ev.preventDefault() + ev.stopPropagation() + onItemSelected?.(personalLink) + } + }, + [personalLink, onItemSelected] + ) + if (editId === personalLink.id) { - return ( - {}} /> - ) + return {}} /> } return ( @@ -86,12 +88,7 @@ export const LinkItem = React.forwardRef( data-disabled={disabled} 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]" - onKeyDown={e => { - if (e.key === "Enter") { - e.preventDefault() - onItemSelected?.(personalLink) - } - }} + onKeyDown={handleKeyDown} >
{ const isTablet = useMedia("(max-width: 640px)") diff --git a/web/components/routes/page/list.tsx b/web/components/routes/page/list.tsx index 4afc59f5..cd085729 100644 --- a/web/components/routes/page/list.tsx +++ b/web/components/routes/page/list.tsx @@ -4,7 +4,7 @@ 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 { useMedia } from "react-use" +import { useMedia } from "@/hooks/use-media" import { useColumnStyles } from "./hooks/use-column-styles" import { PersonalPage, PersonalPageLists } from "@/lib/schema" import { useRouter } from "next/navigation" diff --git a/web/components/routes/page/partials/page-item.tsx b/web/components/routes/page/partials/page-item.tsx index 002a01e1..5a0d1af1 100644 --- a/web/components/routes/page/partials/page-item.tsx +++ b/web/components/routes/page/partials/page-item.tsx @@ -3,7 +3,7 @@ import Link from "next/link" import { cn } from "@/lib/utils" import { PersonalPage } from "@/lib/schema" import { Badge } from "@/components/ui/badge" -import { useMedia } from "react-use" +import { useMedia } from "@/hooks/use-media" import { useColumnStyles } from "../hooks/use-column-styles" import { format } from "date-fns" import { Column } from "@/components/custom/column" diff --git a/web/components/routes/public/Autocomplete.tsx b/web/components/routes/public/Autocomplete.tsx index 6cb80f60..25e49199 100644 --- a/web/components/routes/public/Autocomplete.tsx +++ b/web/components/routes/public/Autocomplete.tsx @@ -1,9 +1,9 @@ -import React, { useState, useRef, useCallback, useMemo, useEffect } from "react" +import * as React from "react" import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command" import { Command as CommandPrimitive } from "cmdk" import { motion, AnimatePresence } from "framer-motion" import { cn, searchSafeRegExp, shuffleArray } from "@/lib/utils" -import { useMountedState } from "react-use" +import { useIsMounted } from "@/hooks/use-is-mounted" interface GraphNode { name: string @@ -18,16 +18,16 @@ interface AutocompleteProps { } export function Autocomplete({ topics = [], onSelect, onInputChange }: AutocompleteProps): JSX.Element { - const inputRef = useRef(null) - const [open, setOpen] = useState(false) - const isMounted = useMountedState() - const [inputValue, setInputValue] = useState("") - const [hasInteracted, setHasInteracted] = useState(false) - const [showDropdown, setShowDropdown] = useState(false) + const inputRef = React.useRef(null) + const [, setOpen] = React.useState(false) + const isMounted = useIsMounted() + const [inputValue, setInputValue] = React.useState("") + const [hasInteracted, setHasInteracted] = React.useState(false) + const [showDropdown, setShowDropdown] = React.useState(false) - const initialShuffledTopics = useMemo(() => shuffleArray(topics).slice(0, 5), [topics]) + const initialShuffledTopics = React.useMemo(() => shuffleArray(topics).slice(0, 5), [topics]) - const filteredTopics = useMemo(() => { + const filteredTopics = React.useMemo(() => { if (!inputValue) { return initialShuffledTopics } @@ -44,7 +44,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl .slice(0, 10) }, [inputValue, topics, initialShuffledTopics]) - const handleSelect = useCallback( + const handleSelect = React.useCallback( (topic: GraphNode) => { setOpen(false) onSelect(topic.name) @@ -52,7 +52,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl [onSelect] ) - const handleInputChange = useCallback( + const handleInputChange = React.useCallback( (value: string) => { setInputValue(value) setShowDropdown(true) @@ -62,34 +62,27 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl [onInputChange] ) - const handleFocus = useCallback(() => { + const handleFocus = React.useCallback(() => { setHasInteracted(true) }, []) - const handleClick = useCallback(() => { + const handleClick = React.useCallback(() => { setShowDropdown(true) setHasInteracted(true) }, []) - const commandKey = useMemo(() => { + const commandKey = React.useMemo(() => { return filteredTopics .map(topic => `${topic.name}:${topic.prettyName}:${topic.connectedTopics.join(",")}`) .join("__") }, [filteredTopics]) - useEffect(() => { + React.useEffect(() => { if (inputRef.current && isMounted() && hasInteracted) { inputRef.current.focus() } }, [commandKey, isMounted, hasInteracted]) - const animationProps = { - initial: { opacity: 0, y: -10 }, - animate: { opacity: 1, y: 0 }, - exit: { opacity: 0, y: -10 }, - transition: { duration: 0.1 } - } - return (
diff --git a/web/components/routes/topics/detail/Header.tsx b/web/components/routes/topics/detail/Header.tsx index 0987e9a0..1349271b 100644 --- a/web/components/routes/topics/detail/Header.tsx +++ b/web/components/routes/topics/detail/Header.tsx @@ -8,6 +8,7 @@ import { useAccountOrGuest } from "@/lib/providers/jazz-provider" import { LearningStateValue } from "@/lib/constants" import { useClerk } from "@clerk/nextjs" import { usePathname } from "next/navigation" +import { useMedia } from "@/hooks/use-media" interface TopicDetailHeaderProps { topic: Topic @@ -16,6 +17,7 @@ interface TopicDetailHeaderProps { export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) { const clerk = useClerk() const pathname = usePathname() + const isMobile = useMedia("(max-width: 770px)") const { me } = useAccountOrGuest({ root: { topicsWantToLearn: [], @@ -90,20 +92,19 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic } return ( -
+
-
- {topic.prettyName} +
+

{topic.prettyName}

-
- ) diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx index 77964333..b232b425 100644 --- a/web/components/routes/topics/detail/partials/link-item.tsx +++ b/web/components/routes/topics/detail/partials/link-item.tsx @@ -151,12 +151,7 @@ export const LinkItem = React.memo( )} - e.preventDefault()} - > + { const isTablet = useMedia("(max-width: 640px)") diff --git a/web/components/routes/topics/list.tsx b/web/components/routes/topics/list.tsx index 09f6c488..4d951498 100644 --- a/web/components/routes/topics/list.tsx +++ b/web/components/routes/topics/list.tsx @@ -4,7 +4,7 @@ import { useAccount } from "@/lib/providers/jazz-provider" import { atom, useAtom } from "jotai" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { TopicItem } from "./partials/topic-item" -import { useMedia } from "react-use" +import { useMedia } from "@/hooks/use-media" import { useRouter } from "next/navigation" import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" import { Column } from "@/components/custom/column" diff --git a/web/components/routes/topics/partials/topic-item.tsx b/web/components/routes/topics/partials/topic-item.tsx index ffef13d8..05aec78d 100644 --- a/web/components/routes/topics/partials/topic-item.tsx +++ b/web/components/routes/topics/partials/topic-item.tsx @@ -139,7 +139,6 @@ export const TopicItem = React.forwardRef(({ top side="bottom" align="end" onClick={e => e.stopPropagation()} - onCloseAutoFocus={e => e.preventDefault()} > (options: ActiveItemSc const { activeIndex } = options const elementRefs = useRef>([]) - const scrollActiveElementIntoView = useCallback((index: number) => { + const scrollActiveElementIntoView = (index: number) => { const activeElement = elementRefs.current[index] activeElement?.focus() // activeElement?.scrollIntoView({ block: "nearest" }) - }, []) + } useEffect(() => { if (activeIndex !== null) { diff --git a/web/hooks/use-event-listener.ts b/web/hooks/use-event-listener.ts new file mode 100644 index 00000000..6bb50a22 --- /dev/null +++ b/web/hooks/use-event-listener.ts @@ -0,0 +1,33 @@ +import * as React from "react" + +type EventMap = WindowEventMap & HTMLElementEventMap & VisualViewportEventMap + +export function useEventListener< + K extends keyof EventMap, + T extends Window | HTMLElement | VisualViewport | null = Window +>( + eventName: K, + handler: (event: EventMap[K]) => void, + element: T = window as unknown as T, // Cast to `unknown` first, then `T` + options: AddEventListenerOptions = {} +) { + const savedHandler = React.useRef<(event: EventMap[K]) => void>() + const { capture, passive, once } = options + + React.useEffect(() => { + savedHandler.current = handler + }, [handler]) + + React.useEffect(() => { + const isSupported = element && element.addEventListener + if (!isSupported) return + + const eventListener = (event: EventMap[K]) => savedHandler.current?.(event) + + const opts = { capture, passive, once } + element.addEventListener(eventName, eventListener as EventListener, opts) + return () => { + element.removeEventListener(eventName, eventListener as EventListener, opts) + } + }, [eventName, element, capture, passive, once]) +} diff --git a/web/hooks/use-is-mounted.ts b/web/hooks/use-is-mounted.ts new file mode 100644 index 00000000..334b1a17 --- /dev/null +++ b/web/hooks/use-is-mounted.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +/** + * Hook to check if component is still mounted + * + * @returns {boolean} true if the component is mounted, false otherwise + */ +export function useIsMounted() { + const isMounted = React.useRef(false) + + React.useEffect(() => { + isMounted.current = true + return () => { + isMounted.current = false + } + }, []) + + return React.useCallback(() => isMounted.current, []) +} diff --git a/web/hooks/use-key-down.ts b/web/hooks/use-key-down.ts new file mode 100644 index 00000000..ac42fed6 --- /dev/null +++ b/web/hooks/use-key-down.ts @@ -0,0 +1,79 @@ +import { isModKey, isTextInput } from "@/lib/utils" +import * as React from "react" + +type Callback = (event: KeyboardEvent) => void + +export type KeyFilter = ((event: KeyboardEvent) => boolean) | string + +export type Options = { + allowInInput?: boolean +} + +type RegisteredCallback = { + callback: Callback + options?: Options +} + +// Registered keyboard event callbacks +let callbacks: RegisteredCallback[] = [] + +// Track if IME input suggestions are open so we can ignore keydown shortcuts +// in this case, they should never be triggered from mobile keyboards. +let imeOpen = false + +// Based on implementation in react-use +// https://github.com/streamich/react-use/blob/master/src/useKey.ts#L15-L22 +const createKeyPredicate = (keyFilter: KeyFilter) => + typeof keyFilter === "function" + ? keyFilter + : typeof keyFilter === "string" + ? (event: KeyboardEvent) => event.key === keyFilter + : keyFilter + ? (_event: KeyboardEvent) => true + : (_event: KeyboardEvent) => false + +export function useKeyDown(key: KeyFilter, fn: Callback, options?: Options): void { + const predicate = createKeyPredicate(key) + + React.useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (predicate(event)) { + fn(event) + } + } + + callbacks.push({ + callback: handler, + options + }) + + return () => { + callbacks = callbacks.filter(cb => cb.callback !== handler) + } + }, [fn, predicate, options]) +} + +window.addEventListener("keydown", event => { + if (imeOpen) { + return + } + + // reverse so that the last registered callbacks get executed first + for (const registered of callbacks.reverse()) { + if (event.defaultPrevented === true) { + break + } + + if (!isTextInput(event.target as HTMLElement) || registered.options?.allowInInput || isModKey(event)) { + registered.callback(event) + } + } +}) + +window.addEventListener("compositionstart", () => { + imeOpen = true +}) + +window.addEventListener("compositionend", () => { + imeOpen = false +}) diff --git a/web/hooks/use-keyboard-manager.ts b/web/hooks/use-keyboard-manager.ts index f73d0994..076f483d 100644 --- a/web/hooks/use-keyboard-manager.ts +++ b/web/hooks/use-keyboard-manager.ts @@ -2,7 +2,18 @@ import { useAtom } from "jotai" import { useEffect, useCallback } from "react" import { keyboardDisableSourcesAtom } from "@/store/keydown-manager" -const allowedKeys = ["Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"] +const allowedKeys = [ + "Escape", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Enter", + "Tab", + "Backspace", + "Home", + "End" +] export function useKeyboardManager(sourceId: string) { const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom) diff --git a/web/hooks/use-keydown-listener.ts b/web/hooks/use-keydown-listener.ts deleted file mode 100644 index 888f140e..00000000 --- a/web/hooks/use-keydown-listener.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useAtomValue } from "jotai" -import { useEffect, useCallback } from "react" -import { keyboardDisableSourcesAtom } from "@/store/keydown-manager" - -export function useKeydownListener(callback: (event: KeyboardEvent) => void) { - const disableSources = useAtomValue(keyboardDisableSourcesAtom) - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (disableSources.size === 0) { - callback(event) - } - }, - [disableSources, callback] - ) - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleKeyDown]) -} diff --git a/web/hooks/use-media.ts b/web/hooks/use-media.ts new file mode 100644 index 00000000..6f68e6bf --- /dev/null +++ b/web/hooks/use-media.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from "react" + +export function useMedia(query: string): boolean { + const [matches, setMatches] = useState(false) + + useEffect(() => { + if (window.matchMedia) { + const media = window.matchMedia(query) + if (media.matches !== matches) { + setMatches(media.matches) + } + const listener = () => { + setMatches(media.matches) + } + media.addListener(listener) + return () => media.removeListener(listener) + } + + return undefined + }, [matches, query]) + + return matches +} diff --git a/web/hooks/use-on-click-outside.ts b/web/hooks/use-on-click-outside.ts new file mode 100644 index 00000000..faed4679 --- /dev/null +++ b/web/hooks/use-on-click-outside.ts @@ -0,0 +1,28 @@ +import * as React from "react" +import { useEventListener } from "./use-event-listener" + +/** + * Hook to detect clicks outside of a specified element. + * + * @param ref The React ref to the element. + * @param callback The handler to call when a click outside the element is detected. + */ +export function useOnClickOutside( + ref: React.RefObject, + callback?: (event: MouseEvent | TouchEvent) => void, + options: AddEventListenerOptions = {} +) { + const listener = React.useCallback( + (event: MouseEvent | TouchEvent) => { + // Do nothing if clicking ref's element or descendent elements + if (!ref.current || ref.current.contains(event.target as Node)) { + return + } + callback?.(event) + }, + [ref, callback] + ) + + useEventListener("mousedown", listener, window, options) + useEventListener("touchstart", listener, window, options) +} diff --git a/web/hooks/use-throttle.ts b/web/hooks/use-throttle.ts new file mode 100644 index 00000000..7b58a4da --- /dev/null +++ b/web/hooks/use-throttle.ts @@ -0,0 +1,34 @@ +import { useRef, useCallback } from "react" + +export function useThrottle void>( + callback: T, + delay: number +): (...args: Parameters) => void { + const lastRan = useRef(Date.now()) + const timeoutRef = useRef(null) + + return useCallback( + (...args: Parameters) => { + const handler = () => { + if (Date.now() - lastRan.current >= delay) { + callback(...args) + lastRan.current = Date.now() + } else { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + timeoutRef.current = setTimeout( + () => { + callback(...args) + lastRan.current = Date.now() + }, + delay - (Date.now() - lastRan.current) + ) + } + } + + handler() + }, + [callback, delay] + ) +} diff --git a/web/hooks/use-touch-sensor.ts b/web/hooks/use-touch-sensor.ts index 437aff41..c0fa78ba 100644 --- a/web/hooks/use-touch-sensor.ts +++ b/web/hooks/use-touch-sensor.ts @@ -1,11 +1,18 @@ import { useState, useEffect } from "react" +const SSR = typeof window === "undefined" + export function useTouchSensor() { const [isTouchDevice, setIsTouchDevice] = useState(false) useEffect(() => { const detectTouch = () => { - setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0) + setIsTouchDevice( + !SSR && + (window.matchMedia?.("(hover: none) and (pointer: coarse)")?.matches || + "ontouchstart" in window || + navigator.maxTouchPoints > 0) + ) } detectTouch() diff --git a/web/lib/utils/index.ts b/web/lib/utils/index.ts index 9be23460..43f9bd26 100644 --- a/web/lib/utils/index.ts +++ b/web/lib/utils/index.ts @@ -34,22 +34,16 @@ export function shuffleArray(array: T[]): T[] { return shuffled } -export const isEditableElement = (element: HTMLElement): boolean => { - if (element.isContentEditable) { - return true - } +const inputs = ["input", "select", "button", "textarea"] // detect if node is a text input element - 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 function isTextInput(element: Element): boolean { + return !!( + element && + element.tagName && + (inputs.indexOf(element.tagName.toLowerCase()) !== -1 || + element.attributes.getNamedItem("role")?.value === "textbox" || + element.attributes.getNamedItem("contenteditable")?.value === "true") + ) } export * from "./urls" diff --git a/web/lib/utils/keyboard.ts b/web/lib/utils/keyboard.ts index 50255db7..27e8c208 100644 --- a/web/lib/utils/keyboard.ts +++ b/web/lib/utils/keyboard.ts @@ -1,47 +1,4 @@ -let isMac: boolean | undefined - -interface Navigator { - userAgentData?: { - brands: { brand: string; version: string }[] - mobile: boolean - platform: string - getHighEntropyValues: (hints: string[]) => Promise<{ - platform: string - platformVersion: string - uaFullVersion: string - }> - } -} - -function getPlatform(): string { - const nav = navigator as Navigator - - if (nav.userAgentData) { - if (nav.userAgentData.platform) { - return nav.userAgentData.platform - } - - nav.userAgentData.getHighEntropyValues(["platform"]).then(highEntropyValues => { - if (highEntropyValues.platform) { - return highEntropyValues.platform - } - }) - } - - if (typeof navigator.platform === "string") { - return navigator.platform - } - - return "" -} - -export function isMacOS() { - if (isMac === undefined) { - isMac = getPlatform().toLowerCase().includes("mac") - } - - return isMac -} +const SSR = typeof window === "undefined" interface ShortcutKeyResult { symbol: string @@ -51,11 +8,11 @@ interface ShortcutKeyResult { export function getShortcutKey(key: string): ShortcutKeyResult { const lowercaseKey = key.toLowerCase() if (lowercaseKey === "mod") { - return isMacOS() ? { symbol: "⌘", readable: "Command" } : { symbol: "Ctrl", readable: "Control" } + return isMac() ? { symbol: "⌘", readable: "Command" } : { symbol: "Ctrl", readable: "Control" } } else if (lowercaseKey === "alt") { - return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" } + return isMac() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" } } else if (lowercaseKey === "shift") { - return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" } + return isMac() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" } } else { return { symbol: key.toUpperCase(), readable: key } } @@ -64,3 +21,39 @@ export function getShortcutKey(key: string): ShortcutKeyResult { export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] { return keys.map(key => getShortcutKey(key)) } + +export function isModKey(event: KeyboardEvent | MouseEvent | React.KeyboardEvent) { + return isMac() ? event.metaKey : event.ctrlKey +} + +export function isMac(): boolean { + if (SSR) { + return false + } + return window.navigator.platform === "MacIntel" +} + +export function isWindows(): boolean { + if (SSR) { + return false + } + return window.navigator.platform === "Win32" +} + +let supportsPassive = false + +try { + const opts = Object.defineProperty({}, "passive", { + get() { + supportsPassive = true + } + }) + // @ts-expect-error ts-migrate(2769) testPassive is not a real event + window.addEventListener("testPassive", null, opts) + // @ts-expect-error ts-migrate(2769) testPassive is not a real event + window.removeEventListener("testPassive", null, opts) +} catch (e) { + // No-op +} + +export const supportsPassiveListener = supportsPassive diff --git a/web/package.json b/web/package.json index 619a7b73..0135be5a 100644 --- a/web/package.json +++ b/web/package.json @@ -78,22 +78,22 @@ "date-fns": "^3.6.0", "framer-motion": "^11.5.6", "geist": "^1.3.1", - "jazz-browser-auth-clerk": "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-tools": "0.7.35-guest-auth.5", + "jazz-browser-auth-clerk": "0.8.0", + "jazz-react": "0.8.0", + "jazz-react-auth-clerk": "0.8.0", + "jazz-tools": "0.8.0", "jotai": "^2.10.0", "lowlight": "^3.1.0", "lucide-react": "^0.429.0", "next": "14.2.10", "next-themes": "^0.3.0", "nuqs": "^1.19.1", + "query-string": "^9.1.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", "react-textarea-autosize": "^8.5.3", - "react-use": "^17.5.1", "ronin": "^4.3.1", "slugify": "^1.6.6", "sonner": "^1.5.0",