tab.tsx 5.9 KB

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