Skip to content

fix: let the guard option veto a blank pointerdown; buttons that click and drag - #3438

Draft
kumilingus wants to merge 9 commits into
clientIO:devfrom
kumilingus:fix/paper-event-surface
Draft

fix: let the guard option veto a blank pointerdown; buttons that click and drag#3438
kumilingus wants to merge 9 commits into
clientIO:devfrom
kumilingus:fix/paper-event-surface

Conversation

@kumilingus

@kumilingus kumilingus commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Supersedes #3437 — carries its commit unchanged (credit to @samuelgja) and builds on it.

Split out of this PR: the eventSurface option now lives in #3441.

1. options.guard can veto a blank pointerdown

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 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 or gutter 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:

  • guardExplicit(evt, view) — decisions about this event: the right mouse button, the guard option, an evt.data.guarded flag. Returns boolean | undefined; undefined means nothing decided. The third state is required because guarded: false is an explicit allow that must beat the target tests.
  • guard(evt, view) — unchanged: guardExplicit() first, then judge the target — tag name, view, event surface.

A press that hit no cell view consults only guardExplicit(), so it opens a blank interaction exactly as before and options.guard can now veto it. GUARDED_TAG_NAMES still 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.guarded is caller-set and was only ever tested against undefined, so a null or 0 used to propagate out of a method typed boolean.

2. joint-react keeps portaled content off the surface

<Paper> children are portaled into paper.el but render outside its SVG, and a press on one must not drive the canvas. joint-core has to leave that press alone (a plain dia.Paper consumer rendering into paper.el relies on it), so the strict behaviour lives in the joint-react preset, which overrides guardExplicit. It runs after the caller's own options.guard.

3. 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:

list decides
FORM_CONTROL_TAG_NAMES keep the browser's default action (no preventDefault) → native click and focus
PREVENT_INTERACTION_TAG_NAMES block element move / link-from-magnet

Same members by default, so core behaviour is unchanged. joint-react's preset drops BUTTON from the second list onlyFORM_CONTROL_TAG_NAMES is inherited untouched, so a button keeps its native default action and still takes focus on press. (Verified in Chrome: document.activeElement is the button after a click.)

To keep one gesture from being both, the preset's 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.

4. Tag lists are matched against the whole path

Both lists were tested against evt.target alone. 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 on dev before 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 OPTION redundant — 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 tagName matches rather than becoming closest() selectors, so all three tag-name options (including the pre-existing GUARDED_TAG_NAMES) keep one matching semantic, and the arrays do not quietly become CSS selector lists.

Story

Examples → Buttons In Magnets shows 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 paper

Typing

Paper.Options['guard'] now declares view optional. It has always been called without a view from pointerclick, pointerdblclick, mouseover and the rest. Custom guards that dereference view will now fail to compile — that surfaces a latent bug rather than creating one.

Tests

  • joint-core QUnit: 2084 pass, incl. the overlay press + guard veto, the <select> bit-exactness case, the two-list split, and a press inside a <button>
  • joint-core test:ts and lint clean
  • joint-react: 96 suites / 1032 tests pass, typecheck and lint clean
  • The interaction behaviour was also driven end to end in Chrome: click counts; drag from the button (and from the span inside it) moves the element without counting; drag off a magnet button creates a link; drag from the input or the select creates nothing and moves nothing

Note for reviewers: joint-react's jest resolves @joint/core to packages/joint-core/dist/joint.min.js, so that suite only exercises core changes after a yarn dist.

🤖 Generated with Claude Code

@kumilingus
kumilingus marked this pull request as draft July 27, 2026 14:57
@kumilingus
kumilingus force-pushed the fix/paper-event-surface branch from ca036f1 to acf847a Compare July 27, 2026 14:57
@kumilingus kumilingus changed the title fix(joint-core): keep blank pointerdown compatible, add paper eventSurface fix(joint-core): revert blank pointerdown guarding, add eventSurface option Jul 27, 2026
@kumilingus
kumilingus force-pushed the fix/paper-event-surface branch from acf847a to 2449b72 Compare July 27, 2026 14:59
@kumilingus kumilingus changed the title fix(joint-core): revert blank pointerdown guarding, add eventSurface option feat(joint-core): add paper eventSurface option Jul 27, 2026
@kumilingus
kumilingus force-pushed the fix/paper-event-surface branch 2 times, most recently from ac297e9 to be7b923 Compare July 27, 2026 15:13
@kumilingus kumilingus changed the title feat(joint-core): add paper eventSurface option feat: paper eventSurface option, keep React-portaled content off the surface Jul 27, 2026
@kumilingus
kumilingus force-pushed the fix/paper-event-surface branch from a442f71 to aeab977 Compare July 28, 2026 19:58
samuelgja and others added 4 commits July 29, 2026 10:51
`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>
@kumilingus
kumilingus force-pushed the fix/paper-event-surface branch from aeab977 to bd60631 Compare July 29, 2026 08:06
@kumilingus kumilingus changed the title feat: paper eventSurface option, keep React-portaled content off the surface fix: let the guard option veto a blank pointerdown; buttons that click and drag Jul 29, 2026
kumilingus and others added 5 commits August 2, 2026 07:44
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants