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
Comments
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. |
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 There are only a few special cases to actually trap capturing, with the rest trapping on the bubble phase. |
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):
|
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 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 |
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 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:
|
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. |
@saulshanabrook For what it's worth, we are already modifying the lumino context menu logic a bit to do two things:
|
Thanks @ian-r-rose! Turned out to be very easy to add a fix here with that pointer (#7877) |
Is there a way to extend this (likely in a second PR) so that all Example:
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). |
@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. |
I did not check with |
Fixes #7670 by disabling JuptyrLab's context menu on all elements which are children of elements with the `data-native-context-menu` property.
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.
The text was updated successfully, but these errors were encountered: