CodeBlock.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753
  1. import { memo, useEffect, useRef, useCallback, useState } from "react"
  2. import styled from "styled-components"
  3. import { useCopyToClipboard } from "@src/utils/clipboard"
  4. import { getHighlighter, isLanguageLoaded, normalizeLanguage, ExtendedLanguage } from "@src/utils/highlighter"
  5. import { bundledLanguages } from "shiki"
  6. import type { ShikiTransformer } from "shiki"
  7. import { ChevronDown, ChevronUp, WrapText, AlignJustify, Copy, Check } from "lucide-react"
  8. import { useAppTranslation } from "@src/i18n/TranslationContext"
  9. export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))"
  10. export const WRAPPER_ALPHA = "cc" // 80% opacity
  11. // Configuration constants
  12. export const WINDOW_SHADE_SETTINGS = {
  13. transitionDelayS: 0.2,
  14. collapsedHeight: 500, // Default collapsed height in pixels
  15. }
  16. // Tolerance in pixels for determining when a container is considered "at the bottom"
  17. export const SCROLL_SNAP_TOLERANCE = 20
  18. /*
  19. overflowX: auto + inner div with padding results in an issue where the top/left/bottom padding renders but the right padding inside does not count as overflow as the width of the element is not exceeded. Once the inner div is outside the boundaries of the parent it counts as overflow.
  20. https://stackoverflow.com/questions/60778406/why-is-padding-right-clipped-with-overflowscroll/77292459#77292459
  21. this fixes the issue of right padding clipped off
  22. “ideal” size in a given axis when given infinite available space--allows the syntax highlighter to grow to largest possible width including its padding
  23. minWidth: "max-content",
  24. */
  25. interface CodeBlockProps {
  26. source?: string
  27. rawSource?: string // Add rawSource prop for copying raw text
  28. language?: string
  29. preStyle?: React.CSSProperties
  30. initialWordWrap?: boolean
  31. collapsedHeight?: number
  32. initialWindowShade?: boolean
  33. onLanguageChange?: (language: string) => void
  34. }
  35. const CodeBlockButton = styled.button`
  36. background: transparent;
  37. border: none;
  38. color: var(--vscode-foreground);
  39. cursor: var(--copy-button-cursor, default);
  40. padding: 4px;
  41. margin: 0 0px;
  42. display: flex;
  43. align-items: center;
  44. justify-content: center;
  45. opacity: 0.4;
  46. border-radius: 3px;
  47. pointer-events: var(--copy-button-events, none);
  48. margin-left: 4px;
  49. height: 24px;
  50. width: 24px;
  51. &:hover {
  52. background: var(--vscode-toolbar-hoverBackground);
  53. opacity: 1;
  54. }
  55. /* Style for Lucide icons to ensure consistent sizing and positioning */
  56. svg {
  57. display: block;
  58. }
  59. `
  60. const CodeBlockButtonWrapper = styled.div`
  61. position: fixed;
  62. top: var(--copy-button-top);
  63. right: var(--copy-button-right, 8px);
  64. height: auto;
  65. z-index: 100;
  66. background: ${CODE_BLOCK_BG_COLOR}${WRAPPER_ALPHA};
  67. overflow: visible;
  68. pointer-events: none;
  69. opacity: var(--copy-button-opacity, 0);
  70. padding: 4px 6px;
  71. border-radius: 3px;
  72. display: inline-flex;
  73. align-items: center;
  74. justify-content: center;
  75. &:hover {
  76. background: var(--vscode-editor-background);
  77. opacity: 1 !important;
  78. }
  79. ${CodeBlockButton} {
  80. position: relative;
  81. top: 0;
  82. right: 0;
  83. }
  84. `
  85. const CodeBlockContainer = styled.div`
  86. position: relative;
  87. overflow: hidden;
  88. border-bottom: 4px solid var(--vscode-sideBar-background);
  89. background-color: ${CODE_BLOCK_BG_COLOR};
  90. ${CodeBlockButtonWrapper} {
  91. opacity: 0;
  92. pointer-events: none;
  93. transition: opacity 0.2s; /* Keep opacity transition for buttons */
  94. }
  95. &[data-partially-visible="true"]:hover ${CodeBlockButtonWrapper} {
  96. opacity: 1;
  97. pointer-events: all;
  98. cursor: pointer;
  99. }
  100. `
  101. export const StyledPre = styled.div<{
  102. preStyle?: React.CSSProperties
  103. wordwrap?: "true" | "false" | undefined
  104. windowshade?: "true" | "false"
  105. collapsedHeight?: number
  106. }>`
  107. background-color: ${CODE_BLOCK_BG_COLOR};
  108. max-height: ${({ windowshade, collapsedHeight }) =>
  109. windowshade === "true" ? `${collapsedHeight || WINDOW_SHADE_SETTINGS.collapsedHeight}px` : "none"};
  110. overflow-y: auto;
  111. padding: 10px;
  112. // transition: max-height ${WINDOW_SHADE_SETTINGS.transitionDelayS} ease-out;
  113. border-radius: 5px;
  114. ${({ preStyle }) => preStyle && { ...preStyle }}
  115. pre {
  116. background-color: ${CODE_BLOCK_BG_COLOR};
  117. border-radius: 5px;
  118. margin: 0;
  119. padding: 10px;
  120. width: 100%;
  121. box-sizing: border-box;
  122. }
  123. pre,
  124. code {
  125. /* Undefined wordwrap defaults to true (pre-wrap) behavior */
  126. white-space: ${({ wordwrap }) => (wordwrap === "false" ? "pre" : "pre-wrap")};
  127. word-break: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "normal")};
  128. overflow-wrap: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "break-word")};
  129. font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px));
  130. font-family: var(--vscode-editor-font-family);
  131. }
  132. pre > code {
  133. .hljs-deletion {
  134. background-color: var(--vscode-diffEditor-removedTextBackground);
  135. display: inline-block;
  136. width: 100%;
  137. }
  138. .hljs-addition {
  139. background-color: var(--vscode-diffEditor-insertedTextBackground);
  140. display: inline-block;
  141. width: 100%;
  142. }
  143. }
  144. .hljs {
  145. color: var(--vscode-editor-foreground, #fff);
  146. background-color: ${CODE_BLOCK_BG_COLOR};
  147. }
  148. `
  149. const LanguageSelect = styled.select`
  150. font-size: 12px;
  151. color: var(--vscode-foreground);
  152. opacity: 0.4;
  153. font-family: monospace;
  154. appearance: none;
  155. background: transparent;
  156. border: none;
  157. cursor: pointer;
  158. padding: 4px;
  159. margin: 0;
  160. vertical-align: middle;
  161. height: 24px;
  162. & option {
  163. background: var(--vscode-editor-background);
  164. color: var(--vscode-foreground);
  165. padding: 0;
  166. margin: 0;
  167. }
  168. &::-webkit-scrollbar {
  169. width: 6px;
  170. }
  171. &::-webkit-scrollbar-thumb {
  172. background: var(--vscode-scrollbarSlider-background);
  173. }
  174. &::-webkit-scrollbar-track {
  175. background: var(--vscode-editor-background);
  176. }
  177. &:hover {
  178. opacity: 1;
  179. background: var(--vscode-toolbar-hoverBackground);
  180. border-radius: 3px;
  181. }
  182. &:focus {
  183. opacity: 1;
  184. outline: none;
  185. border-radius: 3px;
  186. }
  187. `
  188. const CodeBlock = memo(
  189. ({
  190. source,
  191. rawSource,
  192. language,
  193. preStyle,
  194. initialWordWrap = true,
  195. initialWindowShade = true,
  196. collapsedHeight,
  197. onLanguageChange,
  198. }: CodeBlockProps) => {
  199. const [wordWrap, setWordWrap] = useState(initialWordWrap)
  200. const [windowShade, setWindowShade] = useState(initialWindowShade)
  201. const [currentLanguage, setCurrentLanguage] = useState<ExtendedLanguage>(() => normalizeLanguage(language))
  202. const userChangedLanguageRef = useRef(false)
  203. const [highlightedCode, setHighlightedCode] = useState<string>("")
  204. const [showCollapseButton, setShowCollapseButton] = useState(true)
  205. const codeBlockRef = useRef<HTMLDivElement>(null)
  206. const preRef = useRef<HTMLDivElement>(null)
  207. const copyButtonWrapperRef = useRef<HTMLDivElement>(null)
  208. const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
  209. const { t } = useAppTranslation()
  210. // Update current language when prop changes, but only if user hasn't made a selection
  211. useEffect(() => {
  212. const normalizedLang = normalizeLanguage(language)
  213. if (normalizedLang !== currentLanguage && !userChangedLanguageRef.current) {
  214. setCurrentLanguage(normalizedLang)
  215. }
  216. }, [language, currentLanguage])
  217. // Syntax highlighting with cached Shiki instance
  218. useEffect(() => {
  219. const fallback = `<pre style="padding: 0; margin: 0;"><code class="hljs language-${currentLanguage || "txt"}">${source || ""}</code></pre>`
  220. const highlight = async () => {
  221. // Show plain text if language needs to be loaded
  222. if (currentLanguage && !isLanguageLoaded(currentLanguage)) {
  223. setHighlightedCode(fallback)
  224. }
  225. const highlighter = await getHighlighter(currentLanguage)
  226. const html = await highlighter.codeToHtml(source || "", {
  227. lang: currentLanguage || "txt",
  228. theme: document.body.className.toLowerCase().includes("light") ? "github-light" : "github-dark",
  229. transformers: [
  230. {
  231. pre(node) {
  232. node.properties.style = "padding: 0; margin: 0;"
  233. return node
  234. },
  235. code(node) {
  236. // Add hljs classes for consistent styling
  237. node.properties.class = `hljs language-${currentLanguage}`
  238. return node
  239. },
  240. line(node) {
  241. // Preserve existing line handling
  242. node.properties.class = node.properties.class || ""
  243. return node
  244. },
  245. },
  246. ] as ShikiTransformer[],
  247. })
  248. setHighlightedCode(html)
  249. }
  250. highlight().catch((e) => {
  251. console.error("[CodeBlock] Syntax highlighting error:", e, "\nStack trace:", e.stack)
  252. setHighlightedCode(fallback)
  253. })
  254. }, [source, currentLanguage, collapsedHeight])
  255. // Check if content height exceeds collapsed height whenever content changes
  256. useEffect(() => {
  257. const codeBlock = codeBlockRef.current
  258. if (codeBlock) {
  259. const actualHeight = codeBlock.scrollHeight
  260. setShowCollapseButton(actualHeight >= WINDOW_SHADE_SETTINGS.collapsedHeight)
  261. }
  262. }, [highlightedCode])
  263. // Ref to track if user was scrolled up *before* the source update potentially changes scrollHeight
  264. const wasScrolledUpRef = useRef(false)
  265. // Ref to track if outer container was near bottom
  266. const outerContainerNearBottomRef = useRef(false)
  267. // Effect to listen to scroll events and update the ref
  268. useEffect(() => {
  269. const preElement = preRef.current
  270. if (!preElement) return
  271. const handleScroll = () => {
  272. const isAtBottom =
  273. Math.abs(preElement.scrollHeight - preElement.scrollTop - preElement.clientHeight) <
  274. SCROLL_SNAP_TOLERANCE
  275. wasScrolledUpRef.current = !isAtBottom
  276. }
  277. preElement.addEventListener("scroll", handleScroll, { passive: true })
  278. // Initial check in case it starts scrolled up
  279. handleScroll()
  280. return () => {
  281. preElement.removeEventListener("scroll", handleScroll)
  282. }
  283. }, []) // Empty dependency array: runs once on mount
  284. // Effect to track outer container scroll position
  285. useEffect(() => {
  286. const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]')
  287. if (!scrollContainer) return
  288. const handleOuterScroll = () => {
  289. const isAtBottom =
  290. Math.abs(scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight) <
  291. SCROLL_SNAP_TOLERANCE
  292. outerContainerNearBottomRef.current = isAtBottom
  293. }
  294. scrollContainer.addEventListener("scroll", handleOuterScroll, { passive: true })
  295. // Initial check
  296. handleOuterScroll()
  297. return () => {
  298. scrollContainer.removeEventListener("scroll", handleOuterScroll)
  299. }
  300. }, []) // Empty dependency array: runs once on mount
  301. // Store whether we should scroll after highlighting completes
  302. const shouldScrollAfterHighlightRef = useRef(false)
  303. // Check if we should scroll when source changes
  304. useEffect(() => {
  305. // Only set the flag if we're at the bottom when source changes
  306. if (preRef.current && source && !wasScrolledUpRef.current) {
  307. shouldScrollAfterHighlightRef.current = true
  308. } else {
  309. shouldScrollAfterHighlightRef.current = false
  310. }
  311. }, [source])
  312. const updateCodeBlockButtonPosition = useCallback((forceHide = false) => {
  313. const codeBlock = codeBlockRef.current
  314. const copyWrapper = copyButtonWrapperRef.current
  315. if (!codeBlock) return
  316. const rectCodeBlock = codeBlock.getBoundingClientRect()
  317. const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]')
  318. if (!scrollContainer) return
  319. // Get wrapper height dynamically
  320. let wrapperHeight
  321. if (copyWrapper) {
  322. const copyRect = copyWrapper.getBoundingClientRect()
  323. // If height is 0 due to styling, estimate from children
  324. if (copyRect.height > 0) {
  325. wrapperHeight = copyRect.height
  326. } else if (copyWrapper.children.length > 0) {
  327. // Try to get height from the button inside
  328. const buttonRect = copyWrapper.children[0].getBoundingClientRect()
  329. const buttonStyle = window.getComputedStyle(copyWrapper.children[0] as Element)
  330. const buttonPadding =
  331. parseInt(buttonStyle.getPropertyValue("padding-top") || "0", 10) +
  332. parseInt(buttonStyle.getPropertyValue("padding-bottom") || "0", 10)
  333. wrapperHeight = buttonRect.height + buttonPadding
  334. }
  335. }
  336. // If we still don't have a height, calculate from font size
  337. if (!wrapperHeight) {
  338. const fontSize = parseInt(window.getComputedStyle(document.body).getPropertyValue("font-size"), 10)
  339. wrapperHeight = fontSize * 2.5 // Approximate button height based on font size
  340. }
  341. const scrollRect = scrollContainer.getBoundingClientRect()
  342. const copyButtonEdge = 48
  343. const isPartiallyVisible =
  344. rectCodeBlock.top < scrollRect.bottom - copyButtonEdge &&
  345. rectCodeBlock.bottom >= scrollRect.top + copyButtonEdge
  346. // Calculate margin from existing padding in the component
  347. const computedStyle = window.getComputedStyle(codeBlock)
  348. const paddingValue = parseInt(computedStyle.getPropertyValue("padding") || "0", 10)
  349. const margin =
  350. paddingValue > 0 ? paddingValue : parseInt(computedStyle.getPropertyValue("padding-top") || "0", 10)
  351. // Update visibility state and button interactivity
  352. const isVisible = !forceHide && isPartiallyVisible
  353. codeBlock.setAttribute("data-partially-visible", isPartiallyVisible ? "true" : "false")
  354. codeBlock.style.setProperty("--copy-button-cursor", isVisible ? "pointer" : "default")
  355. codeBlock.style.setProperty("--copy-button-events", isVisible ? "all" : "none")
  356. codeBlock.style.setProperty("--copy-button-opacity", isVisible ? "1" : "0")
  357. if (isPartiallyVisible) {
  358. // Keep button within code block bounds using dynamic measurements
  359. const topPosition = Math.max(
  360. scrollRect.top + margin,
  361. Math.min(rectCodeBlock.bottom - wrapperHeight - margin, rectCodeBlock.top + margin),
  362. )
  363. const rightPosition = Math.max(margin, scrollRect.right - rectCodeBlock.right + margin)
  364. codeBlock.style.setProperty("--copy-button-top", `${topPosition}px`)
  365. codeBlock.style.setProperty("--copy-button-right", `${rightPosition}px`)
  366. }
  367. }, [])
  368. useEffect(() => {
  369. const handleScroll = () => updateCodeBlockButtonPosition()
  370. const handleResize = () => updateCodeBlockButtonPosition()
  371. const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]')
  372. if (scrollContainer) {
  373. scrollContainer.addEventListener("scroll", handleScroll)
  374. window.addEventListener("resize", handleResize)
  375. updateCodeBlockButtonPosition()
  376. }
  377. return () => {
  378. if (scrollContainer) {
  379. scrollContainer.removeEventListener("scroll", handleScroll)
  380. window.removeEventListener("resize", handleResize)
  381. }
  382. }
  383. }, [updateCodeBlockButtonPosition])
  384. // Update button position and scroll when highlightedCode changes
  385. useEffect(() => {
  386. if (highlightedCode) {
  387. // Update button position
  388. setTimeout(updateCodeBlockButtonPosition, 0)
  389. // Scroll to bottom if needed (immediately after Shiki updates)
  390. if (shouldScrollAfterHighlightRef.current) {
  391. // Scroll inner container
  392. if (preRef.current) {
  393. preRef.current.scrollTop = preRef.current.scrollHeight
  394. wasScrolledUpRef.current = false
  395. }
  396. // Also scroll outer container if it was near bottom
  397. if (outerContainerNearBottomRef.current) {
  398. const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]')
  399. if (scrollContainer) {
  400. scrollContainer.scrollTop = scrollContainer.scrollHeight
  401. outerContainerNearBottomRef.current = true
  402. }
  403. }
  404. // Reset the flag
  405. shouldScrollAfterHighlightRef.current = false
  406. }
  407. }
  408. }, [highlightedCode, updateCodeBlockButtonPosition])
  409. // Advanced inertial scroll chaining
  410. // This effect handles the transition between scrolling the code block and the outer container.
  411. // When a user scrolls to the boundary of a code block (top or bottom), this implementation:
  412. // 1. Detects the boundary condition
  413. // 2. Applies inertial scrolling to the outer container for a smooth transition
  414. // 3. Adds physics-based momentum for natural deceleration
  415. // This creates a seamless experience where scrolling flows naturally between nested scrollable areas
  416. useEffect(() => {
  417. if (!preRef.current) return
  418. // Find the outer scrollable container
  419. const getScrollContainer = () => {
  420. return document.querySelector('[data-virtuoso-scroller="true"]') as HTMLElement
  421. }
  422. // Inertial scrolling implementation
  423. let velocity = 0
  424. let animationFrameId: number | null = null
  425. const FRICTION = 0.85 // Friction coefficient (lower = more friction)
  426. const MIN_VELOCITY = 0.5 // Minimum velocity before stopping
  427. // Animation function for inertial scrolling
  428. const animate = () => {
  429. const scrollContainer = getScrollContainer()
  430. if (!scrollContainer) return
  431. // Apply current velocity
  432. if (Math.abs(velocity) > MIN_VELOCITY) {
  433. scrollContainer.scrollBy(0, velocity)
  434. velocity *= FRICTION // Apply friction
  435. animationFrameId = requestAnimationFrame(animate)
  436. } else {
  437. velocity = 0
  438. animationFrameId = null
  439. }
  440. }
  441. // Wheel event handler with inertial scrolling
  442. const handleWheel = (e: WheelEvent) => {
  443. // If shift is pressed, let the browser handle default horizontal scrolling
  444. if (e.shiftKey) {
  445. return
  446. }
  447. if (!preRef.current) return
  448. // Only handle wheel events if the inner container has a scrollbar,
  449. // otherwise let the browser handle the default scrolling
  450. const hasScrollbar = preRef.current.scrollHeight > preRef.current.clientHeight
  451. // Pass through events if we don't need special handling
  452. if (!hasScrollbar) {
  453. return
  454. }
  455. const scrollContainer = getScrollContainer()
  456. if (!scrollContainer) return
  457. // Check if we're at the top or bottom of the inner container
  458. const isAtVeryTop = preRef.current.scrollTop === 0
  459. const isAtVeryBottom =
  460. Math.abs(preRef.current.scrollHeight - preRef.current.scrollTop - preRef.current.clientHeight) < 1
  461. // Handle scrolling at container boundaries
  462. if ((e.deltaY < 0 && isAtVeryTop) || (e.deltaY > 0 && isAtVeryBottom)) {
  463. // Prevent default to stop inner container from handling
  464. e.preventDefault()
  465. const boost = 0.15
  466. velocity += e.deltaY * boost
  467. // Start animation if not already running
  468. if (!animationFrameId) {
  469. animationFrameId = requestAnimationFrame(animate)
  470. }
  471. }
  472. }
  473. // Add wheel event listener to inner container
  474. const preElement = preRef.current
  475. preElement.addEventListener("wheel", handleWheel, { passive: false })
  476. // Clean up
  477. return () => {
  478. preElement.removeEventListener("wheel", handleWheel)
  479. // Cancel any ongoing animation
  480. if (animationFrameId) {
  481. cancelAnimationFrame(animationFrameId)
  482. }
  483. }
  484. }, [])
  485. // Track text selection state
  486. const [isSelecting, setIsSelecting] = useState(false)
  487. useEffect(() => {
  488. if (!preRef.current) return
  489. const handleMouseDown = (e: MouseEvent) => {
  490. // Only trigger if clicking the pre element directly
  491. if (e.currentTarget === preRef.current) {
  492. setIsSelecting(true)
  493. }
  494. }
  495. const handleMouseUp = () => {
  496. setIsSelecting(false)
  497. }
  498. const preElement = preRef.current
  499. preElement.addEventListener("mousedown", handleMouseDown)
  500. document.addEventListener("mouseup", handleMouseUp)
  501. return () => {
  502. preElement.removeEventListener("mousedown", handleMouseDown)
  503. document.removeEventListener("mouseup", handleMouseUp)
  504. }
  505. }, [])
  506. const handleCopy = useCallback(
  507. (e: React.MouseEvent) => {
  508. e.stopPropagation()
  509. // Check if code block is partially visible before allowing copy
  510. const codeBlock = codeBlockRef.current
  511. if (!codeBlock || codeBlock.getAttribute("data-partially-visible") !== "true") {
  512. return
  513. }
  514. const textToCopy = rawSource !== undefined ? rawSource : source || ""
  515. if (textToCopy) {
  516. copyWithFeedback(textToCopy, e)
  517. }
  518. },
  519. [source, rawSource, copyWithFeedback],
  520. )
  521. if (source?.length === 0) {
  522. return null
  523. }
  524. return (
  525. <CodeBlockContainer ref={codeBlockRef}>
  526. <MemoizedStyledPre
  527. preRef={preRef}
  528. preStyle={preStyle}
  529. wordWrap={wordWrap}
  530. windowShade={windowShade}
  531. collapsedHeight={collapsedHeight}
  532. highlightedCode={highlightedCode}
  533. updateCodeBlockButtonPosition={updateCodeBlockButtonPosition}
  534. />
  535. {!isSelecting && (
  536. <CodeBlockButtonWrapper
  537. ref={copyButtonWrapperRef}
  538. onMouseOver={() => updateCodeBlockButtonPosition()}
  539. style={{ gap: 0 }}>
  540. {language && (
  541. <LanguageSelect
  542. value={currentLanguage}
  543. style={{
  544. width: `calc(${currentLanguage?.length || 0}ch + 9px)`,
  545. }}
  546. onClick={(e) => {
  547. e.currentTarget.focus()
  548. }}
  549. onChange={(e) => {
  550. const newLang = normalizeLanguage(e.target.value)
  551. userChangedLanguageRef.current = true
  552. setCurrentLanguage(newLang)
  553. if (onLanguageChange) {
  554. onLanguageChange(newLang)
  555. }
  556. }}>
  557. {
  558. // Display original language at top of list for quick selection
  559. language && (
  560. <option
  561. value={normalizeLanguage(language)}
  562. style={{ fontWeight: "bold", textAlign: "left", fontSize: "1.2em" }}>
  563. {normalizeLanguage(language)}
  564. </option>
  565. )
  566. }
  567. {
  568. // Display all available languages in alphabetical order
  569. Object.keys(bundledLanguages)
  570. .sort()
  571. .map((lang) => {
  572. const normalizedLang = normalizeLanguage(lang)
  573. return (
  574. <option
  575. key={normalizedLang}
  576. value={normalizedLang}
  577. style={{
  578. fontWeight:
  579. normalizedLang === currentLanguage ? "bold" : "normal",
  580. textAlign: "left",
  581. fontSize:
  582. normalizedLang === currentLanguage ? "1.2em" : "inherit",
  583. }}>
  584. {normalizedLang}
  585. </option>
  586. )
  587. })
  588. }
  589. </LanguageSelect>
  590. )}
  591. {showCollapseButton && (
  592. <CodeBlockButton
  593. onClick={() => {
  594. // Get the current code block element and scrollable container
  595. const codeBlock = codeBlockRef.current
  596. const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]')
  597. if (!codeBlock || !scrollContainer) return
  598. // Toggle window shade state
  599. setWindowShade(!windowShade)
  600. // After UI updates, ensure code block is visible and update button position
  601. setTimeout(
  602. () => {
  603. codeBlock.scrollIntoView({ behavior: "smooth", block: "nearest" })
  604. // Wait for scroll to complete before updating button position
  605. setTimeout(() => {
  606. updateCodeBlockButtonPosition()
  607. }, 50)
  608. },
  609. WINDOW_SHADE_SETTINGS.transitionDelayS * 1000 + 50,
  610. )
  611. }}
  612. title={t(`chat:codeblock.tooltips.${windowShade ? "expand" : "collapse"}`)}>
  613. {windowShade ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
  614. </CodeBlockButton>
  615. )}
  616. <CodeBlockButton
  617. onClick={() => setWordWrap(!wordWrap)}
  618. title={t(`chat:codeblock.tooltips.${wordWrap ? "disable_wrap" : "enable_wrap"}`)}>
  619. {wordWrap ? <AlignJustify size={16} /> : <WrapText size={16} />}
  620. </CodeBlockButton>
  621. <CodeBlockButton onClick={handleCopy} title={t("chat:codeblock.tooltips.copy_code")}>
  622. {showCopyFeedback ? <Check size={16} /> : <Copy size={16} />}
  623. </CodeBlockButton>
  624. </CodeBlockButtonWrapper>
  625. )}
  626. </CodeBlockContainer>
  627. )
  628. },
  629. )
  630. // Memoized content component to prevent unnecessary re-renders of highlighted code
  631. const MemoizedCodeContent = memo(({ html }: { html: string }) => <div dangerouslySetInnerHTML={{ __html: html }} />)
  632. // Memoized StyledPre component
  633. const MemoizedStyledPre = memo(
  634. ({
  635. preRef,
  636. preStyle,
  637. wordWrap,
  638. windowShade,
  639. collapsedHeight,
  640. highlightedCode,
  641. updateCodeBlockButtonPosition,
  642. }: {
  643. preRef: React.RefObject<HTMLDivElement>
  644. preStyle?: React.CSSProperties
  645. wordWrap: boolean
  646. windowShade: boolean
  647. collapsedHeight?: number
  648. highlightedCode: string
  649. updateCodeBlockButtonPosition: (forceHide?: boolean) => void
  650. }) => (
  651. <StyledPre
  652. ref={preRef}
  653. preStyle={preStyle}
  654. wordwrap={wordWrap ? "true" : "false"}
  655. windowshade={windowShade ? "true" : "false"}
  656. collapsedHeight={collapsedHeight}
  657. onMouseDown={() => updateCodeBlockButtonPosition(true)}
  658. onMouseUp={() => updateCodeBlockButtonPosition(false)}>
  659. <MemoizedCodeContent html={highlightedCode} />
  660. </StyledPre>
  661. ),
  662. )
  663. export default CodeBlock