diff --git a/package.json b/package.json index b5c0066..f6561fa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@ant-design/cssinjs": "^1.11.1", "@ant-design/icons": "^5.2.5", "@gravatar/js": "^1.1.1", + "@monaco-editor/loader": "^1.3.3", "@monaco-editor/react": "^4.4.2", "@types/node": "20.4.1", "@types/react": "18.2.14", diff --git a/src/components/CodeViewer.tsx b/src/components/CodeViewer.tsx index 14c95af..78628a5 100644 --- a/src/components/CodeViewer.tsx +++ b/src/components/CodeViewer.tsx @@ -1,7 +1,23 @@ 'use client'; import Editor from '@monaco-editor/react'; +import loader from '@monaco-editor/loader'; import { File, useFileContent } from '@/hooks/useFile'; +import useHighlightHash, { parseHash } from '@/hooks/useHighlightHash'; import { useThemeMode } from 'antd-style'; +import { useEffect, useRef } from 'react'; + +loader.config({ + paths: { + vs: 'https://registry.npmmirror.com/monaco-editor/0.41.0/files/min/vs', + }, +}); + +function highlightEditor(editor: any) { + const [start, end] = parseHash(window.location.hash?.replace('#', '')); + if (start !== null && window) { + editor.setSelection(new (window as any).monaco.Range(start, 1, end, 1)); + } +} export const CodeViewer = ({ selectedFile, @@ -12,18 +28,36 @@ export const CodeViewer = ({ pkgName: string; spec?: string; }) => { + const editorRef = useRef(null); const { themeMode: theme } = useThemeMode(); - const { data: code, isLoading } = useFileContent( + const { data: code } = useFileContent( { fullname: pkgName, spec }, selectedFile?.path || '' ); - let language = selectedFile?.path.split('.').pop(); - if (language === 'js' || language === 'jsx') language = 'javascript'; + let language = selectedFile?.path.split('.').pop(); + if ( + language === 'js' || + language === 'jsx' || + language === 'map' + ) + language = 'javascript'; else if (language === 'ts' || language === 'tsx') language = 'typescript'; + else if (language === 'md') language = 'markdown'; + + const [_, setRange] = useHighlightHash(); + + const handleEditorMouseDown = () => { + const { startLineNumber, endLineNumber } = editorRef.current.getSelection(); + + if (startLineNumber) { + setRange(startLineNumber, endLineNumber); + } + }; + + if (!selectedFile) return <>; - if (!selectedFile) return null; return (
{ + editorRef.current = editor; + editor.onMouseUp(handleEditorMouseDown); + highlightEditor(editor); + }} />
); diff --git a/src/components/FileTree/index.tsx b/src/components/FileTree/index.tsx index f6ba08a..dad8610 100644 --- a/src/components/FileTree/index.tsx +++ b/src/components/FileTree/index.tsx @@ -71,7 +71,6 @@ const FileDiv = ({file, icon, selectedFile, onClick}: { display: 'flex', alignItems: 'center', paddingLeft: depth * 16, - // backgroundColor: isSelected ? "#eee" : "transparent", cursor: 'pointer', minWidth: 'max-content', }} diff --git a/src/hooks/useHighlightHash.ts b/src/hooks/useHighlightHash.ts new file mode 100644 index 0000000..4d0663d --- /dev/null +++ b/src/hooks/useHighlightHash.ts @@ -0,0 +1,38 @@ +import { useRouter } from 'next/router'; +import { useState, useEffect } from 'react'; + +export function parseHash(hash: string) { + const singleLineMatch = hash?.match(/^L(\d+)$/); + const multiLineMatch = hash?.match(/^L(\d+)-L(\d+)$/); + if (singleLineMatch) { + const line = parseInt(singleLineMatch[1], 10); + return [line, line]; + } else if (multiLineMatch) { + const startLine = parseInt(multiLineMatch[1], 10); + const endLine = parseInt(multiLineMatch[2], 10); + return [startLine, endLine]; + } + return [null, null]; +} + +export default function useHighlightHash() { + const router = useRouter(); + const [highlightRange, setHighlightRange] = useState([null, null]); + + useEffect(() => { + const res = parseHash(router.asPath.split('#')[1]); + setHighlightRange(res); + }, [router.asPath]); + + const setHashFromSelection = (start: number, end: number) => { + const { pathname, search } = window.location; + + if (start === end) { + router.replace(`${pathname}${search}#L${start}`, undefined, { shallow: true }); + } else { + router.replace(`${pathname}${search}#L${start}-L${end}`, undefined, { shallow: true }); + } + }; + + return [highlightRange, setHashFromSelection]; +} diff --git a/src/hooks/usePathState.ts b/src/hooks/usePathState.ts new file mode 100644 index 0000000..1a08b32 --- /dev/null +++ b/src/hooks/usePathState.ts @@ -0,0 +1,31 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useRouter } from 'next/router'; + +export function usePathState(pattern: string): [string, (newValue: string) => void] { + const router = useRouter(); + const regexPattern = useMemo(() => { + return new RegExp(pattern.replace('*', '(.*)')); + }, [pattern]); + const match = router.asPath.match(regexPattern); + + const [state, setState] = useState(match ? match[1] : ''); + + useEffect(() => { + const handleRouteChange = (url: string) => { + const match = url.match(regexPattern); + setState(match ? match[1] : ''); + }; + + router.events.on('routeChangeComplete', handleRouteChange); + return () => { + router.events.off('routeChangeComplete', handleRouteChange); + }; + }, [router, regexPattern]); + + const setPathState = (newValue: string) => { + const newPath = pattern.replace('*', newValue.replace(/^\//, '')); + router.replace(newPath, newPath, { shallow: true }); + }; + + return [state?.split('#')[0], setPathState]; +} diff --git a/src/pages/package/[...slug]/index.tsx b/src/pages/package/[...slug]/index.tsx index 2c88305..92a1d3c 100644 --- a/src/pages/package/[...slug]/index.tsx +++ b/src/pages/package/[...slug]/index.tsx @@ -1,4 +1,4 @@ -import { ThemeMode, ThemeProvider as _ThemeProvider } from 'antd-style'; +import { ThemeProvider as _ThemeProvider } from 'antd-style'; import PageHome from '@/slugs/home' import PageFiles from '@/slugs/files' import PageVersions from '@/slugs/versions' @@ -93,9 +93,8 @@ export default function PackagePage({ ); } - let type = router.asPath - .split('?')[0] - .replace(`/package/${resData.name}/`, '') as keyof typeof PageMap; + let type = router.query?.slug?.[1] as keyof typeof PageMap; + const version = (router.query.version as string) || resData?.['dist-tags']?.latest; diff --git a/src/slugs/files/index.tsx b/src/slugs/files/index.tsx index 3619d7b..a78cc8d 100644 --- a/src/slugs/files/index.tsx +++ b/src/slugs/files/index.tsx @@ -3,22 +3,27 @@ import { CodeViewer } from "@/components/CodeViewer"; import { FileTree } from "@/components/FileTree"; import { Sidebar } from "@/components/Sidebar"; import { useDirs, File } from "@/hooks/useFile"; +import { usePathState } from "@/hooks/usePathState"; import { PageProps } from "@/pages/package/[...slug]"; import { Spin } from "antd"; import { useState } from "react"; const Viewer = ({ manifest, version }: PageProps) => { const [_selectedFile, setSelectedFile] = useState(); + const [path, setPath] = usePathState(`/package/${manifest.name}/files/*`); + const { data: rootDir, isLoading } = useDirs({ fullname: manifest.name, spec: version || 'latest', }); - const selectedFile = - _selectedFile || - rootDir?.files?.find((item: File) => item?.path === '/package.json'); - const onSelect = (file: File) => setSelectedFile(file); + let selectedFile = _selectedFile || { path: `/${path || 'package.json'}`, type: 'file' }; + + const onSelect = (file: File) => { + setSelectedFile(file) + setPath(file.path); + }; if (isLoading) { return (