tab.tsx 4.9 KB

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