Skip to content

fix: let the guard option veto a blank pointerdown; buttons that click and drag (backport to master) - #3446

Open
kumilingus wants to merge 1 commit into
clientIO:masterfrom
kumilingus:fix/paper-event-surface-master
Open

fix: let the guard option veto a blank pointerdown; buttons that click and drag (backport to master)#3446
kumilingus wants to merge 1 commit into
clientIO:masterfrom
kumilingus:fix/paper-event-surface-master

Conversation

@kumilingus

Copy link
Copy Markdown
Contributor

Backport of #3438 onto master (that PR targets dev). Same net change, same 11 files — applied to master's identical core/preset base. The Storybook story resolves on master's Storybook 10 setup (getAPILink, @storybook/react-vite).


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

1. fix(joint-core): 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 that is not inside the paper's own SVG, 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, 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 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. fix(joint-react): keep portaled content from driving the paper

<Paper> children are portaled into paper.el but render outside its SVG, and a press on one must not drive the paper. 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. 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:

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. fix(joint-core): 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 (joint-react)

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 (joint-core)

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

…k and drag

Backport of clientIO#3438 (targets dev) onto master. Net change of the PR's 10
commits, applied to master's identical core/preset base.

- joint-core: split guard() into guardExplicit() (event-level veto/allow)
  + guard() (event + target), so options.guard can veto a blank pointerdown;
  split FORM_CONTROL_TAG_NAMES vs PREVENT_INTERACTION_TAG_NAMES and match
  both across the target path (not just evt.target); drop OPTION.
- joint-react: preset guards portaled overlay content via guardExplicit,
  lets a <button> in a node/magnet both click and drag (drops BUTTON from
  PREVENT_INTERACTION_TAG_NAMES, swallows the trailing click after a move).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kumilingus
kumilingus requested a review from samuelgja August 11, 2026 18:28
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.

1 participant