From 39f82a873979d876be657081769272fe9771ab6c Mon Sep 17 00:00:00 2001 From: EnixCoda Date: Sun, 18 Dec 2022 23:20:07 +0800 Subject: [PATCH] feat: node context menu fixes #149 --- src/components/FileExplorer/Node.tsx | 17 +- .../FileExplorer/hooks/useNodeRenderers.tsx | 170 ++++++++++++++++-- .../FileExplorer/hooks/useOnNodeClick.tsx | 9 + src/components/FileExplorer/index.tsx | 55 +++--- src/components/IconButton.tsx | 17 +- src/components/RoundIconButton.tsx | 10 +- .../settings/KeyboardShortcutSetting.tsx | 4 +- src/containers/PortalContext.tsx | 5 + src/global.d.ts | 2 + src/platforms/GitHub/URLHelper.ts | 16 ++ .../GitHub/getPullRequestTreeData.ts | 13 +- src/platforms/GitHub/index.ts | 47 ++--- src/styles/index.scss | 3 + src/utils/DOMHelper.ts | 9 +- src/utils/general.ts | 2 +- 15 files changed, 301 insertions(+), 78 deletions(-) create mode 100644 src/containers/PortalContext.tsx diff --git a/src/components/FileExplorer/Node.tsx b/src/components/FileExplorer/Node.tsx index be7a443..a202622 100644 --- a/src/components/FileExplorer/Node.tsx +++ b/src/components/FileExplorer/Node.tsx @@ -2,6 +2,7 @@ import { useConfigs } from 'containers/ConfigsContext' import { platform } from 'platforms' import * as React from 'react' import { cx } from 'utils/cx' +import { cancelEvent } from 'utils/DOMHelper' import { getFileIconURL, getFolderIconURL } from 'utils/parseIconMapCSV' import { Icon } from '../Icon' @@ -42,6 +43,7 @@ export const Node = React.memo(function Node({ onFocus, }: Props) { const { compactFileTree: compact } = useConfigs().value + const ref = React.useRef(null) return (
{renderLabelText(node)}
- {renderActions &&
{renderActions(node)}
} + {renderActions && ( +
{ + // exclude elements mounted outside but still bubbles event through react to here + if (e.target instanceof Element && ref.current?.contains(e.target)) cancelEvent(e) + }} + > + {renderActions(node)} +
+ )}
) }) diff --git a/src/components/FileExplorer/hooks/useNodeRenderers.tsx b/src/components/FileExplorer/hooks/useNodeRenderers.tsx index 118fc57..d138343 100644 --- a/src/components/FileExplorer/hooks/useNodeRenderers.tsx +++ b/src/components/FileExplorer/hooks/useNodeRenderers.tsx @@ -1,6 +1,17 @@ -import { CommentIcon } from '@primer/octicons-react' +import { + CheckIcon, + CommentIcon, + CrossReferenceIcon, + KebabHorizontalIcon, +} from '@primer/octicons-react' +import { ActionList, AnchoredOverlay } from '@primer/react' import { useConfigs } from 'containers/ConfigsContext' +import { PortalContext } from 'containers/PortalContext' +import { platform } from 'platforms' import * as React from 'react' +import { useCopyToClipboard } from 'react-use' +import { cx } from 'utils/cx' +import { cancelEvent, onEnterKeyDown } from 'utils/DOMHelper' import { is } from 'utils/is' import { Icon } from '../../Icon' import { SearchMode } from '../../searchModes' @@ -38,6 +49,146 @@ export function useRenderFileStatus() { ) } +function renderNodeContextMenu(node: TreeNode) { + return +} +export function useRenderMoreActions() { + return renderNodeContextMenu +} + +function NodeContextMenu({ node }: { node: TreeNode }) { + const [isOpen, setIsOpen] = React.useState(false) + const [copied, setCopied] = React.useState(null) + const [copyState, copyToClipboard] = useCopyToClipboard() + const portalName = React.useContext(PortalContext) + const actionElements = { + copyPermalink: + node.permalink && + (() => { + const mark = 'permalink' + const onTrigger = (e: React.SyntheticEvent) => { + cancelEvent(e) + if (node.permalink) { + copyToClipboard(node.permalink) + setCopied(mark) + } + } + return ( + + Copy permalink + {copyState.value && copied === mark ? ( + + + + ) : null} + + ) + })(), + copyLink: + node.url && + (() => { + const mark = 'link' + const onTrigger = (e: React.SyntheticEvent) => { + cancelEvent(e) + if (node.url) { + copyToClipboard(node.url) + setCopied(mark) + } + } + return ( + + Copy link + {copyState.value && copied === mark ? ( + + + + ) : null} + + ) + })(), + copyRelativePath: (() => { + const mark = 'path' + const onTrigger = (e: React.SyntheticEvent) => { + cancelEvent(e) + setCopied(mark) + copyToClipboard(node.path) + } + return ( + + Copy relative path + {copyState.value && copied === mark ? ( + + + + ) : null} + + ) + })(), + openRawContent: node.rawLink && ( + + onEnterKeyDown(e, () => e.target instanceof HTMLElement && e.target.click()) + } + href={node.rawLink} + target="_blank" + rel="noopener noreferrer" + onClick={() => setIsOpen(false)} + > + Open raw content + + + + + ), + goToDirectory: node.type === 'tree' && node.url && ( + + onEnterKeyDown(e, () => e.target instanceof HTMLElement && e.target.click()) + } + href={node.url} + data-gitako-bypass-click + rel="noopener noreferrer" + {...platform.delegateFastRedirectAnchorProps?.({ node })} + onClick={() => setIsOpen(false)} + > + Go to directory + + ), + } + + return ( + ( + + )} + open={isOpen} + onOpen={() => setIsOpen(true)} + onClose={() => setIsOpen(false)} + overlayProps={{ + portalContainerName: portalName || undefined, + onKeyDown: e => cancelEvent(e), + }} + > + + {actionElements.copyPermalink} + {actionElements.copyLink} + {actionElements.copyRelativePath} + + {(actionElements.openRawContent || actionElements.goToDirectory) && } + + {actionElements.openRawContent} + {actionElements.goToDirectory} + + + ) +} + export function useRenderFileCommentAmounts() { function renderFileCommentAmounts(node: TreeNode) { return node.comments?.active ? ( @@ -69,11 +220,7 @@ export function useRenderFindInFolderButton( @@ -93,11 +240,7 @@ export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) => @@ -107,3 +250,8 @@ export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) => [searched, goTo], ) } + +const getTriggerProps = (onTrigger: (e: React.SyntheticEvent) => void) => ({ + onClick: onTrigger, + onKeyDown: (e: React.KeyboardEvent) => onEnterKeyDown(e, onTrigger), +}) diff --git a/src/components/FileExplorer/hooks/useOnNodeClick.tsx b/src/components/FileExplorer/hooks/useOnNodeClick.tsx index aa00538..7647227 100644 --- a/src/components/FileExplorer/hooks/useOnNodeClick.tsx +++ b/src/components/FileExplorer/hooks/useOnNodeClick.tsx @@ -23,6 +23,15 @@ export function useHandleNodeClick( // giving recursive toggle action higher priority than default action if (!recursive && isOpenInNewWindowClick(event)) return + // check if clicked inside an element which has `data-gitako-bypass-click` set + if (event.target instanceof HTMLElement) { + let e = event.target + while (e.parentElement) { + if (e.dataset.gitakoBypassClick) return + e = e.parentElement + } + } + event.preventDefault() toggleExpansion(node, { recursive }) break diff --git a/src/components/FileExplorer/index.tsx b/src/components/FileExplorer/index.tsx index 4e59e9a..548bb38 100644 --- a/src/components/FileExplorer/index.tsx +++ b/src/components/FileExplorer/index.tsx @@ -1,8 +1,9 @@ -import { Label, Text } from '@primer/react' +import { Label, registerPortalRoot, Text } from '@primer/react' import { useFocusOnPendingTarget } from 'components/FocusTarget' import { LoadingIndicator } from 'components/LoadingIndicator' import { SearchBar } from 'components/SearchBar' import { useConfigs } from 'containers/ConfigsContext' +import { PortalContext } from 'containers/PortalContext' import { RepoContext } from 'containers/RepoContext' import { platform } from 'platforms' import * as React from 'react' @@ -24,6 +25,7 @@ import { useRenderFileStatus, useRenderFindInFolderButton, useRenderGoToButton, + useRenderMoreActions, } from './hooks/useNodeRenderers' import { useHandleNodeClick } from './hooks/useOnNodeClick' import { useOnSearch } from './hooks/useOnSearch' @@ -107,6 +109,12 @@ function LoadedFileExplorer({ overScan: 10, }) + const portalName = React.useMemo(() => `${Math.random()}`, []) + React.useEffect(() => { + const current = scrollElementRef.current + if (current) registerPortalRoot(current, portalName) + }, [scrollElementRef, portalName]) + // - init loading // - "top" // - jump to file @@ -149,6 +157,7 @@ function LoadedFileExplorer({ useRenderGoToButton(searched, goTo), useRenderFindInFolderButton(onSearch), useRenderFileCommentAmounts(), + useRenderMoreActions(), useRenderFileStatus(), ]) const renderLabelText = useRenderLabelText(searchKey) @@ -211,26 +220,30 @@ function LoadedFileExplorer({ onScroll={onScroll} tabIndex={-1} // prevent getting focus via tab key on GitHub > -
- {visibleRows.map(({ row, style }) => { - const node = nodes[row] - return ( - - ) - })} -
+ +
+ {visibleRows + .map(({ row, style }) => ({ + node: nodes[row], + style, + })) + .map(({ node, style }) => ( + + ))} +
+
diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index 86838d5..2472713 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -1,11 +1,11 @@ import { IconProps } from '@primer/octicons-react' import { Box, merge, SxProp, useTheme } from '@primer/react' -import { getBaseStyles, getSizeStyles, getVariantStyles } from '@primer/react/lib/Button/styles' +import { getBaseStyles, getSizeStyles, getVariantStyles } from '@primer/react/lib-esm/Button/styles' import { IconButtonProps as PrimerIconButtonProps, StyledButton, -} from '@primer/react/lib/Button/types' -import React from 'react' +} from '@primer/react/lib-esm/Button/types' +import React, { forwardRef } from 'react' import { is } from 'utils/is' export type IconButtonProps = PrimerIconButtonProps & { @@ -13,10 +13,13 @@ export type IconButtonProps = PrimerIconButtonProps & { iconColor?: string } -// Modified version of @primer/react/lib/Button/Button.tsx +// Modified version of @primer/react/lib-esm/Button/Button.tsx // Added better support of colors & size -export function IconButton(props: IconButtonProps) { +export const IconButton = forwardRef(function IconButton( + props: IconButtonProps, + ref: React.ForwardedRef, +) { const { variant = 'default', size = 'medium', @@ -40,10 +43,10 @@ export function IconButton(props: IconButtonProps) { ].filter(is.not.undefined), ) return ( - + ) -} +}) diff --git a/src/components/RoundIconButton.tsx b/src/components/RoundIconButton.tsx index ddc2178..49ea825 100644 --- a/src/components/RoundIconButton.tsx +++ b/src/components/RoundIconButton.tsx @@ -1,9 +1,13 @@ -import React from 'react' +import React, { ForwardedRef, forwardRef } from 'react' import { IconButton, IconButtonProps } from './IconButton' -export function RoundIconButton(props: IconButtonProps) { +export const RoundIconButton = forwardRef(function RoundIconButton( + props: IconButtonProps, + ref: ForwardedRef, +) { return ( ) -} +}) diff --git a/src/components/settings/KeyboardShortcutSetting.tsx b/src/components/settings/KeyboardShortcutSetting.tsx index 44f5067..28dfffc 100644 --- a/src/components/settings/KeyboardShortcutSetting.tsx +++ b/src/components/settings/KeyboardShortcutSetting.tsx @@ -1,6 +1,7 @@ import { Box, Button, FormControl, TextInput } from '@primer/react' import * as React from 'react' import { useUpdateEffect } from 'react-use' +import { cancelEvent } from 'utils/DOMHelper' import { friendlyFormatShortcut, noop } from 'utils/general' import { useStateIO } from 'utils/hooks/useStateIO' import * as keyHelper from 'utils/keyHelper' @@ -39,8 +40,7 @@ export function KeyboardShortcutSetting({ label, value, onChange }: Props) { $shortcut.onChange(undefined) return default: - e.preventDefault() - e.stopPropagation() + cancelEvent(e) } $shortcut.onChange(keyHelper.parseEvent(e)) }} diff --git a/src/containers/PortalContext.tsx b/src/containers/PortalContext.tsx new file mode 100644 index 0000000..f259aa8 --- /dev/null +++ b/src/containers/PortalContext.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export type PortalContextShape = string | null + +export const PortalContext = React.createContext(null) diff --git a/src/global.d.ts b/src/global.d.ts index 1182762..4c0e7ea 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -19,6 +19,8 @@ type TreeNode = { path: string type: 'tree' | 'blob' | 'commit' url?: string + permalink?: string + rawLink?: string sha?: string accessDenied?: boolean comments?: { diff --git a/src/platforms/GitHub/URLHelper.ts b/src/platforms/GitHub/URLHelper.ts index a4c3644..ac63f15 100644 --- a/src/platforms/GitHub/URLHelper.ts +++ b/src/platforms/GitHub/URLHelper.ts @@ -92,3 +92,19 @@ export function getCurrentPath(branchName = '') { } return [] } + +export function getItemUrl( + userName: string, + repoName: string, + branchName: string, + type = 'blob', + path = '', +) { + // Modern browsers have great support for handling unsafe URL, + // It may be possible to sanitize path with + // `path => path.includes('#') ? path.replace(/#/g, '%23') : '...' + return `${window.location.origin}/${userName}/${repoName}/${type}/${branchName}/${path + .split('/') + .map(encodeURIComponent) + .join('/')}` +} diff --git a/src/platforms/GitHub/getPullRequestTreeData.ts b/src/platforms/GitHub/getPullRequestTreeData.ts index d7c5852..470882e 100644 --- a/src/platforms/GitHub/getPullRequestTreeData.ts +++ b/src/platforms/GitHub/getPullRequestTreeData.ts @@ -55,13 +55,24 @@ export async function getPullRequestTreeData( url.pathname = `/${userName}/${repoName}/pull/${pullId}/files` const commentsMap = getCommentsMap(commentData) const nodes: TreeNode[] = treeData.map( - ({ filename, sha, additions, deletions, changes, status }) => { + ({ + filename, + sha, + additions, + deletions, + changes, + status, + raw_url: rawLink, + blob_url: permalink, + }) => { url.hash = map.get(filename) || '' return { path: filename || '', type: 'blob', name: filename?.split('/').pop() || '', url: `${url}`, + permalink, + rawLink, sha, comments: commentsMap.get(filename), diff: { diff --git a/src/platforms/GitHub/index.ts b/src/platforms/GitHub/index.ts index 298e798..13bfe5f 100644 --- a/src/platforms/GitHub/index.ts +++ b/src/platforms/GitHub/index.ts @@ -66,22 +66,6 @@ export function processTree(tree: TreeNode[]): TreeNode { return root } -function getUrlForRedirect( - userName: string, - repoName: string, - branchName: string, - type = 'blob', - path = '', -) { - // Modern browsers have great support for handling unsafe URL, - // It may be possible to sanitize path with - // `path => path.includes('#') ? path.replace(/#/g, '%23') : '...' - return `${window.location.origin}/${userName}/${repoName}/${type}/${branchName}/${path - .split('/') - .map(encodeURIComponent) - .join('/')}` -} - export function isEnterprise() { return ( (window.location.host !== 'github.com' && @@ -212,19 +196,19 @@ export const GitHub: Platform = { useGitHubCodeFold(codeFolding) useEnterpriseStatBarStyleFix() }, - delegateFastRedirectAnchorProps: options => { - if (configRef.pjaxMode === 'native' && (!options?.node || options.node.type === 'blob')) { - const pjaxContainerSelector = 'main' - const turboContainerId = 'repo-content-turbo-frame' + delegateFastRedirectAnchorProps() { + if (configRef.pjaxMode !== 'native') return - return { - 'data-pjax': pjaxContainerSelector, - 'data-turbo-frame': - URLHelper.isInPullPage() || URLHelper.isInCommitPage() ? undefined : turboContainerId, - onClick() { - /* Overwriting default onClick */ - }, - } + const pjaxContainerSelector = 'main' + const turboContainerId = 'repo-content-turbo-frame' + + return { + 'data-pjax': pjaxContainerSelector, + 'data-turbo-frame': + URLHelper.isInPullPage() || URLHelper.isInCommitPage() ? undefined : turboContainerId, + onClick() { + /* Overwriting default onClick */ + }, } }, loadWithFastRedirect: (url, element) => { @@ -274,7 +258,12 @@ async function getRepositoryTreeData( name: item.path?.split('/').pop() || '', url: item.url && item.type && item.path - ? getUrlForRedirect(userName, repoName, branchName, item.type, item.path) + ? URLHelper.getItemUrl(userName, repoName, branchName, item.type, item.path) + : undefined, + permalink: URLHelper.getItemUrl(userName, repoName, treeData.sha, item.type, item.path), + rawLink: + item.url && item.type === 'blob' && item.path + ? URLHelper.getItemUrl(userName, repoName, branchName, 'raw', item.path) : undefined, contents: item.type === 'tree' ? [] : undefined, sha: item.sha, diff --git a/src/styles/index.scss b/src/styles/index.scss index 194c608..842bb60 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -735,6 +735,7 @@ html[data-with-gitako-spacing='true'] { } } + .context-menu, .go-to-button, .find-in-folder-button { @include icon-button(); @@ -749,6 +750,7 @@ html[data-with-gitako-spacing='true'] { } &.compact { + .context-menu, .go-to-button, .find-in-folder-button { border-radius: 4px; @@ -758,6 +760,7 @@ html[data-with-gitako-spacing='true'] { } &:not(:hover) { + .context-menu:not(.active), .go-to-button, .find-in-folder-button { display: none; diff --git a/src/utils/DOMHelper.ts b/src/utils/DOMHelper.ts index 1f42159..9c160d4 100644 --- a/src/utils/DOMHelper.ts +++ b/src/utils/DOMHelper.ts @@ -157,7 +157,14 @@ export function parseIntFromElement(e: HTMLElement): number { return parseInt((e.innerText || '').replace(/[^0-9]/g, '')) } -export function cancelEvent(e: KeyboardEvent): void { +export function cancelEvent(e: Event | React.BaseSyntheticEvent): void { e.stopPropagation() e.preventDefault() } + +export function onEnterKeyDown( + e: React.KeyboardEvent, + callback: (e: React.KeyboardEvent) => void, +) { + if (e.key === 'Enter') callback(e) +} diff --git a/src/utils/general.ts b/src/utils/general.ts index bfd8fe8..3587c47 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -163,7 +163,7 @@ export function hasUpperCase(input: string) { } export async function renderReact(element: ReactElement) { - return new Promise(resolve => { + return new Promise(resolve => { const mount = document.createElement('div') ReactDOM.render(element, mount, () => { resolve(mount.childNodes[0])