Skip to content

feat: paper eventSurface option, keep React-portaled content off the surface - #3438

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

feat: paper eventSurface option, keep React-portaled content off the surface#3438
kumilingus wants to merge 3 commits into
clientIO:devfrom
kumilingus:fix/paper-event-surface

Conversation

@kumilingus

@kumilingus kumilingus commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

eventSurface (joint-core)

DOM content rendered into paper.el — a ruler, a gutter, a toolbar — is not part of the paper's interaction surface. guard() rejects such a target, so it gets no blank:pointerclick and no hover events, even though a press on it does open a blank interaction. The gesture comes out half-wired.

The new eventSurface option declares a subtree part of the surface, and every handler then treats it as a blank area:

new dia.Paper({ eventSurface: '.ruler' })

It takes a CSS selector (matched with closest, so it covers any number of subtrees), an element, an array of elements, or a predicate.

The guard split (joint-core)

guard() conflates two questions, so it is split:

  • guardExplicit(evt, view) — decisions made about this event: the right mouse button, the guard option, an evt.data.guarded flag. Returns boolean | undefined, where undefined means nothing decided. The third state is needed because guarded: false is an explicit allow that must win over what the target is, which a plain boolean cannot express.
  • guard(evt, view) — unchanged behaviour: guardExplicit() first, then judge the target — tag name, view, event surface.

A press that hit no cell view consults only guardExplicit(). It opens a blank interaction as it always has, and the guard option can now veto it — previously that path consulted nothing at all, so there was no way to opt out.

The two hooks compose in opposite directions and cover the whole space: eventSurface opens a subtree, guard closes single events within it. Since options.guard runs first and the surface test last, that ordering falls out for free — surface a ruler, then veto wheel events on it.

GUARDED_TAG_NAMES still judges the target rather than the event, so a <select> in an overlay is not treated differently from any other DOM content there. There is a test pinning that.

Portaled content (joint-react)

<Paper> children are portaled into paper.el but render outside its SVG, and a press on one must not drive the canvas. React alone cannot prevent it: 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 has to leave that press alone: a plain dia.Paper consumer rendering their own content into paper.el has always relied on the blank interaction it starts. So the strict behaviour lives in the joint-react paper preset, which overrides guardExplicit to reject it — 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. That is also the "seamless overlay" escape hatch, expressed in the widening direction rather than as a special case.

Typing

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

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 documented and typed as returning boolean. It is coerced at the source.

Tests

  • joint-core QUnit: 2084 pass, incl. the overlay press + guard veto, the <select> case, the with/without-eventSurface gesture, and all four eventSurface forms
  • joint-core test:ts and lint clean
  • joint-react: 95 suites / 1031 tests pass, typecheck and lint clean, incl. a case showing eventSurface opting portaled content back in

Relation to #3437

This branch carries #3437's commit unchanged (credit to @samuelgja) and builds on it, so merging this closes that one out.

It does amend one thing there: #3437 puts the strict behaviour in joint-core, making pointerdown run the full guard() when the press hit no cell view. That rejects any target off the event surface, so HTML inside paper.el stops opening a blank interaction at all — and the whole gesture goes with it, since delegateDragEvents is called from pointerdown, so blank:pointermove and blank:pointerup never arrive either. Moving the same behaviour into the joint-react preset keeps it for the case it was written for, without changing what plain dia.Paper does.

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 from 2449b72 to ac297e9 Compare July 27, 2026 15:08
DOM content rendered into `paper.el` - a ruler, a gutter, a toolbar - is not part
of the paper's interaction surface. `guard()` rejects such a target, so it gets
no `blank:pointerclick` and no hover events, even though a press on it does open
a blank interaction. The new `eventSurface` option declares a subtree part of the
surface, and every handler then treats it as a blank area:

    new dia.Paper({ eventSurface: '.ruler' })

It takes a CSS selector (matched with `closest`, so it covers any number of
subtrees), an element, an array of elements, or a predicate.

`guard()` is split so that the two compose. `guardExplicit()` holds the decisions
made about a single event - the right mouse button, the `guard` option, an
`evt.data.guarded` flag - and returns `undefined` when none of them has an
opinion. `guard()` calls it first, then judges the target itself: its tag name,
its view, the event surface.

A press that hit no cell view consults only `guardExplicit()`. So it opens a
blank interaction as it always has, and the `guard` option can now veto it -
which matters for content the paper cannot be reasoned with from the outside,
such as React children portaled into `paper.el`, where a `stopPropagation()`
always arrives after the paper has already reacted.

So `eventSurface` opens a subtree and `guard` closes single events within it:
surface a ruler, then veto wheel events on it.

Also types `Paper.Options['guard']`'s `view` parameter as optional - it has
always been called without a view from `pointerclick`, `mouseover` and the other
handlers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kumilingus
kumilingus force-pushed the fix/paper-event-surface branch from ac297e9 to be7b923 Compare July 27, 2026 15:13
`<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>
@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
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