diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx index 4f86a094..c5928be9 100644 --- a/web/app/(pages)/layout.tsx +++ b/web/app/(pages)/layout.tsx @@ -3,8 +3,9 @@ import { Sidebar } from "@/components/custom/sidebar/sidebar" import { CommandPalette } from "@/components/custom/command-palette/command-palette" import { useAccountOrGuest } from "@/lib/providers/jazz-provider" -import SlidingMenu from "@/components/ui/sliding-menu" import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" +import { Shortcut } from "@/components/custom/Shortcut/shortcut" +import { GlobalKeydownHandler } from "@/components/custom/global-keydown-handler" export default function PageLayout({ children }: { children: React.ReactNode }) { const { me } = useAccountOrGuest() @@ -13,11 +14,16 @@ export default function PageLayout({ children }: { children: React.ReactNode })
+ - {me._type !== "Anonymous" && } + {me._type !== "Anonymous" && ( + <> + + + + )}
-
{children}
diff --git a/web/components/custom/Shortcut/shortcut.tsx b/web/components/custom/Shortcut/shortcut.tsx new file mode 100644 index 00000000..e4bd5fc6 --- /dev/null +++ b/web/components/custom/Shortcut/shortcut.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { atom, useAtom } from "jotai" +import { Sheet, SheetPortal, SheetOverlay, SheetTitle, sheetVariants, SheetDescription } from "@/components/ui/sheet" +import { LaIcon } from "../la-icon" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" + +export const showShortcutAtom = atom(false) + +type ShortcutItem = { + label: string + keys: string[] + then?: string[] +} + +type ShortcutSection = { + title: string + shortcuts: ShortcutItem[] +} + +const SHORTCUTS: ShortcutSection[] = [ + { + title: "General", + shortcuts: [ + { label: "Open command menu", keys: ["⌘", "k"] }, + { label: "Log out", keys: ["⌥", "⇧", "q"] } + ] + }, + { + title: "Navigation", + shortcuts: [ + { label: "Go to link", keys: ["G"], then: ["L"] }, + { label: "Go to page", keys: ["G"], then: ["P"] }, + { label: "Go to topic", keys: ["G"], then: ["T"] } + ] + } +] + +const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => ( + +) + +const ShortcutItem: React.FC = ({ label, keys, then }) => ( +
+
+ {label} +
+
+ + + {keys.map((key, index) => ( + + ))} + {then && ( + <> + then + {then.map((key, index) => ( + + ))} + + )} + + +
+
+) + +const ShortcutSection: React.FC = ({ title, shortcuts }) => ( +
+

{title}

+
+ {shortcuts.map((shortcut, index) => ( + + ))} +
+
+) + +export function Shortcut() { + const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom) + const [searchQuery, setSearchQuery] = React.useState("") + + const { disableKeydown } = useKeyboardManager("shortcutSection") + + React.useEffect(() => { + disableKeydown(showShortcut) + }, [showShortcut, disableKeydown]) + + const filteredShortcuts = React.useMemo(() => { + if (!searchQuery) return SHORTCUTS + + return SHORTCUTS.map(section => ({ + ...section, + shortcuts: section.shortcuts.filter(shortcut => shortcut.label.toLowerCase().includes(searchQuery.toLowerCase())) + })).filter(section => section.shortcuts.length > 0) + }, [searchQuery]) + + return ( + + + + +
+ Keyboard Shortcuts + Quickly navigate around the app + +
+ + + + Close + +
+ +
+
+ + setSearchQuery(e.target.value)} + /> + +
+ +
+
+
+ {filteredShortcuts.map((section, index) => ( + + ))} +
+
+
+
+
+
+ ) +} diff --git a/web/components/custom/command-palette/command-palette.tsx b/web/components/custom/command-palette/command-palette.tsx index d797e381..39aad7c9 100644 --- a/web/components/custom/command-palette/command-palette.tsx +++ b/web/components/custom/command-palette/command-palette.tsx @@ -9,6 +9,7 @@ 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) @@ -29,17 +30,17 @@ export function CommandPalette() { const raw_graph_data = React.use(graph_data_promise) as GraphNode[] - React.useEffect(() => { - const down = (e: KeyboardEvent) => { + const handleKeydown = React.useCallback( + (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen(prev => !prev) } - } + }, + [setOpen] + ) - document.addEventListener("keydown", down) - return () => document.removeEventListener("keydown", down) - }, [setOpen]) + useKeydownListener(handleKeydown) const bounce = React.useCallback(() => { if (dialogRef.current) { @@ -118,11 +119,9 @@ export function CommandPalette() { if (activePage === "home") { if (!inputValue) { - // Only show items from the home object when there's no search input return commandGroups.home } - // When there's a search input, search across all categories const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics] return allGroups @@ -133,7 +132,6 @@ export function CommandPalette() { .filter(group => group.items.length > 0) } - // Handle other active pages (searchLinks, searchPages, etc.) switch (activePage) { case "searchLinks": return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }] diff --git a/web/components/custom/discordIcon.tsx b/web/components/custom/discordIcon.tsx index 9311a3e0..9d172b4b 100644 --- a/web/components/custom/discordIcon.tsx +++ b/web/components/custom/discordIcon.tsx @@ -3,21 +3,21 @@ export const DiscordIcon = () => ( ) diff --git a/web/components/custom/global-keydown-handler.tsx b/web/components/custom/global-keydown-handler.tsx new file mode 100644 index 00000000..527162cd --- /dev/null +++ b/web/components/custom/global-keydown-handler.tsx @@ -0,0 +1,63 @@ +"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/sidebar/partial/profile-section.tsx b/web/components/custom/sidebar/partial/profile-section.tsx index 2f768c5d..2396c7ac 100644 --- a/web/components/custom/sidebar/partial/profile-section.tsx +++ b/web/components/custom/sidebar/partial/profile-section.tsx @@ -1,7 +1,14 @@ -import { LaIcon } from "@/components/custom/la-icon" -import { DiscordIcon } from "../../discordIcon" -import { useState } from "react" +"use client" + +import { useEffect, useState } from "react" import { SignInButton, useAuth, useUser } from "@clerk/nextjs" +import { useAtom } from "jotai" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { icons } from "lucide-react" + +import { LaIcon } from "@/components/custom/la-icon" +import { DiscordIcon } from "@/components/custom/discordIcon" import { DropdownMenu, DropdownMenuContent, @@ -10,17 +17,25 @@ import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Avatar, AvatarImage } from "@/components/ui/avatar" -import Link from "next/link" -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { usePathname } from "next/navigation" +import { cn } from "@/lib/utils" import { Feedback } from "./feedback" +import { showShortcutAtom } from "@/components/custom/Shortcut/shortcut" +import { ShortcutKey } from "@/components/minimal-tiptap/components/shortcut-key" +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 pathname = usePathname() + const [, setShowShortcut] = useAtom(showShortcutAtom) + + const { disableKeydown } = useKeyboardManager("profileSection") + + useEffect(() => { + disableKeydown(menuOpen) + }, [menuOpen, disableKeydown]) if (!isSignedIn) { return ( @@ -38,88 +53,104 @@ export const ProfileSection: React.FC = () => { return (
-
- - - - - - - -
- - My profile -
- -
- - - -
- - Onboarding -
- -
- - - - -
- - Docs -
- -
- - - - -
- - GitHub -
- -
- - - - -
- - Discord -
- -
- - - signOut()}> -
- - Log out -
-
-
-
-
- +
) } + +interface ProfileDropdownProps { + user: any + menuOpen: boolean + setMenuOpen: (open: boolean) => void + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const ProfileDropdown: React.FC = ({ user, menuOpen, setMenuOpen, signOut, setShowShortcut }) => ( +
+ + + + + + + + +
+) + +interface DropdownMenuItemsProps { + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const DropdownMenuItems: React.FC = ({ signOut, setShowShortcut }) => ( + <> + + setShowShortcut(true)}> + + Shortcut + + + + + + + + +
+ + Log out +
+ +
+
+
+ +) + +interface MenuLinkProps { + href: string + icon: keyof typeof icons | React.FC + text: string + iconClass?: string +} + +const MenuLink: React.FC = ({ href, icon, text, iconClass = "" }) => { + const IconComponent = typeof icon === "string" ? icons[icon] : icon + return ( + + +
+ + {text} +
+ +
+ ) +} + +export default ProfileSection diff --git a/web/components/la-editor/components/ui/shortcut.tsx b/web/components/la-editor/components/ui/shortcut.tsx index 978a2b4a..cdb2f976 100644 --- a/web/components/la-editor/components/ui/shortcut.tsx +++ b/web/components/la-editor/components/ui/shortcut.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { cn } from "@/lib/utils" -import { getShortcutKey } from "../../lib/utils" +import { getShortcutKey } from "@/lib/utils" export interface ShortcutKeyWrapperProps extends React.HTMLAttributes { ariaLabel: string @@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef(({ class {...props} ref={ref} > - {getShortcutKey(shortcut)} + {getShortcutKey(shortcut).symbol} ) }) diff --git a/web/components/la-editor/extensions/slash-command/menu-list.tsx b/web/components/la-editor/extensions/slash-command/menu-list.tsx index 10377d18..ebf92265 100644 --- a/web/components/la-editor/extensions/slash-command/menu-list.tsx +++ b/web/components/la-editor/extensions/slash-command/menu-list.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { Command, MenuListProps } from "./types" -import { getShortcutKeys } from "../../lib/utils" +import { getShortcutKeys } from "@/lib/utils" import { Icon } from "../../components/ui/icon" import { PopoverWrapper } from "../../components/ui/popover-wrapper" import { Shortcut } from "../../components/ui/shortcut" @@ -136,7 +136,11 @@ export const MenuList = React.forwardRef((props: MenuListProps, ref) => { {command.label}
- + shortcut.readable) + .join(" + ")} + > {command.shortcuts.map(shortcut => ( ))} diff --git a/web/components/la-editor/lib/utils/index.ts b/web/components/la-editor/lib/utils/index.ts index 885f4fa4..60123d5b 100644 --- a/web/components/la-editor/lib/utils/index.ts +++ b/web/components/la-editor/lib/utils/index.ts @@ -8,7 +8,5 @@ export function getOutput(editor: Editor, output: LAEditorProps["output"]) { return "" } -export * from "./keyboard" -export * from "./platform" export * from "./isCustomNodeSelected" export * from "./isTextSelected" diff --git a/web/components/la-editor/lib/utils/keyboard.ts b/web/components/la-editor/lib/utils/keyboard.ts deleted file mode 100644 index 09739fc2..00000000 --- a/web/components/la-editor/lib/utils/keyboard.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isMacOS } from "./platform" - -export const getShortcutKey = (key: string) => { - const lowercaseKey = key.toLowerCase() - const macOS = isMacOS() - - switch (lowercaseKey) { - case "mod": - return macOS ? "⌘" : "Ctrl" - case "alt": - return macOS ? "⌥" : "Alt" - case "shift": - return macOS ? "⇧" : "Shift" - default: - return key - } -} - -export const getShortcutKeys = (keys: string | string[], separator: string = "") => { - const keyArray = Array.isArray(keys) ? keys : keys.split(/\s+/) - const shortcutKeys = keyArray.map(getShortcutKey) - return shortcutKeys.join(separator) -} - -export default { getShortcutKey, getShortcutKeys } diff --git a/web/components/la-editor/lib/utils/platform.ts b/web/components/la-editor/lib/utils/platform.ts deleted file mode 100644 index 2dffa98a..00000000 --- a/web/components/la-editor/lib/utils/platform.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface NavigatorWithUserAgentData extends Navigator { - userAgentData?: { - brands: { brand: string; version: string }[] - mobile: boolean - platform: string - getHighEntropyValues: (hints: string[]) => Promise<{ - platform: string - platformVersion: string - uaFullVersion: string - }> - } -} - -let isMac: boolean | undefined - -const getPlatform = () => { - const nav = navigator as NavigatorWithUserAgentData - if (nav.userAgentData) { - if (nav.userAgentData.platform) { - return nav.userAgentData.platform - } - - nav.userAgentData - .getHighEntropyValues(["platform"]) - .then(highEntropyValues => { - if (highEntropyValues.platform) { - return highEntropyValues.platform - } - }) - .catch(() => { - return navigator.platform || "" - }) - } - - return navigator.platform || "" -} - -export const isMacOS = () => { - if (isMac === undefined) { - isMac = getPlatform().toLowerCase().includes("mac") - } - - return isMac -} - -export default isMacOS diff --git a/web/components/minimal-tiptap/components/shortcut-key.tsx b/web/components/minimal-tiptap/components/shortcut-key.tsx index 2691528d..e81bdd88 100644 --- a/web/components/minimal-tiptap/components/shortcut-key.tsx +++ b/web/components/minimal-tiptap/components/shortcut-key.tsx @@ -1,33 +1,33 @@ -import * as React from 'react' -import { cn } from '@/lib/utils' -import { getShortcutKey } from '../utils' +import * as React from "react" +import { cn } from "@/lib/utils" +import { getShortcutKey } from "@/lib/utils" export interface ShortcutKeyProps extends React.HTMLAttributes { - keys: string[] + keys: string[] } export const ShortcutKey = React.forwardRef(({ className, keys, ...props }, ref) => { - const modifiedKeys = keys.map(key => getShortcutKey(key)) - const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ') + const modifiedKeys = keys.map(key => getShortcutKey(key)) + const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ") - return ( - - {modifiedKeys.map(shortcut => ( - + {modifiedKeys.map(shortcut => ( + - {shortcut.symbol} - - ))} - - ) + className + )} + {...props} + ref={ref} + > + {shortcut.symbol} + + ))} + + ) }) -ShortcutKey.displayName = 'ShortcutKey' +ShortcutKey.displayName = "ShortcutKey" diff --git a/web/components/minimal-tiptap/components/toolbar-section.tsx b/web/components/minimal-tiptap/components/toolbar-section.tsx index e296fd17..9aacc01b 100644 --- a/web/components/minimal-tiptap/components/toolbar-section.tsx +++ b/web/components/minimal-tiptap/components/toolbar-section.tsx @@ -1,112 +1,112 @@ -import * as React from 'react' -import type { Editor } from '@tiptap/react' -import { cn } from '@/lib/utils' -import { CaretDownIcon } from '@radix-ui/react-icons' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { ToolbarButton } from './toolbar-button' -import { ShortcutKey } from './shortcut-key' -import { getShortcutKey } from '../utils' -import type { FormatAction } from '../types' -import type { VariantProps } from 'class-variance-authority' -import type { toggleVariants } from '@/components/ui/toggle' +import * as React from "react" +import type { Editor } from "@tiptap/react" +import { cn } from "@/lib/utils" +import { CaretDownIcon } from "@radix-ui/react-icons" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { ToolbarButton } from "./toolbar-button" +import { ShortcutKey } from "./shortcut-key" +import { getShortcutKey } from "@/lib/utils" +import type { FormatAction } from "../types" +import type { VariantProps } from "class-variance-authority" +import type { toggleVariants } from "@/components/ui/toggle" interface ToolbarSectionProps extends VariantProps { - editor: Editor - actions: FormatAction[] - activeActions?: string[] - mainActionCount?: number - dropdownIcon?: React.ReactNode - dropdownTooltip?: string - dropdownClassName?: string + editor: Editor + actions: FormatAction[] + activeActions?: string[] + mainActionCount?: number + dropdownIcon?: React.ReactNode + dropdownTooltip?: string + dropdownClassName?: string } export const ToolbarSection: React.FC = ({ - editor, - actions, - activeActions = actions.map(action => action.value), - mainActionCount = 0, - dropdownIcon, - dropdownTooltip = 'More options', - dropdownClassName = 'w-12', - size, - variant + editor, + actions, + activeActions = actions.map(action => action.value), + mainActionCount = 0, + dropdownIcon, + dropdownTooltip = "More options", + dropdownClassName = "w-12", + size, + variant }) => { - const { mainActions, dropdownActions } = React.useMemo(() => { - const sortedActions = actions - .filter(action => activeActions.includes(action.value)) - .sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value)) + const { mainActions, dropdownActions } = React.useMemo(() => { + const sortedActions = actions + .filter(action => activeActions.includes(action.value)) + .sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value)) - return { - mainActions: sortedActions.slice(0, mainActionCount), - dropdownActions: sortedActions.slice(mainActionCount) - } - }, [actions, activeActions, mainActionCount]) + return { + mainActions: sortedActions.slice(0, mainActionCount), + dropdownActions: sortedActions.slice(mainActionCount) + } + }, [actions, activeActions, mainActionCount]) - const renderToolbarButton = React.useCallback( - (action: FormatAction) => ( - action.action(editor)} - disabled={!action.canExecute(editor)} - isActive={action.isActive(editor)} - tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(' ')}`} - aria-label={action.label} - size={size} - variant={variant} - > - {action.icon} - - ), - [editor, size, variant] - ) + const renderToolbarButton = React.useCallback( + (action: FormatAction) => ( + action.action(editor)} + disabled={!action.canExecute(editor)} + isActive={action.isActive(editor)} + tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`} + aria-label={action.label} + size={size} + variant={variant} + > + {action.icon} + + ), + [editor, size, variant] + ) - const renderDropdownMenuItem = React.useCallback( - (action: FormatAction) => ( - action.action(editor)} - disabled={!action.canExecute(editor)} - className={cn('flex flex-row items-center justify-between gap-4', { - 'bg-accent': action.isActive(editor) - })} - aria-label={action.label} - > - {action.label} - - - ), - [editor] - ) + const renderDropdownMenuItem = React.useCallback( + (action: FormatAction) => ( + action.action(editor)} + disabled={!action.canExecute(editor)} + className={cn("flex flex-row items-center justify-between gap-4", { + "bg-accent": action.isActive(editor) + })} + aria-label={action.label} + > + {action.label} + + + ), + [editor] + ) - const isDropdownActive = React.useMemo( - () => dropdownActions.some(action => action.isActive(editor)), - [dropdownActions, editor] - ) + const isDropdownActive = React.useMemo( + () => dropdownActions.some(action => action.isActive(editor)), + [dropdownActions, editor] + ) - return ( - <> - {mainActions.map(renderToolbarButton)} - {dropdownActions.length > 0 && ( - - - - {dropdownIcon || } - - - - {dropdownActions.map(renderDropdownMenuItem)} - - - )} - - ) + return ( + <> + {mainActions.map(renderToolbarButton)} + {dropdownActions.length > 0 && ( + + + + {dropdownIcon || } + + + + {dropdownActions.map(renderDropdownMenuItem)} + + + )} + + ) } export default ToolbarSection diff --git a/web/components/minimal-tiptap/utils.ts b/web/components/minimal-tiptap/utils.ts index d8772d84..d749a3cc 100644 --- a/web/components/minimal-tiptap/utils.ts +++ b/web/components/minimal-tiptap/utils.ts @@ -1,81 +1,14 @@ -import type { Editor } from '@tiptap/core' -import type { MinimalTiptapProps } from './minimal-tiptap' +import type { Editor } from "@tiptap/core" +import type { MinimalTiptapProps } from "./minimal-tiptap" -let isMac: boolean | undefined +export function getOutput(editor: Editor, format: MinimalTiptapProps["output"]) { + if (format === "json") { + return editor.getJSON() + } -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 -} - -interface ShortcutKeyResult { - symbol: string - readable: string -} - -export function getShortcutKey(key: string): ShortcutKeyResult { - const lowercaseKey = key.toLowerCase() - if (lowercaseKey === 'mod') { - return isMacOS() ? { symbol: '⌘', readable: 'Command' } : { symbol: 'Ctrl', readable: 'Control' } - } else if (lowercaseKey === 'alt') { - return isMacOS() ? { symbol: '⌥', readable: 'Option' } : { symbol: 'Alt', readable: 'Alt' } - } else if (lowercaseKey === 'shift') { - return isMacOS() ? { symbol: '⇧', readable: 'Shift' } : { symbol: 'Shift', readable: 'Shift' } - } else { - return { symbol: key, readable: key } - } -} - -export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] { - return keys.map(key => getShortcutKey(key)) -} - -export function getOutput(editor: Editor, format: MinimalTiptapProps['output']) { - if (format === 'json') { - return editor.getJSON() - } - - if (format === 'html') { - return editor.getText() ? editor.getHTML() : '' - } - - return editor.getText() + if (format === "html") { + return editor.getText() ? editor.getHTML() : "" + } + + return editor.getText() } diff --git a/web/components/routes/link/bottom-bar.tsx b/web/components/routes/link/bottom-bar.tsx index a9e8eae3..df2165ab 100644 --- a/web/components/routes/link/bottom-bar.tsx +++ b/web/components/routes/link/bottom-bar.tsx @@ -1,9 +1,11 @@ +"use client" + import React, { useCallback, useEffect, useRef } from "react" import { motion, AnimatePresence } from "framer-motion" -import { icons, ZapIcon } from "lucide-react" +import type { icons } from "lucide-react" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { getSpecialShortcut, formatShortcut, isMacOS, cn } 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" @@ -13,7 +15,7 @@ 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 { showHotkeyPanelAtom } from "@/store/sidebar" +import { useKeydownListener } from "@/hooks/use-keydown-listener" interface ToolbarButtonProps extends React.ComponentPropsWithoutRef { icon: keyof typeof icons @@ -73,8 +75,6 @@ export const LinkBottomBar: React.FC = () => { }, 100) }, [setEditId, setCreateMode]) - const [, setShowHotkeyPanel] = useAtom(showHotkeyPanelAtom) - useEffect(() => { setGlobalLinkFormExceptionRefsAtom([ overlayRef, @@ -119,24 +119,21 @@ export const LinkBottomBar: React.FC = () => { } } - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isCreateShortcut = isMacOS() - ? event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n" - : event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey) + const handleKeydown = useCallback( + (event: KeyboardEvent) => { + const isCreateShortcut = event.key === "c" if (isCreateShortcut) { event.preventDefault() handleCreateMode() } - } + }, + [handleCreateMode] + ) - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleCreateMode]) + useKeydownListener(handleKeydown) - const shortcutKeys = getSpecialShortcut("expandToolbar") - const shortcutText = formatShortcut(shortcutKeys) + const shortcutText = getShortcutKeys(["c"]) return ( { s.symbol).join("")})`} ref={plusBtnRef} /> )} - {/* */} )} -
- { - setShowHotkeyPanel(true) - }} - /> -
) } diff --git a/web/components/routes/topics/detail/TopicDetailRoute.tsx b/web/components/routes/topics/detail/TopicDetailRoute.tsx index e805fcc8..310ae004 100644 --- a/web/components/routes/topics/detail/TopicDetailRoute.tsx +++ b/web/components/routes/topics/detail/TopicDetailRoute.tsx @@ -22,6 +22,7 @@ export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) { const raw_graph_data = React.use(graph_data_promise) as GraphNode[] const { me } = useAccountOrGuest({ root: { personalLinks: [] } }) + const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me]) const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } }) const [activeIndex, setActiveIndex] = useState(-1) diff --git a/web/components/ui/sheet.tsx b/web/components/ui/sheet.tsx new file mode 100644 index 00000000..3b5c5dc7 --- /dev/null +++ b/web/components/ui/sheet.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +export const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm" + } + }, + defaultVariants: { + side: "right" + } + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef, SheetContentProps>( + ({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + + ) +) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription +} diff --git a/web/components/ui/sliding-menu.tsx b/web/components/ui/sliding-menu.tsx deleted file mode 100644 index 1381d753..00000000 --- a/web/components/ui/sliding-menu.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { XIcon } from "lucide-react" -import { useState, useEffect, useRef } from "react" -import { motion, AnimatePresence } from "framer-motion" -import { showHotkeyPanelAtom } from "@/store/sidebar" -import { useAtom } from "jotai/react" - -export default function SlidingMenu() { - const [isOpen, setIsOpen] = useAtom(showHotkeyPanelAtom) - const panelRef = useRef(null) - const [shortcuts] = useState<{ name: string; shortcut: string[] }[]>([ - // TODO: change to better keybind - // TODO: windows users don't understand these symbols, figure out better way to show keybinds - { name: "New Todo", shortcut: ["⌘", "⌃", "n"] }, - { name: "CMD Palette", shortcut: ["⌘", "k"] } - // TODO: add - // { name: "Global Search", shortcut: ["."] }, - // { name: "(/pages)", shortcut: [".", "."] } - ]) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (panelRef.current && !panelRef.current.contains(event.target as Node)) { - setIsOpen(false) - } - } - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside) - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - }, [isOpen, setIsOpen]) - - return ( - - {isOpen && ( - <> - setIsOpen(false)} - /> - -
-
-
Shortcuts
- -
-
- {shortcuts.map((shortcut, index) => ( -
-
{shortcut.name}
-
- {shortcut.shortcut.join(" ")} -
-
- ))} -
-
-
- - )} -
- ) -} diff --git a/web/hooks/use-keyboard-manager.ts b/web/hooks/use-keyboard-manager.ts new file mode 100644 index 00000000..bd8c34de --- /dev/null +++ b/web/hooks/use-keyboard-manager.ts @@ -0,0 +1,38 @@ +import { useAtom } from "jotai" +import { useEffect, useCallback } from "react" +import { keyboardDisableSourcesAtom } from "@/store/keydown-manager" + +export function useKeyboardManager(sourceId: string) { + const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (disableSources.size > 0) { + event.preventDefault() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [disableSources]) + + const disableKeydown = useCallback( + (disable: boolean) => { + console.log(`${sourceId} disable:`, disable) + setDisableSources(prev => { + const next = new Set(prev) + if (disable) { + next.add(sourceId) + } else { + next.delete(sourceId) + } + return next + }) + }, + [setDisableSources, sourceId] + ) + + const isKeyboardDisabled = disableSources.size > 0 + + return { disableKeydown, isKeyboardDisabled } +} diff --git a/web/hooks/use-keydown-listener.ts b/web/hooks/use-keydown-listener.ts new file mode 100644 index 00000000..888f140e --- /dev/null +++ b/web/hooks/use-keydown-listener.ts @@ -0,0 +1,21 @@ +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/lib/utils/keyboard.ts b/web/lib/utils/keyboard.ts index faaaf7ce..50255db7 100644 --- a/web/lib/utils/keyboard.ts +++ b/web/lib/utils/keyboard.ts @@ -55,11 +55,7 @@ export function getShortcutKey(key: string): ShortcutKeyResult { } else if (lowercaseKey === "alt") { return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" } } else if (lowercaseKey === "shift") { - return { symbol: "⇧", readable: "Shift" } - } else if (lowercaseKey === "control") { - return { symbol: "⌃", readable: "Control" } - } else if (lowercaseKey === "windows" && !isMacOS()) { - return { symbol: "Win", readable: "Windows" } + return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" } } else { return { symbol: key.toUpperCase(), readable: key } } @@ -68,21 +64,3 @@ export function getShortcutKey(key: string): ShortcutKeyResult { export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] { return keys.map(key => getShortcutKey(key)) } - -export function getSpecialShortcut(shortcutName: string): ShortcutKeyResult[] { - if (shortcutName === "expandToolbar") { - return isMacOS() - ? [getShortcutKey("control"), getShortcutKey("mod"), getShortcutKey("n")] - : [getShortcutKey("mod"), getShortcutKey("windows"), getShortcutKey("n")] - } - - return [] -} - -export function formatShortcut(shortcutKeys: ShortcutKeyResult[]): string { - return shortcutKeys.map(key => key.symbol).join("") -} - -export function formatReadableShortcut(shortcutKeys: ShortcutKeyResult[]): string { - return shortcutKeys.map(key => key.readable).join(" + ") -} diff --git a/web/store/keydown-manager.ts b/web/store/keydown-manager.ts new file mode 100644 index 00000000..d6617dc1 --- /dev/null +++ b/web/store/keydown-manager.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai" + +export const keyboardDisableSourcesAtom = atom>(new Set()) diff --git a/web/store/sidebar.ts b/web/store/sidebar.ts index 79854872..a3638924 100644 --- a/web/store/sidebar.ts +++ b/web/store/sidebar.ts @@ -5,4 +5,3 @@ export const toggleCollapseAtom = atom( get => get(isCollapseAtom), (get, set) => set(isCollapseAtom, !get(isCollapseAtom)) ) -export const showHotkeyPanelAtom = atom(false)