feat: paper eventSurface option, keep React-portaled content off the surface - #3438
Draft
kumilingus wants to merge 3 commits into
Draft
feat: paper eventSurface option, keep React-portaled content off the surface#3438kumilingus wants to merge 3 commits into
kumilingus wants to merge 3 commits into
Conversation
kumilingus
marked this pull request as draft
July 27, 2026 14:57
kumilingus
force-pushed
the
fix/paper-event-surface
branch
from
July 27, 2026 14:57
ca036f1 to
acf847a
Compare
kumilingus
force-pushed
the
fix/paper-event-surface
branch
from
July 27, 2026 14:59
acf847a to
2449b72
Compare
kumilingus
force-pushed
the
fix/paper-event-surface
branch
from
July 27, 2026 15:08
2449b72 to
ac297e9
Compare
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
force-pushed
the
fix/paper-event-surface
branch
from
July 27, 2026 15:13
ac297e9 to
be7b923
Compare
`<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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 noblank:pointerclickand no hover events, even though a press on it does open a blank interaction. The gesture comes out half-wired.The new
eventSurfaceoption declares a subtree part of the surface, and every handler then treats it as a blank area: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, theguardoption, anevt.data.guardedflag. Returnsboolean | undefined, whereundefinedmeans nothing decided. The third state is needed becauseguarded: falseis 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 theguardoption 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:
eventSurfaceopens a subtree,guardcloses single events within it. Sinceoptions.guardruns first and the surface test last, that ordering falls out for free — surface a ruler, then veto wheel events on it.GUARDED_TAG_NAMESstill 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 intopaper.elbut 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 ISpaper.el, the node the paper delegates on, and the paper got there first — so a ReactonMouseDownruns after the paper has already reacted. Nativemousedown/touchstartlisteners do work, but they break React's ownonMouseDownon the overlay content.joint-core has to leave that press alone: a plain
dia.Paperconsumer rendering their own content intopaper.elhas always relied on the blank interaction it starts. So the strict behaviour lives in the joint-react paper preset, which overridesguardExplicitto 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.guardand defers toeventSurface, 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 declaresviewoptional. It has always been called without a view frompointerclick,pointerdblclick,mouseoverand the rest — the old signature was simply wrong. Custom guards that dereferenceviewwill now fail to compile; that surfaces a latent bug rather than creating one.guard()also returns a real boolean now.evt.data.guardedis set by the caller and was only ever tested againstundefined, so anullor0there used to propagate out of a method documented and typed as returningboolean. It is coerced at the source.Tests
guardveto, the<select>case, the with/without-eventSurfacegesture, and all foureventSurfaceformstest:tsand lint cleaneventSurfaceopting portaled content back inRelation 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
pointerdownrun the fullguard()when the press hit no cell view. That rejects any target off the event surface, so HTML insidepaper.elstops opening a blank interaction at all — and the whole gesture goes with it, sincedelegateDragEventsis called frompointerdown, soblank:pointermoveandblank:pointerupnever arrive either. Moving the same behaviour into the joint-react preset keeps it for the case it was written for, without changing what plaindia.Paperdoes.Note 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