Search code examples
javascriptreactjshotkeys

Context aware copy and paste in react app


I am building a react app that allows the user to copy and paste text (from one input field to another) as well as copy list items within a list.

Now I want to support keyboard shortcuts for both use cases. I tried overriding the default behavior using MouseTrap (and also tried hotkeys) and that's working fine as long as I only copy text OR list items. But I haven't managed to support context aware copy paste. I want the copy command to copy list items when the list item is focused (or mouse cursor within the area of the list) and copy text when the list is not focused.

What makes things even worse: I have pages where there is no item list, so there I just want to have the default copy paste text behaviour.

I have tried following:

  1. hook up hotkeys in the componentDidMount of the App component
  2. hook up hotkeys in the componentDidMount of the ListView component

When I press ctrl+c BOTH components fire the event, and even if I return false (which should stop the event from bubbling), it fires in both of them. What am I doing wrong?

// ListView.js
componentDidMount() {
  hotkeys('cmd+c,cmd+v', 'TestView', this.onHotKey)
}
onHotKey = (event, handler) => {
  switch (handler.key) {
    case 'cmd+c':
      console.log('Testview: COPY!')
      break
    case 'cmd+v':
      console.log('Testview: PASTE!')
      break
  }
  event.preventDefault()
  return false
}

// App.js
componentDidMount = () => {
  hotkeys('cmd+c,cmd+v', this.onHotKey);
}
onHotKey = (event, handler) => {
  switch (handler.key) {
    case 'cmd+c': console.log('App: COPY!')
      break;
    case 'cmd+v': console.log('App: PASTE!')
      break;
  }
}

Solution

  • Let's split your problem into parts.

    1. You want copy to work as intended in all general cases
    2. You want to override default behavior and if list is focused - copy list items
    3. You want to override default behavior and if list item is focused - copy focused item

    Text input behavior that you describe is generic and unless you want to modify it somehow I leave it out.

    Assuming that your elements already focusable (have tabIndex attribute), you have for each item and for whole list this states: "focused" and "not focused". To detect change from one state to another you can use event listeners that handle "blur" and "focus" events. There is a catch that event from item will bubble up to list, so, in my example I use only listeners on list. You might want more fine grained event listeners attachment (you can even transform it into HOC).

    Another thing you will have to handle is how you intend to store copied and pasted information. If you will store it in state as I did in example, user will loose ability to copy something and paste outside of your application. Also, in my example I couple copy and paste, so you will be able to paste only when you focused one of the elements. You probably want paste to work universally. You can use Clipboard API or deprecated execCommand for this.

    And finally, since I use Windows, cmd don't work, so I changed it to ctrl.

    Now, to example:

    import { Component } from "react";
    import "./styles.css";
    import hotkeys from "hotkeys-js";
    
    class List extends Component {
      // this is bad place to store copied values. Example only!
      state = {
        copiedText: ""
      };
      // this is your code, modified to store elements inner text
      onHotKey = (event, handler) => {
        switch (handler.key) {
          case "ctrl+c": {
            console.log("Copy", document.activeElement.innerText);
            this.setState({ copiedText: document.activeElement.innerText });
            break;
          }
          case "ctrl+v": {
            console.log("Paste:", this.state.copiedText);
            break;
          }
          default:
            break;
        }
      };
    
      onFocus = (event) => {
        // we stop bubbling to prevent something higher in the tree from setting it's own handler
        event.stopPropagation();
        // attaching hotkeys
        hotkeys("ctrl+c,ctrl+v", this.onHotKey);
      };
      /**
       * @param {React.FocusEvent<HTMLElement>} event
       */
      onBlur = (event) => {
        // we again stop event from bubbling
        event.stopPropagation();
        // and removing hotkey
        hotkeys.unbind("ctrl+c,ctrl+v");
      };
      componentWillUnmount() {
        // This is precaution. Without it fast refresh can break our page
        hotkeys.unbind("ctrl+c,ctrl+v");
      }
      render() {
        return (
          <ul tabIndex={0} onFocus={this.onFocus} onBlur={this.onBlur}>
            {this.props.children}
          </ul>
        );
      }
    }
    
    // ListItem just attaches tabIndex
    class ListItem extends Component {
      render() {
        return <li tabIndex={0}>{this.props.children}</li>;
      }
    }
    
    export default function App() {
      return (
        <div className="App">
          <List>
            <ListItem>First element</ListItem>
            <ListItem>Second element</ListItem>
          </List>
        </div>
      );
    }
    

    You can see live version here: https://codesandbox.io/s/mutable-bush-jfosr?file=/src/App.js:0-1752

    This example is only for you to start. It will not solve all your problems, but it should give you basic overview on how you can proceed further. Your task is pretty complex, so there will be additional challenges which you will have to solve yourself.