tab.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import {
  2. EditorFileTab,
  3. TAB_TRANSFER_TYPE,
  4. } from '@/features/ide-react/context/tabs-context'
  5. import FileTreeIcon from '@/features/file-tree/components/file-tree-icon'
  6. import MaterialIcon from '@/shared/components/material-icon'
  7. import { debugConsole } from '@/utils/debugging'
  8. import classNames from 'classnames'
  9. import { throttle } from 'lodash'
  10. import {
  11. memo,
  12. useCallback,
  13. useEffect,
  14. useLayoutEffect,
  15. useMemo,
  16. useRef,
  17. useState,
  18. } from 'react'
  19. import { useTranslation } from 'react-i18next'
  20. type TabProps = {
  21. tab: EditorFileTab
  22. openTab: (id: string) => void
  23. closeTab: (id: string) => void
  24. makeTabPermanent: (id: string) => void
  25. openContextMenu: (
  26. coords: { top: number; left: number },
  27. tabId: string
  28. ) => void
  29. closeContextMenu: () => void
  30. isSelected: boolean
  31. onTabDrop: (
  32. sourceTabId: string,
  33. targetTabId: string,
  34. position: 'left' | 'right'
  35. ) => void
  36. }
  37. function getSideOfTargetFromEvent(
  38. element: Element,
  39. clientX: number
  40. ): 'left' | 'right' {
  41. const rect = element.getBoundingClientRect()
  42. if (rect.left + rect.width / 2 < clientX) {
  43. return 'right'
  44. } else {
  45. return 'left'
  46. }
  47. }
  48. export const Tab = memo(function Tab({
  49. tab,
  50. openTab,
  51. closeTab,
  52. makeTabPermanent,
  53. openContextMenu,
  54. closeContextMenu,
  55. isSelected,
  56. onTabDrop,
  57. }: TabProps) {
  58. const { t } = useTranslation()
  59. const tabRef = useRef<HTMLDivElement>(null)
  60. const [dropTargetPosition, setDropTargetPosition] = useState<
  61. 'left' | 'right' | null
  62. >(null)
  63. const onDragStart = useCallback(
  64. (e: React.DragEvent) => {
  65. e.stopPropagation()
  66. e.dataTransfer.setData(TAB_TRANSFER_TYPE, tab.id)
  67. e.dataTransfer.effectAllowed = 'move'
  68. },
  69. [tab]
  70. )
  71. const throttledOnDragOver = useMemo(
  72. () =>
  73. throttle((element: Element, clientX: number) => {
  74. setDropTargetPosition(getSideOfTargetFromEvent(element, clientX))
  75. }, 50),
  76. []
  77. )
  78. const onDragOver = useCallback(
  79. (e: React.DragEvent) => {
  80. e.preventDefault()
  81. e.stopPropagation()
  82. e.dataTransfer.dropEffect = 'move'
  83. throttledOnDragOver(e.currentTarget, e.clientX)
  84. },
  85. [throttledOnDragOver]
  86. )
  87. const onDragLeave = useCallback(
  88. (e: React.DragEvent) => {
  89. e.stopPropagation()
  90. throttledOnDragOver.cancel()
  91. setDropTargetPosition(null)
  92. },
  93. [throttledOnDragOver]
  94. )
  95. const onDrop = useCallback(
  96. (e: React.DragEvent) => {
  97. throttledOnDragOver.cancel()
  98. setDropTargetPosition(null)
  99. e.preventDefault()
  100. e.stopPropagation()
  101. const draggedTabId = e.dataTransfer.getData(TAB_TRANSFER_TYPE)
  102. if (!draggedTabId) {
  103. debugConsole.warn('No dragged tab id found in dataTransfer')
  104. return
  105. }
  106. onTabDrop(
  107. draggedTabId,
  108. tab.id,
  109. getSideOfTargetFromEvent(e.currentTarget, e.clientX)
  110. )
  111. },
  112. [onTabDrop, tab, throttledOnDragOver]
  113. )
  114. const onDoubleClick = useCallback(() => {
  115. makeTabPermanent(tab.id)
  116. }, [makeTabPermanent, tab])
  117. const onClick = useCallback(
  118. (e: React.MouseEvent) => {
  119. if (e.button === 0) {
  120. openTab(tab.id)
  121. }
  122. },
  123. [openTab, tab]
  124. )
  125. const onKeyDown = useCallback(
  126. (e: React.KeyboardEvent) => {
  127. if (e.code === 'Enter') {
  128. openTab(tab.id)
  129. }
  130. },
  131. [openTab, tab]
  132. )
  133. const onCloseClick = useCallback(
  134. (e: React.MouseEvent) => {
  135. e.stopPropagation()
  136. closeTab(tab.id)
  137. },
  138. [closeTab, tab]
  139. )
  140. const onMouseUp = useCallback(
  141. (e: React.MouseEvent) => {
  142. if (e.button === 1) {
  143. e.stopPropagation()
  144. closeTab(tab.id)
  145. }
  146. },
  147. [closeTab, tab]
  148. )
  149. const onContextMenu = useCallback(
  150. (e: React.MouseEvent) => {
  151. if (e.shiftKey) {
  152. closeContextMenu()
  153. return
  154. }
  155. e.preventDefault()
  156. openContextMenu({ top: e.pageY, left: e.pageX }, tab.id)
  157. },
  158. [openContextMenu, closeContextMenu, tab]
  159. )
  160. useLayoutEffect(() => {
  161. if (isSelected && tabRef.current) {
  162. tabRef.current.scrollIntoView({
  163. block: 'nearest',
  164. inline: 'nearest',
  165. })
  166. }
  167. }, [isSelected])
  168. useEffect(() => {
  169. if (isSelected && tab.lifetime === 'temporary') {
  170. const handler = () => {
  171. makeTabPermanent(tab.id)
  172. }
  173. document.body.addEventListener('keydown', handler)
  174. return () => {
  175. document.body.removeEventListener('keydown', handler)
  176. }
  177. }
  178. }, [isSelected, makeTabPermanent, tab])
  179. return (
  180. <div
  181. ref={tabRef}
  182. onDragStart={onDragStart}
  183. onDragOver={onDragOver}
  184. onDragLeave={onDragLeave}
  185. draggable
  186. onDrop={onDrop}
  187. onDoubleClick={onDoubleClick}
  188. role="tab"
  189. aria-selected={isSelected}
  190. key={tab.id}
  191. onClick={onClick}
  192. onMouseUp={onMouseUp}
  193. onKeyDown={onKeyDown}
  194. onContextMenu={onContextMenu}
  195. data-tab-id={tab.id}
  196. tabIndex={0}
  197. className={classNames('editor-file-tab', {
  198. 'tab-selected': isSelected,
  199. 'tab-drop-left': dropTargetPosition === 'left',
  200. 'tab-drop-right': dropTargetPosition === 'right',
  201. 'tab-temporary': tab.lifetime === 'temporary',
  202. })}
  203. >
  204. <span className="editor-file-tab-icon">
  205. <FileTreeIcon isLinkedFile={tab.isLinkedFile} name={tab.name} />
  206. </span>
  207. <div className="editor-file-tab-path">&lrm;{tab.displayPath}</div>
  208. <div className="editor-file-tab-action">
  209. <button
  210. onClick={onCloseClick}
  211. className="editor-file-tab-close-action"
  212. aria-label={t('close_tab')}
  213. >
  214. <MaterialIcon type="close" />
  215. </button>
  216. </div>
  217. </div>
  218. )
  219. })