diff --git a/.changeset/two-impalas-speak.md b/.changeset/two-impalas-speak.md new file mode 100644 index 000000000..e7fbce9ad --- /dev/null +++ b/.changeset/two-impalas-speak.md @@ -0,0 +1,5 @@ +--- +'@hashicorp/react-code-block': minor +--- + +Implements support for code wrapping, through an options.wrapCode boolean property. diff --git a/packages/code-block/docs.mdx b/packages/code-block/docs.mdx index bf85a4a76..6fbf2b870 100644 --- a/packages/code-block/docs.mdx +++ b/packages/code-block/docs.mdx @@ -34,6 +34,9 @@ Longer lines of code may take up more space than the available content width. In ```` ``` A line that goes on for a very long time so that it overflows the container in which it is located, which might be a pretty wide container. +This is a second line of code. +And a third line. +And another line, this is the fourth line. ``` ```` @@ -42,7 +45,54 @@ A line that goes on for a very long time so that it overflows the container in w + +Note this also works with line numbers and line highlighting. + + + +#### Wrap Code + +In cases where wrapping code to new lines is preferred over horizontal scrolling, the `options.wrapCode` prop can be set to `true`. Note that this option is not yet supported in MDX contexts. + +`Source` + +```` +``` +A line that goes on for a very long time so that it overflows the container in which it is located, which might be a pretty wide container. +This is a second line of code. +And a third line. +And another line, this is the fourth line. +``` +```` + +`Result` + + + +Note this also works with line numbers and line highlighting. + + #### Syntax Highlighting @@ -219,7 +269,7 @@ function hello() { options={{ lineNumbers: true, showClipboard: true }} code={`const foo = 'bar' function hello() { -{/* */} return Math.random() > 0.5 ? 'Hello' : 'Bonjour' + return Math.random() > 0.5 ? 'Hello' : 'Bonjour' }`} /> diff --git a/packages/code-block/index.tsx b/packages/code-block/index.tsx index bfc9eda7c..09b2ff187 100644 --- a/packages/code-block/index.tsx +++ b/packages/code-block/index.tsx @@ -21,12 +21,13 @@ import s from './style.module.css' export interface CodeBlockOptions { showChrome?: boolean - highlight?: boolean + highlight?: string lineNumbers?: boolean showClipboard?: boolean showWindowBar?: boolean filename?: string heading?: string + wrapCode?: boolean } export interface CodeBlockProps { @@ -48,10 +49,10 @@ function CodeBlock({ onCopyCallBack, options = { showChrome: false, - highlight: false, lineNumbers: false, showClipboard: false, showWindowBar: false, + wrapCode: false, }, }: CodeBlockProps) { const copyRef = useRef() @@ -76,6 +77,7 @@ function CodeBlock({ lineNumbers, showClipboard, showWindowBar, + wrapCode, } = options if (showWindowBar) { console.warn( @@ -119,6 +121,7 @@ function CodeBlock({ highlight={highlight} lineNumbers={lineNumbers} hasFloatingCopyButton={hasFloatingCopyButton} + wrapCode={wrapCode} /> {hasFloatingCopyButton ? (
diff --git a/packages/code-block/partials/code-lines/style.module.css b/packages/code-block/partials/code-lines/code-lines.module.css similarity index 82% rename from packages/code-block/partials/code-lines/style.module.css rename to packages/code-block/partials/code-lines/code-lines.module.css index 875e10bb6..a3611cc08 100644 --- a/packages/code-block/partials/code-lines/style.module.css +++ b/packages/code-block/partials/code-lines/code-lines.module.css @@ -9,6 +9,12 @@ code-block/theme-(dark|light).module.css to be present. */ +/* + +SHARED + +*/ + pre.pre { --code-padding: 16px; --code-font-size: 0.84375rem; /* 13.5 px at default text size */ @@ -32,6 +38,83 @@ pre.pre { overflow: hidden; } +.LineNumber { + composes: g-type-code from global; + display: block; + font-size: var(--code-font-size); + line-height: var(--code-line-height); + white-space: pre; + padding: 0 var(--code-padding); + color: var(--text-color-faded); + + &.wrap { + border-right: 1px solid var(--divider-line-color); + } + + &.highlight { + background: var(--background-highlighted-line); + color: var(--text-color-base); + } + + &.dim > * { + color: var(--text-color-faded); + } +} + +.LineOfCode { + composes: g-type-code from global; + display: block; + color: var(--text-color-base); + padding-right: calc(var(--code-padding) * 2); + padding-left: var(--code-padding); + font-size: var(--code-font-size); + line-height: var(--code-line-height); + min-width: max-content; + white-space: pre; + + &.wrap { + white-space: pre-wrap; + overflow-wrap: break-word; + min-width: 0; + width: 100%; + } + + &.padRight { + /* Adds right padding so that the floating copy button + does not obscure content at the end of the line */ + padding-right: 96px; + } + + &.highlight { + background: var(--background-highlighted-line); + } + + &.dim { + opacity: 0.7; + + &, + & * { + /* !important is necessary here to override + syntax highlight color styles, which are + globally scoped by necessity */ + color: var(--text-color-faded) !important; + } + } +} + +/* + +SCROLL OVERFLOW LAYOUT + +*/ + +.numbersColumn { + display: block; + border-right: 1px solid var(--divider-line-color); + flex-shrink: 0; + padding: var(--code-padding) 0; +} + .linesColumn { display: block; flex-grow: 1; @@ -76,7 +159,7 @@ pre.pre { } } -.linesWrapper { +.linesScrollContainer { display: flex; width: max-content; min-width: 100%; @@ -84,62 +167,28 @@ pre.pre { flex-shrink: 0; } -.numbersColumn { - display: block; - border-right: 1px solid var(--divider-line-color); - flex-shrink: 0; - padding: var(--code-padding) 0; -} +/* -.LineNumber { - composes: g-type-code from global; - display: block; - font-size: var(--code-font-size); - line-height: var(--code-line-height); - white-space: pre; - padding: 0 var(--code-padding); - color: var(--text-color-faded); +WRAP LAYOUT - &.isHighlighted { - background: var(--background-highlighted-line); - color: var(--text-color-base); - } +*/ - &.isNotHighlighted > * { - color: var(--text-color-faded); - } +.wrappedLinesContainer { + display: flex; + width: 100%; + flex-direction: column; + flex-shrink: 0; } -.LineOfCode { - composes: g-type-code from global; - display: block; - color: var(--text-color-base); - padding-right: calc(var(--code-padding) * 2); - padding-left: var(--code-padding); - font-size: var(--code-font-size); - line-height: var(--code-line-height); - min-width: max-content; - white-space: pre; - - &.hasFloatingCopyButton { - /* Adds right padding so that the floating copy button - does not obscure content at the end of the line */ - padding-right: 96px; - } - - &.isHighlighted { - background: var(--background-highlighted-line); - } - - &.isNotHighlighted { - opacity: 0.7; +.wrappedLine { + display: flex; + flex-wrap: nowrap; +} - &, - & * { - /* !important is necessary here to override - syntax highlight color styles, which are - globally scoped by necessity */ - color: var(--text-color-faded) !important; - } - } +/* adds space at the top and bottom of the block, + while supporting a continuous border for line numbers */ +.wrappedLinesSpacer { + display: flex; + flex-wrap: nowrap; + height: var(--code-padding); } diff --git a/packages/code-block/partials/code-lines/index.js b/packages/code-block/partials/code-lines/index.js deleted file mode 100644 index c9c80aa1e..000000000 --- a/packages/code-block/partials/code-lines/index.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import React, { useMemo } from 'react' -import parseHighlightedLines from '../../utils/parse-highlighted-lines' -import splitJsxIntoLines from './utils/split-jsx-into-lines' -import splitHtmlIntoLines from './utils/split-html-into-lines' -import classNames from 'classnames' -import s from './style.module.css' - -function CodeLines({ - code, - language, - lineNumbers, - highlight, - hasFloatingCopyButton, -}) { - const linesOfCode = useMemo(() => { - const isHtmlString = typeof code === 'string' - return isHtmlString ? splitHtmlIntoLines(code) : splitJsxIntoLines(code) - }, [code]) - - const lineCount = linesOfCode.length - const highlightedLines = parseHighlightedLines(highlight) - - return ( -
-      
-        {lineNumbers ? (
-          
-            {linesOfCode.map((_lineChildren, stableIdx) => {
-              const number = stableIdx + 1
-              const isHighlighted = highlightedLines.indexOf(number) !== -1
-              const isNotHighlighted =
-                highlightedLines.length > 0 && !isHighlighted
-              return (
-                
-              )
-            })}
-          
-        ) : null}
-        
-          
-            {linesOfCode.map((lineChildren, stableIdx) => {
-              const number = stableIdx + 1
-              const isHighlighted = highlightedLines.indexOf(number) !== -1
-              const isNotHighlighted = highlightedLines.length && !isHighlighted
-              return (
-                
-                  {lineChildren}
-                  {'\n'}
-                
-              )
-            })}
-          
-        
-      
-    
- ) -} - -function LineNumber({ number, isHighlighted, isNotHighlighted, lineCount }) { - const padLevel = Math.max(lineCount.toString().length, 1) - const paddedNumber = number.toString().padEnd(padLevel) - return ( - - ) -} - -function LineOfCode({ - children, - isHighlighted, - isNotHighlighted, - hasFloatingCopyButton, -}) { - return ( - - {children} - - ) -} - -export default CodeLines diff --git a/packages/code-block/partials/code-lines/index.tsx b/packages/code-block/partials/code-lines/index.tsx new file mode 100644 index 000000000..f5a0f70ee --- /dev/null +++ b/packages/code-block/partials/code-lines/index.tsx @@ -0,0 +1,240 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * Note: lines of code are expected to be stable. If we need to work with + * dynamic code blocks in the future, we could assign random unique IDs + * to each line during the `linesOfCode` `useMemo` function. + * + * For now, we disable react/no-array-index key for the entire file. + */ +/* eslint-disable react/no-array-index-key */ + +import React, { PropsWithChildren, ReactElement, useMemo } from 'react' +import classNames from 'classnames' +import parseHighlightedLines from '../../utils/parse-highlighted-lines' +import splitJsxIntoLines from './utils/split-jsx-into-lines' +import splitHtmlIntoLines from './utils/split-html-into-lines' +import s from './code-lines.module.css' + +interface CodeLinesProps { + code: string | ReactElement + language?: string + lineNumbers?: boolean + highlight?: string + hasFloatingCopyButton?: boolean + wrapCode?: boolean +} + +/** + * Render the provided code into separate line elements, + * accounting for all provided options. + */ +function CodeLines({ + code, + language, + lineNumbers, + highlight, + hasFloatingCopyButton, + wrapCode, +}: CodeLinesProps) { + // Parse out an array of integers representing which lines to highlight + const highlightedLines = parseHighlightedLines(highlight) as number[] + + /** + * Split the incoming code into lines. + * We need to do this in order to render each line of code in a + * separate element, which is necessary for features such as highlighting + * specific lines and allowing code to wrap. + */ + const linesOfCode = useMemo(() => { + const isHtmlString = typeof code === 'string' + const lineElements = isHtmlString + ? splitHtmlIntoLines(code) + : splitJsxIntoLines(code) + const lineCount = lineElements.length + const padLevel = Math.max(lineCount.toString().length, 1) + return lineElements.map((children, index) => { + const number = index + 1 + const numberPadded = number.toString().padEnd(padLevel) + const highlight = highlightedLines.indexOf(number) !== -1 + const dim = highlightedLines.length > 0 && !highlight + return { children, number: numberPadded, highlight, dim } + }) + }, [code, highlightedLines]) + + // When rendering wrapped code with line numbers shown, we need a spacer value + // that matches the padding inset of all other line numbers + let numberSpacer: string | null = null + if (lineNumbers) { + const padLevel = Math.max(linesOfCode.length.toString().length, 1) + numberSpacer = ''.padEnd(padLevel) + } + + // When the floating copy button is present, we add padding to many lines + const padRight = Boolean(hasFloatingCopyButton) + + if (wrapCode) { + /** + * For wrapped code, we use a single-column flex layout. + * Lines of code are stacked in a single container, and each line row renders + * its own line number, which ensures that when lines wrap, the line numbers + * are aligned as expected + */ + return ( + +
+ + {linesOfCode.map(({ number, children, highlight, dim }, idx) => ( +
+ {lineNumbers ? ( + + ) : null} + + {children} + {'\n'} + +
+ ))} + +
+
+ ) + } else { + /** + * For overflowing code, we use a two-column layout. + * The first column contains line numbers, and is effectively fixed. + * The second column contains the lines themselves, and is an overflow + * container to allow extra long lines to scroll as needed. + */ + return ( + + {lineNumbers ? ( + + {linesOfCode.map(({ number, highlight, dim }, idx) => ( + + ))} + + ) : null} + + + {linesOfCode.map(({ children, highlight, dim }, idx) => ( + + {children} + {'\n'} + + ))} + + + + ) + } +} + +/** + * Set up the `
` + `` container
+ * which is necessary for language-specific syntax highlighting styles
+ */
+function PreCode({
+  children,
+  language,
+}: PropsWithChildren<{ language?: string }>) {
+  return (
+    
+      
+        {children}
+      
+    
+ ) +} + +/** + * Provides "padding" at the top and bottom of a code block with wrapping lines + * while retaining the "numbers" and "lines" separation border. + * + * For context, with wrapped code, we don't have separate "numbers" and "lines" + * columns as we would with overflowing code. So, we can't add padding + * to those columns as we do with overflowing code. + * + * To create padding-equivalent space, while also rendering a continuous border + * between the "numbers" and "lines" columns, we use this component, + * which is essentially and empty line of code that's been shortened a bit. + */ +function WrappedLinesSpacer({ + number, + padRight, +}: { + number: string | null + padRight: boolean +}) { + return ( +
+ {number ? : null} + {'\n'} +
+ ) +} + +/** + * Renders a line number. + * + * Note the `number` is rendered in monospace in a whitespace-sensitive way, + * so that if a padded string is passed, we can allow for table-like alignment + * of line numbers, and consistent horizontal width of numbers across all lines. + */ +function LineNumber({ + number, + highlight, + dim, + wrap, +}: { + number: number | string + highlight?: boolean + dim?: boolean + wrap?: boolean +}) { + return ( + + {number} + + ) +} + +/** + * Renders a line of code + */ +function LineOfCode({ + children, + highlight, + dim, + padRight, + wrap, +}: PropsWithChildren<{ + highlight?: boolean + dim?: boolean + padRight?: boolean + wrap?: boolean +}>) { + return ( + + {children} + + ) +} + +export default CodeLines diff --git a/packages/code-block/props.js b/packages/code-block/props.js index acd6a5dd4..b1e38d3a8 100644 --- a/packages/code-block/props.js +++ b/packages/code-block/props.js @@ -62,6 +62,11 @@ module.exports = { description: 'Set to `true` to show the copy-to-clipboard prompt and functionality.', }, + wrapCode: { + type: 'boolean', + description: + 'Set to `true` to make long lines of code wrap rather than overflow.', + }, }, }, }