Repository: FormidableLabs/spectacle
Pull request #843 (2020-02-17 22:40:08)

Add code range highlighting and stepping
### Description

This adds code range highlighting/stepper support, similar to [this project](https://github.com/jamiebuilds/spectacle-code-slide).

#### Type of Change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

### How Has This Been Tested?

Added tests for the `searchChildrenForStepper` util, which is handling most of the logic.


ryan-roemer: Weirdly, when I run `yarn build-one-page`, I get a diff:

```diff
diff --git a/examples/one-page.html b/examples/one-page.html
index b7ea1a8..f657c7d 100644
--- a/examples/one-page.html
+++ b/examples/one-page.html
@@ -64,8 +64,7 @@
       `;
       // SPECTACLE_CLI_TEMPLATE_END
 
-      const formidableLogo =
-        'https://avatars2.githubusercontent.com/u/5078602?s=280&v=4';
+      const formidableLogo = 'https://avatars2.githubusercontent.com/u/5078602?s=280&v=4';
       const cppCodeBlock = `#include <iostream>
       #include <cstdlib>
       #include <sstream>
@@ -132,22 +131,13 @@
           </${Slide}>
           <${Slide} transitionEffect="slide">
             <${Heading}>Code Blocks</${Heading}>
-            <${Stepper} defaultValue=${[]} values=${[
-        [1, 1],
-        [23, 25],
-        [40, 42]
-      ]}>
+            <${Stepper} defaultValue=${[]} values=${[[1, 1], [23, 25], [40, 42]]}>
               ${(value, step) => html`<${Box} position="relative">
-                <${CodePane} highlightStart=${value[0]} highlightEnd=${
-                value[1]
-              } fontSize=${18} language="cpp" autoFillHeight>
+                <${CodePane} highlightStart=${value[0]} highlightEnd=${value[1]} fontSize=${18} language="cpp" autoFillHeight>
                   ${cppCodeBlock}
                 </${CodePane}>
                 <${Box} position="absolute" bottom="0rem" left="0rem" right="0rem" bg="black">
-                  ${step === 1 &&
-                    html`<${Text} fontSize="1.5rem" margin="0rem">This is a note!</${Text}>`}${step ===
-                2 &&
-                html`<${Text} fontSize="1.5rem" margin="0rem">You can use the stepper state to render whatever you like as you step through the code.</${Text}>`}
+                  ${step === 1 && html`<${Text} fontSize="1.5rem" margin="0rem">This is a note!</${Text}>`}${step === 2 && html`<${Text} fontSize="1.5rem" margin="0rem">You can use the stepper state to render whatever you like as you step through the code.</${Text}>`}
                 </${Box}>
               </${Box}>`}
             </${Stepper}>
@@ -183,16 +173,9 @@
               </${Box}>
             </${Grid}>
             <${Grid} gridTemplateColumns="1fr 1fr 1fr" gridTemplateRows="1fr 1fr 1fr" alignItems="center" justifyContent="center" gridRowGap=${1}>
-              ${Array(9)
-                .fill('')
-                .map(
-                  (
-                    _,
-                    index
-                  ) => html`<${FlexBox} paddingTop=${0} key=${`formidable-logo-${index}`} flex=${1}>
+              ${Array(9).fill('').map((_, index) => html`<${FlexBox} paddingTop=${0} key=${`formidable-logo-${index}`} flex=${1}>
                 <${Image} src=${formidableLogo} width=${100} />
-              </${FlexBox}>`
-                )}
+              </${FlexBox}>`)}
             </${Grid}>
           </${Slide}>
           <${Slide}>
@@ -242,12 +225,7 @@
         </${Deck}>
       `;
 
-      ReactDOM.render(
-        html`
-          <${Presentation} />
-        `,
-        document.getElementById('root')
-      );
+      ReactDOM.render(html`<${Presentation}/>`, document.getElementById('root'));
     </script>
   </body>
 </html>
```

@treyhoover can you run `yarn build-one-page` again and see if you get a diff?
treyhoover: @ryan-roemer Weird indeed. Yes, I got one when I ran it again. Updated now.
treyhoover: Should I merge now, @carlos-kelly ?
treyhoover: It's just convenient when checking whether to dim the line, as this won't
dim anything by default and doesn't require any special case handling. We
can leave it undefined and update the `isLineDimmed` function to handle
that, though.

On Fri, Feb 21, 2020, 5:21 PM Kylie Stewart <notifications@github.com>
wrote:

> *@kale-stew* commented on this pull request.
> ------------------------------
>
> In src/components/code-pane.js
> <https://github.com/FormidableLabs/spectacle/pull/843#discussion_r382834072>
> :
>
> >    theme: propTypes.object
>  };
>
>  CodePane.defaultProps = {
>    language: 'javascript',
>    theme: theme,
> -  fontSize: 15
> +  fontSize: 15,
> +  highlightStart: -Infinity,
>
> Is there a reason this value *needs* to be set and we can't rely on null?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/FormidableLabs/spectacle/pull/843?email_source=notifications&email_token=ACQT4GSIVZWYYDSHMP3KLZTREBHWFA5CNFSM4KWZRMNKYY3PNVWWK3TUL52HS4DFWFIHK3DMKJSXC5LFON2FEZLWNFSXPKTDN5WW2ZLOORPWSZGOCWRDQXQ#pullrequestreview-362952798>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ACQT4GTA4JGFIJZ44IR4LTDREBHWFANCNFSM4KWZRMNA>
> .
>


Patch:
diff --git a/docs/content/api-reference.md b/docs/content/api-reference.md
index c762aae..2cd06d8 100644
--- a/docs/content/api-reference.md
+++ b/docs/content/api-reference.md
@@ -84,11 +84,15 @@ Appear is a component that makes a component animate on the slide on key press.
 
 CodePane is a component for showing a syntax-highlighted block of source code. It will scroll for overflow amounts of code. The Code Pane will trim whitespace and normalize indents. It will also wrap long lines of code and preserve the indent. Optionally you can have the Code Pane fill the available empty space on your slide via the `autoFillHeight` prop. Themes are configurable objects and can be imported from the [prism-react-renderer themes](https://github.com/FormidableLabs/prism-react-renderer/tree/master/src/themes).
 
+Additionally, `highlightStart` and `highlightEnd` props can be used to highlight certain ranges of code. Combine this with the [Stepper](#stepper) component to iterate over lines of code as you present.
+
 | Props          | Type              | Example               |
 | -------------- | ----------------- | --------------------- |
 | autoFillHeight | PropTypes.boolean | `false`               |
 | children       | PropTypes.string  | `let name = "Carlos"` |
 | fontSize       | PropTypes.number  | `16`                  |
+| highlightEnd   | PropTypes.number  | `2`                   |
+| highlightStart | PropTypes.number  | `1`                   |
 | language       | PropTypes.string  | `javascript`          |
 | theme          | Prism Theme       | —                     |
 
@@ -108,6 +112,18 @@ import lightTheme from 'prism-react-renderer/themes/nightOwlLight';
 );
 ```
 
+<a name="stepper"></a>
+
+## Stepper
+
+Stepper is a render-prop component that allows you to step over a set of values in your presentation, providing the current value and step as arguments in the child function. Like [Appear](#appear), this iteration happens on key press. Especially useful for stepping through the [Code Pane](#code-pane) component.
+
+```jsx
+<Stepper values={[1, 2, 3]}>
+  {(value, step) => <p>Current value: {value}</p>}
+</Stepper>
+```
+
 <a name="full-screen"></a>
 
 ## FullScreen
diff --git a/examples/js/index.js b/examples/js/index.js
index 3b398bc..f8cdd04 100644
--- a/examples/js/index.js
+++ b/examples/js/index.js
@@ -17,10 +17,11 @@ import {
   Notes,
   OrderedList,
   Progress,
-  SpectacleLogo,
   Slide,
+  SpectacleLogo,
+  Stepper,
   Text,
-  indentNormalizer
+  indentNormalizer,
 } from 'spectacle';
 
 // SPECTACLE_CLI_THEME_START
@@ -129,9 +130,51 @@ const Presentation = () => (
     </Slide>
     <Slide transitionEffect="slide">
       <Heading>Code Blocks</Heading>
-      <CodePane fontSize={18} language="cpp" autoFillHeight>
-        {cppCodeBlock}
-      </CodePane>
+      <Stepper
+        defaultValue={[]}
+        values={[
+          [1, 1],
+          [23, 25],
+          [40, 42]
+        ]}
+      >
+        {(value, step) => (
+          <Box position="relative">
+            <CodePane
+              highlightStart={value[0]}
+              highlightEnd={value[1]}
+              fontSize={18}
+              language="cpp"
+              autoFillHeight
+            >
+              {cppCodeBlock}
+            </CodePane>
+
+            <Box
+              position="absolute"
+              bottom="0rem"
+              left="0rem"
+              right="0rem"
+              bg="black"
+            >
+              {/* This notes container won't appear for step 0 */}
+
+              {step === 1 && (
+                <Text fontSize="1.5rem" margin="0rem">
+                  This is a note!
+                </Text>
+              )}
+
+              {step === 2 && (
+                <Text fontSize="1.5rem" margin="0rem">
+                  You can use the stepper state to render whatever you like as
+                  you step through the code.
+                </Text>
+              )}
+            </Box>
+          </Box>
+        )}
+      </Stepper>
       <Text>
         Code Blocks now auto size and scroll when there is an overflow of
         content! They also auto-wrap longer lines.
diff --git a/examples/one-page.html b/examples/one-page.html
index 0ead43b..2e89dcd 100644
--- a/examples/one-page.html
+++ b/examples/one-page.html
@@ -33,8 +33,9 @@
         Notes,
         OrderedList,
         Progress,
-        SpectacleLogo,
         Slide,
+        SpectacleLogo,
+        Stepper,
         Text,
         indentNormalizer
       } = Spectacle;
@@ -132,9 +133,16 @@
           </${Slide}>
           <${Slide} transitionEffect="slide">
             <${Heading}>Code Blocks</${Heading}>
-            <${CodePane} fontSize=${18} language="cpp" autoFillHeight>
-              ${cppCodeBlock}
-            </${CodePane}>
+            <${Stepper} defaultValue=${[]} values=${[[1, 1], [23, 25], [40, 42]]}>
+              ${(value, step) => html`<${Box} position="relative">
+                <${CodePane} highlightStart=${value[0]} highlightEnd=${value[1]} fontSize=${18} language="cpp" autoFillHeight>
+                  ${cppCodeBlock}
+                </${CodePane}>
+                <${Box} position="absolute" bottom="0rem" left="0rem" right="0rem" bg="black">
+                  ${step === 1 && html`<${Text} fontSize="1.5rem" margin="0rem">This is a note!</${Text}>`}${step === 2 && html`<${Text} fontSize="1.5rem" margin="0rem">You can use the stepper state to render whatever you like as you step through the code.</${Text}>`}
+                </${Box}>
+              </${Box}>`}
+            </${Stepper}>
             <${Text}>Code Blocks now auto size and scroll when there is an overflow of content! They also auto-wrap longer lines.</${Text}>
           </${Slide}>
           <${Slide}>
diff --git a/index.d.ts b/index.d.ts
index d8cde4c..2cf639f 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -61,6 +61,14 @@ declare module 'spectacle' {
       plain: Record<string, string>;
       styles: Array<{ types: Array<string>; style: Record<string, string> }>;
     };
+    highlightStart?: number;
+    highlightEnd?: number;
+  }>;
+
+  export const Stepper: React.FC<{
+    children: (value: any, step: number) => React.ReactNode;
+    values: any[];
+    defaultValue?: any;
   }>;
 
   type TypographyProps = {
diff --git a/src/components/code-pane.js b/src/components/code-pane.js
index 9e4cbd8..6248834 100644
--- a/src/components/code-pane.js
+++ b/src/components/code-pane.js
@@ -18,6 +18,8 @@ const lineNumberStyles = {
 export default function CodePane(props) {
   const canvas = React.useRef(document.createElement('canvas'));
   const context = React.useRef(canvas.current.getContext('2d'));
+  const scrollContainerRef = React.useRef(null);
+  const lineRef = React.useRef(null);
   const themeContext = React.useContext(ThemeContext);
   const {
     state: { printMode }
@@ -59,6 +61,12 @@ export default function CodePane(props) {
     [font, fontSize, themeContext]
   );
 
+  const isLineDimmed = React.useCallback(
+    lineNumber =>
+      lineNumber < props.highlightStart || lineNumber > props.highlightEnd,
+    [props.highlightStart, props.highlightEnd]
+  );
+
   const measureIndentation = React.useCallback(
     indentation => {
       if (indentation === 0) {
@@ -72,6 +80,17 @@ export default function CodePane(props) {
     [props.fontSize, font]
   );
 
+  // Auto-scroll to highlighted range
+  React.useLayoutEffect(() => {
+    const lineHeight = lineRef.current.clientHeight;
+    const top = Math.max(0, (props.highlightStart - 1) * lineHeight);
+
+    scrollContainerRef.current.scroll({
+      top,
+      behavior: 'smooth'
+    });
+  }, [lineRef.current, props.highlightStart]);
+
   return (
     <>
       <Highlight
@@ -82,6 +101,7 @@ export default function CodePane(props) {
       >
         {({ className, style, tokens, getLineProps, getTokenProps }) => (
           <pre
+            ref={scrollContainerRef}
             className={`${className} ${props.autoFillHeight &&
               'spectacle-auto-height-fill'}`}
             style={{ ...style, ...preStyles }}
@@ -89,18 +109,21 @@ export default function CodePane(props) {
             {tokens.map((line, i) => {
               const lineProps = getLineProps({ line, key: i });
               const lineIndentation = line[0].content.search(spaceSearch);
+
               lineProps.style = {
                 ...(lineProps.style || {}),
                 whiteSpace: 'pre-wrap',
                 display: 'flex',
                 alignItems: 'center',
-                justifyContent: 'flex-start'
+                justifyContent: 'flex-start',
+                opacity: isLineDimmed(i + 1) ? 0.5 : 1
               };
               if (line[0].content && !line[0].empty) {
                 line[0].content = line[0].content.trimLeft();
               }
+
               return (
-                <div key={i} {...lineProps}>
+                <div key={i} {...lineProps} ref={i === 0 ? lineRef : undefined}>
                   <div style={lineNumberStyles}>{i + 1}</div>
                   <div
                     style={{
@@ -128,11 +151,15 @@ CodePane.propTypes = {
   children: propTypes.string.isRequired,
   fontSize: propTypes.number,
   language: propTypes.string.isRequired,
+  highlightEnd: propTypes.number,
+  highlightStart: propTypes.number,
   theme: propTypes.object
 };
 
 CodePane.defaultProps = {
   language: 'javascript',
   theme: theme,
-  fontSize: 15
+  fontSize: 15,
+  highlightStart: -Infinity,
+  highlightEnd: Infinity
 };
diff --git a/src/components/deck/index.js b/src/components/deck/index.js
index 83deacb..f81bda2 100644
--- a/src/components/deck/index.js
+++ b/src/components/deck/index.js
@@ -24,6 +24,7 @@ import {
   DEFAULT_SLIDE_INDEX
 } from '../../utils/constants';
 import searchChildrenForAppear from '../../utils/search-children-appear';
+import searchChildrenForStepper from '../../utils/search-children-stepper';
 import OverviewDeck from './overview-deck';
 import { Markdown, Slide, Notes } from '../../index';
 import { isolateNotes, removeNotes } from '../../utils/notes';
@@ -138,11 +139,14 @@ const Deck = props => {
   }
 
   const slideElementMap = React.useMemo(() => {
-    const map = {};
-    filteredChildren.filter((slide, index) => {
-      map[index] = searchChildrenForAppear(slide.props.children);
-    });
-    return map;
+    return filteredChildren.reduce((map, slide, index) => {
+      const appearElements = searchChildrenForAppear(slide.props.children);
+      const stepperElements = searchChildrenForStepper(slide.props.children);
+
+      map[index] = appearElements + stepperElements;
+
+      return map;
+    }, {});
   }, [filteredChildren]);
 
   // Initialise useDeck hook and get state and dispatch off of it
diff --git a/src/components/stepper.js b/src/components/stepper.js
new file mode 100644
index 0000000..4e68595
--- /dev/null
+++ b/src/components/stepper.js
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import propTypes from 'prop-types';
+import { SlideContext } from '../hooks/use-slide';
+
+const Stepper = ({ children: render, values, defaultValue }) => {
+  const {
+    state: { currentSlideElement: step }
+  } = React.useContext(SlideContext);
+
+  const value = step === -1 ? defaultValue : values[step];
+
+  return render(value, step);
+};
+
+Stepper.propTypes = {
+  values: propTypes.array.isRequired,
+  defaultValue: propTypes.array
+};
+
+export default Stepper;
diff --git a/src/index.js b/src/index.js
index 82e8894..9eb4854 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,6 +2,7 @@ import Deck from './components/deck';
 import Slide from './components/slide';
 import Appear from './components/appear';
 import CodePane from './components/code-pane';
+import Stepper from './components/stepper';
 import {
   OrderedList,
   Quote,
@@ -53,6 +54,7 @@ export {
   FullScreen,
   Markdown,
   SpectacleLogo,
+  Stepper,
   Table,
   TableCell,
   TableRow,
diff --git a/src/utils/search-children-appear.js b/src/utils/search-children-appear.js
index 2ae5a8e..fb4d524 100644
--- a/src/utils/search-children-appear.js
+++ b/src/utils/search-children-appear.js
@@ -8,7 +8,7 @@ export default function searchChildrenForAppear(children) {
   return children.reduce((memo, current) => {
     if (isComponentType(current, Appear.name)) {
       memo += 1;
-    } else if (current.props.children && current.props.children.length > 0) {
+    } else if (current?.props?.children?.length > 0) {
       memo += searchChildrenForAppear(current.props.children);
     }
     return memo;
diff --git a/src/utils/search-children-stepper.js b/src/utils/search-children-stepper.js
new file mode 100644
index 0000000..0e5a2d6
--- /dev/null
+++ b/src/utils/search-children-stepper.js
@@ -0,0 +1,18 @@
+import isComponentType from './is-component-type';
+import Stepper from '../components/stepper';
+
+export default function searchChildrenForStepper(children) {
+  if (!Array.isArray(children)) {
+    return 0;
+  }
+  return children.reduce((memo, current) => {
+    if (isComponentType(current, Stepper.name)) {
+      const { values } = current.props;
+
+      memo += Array.isArray(values) ? values.length : 0;
+    } else if (current?.props?.children?.length > 0) {
+      memo += searchChildrenForStepper(current.props.children);
+    }
+    return memo;
+  }, 0);
+}


Test patch:
diff --git a/src/utils/search-children-stepper.test.js b/src/utils/search-children-stepper.test.js
new file mode 100644
index 0000000..7fab75e
--- /dev/null
+++ b/src/utils/search-children-stepper.test.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import Enzyme from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+
+import Stepper from '../components/stepper';
+
+import searchChildrenForStepper from './search-children-stepper';
+
+Enzyme.configure({ adapter: new Adapter() });
+
+describe('search children for stepper', () => {
+  it('returns 0 when there are no children', () => {
+    expect(searchChildrenForStepper([])).toEqual(0);
+  });
+
+  it('returns 0 when there are no stepper elements', () => {
+    expect(
+      searchChildrenForStepper([
+        <div key={0}>not a stepper element</div>,
+        <div key={1}>nor me</div>
+      ])
+    ).toEqual(0);
+  });
+
+  it('returns the amount of stepper elements', () => {
+    expect(
+      searchChildrenForStepper([
+        <div key={0}>not a stepper element</div>,
+        <Stepper key={1} values={[[1, 1]]}>
+          {() => 'But I have 1 value'}
+        </Stepper>,
+        <div key={2} />,
+        <Stepper
+          key={3}
+          values={[
+            [1, 1],
+            [2, 2],
+            [3, 3]
+          ]}
+        >
+          {() => 'And I have 3 values'}
+        </Stepper>
+      ])
+    ).toEqual(4);
+  });
+});