tab.tsx 5.8 KB

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