From d6c2e60b6be354c9a89174cfcbd91bb4f1de0ac8 Mon Sep 17 00:00:00 2001 From: Daniel Bryan Date: Fri, 10 Mar 2023 00:47:50 -0500 Subject: [PATCH 01/13] Changes for contextMenu on longtouch: 1. longtouch timer in mouseDown sends a contextmenu PointerEvent with x,y coordinates 2. handleContextMenu should allow wiggle room for the longtouch contextmenu event 3. Clear the longtouch timeout if mousemove detects we've dragged away from the target 4. Mark isDragging false when actually invoking the context menu, so we ignore mouseups 5. Add longTouchDuration and longTouchTimeout to instance data --- src/GLViewer.ts | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/GLViewer.ts b/src/GLViewer.ts index 643236a47..1b79ca662 100644 --- a/src/GLViewer.ts +++ b/src/GLViewer.ts @@ -45,6 +45,7 @@ export class GLViewer { private contextMenuEnabledAtoms = []; // atoms with context menu private current_hover: any = null; private hoverDuration = 500; + private longTouchDuration = 1000; private viewer_frame = 0; private WIDTH: number; private HEIGHT: number; @@ -106,6 +107,7 @@ export class GLViewer { private mouseButton: any; private hoverTimeout: any; + private longTouchTimeout: any; private divwatcher: any; private spinInterval: any; @@ -877,19 +879,26 @@ export class GLViewer { this.cslabFar = this.slabFar; let self = this; - setTimeout(function () { - if (ev.targetTouches) { + if (ev.targetTouches && ev.targetTouches.length === 1) { + this.longTouchTimeout = setTimeout(function () { if (self.touchHold == true) { // console.log('Touch hold', x,y); self.glDOM = self.renderer.domElement; - self.glDOM.dispatchEvent(new Event('contextmenu')); + const touch = ev.targetTouches[0]; + const newEvent = new PointerEvent('contextmenu', { + ...ev, + pageX: touch.pageX, pageY: touch.pageY, + screenX: touch.screenX, screenY: touch.screenY, + clientX: touch.clientX, clientY: touch.clientY, + }); + self.glDOM.dispatchEvent(newEvent); } else { // console.log('Touch hold ended earlier'); } - } - }, 1000); + }, this.longTouchDuration); + } }; @@ -1059,6 +1068,11 @@ export class GLViewer { return; } + // Cancel longtouch timer to avoid invoking context menu if dragged away from start + if (ev.targetTouches && (ev.targetTouches.length > 1 || + (ev.targetTouches.length === 1 && !this.closeEnoughForClick(ev)))) { + clearTimeout(this.longTouchTimeout); + } var dx = (x - this.mouseStartX) / this.WIDTH; var dy = (y - this.mouseStartY) / this.HEIGHT; @@ -1115,12 +1129,9 @@ export class GLViewer { public _handleContextMenu(ev) { ev.preventDefault(); - var newX = this.getX(ev); - var newY = this.getY(ev); - - if (newX != this.mouseStartX || newY != this.mouseStartY) { - return; - } else { + // contextmenu event is synthetic (not trusted) if it is in response to a longtouch, + // so we should allow wiggle room when checking the position. + if (this.closeEnoughForClick(ev, { allowTolerance: !ev.isTrusted })) { var x = this.mouseStartX; var y = this.mouseStartY; var offset = this.canvasOffset(); @@ -1137,6 +1148,10 @@ export class GLViewer { var y = this.mouseStartY - offset.top; if (this.userContextMenuHandler) { this.userContextMenuHandler(selected, x, y,intersects); + // We've processed this as a context menu evt; ignore further mouseup / touchend. + // This is really for touchend after longtouch, since the mouseup for right-click + // occurs before the contextmenu event. + this.isDragging = false; } } }; From b8a3fa79fa2f528b1013fe7fc6282bc125b28bd3 Mon Sep 17 00:00:00 2001 From: Daniel Bryan Date: Fri, 10 Mar 2023 01:17:20 -0500 Subject: [PATCH 02/13] Support context menu events for Shapes --- src/GLShape.ts | 4 ++++ src/GLViewer.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/GLShape.ts b/src/GLShape.ts index 2add06545..1cf1c3903 100644 --- a/src/GLShape.ts +++ b/src/GLShape.ts @@ -628,6 +628,7 @@ export class GLShape { shape.hoverable = stylespec.hoverable ? true : false; shape.hover_callback = makeFunction(stylespec.hover_callback); shape.unhover_callback = makeFunction(stylespec.unhover_callback); + shape.contextMenuEnabled = !!stylespec.contextMenuEnabled; shape.hidden = stylespec.hidden; shape.frame = stylespec.frame; @@ -645,6 +646,7 @@ export class GLShape { hoverable = false; hover_callback: Func; unhover_callback: Func; + contextMenuEnabled: boolean = false; frame: any; side = DoubleSide; shapePosition: any; @@ -1574,6 +1576,8 @@ export interface ShapeSpec { hover_callback?: Func; /** unhover callback */ unhover_callback?: Func; + /** if true, user can right-click or long press to trigger callback */ + contextMenuEnabled?: boolean; /** if set, only display in this frame of an animation */ frame?: number; side?: number; diff --git a/src/GLViewer.ts b/src/GLViewer.ts index 1b79ca662..4054c8624 100644 --- a/src/GLViewer.ts +++ b/src/GLViewer.ts @@ -329,6 +329,9 @@ export class GLViewer { if (shape && shape.hoverable) { this.hoverables.push(shape); } + if (shape && shape.contextMenuEnabled) { + this.contextMenuEnabledAtoms.push(shape); + } } }; From e9c5c2adeb557b686eda6081bc02bdf385dc4fd5 Mon Sep 17 00:00:00 2001 From: Daniel Bryan Date: Fri, 10 Mar 2023 08:50:57 -0500 Subject: [PATCH 03/13] Suppress click event from mouseup when invoking right-click contextmenu --- src/GLViewer.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/GLViewer.ts b/src/GLViewer.ts index 4054c8624..063655367 100644 --- a/src/GLViewer.ts +++ b/src/GLViewer.ts @@ -346,7 +346,16 @@ export class GLViewer { selected.callback = makeFunction(selected.callback); } if (typeof (selected.callback) === "function") { - selected.callback(selected, this._viewer, event, this.container, intersects); + // Suppress click callbacks when context menu will be invoked. + // This only applies to clicks from "mouseup" events after right-click. + // Clicks from "touchend" after longtouch contextmenu are suppressed + // in _handleContextMenu. + const isContextMenu = this.mouseButton === 3 + && this.contextMenuEnabledAtoms.includes(selected) + && this.userContextMenuHandler; + if (!isContextMenu) { + selected.callback(selected, this._viewer, event, this.container, intersects); + } } } } From 9caef531bb7d4e4adfa3d676b91ea9665d0ae087 Mon Sep 17 00:00:00 2001 From: Daniel Bryan Date: Thu, 28 Mar 2024 00:44:52 -0400 Subject: [PATCH 04/13] Rename contextMenuEnabledAtoms --- src/GLViewer.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/GLViewer.ts b/src/GLViewer.ts index 065a88709..f32f0b56a 100644 --- a/src/GLViewer.ts +++ b/src/GLViewer.ts @@ -42,7 +42,7 @@ export class GLViewer { private labels: Label[] = []; private clickables = []; //things you can click on private hoverables = []; //things you can hover over - private contextMenuEnabledAtoms = []; // atoms with context menu + private contextMenuEnabledObjects = []; // atoms and shapes with context menu private current_hover: any = null; private hoverDuration = 500; private longTouchDuration = 1000; @@ -298,7 +298,7 @@ export class GLViewer { private updateClickables() { this.clickables.splice(0, this.clickables.length); this.hoverables.splice(0, this.hoverables.length); - this.contextMenuEnabledAtoms.splice(0, this.contextMenuEnabledAtoms.length); + this.contextMenuEnabledObjects.splice(0, this.contextMenuEnabledObjects.length); for (let i = 0, il = this.models.length; i < il; i++) { let model = this.models[i]; @@ -322,9 +322,9 @@ export class GLViewer { this.clickables.push(atoms[m]); } - // add atoms into contextMenuEnabledAtoms + // add atoms into contextMenuEnabledObjects for (let m = 0; m < contextMenuEnabled_atom.length; m++) { - this.contextMenuEnabledAtoms.push(contextMenuEnabled_atom[m]); + this.contextMenuEnabledObjects.push(contextMenuEnabled_atom[m]); } } @@ -339,7 +339,7 @@ export class GLViewer { this.hoverables.push(shape); } if (shape && shape.contextMenuEnabled) { - this.contextMenuEnabledAtoms.push(shape); + this.contextMenuEnabledObjects.push(shape); } } }; @@ -360,7 +360,7 @@ export class GLViewer { // Clicks from "touchend" after longtouch contextmenu are suppressed // in _handleContextMenu. const isContextMenu = this.mouseButton === 3 - && this.contextMenuEnabledAtoms.includes(selected) + && this.contextMenuEnabledObjects.includes(selected) && this.userContextMenuHandler; if (!isContextMenu) { selected.callback(selected, this._viewer, event, this.container, intersects); @@ -1192,7 +1192,7 @@ export class GLViewer { let mouseX = mouse.x; let mouseY = mouse.y; - let intersects = this.targetedObjects(mouseX, mouseY, this.contextMenuEnabledAtoms); + let intersects = this.targetedObjects(mouseX, mouseY, this.contextMenuEnabledObjects); var selected = null; if (intersects.length) { selected = intersects[0].clickable; From 24fdd96a556206096ae41913bd82cb889158e9b2 Mon Sep 17 00:00:00 2001 From: Daniel Bryan Date: Thu, 28 Mar 2024 13:29:25 -0400 Subject: [PATCH 05/13] Add contextmenu integration test --- tests/auto/tests/contextmenu.js | 115 ++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/auto/tests/contextmenu.js diff --git a/tests/auto/tests/contextmenu.js b/tests/auto/tests/contextmenu.js new file mode 100644 index 000000000..1ec584425 --- /dev/null +++ b/tests/auto/tests/contextmenu.js @@ -0,0 +1,115 @@ +let correctMenuPosition = false; + +function setup(viewer) { + viewer.setHeight(400); + viewer.setWidth(400); + viewer.userContextMenuHandler = (selected, x, y, intersects) => { + openMenu(viewer, selected, x, y, intersects); + } + viewer.zoomTo(); + + // atoms have to be clickable to support context menu + viewer.setClickable({}, true); + viewer.enableContextMenu({},true); + viewer.render( ); + + // Emulate a contextmenu event for the 2-oclock H. + // Since this is a synthetic event (not actually generated by the browser), + // It will get processed by closeEnoughForClick, which checks mouseStartX/Y. + // Trigger mousedown first so mouseStartX/Y is captured. + const evt = { pageX: 398, pageY: 274, preventDefault: () => {}}; + viewer._handleMouseDown(evt); + viewer._handleContextMenu(evt); +}; + +function closeMenu() { + document.getElementById('menu').style.display = 'none'; +} + +function openMenu(viewer, selected, x, y, intersects) { + const type = targetType(selected); + const menuElt = document.getElementById('menu'); + const menuTargetElt = document.getElementById('menu_target_label'); + const [menuItem1, menuItem2, menuItem3, menuItem4] = [1, 2, 3, 4].map( + (n) => document.getElementById(`menu-item-${n}`) + ); + + function finishMenu() { viewer.render(); closeMenu(); } + function positionMenu() { + const offsetX = 100; // left style of "parent" elt + const offsetY = 100; // top style of "parent" elt + menuElt.style.left = x + (correctMenuPosition ? offsetX : 0); + menuElt.style.top = y + (correctMenuPosition ? offsetY : 0); + menuElt.style.display = 'block'; + const correctionVerb = correctMenuPosition ? 'Disable' : 'Enable'; + menuItem3.innerText = `${correctionVerb} Menu Position Correction`; + } + const sphereBase = { + color: 'purple', radius: 1.0, alpha: 0.4, contextMenuEnabled: true, + }; + const menuData = { + atom: { + label: `atom ${selected?.atom}`, + itemLabel: 'Show Sphere Around Atom', + itemAction: () => { + viewer.addSphere({ center: selected, ...sphereBase }); + finishMenu(); + }, + }, + shape: { + label: 'shape', + itemLabel: 'Remove Sphere', + itemAction: () => { + viewer.removeShape(selected); + finishMenu(); + }, + }, + nothing: { + label: 'nothing', + itemLabel: 'Draw Sphere at Origin', + itemAction: () => { + viewer.addSphere({center:{x: 0, y: 0, z: 0}, ...sphereBase }); + finishMenu(); + }, + }, + }; + + const { label, itemLabel, itemAction } = menuData[type]; + menuTargetElt.innerText = label; + menuItem1.innerText = itemLabel; + menuItem1.onclick = itemAction; + menuItem2.onclick = () => { + viewer.removeAllShapes(); + finishMenu(); + }; + menuItem3.onclick = () => { + correctMenuPosition = !correctMenuPosition; + positionMenu(); + }; + + positionMenu(); +} + +function targetType(selected) { + if (!selected) return 'nothing'; + return selected.atom ? 'atom' : 'shape'; +} + + /* @div +
+
+
+
+ +
+*/ + + From c5031bf8bc9389d28a5f9d2e41ee9e73e7f017c4 Mon Sep 17 00:00:00 2001 From: Daniel Bryan Date: Mon, 1 Apr 2024 08:36:30 -0400 Subject: [PATCH 06/13] GLViewer_handleContextMenu revert to default closeEnoughForClick logic --- src/GLViewer.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/GLViewer.ts b/src/GLViewer.ts index f32f0b56a..8804ef291 100644 --- a/src/GLViewer.ts +++ b/src/GLViewer.ts @@ -1182,9 +1182,7 @@ export class GLViewer { public _handleContextMenu(ev) { ev.preventDefault(); - // contextmenu event is synthetic (not trusted) if it is in response to a longtouch, - // so we should allow wiggle room when checking the position. - if (this.closeEnoughForClick(ev, { allowTolerance: !ev.isTrusted })) { + if (this.closeEnoughForClick(ev)) { var x = this.mouseStartX; var y = this.mouseStartY; var offset = this.canvasOffset(); From 716158eba696a11bd518756677ef0336a6afacbe Mon Sep 17 00:00:00 2001 From: Daniel Bryan Date: Mon, 1 Apr 2024 14:56:58 -0400 Subject: [PATCH 07/13] Add mouseevent to userContextMenuHandler calls --- src/GLViewer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GLViewer.ts b/src/GLViewer.ts index 8804ef291..45a4f23aa 100644 --- a/src/GLViewer.ts +++ b/src/GLViewer.ts @@ -1200,7 +1200,7 @@ export class GLViewer { var x = this.mouseStartX - offset.left; var y = this.mouseStartY - offset.top; if (this.userContextMenuHandler) { - this.userContextMenuHandler(selected, x, y, intersects); + this.userContextMenuHandler(selected, x, y, intersects, ev); // We've processed this as a context menu evt; ignore further mouseup / touchend. // This is really for touchend after longtouch, since the mouseup for right-click // occurs before the contextmenu event. From 393a5d26b581e49e8ebff22e347175989a1c3101 Mon Sep 17 00:00:00 2001 From: Daniel Bryan Date: Mon, 1 Apr 2024 14:59:50 -0400 Subject: [PATCH 08/13] Update contextmenu tests to demonstrate all changes on load --- tests/auto/tests/contextmenu.js | 236 ++++++++++++++++++++------------ 1 file changed, 145 insertions(+), 91 deletions(-) diff --git a/tests/auto/tests/contextmenu.js b/tests/auto/tests/contextmenu.js index 1ec584425..a37b8d054 100644 --- a/tests/auto/tests/contextmenu.js +++ b/tests/auto/tests/contextmenu.js @@ -1,114 +1,168 @@ -let correctMenuPosition = false; +/** contextmenu.js + * + * Test updates for context menu. + * Original behavior supported invoking context menu by right-clicking on an + * atom in the same absolute parent. + * + * Updated behavior: + * - Support longtouch to open context menu + * - Support context menu for Shapes + * - Suppress "click" events (mouseup, touchup) when context menu is invoked + * - Pass the mouse event to the userContextMenuHandler in case of a different + * offset parent. + * + * How this tests the features: + * For View1, the canvas and the menu have the same absolute parent element, so + * the click handler can use the default x and y to position the menu. + * For View2, the canvas and the menu have different absolute parent elements, + * so the click handler needs other mouseevent coords to position the menu. + * + * 1. View1 simulates mouse right-clicks: + * - Right-clicking an atom should open the menu at the atom + * - The "Click" label should not be seen if the click event is suppressed. + * 2. View2 simulates right-click on background and longtouch on Shapes + * - Right-clicking on the background should open the menu at click location. + * - Longpress on the Sphere should open the context menu at the sphere. + * - The "Click" label should not be seen if click event is suppressed. + */ -function setup(viewer) { - viewer.setHeight(400); - viewer.setWidth(400); - viewer.userContextMenuHandler = (selected, x, y, intersects) => { - openMenu(viewer, selected, x, y, intersects); +function testView1(viewer) { + viewer.userContextMenuHandler = (selected, x, y, intersects, ev) => { + // Since the menu is in the same absolute parent as the viewer, + // we can use the same x,y coordinates. + openMenu(viewer, selected, x, y); } - viewer.zoomTo(); - - // atoms have to be clickable to support context menu - viewer.setClickable({}, true); - viewer.enableContextMenu({},true); - viewer.render( ); + setupViewer(viewer); + console.log('Testing right-click on atom in View1'); + rightClickAt(viewer, { x: 68, y: 120 }); +} - // Emulate a contextmenu event for the 2-oclock H. - // Since this is a synthetic event (not actually generated by the browser), - // It will get processed by closeEnoughForClick, which checks mouseStartX/Y. - // Trigger mousedown first so mouseStartX/Y is captured. - const evt = { pageX: 398, pageY: 274, preventDefault: () => {}}; - viewer._handleMouseDown(evt); - viewer._handleContextMenu(evt); -}; +function testView2(viewer) { + viewer.userContextMenuHandler = (selected, x, y, intersects, ev) => { + // Since the menu has a different absolute parent as the canvas, + // we need to look at the mouseEvent to get preferred menu coordinates. + openMenu(viewer, selected, ev?.pageX || x, ev?.pageY || y); + if (!ev) { + console.warn( + 'event not passed into handler; View2 will has offset position' + ); + } + } + viewer.addSphere({ + center: { x: -1.8, y: 1.67, z: 0 }, radius: 1.0, + color: 'purple', alpha: 0.4, + contextMenuEnabled: true, + clickable: true, + callback: (shape) => viewer.addLabel("Click", { position: shape }), + }); -function closeMenu() { - document.getElementById('menu').style.display = 'none'; + setupViewer(viewer); + console.log('Testing right-click on background in View2'); + rightClickAt(viewer, { x: 229, y: 369 }); + console.log('Testing longtouch on sphere in View2'); + // Add a slight change of position to long touch, because physically, it is + // hard to have completely zero movement when doing a long touch. + longTouchAt(viewer, { x: 270, y: 270, endX: 270, endY: 270 }); } -function openMenu(viewer, selected, x, y, intersects) { - const type = targetType(selected); - const menuElt = document.getElementById('menu'); - const menuTargetElt = document.getElementById('menu_target_label'); - const [menuItem1, menuItem2, menuItem3, menuItem4] = [1, 2, 3, 4].map( - (n) => document.getElementById(`menu-item-${n}`) - ); - - function finishMenu() { viewer.render(); closeMenu(); } - function positionMenu() { - const offsetX = 100; // left style of "parent" elt - const offsetY = 100; // top style of "parent" elt - menuElt.style.left = x + (correctMenuPosition ? offsetX : 0); - menuElt.style.top = y + (correctMenuPosition ? offsetY : 0); - menuElt.style.display = 'block'; - const correctionVerb = correctMenuPosition ? 'Disable' : 'Enable'; - menuItem3.innerText = `${correctionVerb} Menu Position Correction`; +function rightClickAt(viewer, { x, y, endX=0, endY=0 }) { + // Emulate the sequence of events when a user clicks to open context menu: + // 1. mousedown + // 2. mousemove (if there is movement) + // 2. mouseup (GLViewer will maybe interpret this as a "click") + // 3. contextmenu + // endX,endY params allow to specify movement between mousedown and mouseup; + // for mouse right-clicks, contextmenu is only fired if there's no movement. + const preventDefault = () => {}; + const startEvt = { pageX: x, pageY: y, preventDefault, which: 3 }; + const endEvt = { pageX: endX || x, pageY: endY || y, preventDefault}; + viewer._handleMouseDown(startEvt); + if (endX || endY) { + viewer._handleMouseMove(endEvt); } - const sphereBase = { - color: 'purple', radius: 1.0, alpha: 0.4, contextMenuEnabled: true, - }; - const menuData = { - atom: { - label: `atom ${selected?.atom}`, - itemLabel: 'Show Sphere Around Atom', - itemAction: () => { - viewer.addSphere({ center: selected, ...sphereBase }); - finishMenu(); - }, - }, - shape: { - label: 'shape', - itemLabel: 'Remove Sphere', - itemAction: () => { - viewer.removeShape(selected); - finishMenu(); - }, - }, - nothing: { - label: 'nothing', - itemLabel: 'Draw Sphere at Origin', - itemAction: () => { - viewer.addSphere({center:{x: 0, y: 0, z: 0}, ...sphereBase }); - finishMenu(); - }, - }, - }; + viewer._handleMouseUp(endEvt); + viewer._handleContextMenu(endEvt); +} - const { label, itemLabel, itemAction } = menuData[type]; - menuTargetElt.innerText = label; - menuItem1.innerText = itemLabel; - menuItem1.onclick = itemAction; - menuItem2.onclick = () => { - viewer.removeAllShapes(); - finishMenu(); +function longTouchAt(viewer, { x, y, endX=0, endY=0 }) { + // Emulate the sequence of events when a user clicks to open context menu: + // 1. touchstart (starts the longtouch timer) + // 2. touchmove (allow a small amount of movement for longtouch contextmenu) + // 3. touchend (fires after longtouch timer completes) + const preventDefault = () => {}; + const startEvt = { + type: 'touchstart', + targetTouches: [{ pageX: x, pageY: y, clientX: x, clientY: y }], + preventDefault, }; - menuItem3.onclick = () => { - correctMenuPosition = !correctMenuPosition; - positionMenu(); + const endEvt = { + type: 'touchmove', + targetTouches: [{ + pageX: endX || x, pageY: endY || y, + clientX: endX || x, clientY: endY || y, + }], + preventDefault, }; + viewer._handleMouseDown(startEvt); + viewer._handleMouseMove(endEvt); + setTimeout( + () => viewer._handleMouseUp({ ...endEvt, type: 'touchend' }), + 2000 + ); +} + +function setupViewer(viewer) { + viewer.setHeight(180); + viewer.setWidth(180); + viewer.zoomTo(); - positionMenu(); + // Note: atoms have to be clickable to support context menu. + // This is because they only get an insersectObject if they are clickable. + viewer.setClickable({}, true, (atom) => { + viewer.addLabel("Click", { position: atom }); + }); + viewer.enableContextMenu({},true); + viewer.render( ); +} + +function openMenu(viewer, selected, x, y) { + const type = targetType(selected); + const menuElt = document.getElementById(`${type}-menu`); + console.log(`${type} Menu ${x}, ${y}, ${selected?.x}, ${selected?.y}`); + menuElt.style.left = x; + menuElt.style.top = y; + menuElt.style.display = 'block'; } function targetType(selected) { - if (!selected) return 'nothing'; + if (!selected) return 'background'; return selected.atom ? 'atom' : 'shape'; } /* @div -
-
-
-
-