Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show output's context menu and prevent lab's global context menu #7670

Closed
KrishnaKumarHariprasannan opened this issue Dec 20, 2019 · 11 comments · Fixed by #7877
Closed

Show output's context menu and prevent lab's global context menu #7670

KrishnaKumarHariprasannan opened this issue Dec 20, 2019 · 11 comments · Fixed by #7877
Assignees
Labels
question status:Needs Discussion status:resolved-locked
Milestone

Comments

@KrishnaKumarHariprasannan
Copy link
Contributor

@KrishnaKumarHariprasannan KrishnaKumarHariprasannan commented Dec 20, 2019

Description

For mime renderers that return react components, its not possible to prevent lab's global context menu and also show a custom context menu.

Jupyterlab's docs on context menus suggests users to preempt the global context menu at the widget layer. But this does not work as intended(react element's custom context menu is also suppressed) as the events do not reach the react handlers.

This can be explained by the way react events and dom events work - synthetic react events are fired only when the corresponding dom event reaches the document object. The changes made as part of #3554 registers these events in its capture phase and when we stop its propagation at this phase, it never gets to create a react event.

One way to workaround this is for users can stop/modify the contextmenu event at the widget layer and launch another event and listen in on the new event in their mime renderers. This will require changes in all such components. What is the recommended way to work around this issue?

Reproduce

I have a minimal reproducer at https://github.com/KrishnaKumarHariprasannan/jupyterlab-event-order-demo - follow instructions in README.

@saulshanabrook saulshanabrook self-assigned this Jan 9, 2020
@saulshanabrook
Copy link
Member

@saulshanabrook saulshanabrook commented Jan 21, 2020

Thanks for making a reproducible example here. I am tracking this down and I think some of the issue is that React has its own event bubbling system that always executes after the doms. There is an open PR to change this in React (facebook/react#2043).

I will continue investigating how you can have a custom contextmenu open using React and disable JupyterLab's context menu.

@saulshanabrook
Copy link
Member

@saulshanabrook saulshanabrook commented Jan 21, 2020

I thought maybe I could use React's capture event tracking to intercept the event before it gets to the bubbling phase (which is where JupyterLab's context menu is opened). However, even though React does allow you to listen to on the capture phase (by using the onContextMenuCapture prop), it actually seems to bind this to the browsers bubbling phase.

https://github.com/facebook/react/blob/e706721490e50d0bd6af2cd933dbf857fd8b61ed/packages/react-dom/src/events/ReactBrowserEventEmitter.js#L143-L186

There are only a few special cases to actually trap capturing, with the rest trapping on the bubble phase.

@saulshanabrook
Copy link
Member

@saulshanabrook saulshanabrook commented Jan 21, 2020

I was able to work around this by intercepting the native browser events in the React component, instead of using React's event system. I was able to get the inner callback to trigger and interrupt the event there before JupyterLab callback is triggered.

function Wrapper({ data }: { data: JSONObject }) {
  const callback = React.useRef((e: MouseEvent) => {
    console.log("Mime Renderer's event handler", e.eventPhase);
    e.stopPropagation();
    e.preventDefault();
  });

  // save the last node we were listening on to clean up after
  const [
    listeningNode,
    setListeningNode
  ] = React.useState<null | HTMLDivElement>(null);

  const divRef = React.useCallback((node: null | HTMLDivElement) => {
    if (node !== null) {
      if (listeningNode) {
        listeningNode.removeEventListener('contextmenu', callback.current, {
          capture: false
        });
      }
      node.addEventListener('contextmenu', callback.current, {
        capture: false
      });
      setListeningNode(node);
    }
  }, []);
  const textContent: string = JSON.stringify(data);
  return <div ref={divRef}> {textContent} </div>;
}


/**
 * A widget for rendering my_type.
 */
export class OutputWidget extends Widget implements IRenderMime.IRenderer {
  /**
   * Construct a new output widget.
   */
  constructor(options: IRenderMime.IRendererOptions) {
    super();
    this._mimeType = options.mimeType;
    this.addClass(CLASS_NAME);
  }

  /**
   * Render my_type into this widget's node.
   */
  renderModel(model: IRenderMime.IMimeModel): Promise<void> {
    let data = model.data[this._mimeType] as JSONObject;

    return new Promise<void>((resolve, reject) => {
      ReactDOM.render(<Wrapper data={data} />, this.node, () => {
        resolve();
      });
    });
    return Promise.resolve();
  }

  protected onAfterAttach(msg: Message): void {
    this.node.addEventListener('contextmenu', this, false);
  }

  handleEvent(event: Event) {
    if (event.type == 'contextmenu') {
      console.log("Widget's event handler", event.eventPhase);
      // UNCOMMENT THE BELOW LINE TO SEE THE ISSUE
      // event.stopPropagation();
    }
  }

  private _mimeType: string;
}

We can refactor this behavior to a hook:

function useCustomContextMenu(
  callback: (event: MouseEvent) => void
): (node: null | HTMLElement) => void {
  // save the last node and listening fn, so we can clean up after
  const [listening, setListening] = React.useState<
    null | [HTMLDivElement, (e: MouseEvent) => void]
  >(null);

  return React.useCallback((node: null | HTMLDivElement) => {
    if (node !== null) {
      if (listening) {
        listening[0].removeEventListener('contextmenu', listening[1], {
          capture: false
        });
      }
      node.addEventListener('contextmenu', callback, {
        capture: false
      });
      setListening([node, callback]);
    }
  }, []);
}

function Wrapper({ data }: { data: JSONObject }) {
  const ref = useCustomContextMenu(event => {
    event.preventDefault();
    event.stopPropagation();
    console.log('Put up custom context menu');
  });

  const textContent: string = JSON.stringify(data);
  return <div ref={ref}> {textContent} </div>;
}

Another way to resolve this issue would be fix the upstream React issue, which is on their roadmap (facebook/react#13525):

Attach events at the React root rather than the document (facebook/react#2043). Attaching event handlers to the document becomes an issue when embedding React apps into larger systems. The Atom editor was one of the first cases that bumped into this. Any big website also eventually develops very complex edge cases related to stopPropagation interacting with non-React code or across React roots (facebook/react#8693, facebook/react#8117, facebook/react#12518). We will also want to attach events eagerly to every root so that we can do less runtime checks during updates.

@saulshanabrook
Copy link
Member

@saulshanabrook saulshanabrook commented Jan 24, 2020

After some reflection, I have come up with a slightly more complete solution. In our custom context menu, we can use React DOM's testing utils to dispatch a custom react event to the target node for the event. This means any children of that node that use onContextMenu will be dispatched correctly:

import ReactTestUtils from 'react-dom/test-utils';


function useCustomContextMenu(
  callback: (event: MouseEvent) => void
): (node: null | HTMLElement) => void {
  // save the last node and listening fn, so we can clean up after
  const [listening, setListening] = React.useState<
    null | [HTMLDivElement, (e: MouseEvent) => void]
  >(null);

  return React.useCallback((node: null | HTMLDivElement) => {
    if (node !== null) {
      if (listening) {
        listening[0].removeEventListener('contextmenu', listening[1], {
          capture: false
        });
      }
      node.addEventListener('contextmenu', callback, {
        capture: false
      });
      setListening([node, callback]);
    }
  }, []);
}

function useNoDefaultContextMenu(): (node: null | HTMLElement) => void {
  return useCustomContextMenu(event => {
    // Stop browser default context menu
    event.preventDefault();
    // Stop JupyterLab context menu
    event.stopPropagation();
    // Dispatch React context menu event
    ReactTestUtils.Simulate.contextMenu(event.target as any);
  });
}

function Wrapper({ data }: { data: JSONObject }) {
  const ref = useNoDefaultContextMenu();

  const textContent: string = JSON.stringify(data);
  return (
    <div ref={ref}>
      <div
        onContextMenu={() => {
          console.log('handling react context menu');
        }}
      >
        {textContent}
      </div>
    </div>
  );
}

It might make sense to add useNoDefaultContextMenu to JupyterLab core to make it easy for any React widget to add their own context menu. All they have to do is to make sure the outer component gets passed the useNoDefaultContextMenu() ref.

@saulshanabrook saulshanabrook added this to the Future milestone Jan 26, 2020
@KrishnaKumarHariprasannan
Copy link
Contributor Author

@KrishnaKumarHariprasannan KrishnaKumarHariprasannan commented Feb 3, 2020

Thanks for looking into this @saulshanabrook ! Although the above does work as expected, I find it odd to use test utilities of a library as factors such as the bundle size, breaking changes, documentation, etc, are not guaranteed.

The current workaround that I have stops the original event and fires another with the shiftKey set (to get around lab's contextmenu). Attaching the same for reference:

export class OutputWidget extends Widget implements IRenderMime.IRenderer {
  /**
   * Construct a new output widget.
   */
  constructor(options: IRenderMime.IRendererOptions) {
    super();
    this._mimeType = options.mimeType;
    this.addClass(CLASS_NAME);
  }

  /**
   * Render my_type into this widget's node.
   */
  renderModel(model: IRenderMime.IMimeModel): Promise<void> {
    let data = model.data[this._mimeType] as JSONObject;

    return new Promise<void>((resolve, reject) => {
      ReactDOM.render(<Wrapper data={data} />, this.node, () => {
        resolve();
      });
    });
    return Promise.resolve();
  }

  protected onAfterAttach(msg: Message): void {
    this.node.addEventListener('contextmenu', this);
  }

  handleEvent(event: MouseEvent){
    if (event.type === 'contextmenu' && !event.shiftKey) {
      const shiftRightClickEvent = new MouseEvent('contextmenu', {
        clientX: event.clientX,
        clientY: event.clientY,
        screenX: event.screenX,
        screenY: event.screenY,
        shiftKey: true, // ## Important ##
        bubbles: true
      });

      // Stop original event's propagation
      event.preventDefault();
      event.stopPropagation();
      // Dispatch a similar event with shiftKey
      // @ts-ignore
      event.target.dispatchEvent(shiftRightClickEvent);
    }
  }

  private _mimeType: string;
}

I'll also note a few other possibilities below:

  1. Converting the react event handlers in the extensions to native event handlers - this is not desirable as the components may be shared with other react applications which can lead to unexpected issues
  2. Remove lab event handlers for contextmenu from outputarea widgets - this would mean that lab's contextmenu will not be shown in any of the output area inside a notebook

@tgeorgeux tgeorgeux self-assigned this Feb 12, 2020
@tgeorgeux tgeorgeux removed their assignment Feb 12, 2020
@saulshanabrook
Copy link
Member

@saulshanabrook saulshanabrook commented Feb 12, 2020

We chatted about this in the JupyterLab meeting today. I brought up the idea of allowing you to disable JupyterLab's context menu on any element and its children. It was preferred over allowing some option-shift-right click to allow a third context menu.

So I will look at how to add this to core. @afshin suggested that we could do this without having to modify lumino by modifying our event listener for the right click to ignore nodes, maybe that have a certain data attribute. Then all you would have to do is put that data attribute on the root of your output and you would be able to handle them yourself.

@ian-r-rose
Copy link
Member

@ian-r-rose ian-r-rose commented Feb 12, 2020

@saulshanabrook For what it's worth, we are already modifying the lumino context menu logic a bit to do two things:

  1. Capture the target of the click so that commands can inspect that for more information than is provided by the args, and
  2. Showing the shift-right-click hint.

@saulshanabrook
Copy link
Member

@saulshanabrook saulshanabrook commented Feb 12, 2020

Thanks @ian-r-rose! Turned out to be very easy to add a fix here with that pointer (#7877)

@mlucool
Copy link
Contributor

@mlucool mlucool commented Feb 19, 2020

Is there a way to extend this (likely in a second PR) so that all img tags by default use their native context menu. This is because copy/pasting an image from lab->elsewhere is very common and today's setup makes this harder.

Example:

%matplotlib inline
plot([1,2,3], [1,4,9])

On this image, I'd like to copy the image so it can paste into something like slack. If I drag+highlight then ctrl+c, it does not copy in a way that makes most applications happy enough to accept it (I'll note outlook will paste correctly).

@saulshanabrook
Copy link
Member

@saulshanabrook saulshanabrook commented Feb 20, 2020

@mlucool I thought we did something a while back about making images use the native context menus... I'll try to find the old issue or reproduce this.

@mlucool
Copy link
Contributor

@mlucool mlucool commented Feb 20, 2020

I did not check with 2.x so maybe it is fixed. I am using 1.2.3.

vidartf pushed a commit that referenced this issue Mar 18, 2020
Fixes #7670 by disabling JuptyrLab's context menu on all elements which
are children of elements with the `data-native-context-menu` property.
@lock lock bot added the status:resolved-locked label Apr 17, 2020
@lock lock bot locked as resolved and limited conversation to collaborators Apr 17, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
question status:Needs Discussion status:resolved-locked
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants