fix: let the guard option veto a blank pointerdown; buttons that click and drag - #3438
fix: let the guard option veto a blank pointerdown; buttons that click and drag#3438kumilingus wants to merge 10 commits into
Conversation
ca036f1 to
acf847a
Compare
acf847a to
2449b72
Compare
ac297e9 to
be7b923
Compare
a442f71 to
aeab977
Compare
`pointerdown` consulted `guard()` only when the press hit a cell view, so nothing could suppress a blank interaction. That matters for DOM content rendered into `paper.el` - an overlay, a popup, a toolbar - where a press starts a drag the consumer may not want, and where the paper cannot be reasoned with from outside: its listeners are delegated on `paper.el`, so a `stopPropagation()` from content inside it always arrives after the paper has already reacted. Running the full `guard()` there is not an option: it rejects any target off the paper's event surface, so a ruler, a gutter or a toolbar in `paper.el` would stop opening a blank interaction at all - and the whole gesture with it, since the document-level drag listeners are delegated from `pointerdown`. So `guard()` is split in two: - `guardExplicit()` - decisions made about this very event: the right mouse button, the `guard` option, an `evt.data.guarded` flag. Returns a boolean, or `undefined` when none of them has an opinion. - `guard()` - unchanged: `guardExplicit()` first, then judge the target itself (its tag name, its view, whether it is on the event surface). A press that hit no cell view consults only `guardExplicit()`. It opens a blank interaction as it always has, and `options.guard` can now veto it. Behaviour is otherwise unchanged: `GUARDED_TAG_NAMES` still judges the target, so a `<select>` in an overlay is not treated differently. `guard()` also returns a real boolean now - `evt.data.guarded` is set by the caller and was only ever tested against `undefined`, so a `null` or `0` there used to propagate out of a method typed as returning `boolean`. Also types `Paper.Options['guard']`'s `view` parameter as optional: it has always been called without a view from `pointerclick`, `mouseover` and the others. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`<Paper>` children are portaled into `paper.el` but render outside its SVG. A press on such an overlay, popup or toolbar must not open a blank interaction, and React alone cannot prevent one: React attaches its delegated listeners to the portal container, which IS `paper.el` - the node the paper delegates on, and the paper got there first - so a React `onMouseDown` runs after the paper has already reacted. Native `mousedown` / `touchstart` listeners do work, but they break React's own `onMouseDown` on the overlay content. joint-core leaves that press alone, because a plain `dia.Paper` consumer rendering their own content into `paper.el` has always relied on the blank interaction it starts. This overrides `guardExplicit` in the paper preset to reject it here, where portaling is the documented model and driving the canvas from that content is never what is meant. The override runs after the caller's own `options.guard` and defers to `eventSurface`, so content that *should* drive the canvas can still say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `<button>` in a node body or in a magnet could only ever be clicked. joint-core blocks every form control from starting an interaction, so there was no way to drag a node by a button inside it, or to start a link from a button in a magnet - which is the natural gesture when the magnet is a row with a control in it. The block came from one flag answering two questions, `FORM_CONTROL_TAG_NAMES` deciding both "keep the browser's default action" and "block paper interactions". A button needs the first and not the second, so the two are now separate lists: - `FORM_CONTROL_TAG_NAMES` - the paper does not call `preventDefault()`, so the control keeps its native behaviour and stays focusable. - `PREVENT_INTERACTION_TAG_NAMES` - a press does not start an element move or a link. Defaults to the same members, so core behaviour is unchanged. joint-react's paper preset drops `BUTTON` from the second list only. To keep one gesture from being both, `pointerup` withholds the next native `click` once the pointer has travelled past `clickThreshold`. joint-core already withholds its own `pointerclick` at that point; the browser does not, because press and release share a target whenever the node follows the pointer - exactly what happens when an element is dragged by a button inside it. Adds a story with both cases: a button in a node body, and two magnets each with a button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aeab977 to
bd60631
Compare
Both tag-name lists were tested against `evt.target` alone, so a control with any markup inside it behaved the opposite way round from a bare one. The press target of `<button><span>Save</span></button>` is the SPAN, which is in neither list, so that button lost its default action (no focus) and did start an element move - exactly backwards from a bare `<button>`. `hasTagNameInPath()` walks from the target up to the cell view instead, so a press anywhere inside a control counts as a press on the control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The story demonstrated a button only. It now covers one control per tag list, in a node body and inside a magnet: - `<button>` - dropped from `PREVENT_INTERACTION_TAG_NAMES` by the paper preset, so it both clicks and drags (moving the element, or starting a link from its magnet) - `<input>` - a form control, so it keeps every gesture that stays inside it and selects text rather than dragging - `<select>` - in `GUARDED_TAG_NAMES`, so the event never reaches the paper at all The button's label sits in a `<span>`, so the story also exercises a press landing inside a control rather than on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_TAG_NAMES `OPTION` is redundant now that the lists are matched against the whole path: an `<option>` only exists inside a `<select>`, which is listed, so the walk finds it. A `<select multiple>` renders its options inline and does deliver real presses on them - covered by a test. Also removes a `FORM_CONTROL_TAG_NAMES` override that had crept into the joint-react preset. It dropped `BUTTON`, which meant the paper called `preventDefault()` on a button press there, so the button never took focus - the exact regression the two-list split exists to avoid, and the opposite of what the comment beside it claimed. joint-react now inherits the list from joint-core. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`parentElement` is defined on `Node` and returns `null` once the parent is not an element, so the explicit `nodeType` test and the `Node` global reference are both redundant. A non-element target now falls through harmlessly too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR refines JointJS paper interaction guarding so options.guard can veto blank-area pointerdown events (including DOM overlays inside paper.el), while also enabling joint-react’s preset to keep portaled overlay content inert and allow <button> inside nodes/magnets to both click and drag without double-firing clicks after a drag.
Changes:
- Split guard logic into
guardExplicit()(event-level veto/allow) plusguard()(event-level + target checks), and apply guarding to blank pointerdowns. - Split form-control handling into
FORM_CONTROL_TAG_NAMES(preserve native default) vsPREVENT_INTERACTION_TAG_NAMES(block paper interactions), and match both lists across the target path (not justevt.target). - joint-react preset: guard portaled overlay content, allow button-driven drags, and swallow the next native click after a moved gesture; add story + tests.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/joint-react/stories/examples/buttons-in-magnets/story.tsx | Adds Storybook entry showcasing button/input/select interactions in nodes/magnets. |
| packages/joint-react/stories/examples/buttons-in-magnets/code.tsx | Implements the example UI demonstrating click+drag behaviors and guarded controls. |
| packages/joint-react/src/presets/paper.ts | Extends Paper preset with guardExplicit override, button interaction changes, and native click swallowing after drags. |
| packages/joint-react/src/presets/tests/paper-button-interactions.test.tsx | Adds Jest coverage for “button can drag without clicking” vs “click without drag”. |
| packages/joint-react/src/hooks/use-markup.ts | Updates hook documentation for interaction patterns inside magnets. |
| packages/joint-react/src/hooks/tests/use-markup.test.tsx | Adjusts test formatting / lint disables related to React perf rules. |
| packages/joint-react/src/components/paper/tests/paper-html-content-events.test.tsx | Adds Jest coverage ensuring portaled overlay presses don’t start blank interactions and React events still fire. |
| packages/joint-core/types/dia.d.ts | Updates typings for optional view in guard, adds PREVENT_INTERACTION_TAG_NAMES, and guardExplicit. |
| packages/joint-core/test/ts/index.test.ts | Adds TS-level coverage for optional view in guard callback typing. |
| packages/joint-core/test/jointjs/paper.js | Adds QUnit coverage for overlay guarding, path-based tag matching, and list split behavior. |
| packages/joint-core/src/dia/Paper.mjs | Core implementation: guardExplicit, blank-pointerdown guarding, split tag lists, and path-based matching helper. |
Suppressed comments (1)
packages/joint-react/stories/examples/buttons-in-magnets/code.tsx:218
- This block comment is inaccurate:
MAGNET_COUNTis 3 and only the first magnet row contains a button (the others render a text field and a select). The comment should match the actual rendered content.
/** Card with two magnets, each containing a button. */
function MagnetButtonsNode({ name }: Readonly<Partial<NodeData>>) {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Address PR review: doc and story text still described a button press as always a click. The react preset now lets a <button> both click and drag (withholding the trailing click after a move), so update use-markup docs and the buttons-in-magnets story text, and fix the node comment that claimed two magnets with buttons (it renders three rows of mixed controls). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/joint-react/src/presets/paper.ts:253
- The preset override of
guardshould accept an optionalviewto match joint-core’s updated signature (guard(evt, view?)). Keeping it required makes the override’s typing stricter than the base class and can mask blank/HTML-target call sites.
guard(this: dia.Paper, event: dia.Event, view: dia.CellView) {
return protectedProto.guard.call(this, event, view) || wheelGuard(event);
},
packages/joint-react/src/presets/paper.ts:27
dia.Paper#guardcan be invoked with no resolved cell view (e.g. blank targets / delegated handlers). This preset’sProtectedPaperPrototype.guardtype still requiresview, which makes the local typing stricter than the core API and can hide undefined/null call sites in this file.
This issue also appears on line 251 of the same file.
type ProtectedPaperPrototype = {
readonly pointermove: (event: dia.Event) => void;
readonly pointerup: (event: dia.Event) => void;
readonly startListening: () => void;
readonly guard: (event: dia.Event, view: dia.CellView) => boolean;
readonly guardExplicit: (event: dia.Event, view?: dia.CellView) => boolean | undefined;
};
aecdb9e to
ec859b2
Compare
Supersedes #3437 — carries its commit unchanged (credit to @samuelgja) and builds on it.
1. fix(joint-core):
options.guardcan veto a blank pointerdownpointerdownconsultedguard()only when the press hit a cell view, so nothing could suppress a blank interaction. That matters for DOM content rendered intopaper.el— an overlay, a popup, a toolbar — where the paper cannot be reasoned with from outside: its listeners are delegated onpaper.el, so astopPropagation()from content inside it always arrives after the paper has already reacted.Running the full
guard()there is not an option — it rejects any target that is not inside the paper's own SVG, so a ruler or gutter inpaper.elwould stop opening a blank interaction at all, and the whole gesture with it, since the document-level drag listeners are delegated frompointerdown.So
guard()is split:guardExplicit(evt, view)— decisions about this event: the right mouse button, theguardoption, anevt.data.guardedflag. Returnsboolean | undefined;undefinedmeans nothing decided. The third state is required becauseguarded: falseis an explicit allow that must beat the target tests.guard(evt, view)— unchanged:guardExplicit()first, then judge the target — tag name, view, whether it lies inside the paper's SVG.A press that hit no cell view consults only
guardExplicit(), so it opens a blank interaction exactly as before andoptions.guardcan now veto it.GUARDED_TAG_NAMESstill judges the target, so a<select>in an overlay is not treated differently — there is a test pinning that.guard()also returns a real boolean now:evt.data.guardedis caller-set and was only ever tested againstundefined, so anullor0used to propagate out of a method typedboolean.2. fix(joint-react): keep portaled content from driving the paper
<Paper>children are portaled intopaper.elbut render outside its SVG, and a press on one must not drive the paper. joint-core has to leave that press alone (a plaindia.Paperconsumer rendering intopaper.elrelies on it), so the strict behaviour lives in the joint-react preset, which overridesguardExplicit. It runs after the caller's ownoptions.guard.3. fix(joint-core, joint-react): a button inside a node can both click and drag
A
<button>in a node body or a magnet could only ever be clicked, so there was no way to drag a node by a button inside it, or to start a link from a button in a magnet — the natural gesture when the magnet is a row with a control in it.The block came from one flag answering two questions. Now two lists:
FORM_CONTROL_TAG_NAMESpreventDefault) → native click and focusPREVENT_INTERACTION_TAG_NAMESSame members by default, so core behaviour is unchanged. joint-react's preset drops
BUTTONfrom the second list only —FORM_CONTROL_TAG_NAMESis inherited untouched, so a button keeps its native default action and still takes focus on press. (Verified in Chrome:document.activeElementis the button after a click.)To keep one gesture from being both, the preset's
pointerupwithholds the next nativeclickonce the pointer has travelled pastclickThreshold. joint-core already withholds its ownpointerclickat that point; the browser does not, because press and release share a target whenever the node follows the pointer — exactly what happens when an element is dragged by a button inside it.4. fix(joint-core): tag lists are matched against the whole path
Both lists were tested against
evt.targetalone. The press target of<button><span>Save</span></button>is the SPAN, which is in neither list — so a button with an icon or a label span inside it behaved the opposite way round from a bare one: it lost its default action (no focus) and did start an element move. Confirmed ondevbefore fixing.hasTagNameInPath()walks from the target up to the cell view, so a press anywhere inside a control counts as a press on the control. Note this is a behaviour change for anyone who was (accidentally) dragging a node by markup nested inside one of these controls.Path matching also makes
OPTIONredundant — an<option>only exists inside a<select>, which is listed — so it is dropped from both lists. Covered by a test using<select multiple>, whose options render inline and do receive real presses.The lists stay exact
tagNamematches rather than becomingclosest()selectors, so all three tag-name options (including the pre-existingGUARDED_TAG_NAMES) keep one matching semantic, and the arrays do not quietly become CSS selector lists.Story (joint-react)
Examples → Buttons In Magnetsshows one control per list, both in a node body and inside a magnet:<button>— clicks and drags; its label sits in a<span>, so it also exercises the nested-target case<input>— keeps every gesture that stays inside it, selecting text rather than dragging<select>— guarded outright; the event never reaches the paperTyping (joint-core)
Paper.Options['guard']now declaresviewoptional. It has always been called without a view frompointerclick,pointerdblclick,mouseoverand the rest. Custom guards that dereferenceviewwill now fail to compile — that surfaces a latent bug rather than creating one.Tests
guardveto, the<select>bit-exactness case, the two-list split, and a press inside a<button>test:tsand lint cleanNote for reviewers: joint-react's jest resolves
@joint/coretopackages/joint-core/dist/joint.min.js, so that suite only exercises core changes after ayarn dist.🤖 Generated with Claude Code