feat: node context menu

fixes #149
This commit is contained in:
EnixCoda 2022-12-18 23:20:07 +08:00
parent 0492784b70
commit 39f82a8739
15 changed files with 301 additions and 78 deletions

View file

@ -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<HTMLDivElement>(null)
return (
<a
href={node.url}
@ -52,13 +54,24 @@ export const Node = React.memo(function Node({
title={node.path}
target={node.type === 'commit' ? '_blank' : undefined}
rel="noopener noreferrer"
{...platform.delegateFastRedirectAnchorProps?.({ node })}
{...(node.type === 'blob' ? platform.delegateFastRedirectAnchorProps?.({ node }) : null)}
>
<div className={'node-item-label'}>
<NodeItemIcon node={node} open={expanded} loading={loading} />
{renderLabelText(node)}
</div>
{renderActions && <div className={'actions'}>{renderActions(node)}</div>}
{renderActions && (
<div
ref={ref}
className={'actions'}
onClick={e => {
// 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)}
</div>
)}
</a>
)
})

View file

@ -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 <NodeContextMenu node={node} />
}
export function useRenderMoreActions() {
return renderNodeContextMenu
}
function NodeContextMenu({ node }: { node: TreeNode }) {
const [isOpen, setIsOpen] = React.useState(false)
const [copied, setCopied] = React.useState<string | null>(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 (
<ActionList.Item {...getTriggerProps(onTrigger)}>
Copy permalink
{copyState.value && copied === mark ? (
<ActionList.TrailingVisual>
<CheckIcon />
</ActionList.TrailingVisual>
) : null}
</ActionList.Item>
)
})(),
copyLink:
node.url &&
(() => {
const mark = 'link'
const onTrigger = (e: React.SyntheticEvent) => {
cancelEvent(e)
if (node.url) {
copyToClipboard(node.url)
setCopied(mark)
}
}
return (
<ActionList.Item {...getTriggerProps(onTrigger)}>
Copy link
{copyState.value && copied === mark ? (
<ActionList.TrailingVisual>
<CheckIcon />
</ActionList.TrailingVisual>
) : null}
</ActionList.Item>
)
})(),
copyRelativePath: (() => {
const mark = 'path'
const onTrigger = (e: React.SyntheticEvent) => {
cancelEvent(e)
setCopied(mark)
copyToClipboard(node.path)
}
return (
<ActionList.Item {...getTriggerProps(onTrigger)}>
Copy relative path
{copyState.value && copied === mark ? (
<ActionList.TrailingVisual>
<CheckIcon />
</ActionList.TrailingVisual>
) : null}
</ActionList.Item>
)
})(),
openRawContent: node.rawLink && (
<ActionList.LinkItem
onKeyDown={e =>
onEnterKeyDown(e, () => e.target instanceof HTMLElement && e.target.click())
}
href={node.rawLink}
target="_blank"
rel="noopener noreferrer"
onClick={() => setIsOpen(false)}
>
Open raw content
<ActionList.TrailingVisual>
<CrossReferenceIcon />
</ActionList.TrailingVisual>
</ActionList.LinkItem>
),
goToDirectory: node.type === 'tree' && node.url && (
<ActionList.LinkItem
onKeyDown={e =>
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
</ActionList.LinkItem>
),
}
return (
<AnchoredOverlay
renderAnchor={anchorProps => (
<button
{...anchorProps}
aria-label={`More actions`}
className={cx('context-menu', anchorProps.className, { active: isOpen })}
>
<Icon IconComponent={KebabHorizontalIcon} />
</button>
)}
open={isOpen}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
overlayProps={{
portalContainerName: portalName || undefined,
onKeyDown: e => cancelEvent(e),
}}
>
<ActionList>
{actionElements.copyPermalink}
{actionElements.copyLink}
{actionElements.copyRelativePath}
{(actionElements.openRawContent || actionElements.goToDirectory) && <ActionList.Divider />}
{actionElements.openRawContent}
{actionElements.goToDirectory}
</ActionList>
</AnchoredOverlay>
)
}
export function useRenderFileCommentAmounts() {
function renderFileCommentAmounts(node: TreeNode) {
return node.comments?.active ? (
@ -69,11 +220,7 @@ export function useRenderFindInFolderButton(
<button
title={'Find in folder...'}
className={'find-in-folder-button'}
onClick={e => {
e.stopPropagation()
e.preventDefault()
onSearch(node.path + '/', searchMode)
}}
onClick={() => onSearch(node.path + '/', searchMode)}
>
<Icon type="search" />
</button>
@ -93,11 +240,7 @@ export function useRenderGoToButton(searched: boolean, goTo: (path: string[]) =>
<button
title={'Reveal in file tree'}
className={'go-to-button'}
onClick={e => {
e.stopPropagation()
e.preventDefault()
goTo(node.path.split('/'))
}}
onClick={() => goTo(node.path.split('/'))}
>
<Icon type="go-to" />
</button>
@ -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<HTMLElement>) => onEnterKeyDown(e, onTrigger),
})

View file

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

View file

@ -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
>
<div style={containerStyle}>
{visibleRows.map(({ row, style }) => {
const node = nodes[row]
return (
<Node
key={node.path}
node={node}
style={style}
depth={depths.get(node) || 0}
focused={focusedNode?.path === node.path}
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={handleNodeClick}
onFocus={handleNodeFocus}
renderLabelText={renderLabelText}
renderActions={renderActions}
/>
)
})}
</div>
<PortalContext.Provider value={portalName}>
<div style={containerStyle}>
{visibleRows
.map(({ row, style }) => ({
node: nodes[row],
style,
}))
.map(({ node, style }) => (
<Node
key={node.path}
node={node}
style={style}
depth={depths.get(node) || 0}
focused={focusedNode?.path === node.path}
loading={loading.has(node.path)}
expanded={expandedNodes.has(node.path)}
onClick={handleNodeClick}
onFocus={handleNodeFocus}
renderLabelText={renderLabelText}
renderActions={renderActions}
/>
))}
</div>
</PortalContext.Provider>
</div>
</div>
</div>

View file

@ -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<HTMLButtonElement>,
) {
const {
variant = 'default',
size = 'medium',
@ -40,10 +43,10 @@ export function IconButton(props: IconButtonProps) {
].filter(is.not.undefined),
)
return (
<StyledButton sx={sxStyles} {...rest}>
<StyledButton sx={sxStyles} ref={ref} {...rest}>
<Box as="span" sx={{ display: 'inline-block' }}>
<Icon size={iconSize} />
</Box>
</StyledButton>
)
}
})

View file

@ -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<HTMLButtonElement>,
) {
return (
<IconButton
ref={ref}
variant="invisible"
title={props['aria-label']}
{...props}
@ -13,4 +17,4 @@ export function RoundIconButton(props: IconButtonProps) {
}}
/>
)
}
})

View file

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

View file

@ -0,0 +1,5 @@
import * as React from 'react'
export type PortalContextShape = string | null
export const PortalContext = React.createContext<PortalContextShape>(null)

2
src/global.d.ts vendored
View file

@ -19,6 +19,8 @@ type TreeNode = {
path: string
type: 'tree' | 'blob' | 'commit'
url?: string
permalink?: string
rawLink?: string
sha?: string
accessDenied?: boolean
comments?: {

View file

@ -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('/')}`
}

View file

@ -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: {

View file

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

View file

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

View file

@ -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 extends HTMLElement>(
e: React.KeyboardEvent<E>,
callback: (e: React.KeyboardEvent<E>) => void,
) {
if (e.key === 'Enter') callback(e)
}

View file

@ -163,7 +163,7 @@ export function hasUpperCase(input: string) {
}
export async function renderReact(element: ReactElement) {
return new Promise<Node>(resolve => {
return new Promise<ChildNode>(resolve => {
const mount = document.createElement('div')
ReactDOM.render(element, mount, () => {
resolve(mount.childNodes[0])