Skip to content

feat(joint-react): add a fitToContent prop and usePaper().fitToContent() - #3501

Open
samuelgja wants to merge 9 commits into
clientIO:devfrom
samuelgja:feat/joint-react-fit-to-content-v2
Open

feat(joint-react): add a fitToContent prop and usePaper().fitToContent()#3501
samuelgja wants to merge 9 commits into
clientIO:devfrom
samuelgja:feat/joint-react-fit-to-content-v2

Conversation

@samuelgja

Copy link
Copy Markdown
Contributor

Description

Framing a freshly mounted diagram currently takes a hand-written effect in every
app, and getting it right means solving two problems that have nothing to do with
the app: React-rendered elements have no size until they are measured, so the fit
has to wait for a measurement pass; and when a <PaperScroller> is mounted it —
not the paper — owns the viewport, so paper.transformToFitContent() fits to the
wrong box. It derives its fitting box from paper.getComputedSize(), which under
a scroller is the full sheet rather than the visible window, so the content is
scaled wrongly and the scroller is never scrolled to it.

This adds a fitToContent prop that solves both, plus a matching one-shot on
usePaper().

<Paper fitToContent />                                              // centred zoom-to-fit, re-fits on host resize
<Paper fitToContent={{ padding: 40, refit: 'once' }} />
<Paper fitToContent={{ mode: 'resize', allowNewOrigin: 'any' }} />  // grow the paper instead of scaling

packages/joint-react/src/components/paper/paper.types.ts

  • fitToContent?: boolean | FitToContentOptions on PaperProps.
  • FitToContentOptions is a union discriminated by mode, each arm extending the
    matching core option type — FitToContentZoomOptions extends Readonly<dia.Paper.TransformToFitContentOptions>, FitToContentResizeOptions extends Readonly<dia.Paper.FitToContentOptions>. Every core option is
    therefore forwarded and documented once, in core, and mode narrows without a
    cast.
  • Defaults differ from core where the React use case does: mode: 'zoom',
    refit: 'resize', useModelGeometry: true, and verticalAlign /
    horizontalAlign: 'middle' (core defaults to top/left; the effects this
    replaces all centre).

packages/joint-react/src/utils/fit-to-content.ts (new)

  • normalizeFitOptions() resolves boolean | FitToContentOptions into one
    object with mode and refit always present.
  • runFit() executes the fit against the correct owner. A <PaperScroller>
    registers itself into this package's own PaperStore.features under the
    paperScroller key, so the scroller is found there and the fit routed to
    scroller.zoomToRect(contentArea, { minScale, maxScale, ...options }) — the
    same call usePaperScroller().zoomToFit() makes, so the two never disagree.
    Feature.instance is typed unknown, so it is narrowed by a user-defined type
    guard against a locally declared FitScrollerLike; no @joint/plus import, no
    as, no any anywhere in the new code.
  • mode: 'resize' under a scroller is a no-op with a dev warning: the scroller
    sets the paper's dimensions through adjustPaper and overwrites it next tick.
  • Empty or zero-area content returns early.

packages/joint-react/src/hooks/use-fit-to-content.ts (new)

  • Decides when to fit. Every refit policy fits on the first measurement pass;
    'resize' adds a ResizeObserver on the fit host (the scroller's element when
    one owns the paper, otherwise paper.el); 'always' adds graph add /
    remove / reset / change:position / change:size and later measurement
    passes.
  • Each subscription is keyed on the primitive refit, so changing an unrelated
    fit option never re-subscribes; option values are read from a latest-ref at fit
    time. All triggers funnel through the existing simpleScheduler microtask
    batch, so a burst of resize callbacks produces one fit.
  • mode: 'resize' changes the paper's own size and so feeds its own observer.
    The guard records the host size at each fit and drops the callback reporting
    that same size. A timer-based guard was tried first and rejected: browsers run
    no requestAnimationFrame in a background tab, so a flag cleared on the next
    frame latches on forever and blocks every later fit.
  • fitToContent is ignored, with a dev warning, when transform is also set —
    both write the viewport matrix and the outcome would depend on effect order.

packages/joint-react/src/hooks/use-paper.ts

  • PaperApi.fitToContent(options?), the same executor as the prop. Preferred
    over calling paper.transformToFitContent() from app code, because it is
    correct under a <PaperScroller> and the raw paper call is not. The existing
    JSDoc example is updated to use it.

Tests

  • src/utils/__tests__/fit-to-content.test.ts — 14 cases: option normalization
    and defaults for both arms, the scroller type guard (including partial
    look-alikes), zoom/resize dispatch, scroller routing with zoom-bound clamping,
    the resize-under-scroller skip, the zero-area guard, and host resolution.
  • src/hooks/__tests__/use-fit-to-content.test.tsx — 9 cases covering each
    refit policy, the resize-mode echo guard, and the transform conflict.
  • src/hooks/__tests__/use-fit-to-content.compiler.test.tsx — the trigger wiring
    again under babel-plugin-react-compiler, so auto-memoization cannot silently
    stop a fit being scheduled.
  • src/hooks/__tests__/use-paper.test.tsx — the imperative one-shot.
  • stories/examples/fit-to-content/ — an example switching between all four
    presets, with an "Add far element" button that shows 'always' re-framing
    where 'once' does not.

yarn test passes on this branch: typecheck, lint, knip, and Jest on React 19
(1087) and React 18 (1080).

Beyond the suite, the behaviour was verified in a browser, because green unit
tests do not show whether anything is actually framed. In headless Chrome
against the story: initial fit centres at scale 0.832; shrinking the host from
1366 to 698 px re-fits to 0.542 under refit: 'resize' and leaves the transform
byte-identical under 'once'; mode: 'resize' grows the paper SVG to 1044×446
instead of scaling; the imperative call recovers from a manual pan; no console
warnings. The scroller path was then exercised against a real
ui.PaperScroller — a throwaway suite run inside @joint/react-plus, which
symlinks this package, and deleted afterwards so that package is untouched. It
confirmed zoomToRect receives the correct content area and the scroller's own
zoom bounds, and that the only transformToFitContent call carries the
scroller's explicit fittingBBox rather than coming from this code directly.

Changesets: @joint/react minor — one for the <Paper /> prop, one for
usePaper.

Motivation and Context

Re-lands #3498, which was merged to dev and then reverted so it could go
through review properly. This branch is the same nine commits cherry-picked
onto the reverted dev: the eight from #3498 plus the follow-up that addressed
its Copilot review threads — normalizeFitOptions re-applying every React
default with ?? after the spread (an explicit undefined no longer erases
verticalAlign / horizontalAlign / useModelGeometry), and the fit
re-running when any option changes even if refit is unchanged (a
content-signature dependency, so an equal inline object is still a no-op).

Apps repeat the same effect to frame a diagram on mount — in @joint/plus apps
as paperScroller.centerContent({ useModelGeometry: true }) in a
useEffect, and in @joint/react as a useOnElementsMeasured callback calling
paper.transformToFitContent(...), which is what the shipped flowchart demo
does. Requested on the JointJS project board (item 237936138) to make fitting a
prop rather than boilerplate.

This is @joint/react only. @joint/react-plus is unchanged, and
usePaperScroller().zoomToFit() stays as it is; the new code produces the same
framing so the two agree.

Notes

  • Targets dev as a next-minor feature. PRs to dev get no CI in this repo, so
    the full suite was run locally, on this branch, against a freshly built
    @joint/core dist — Jest resolves the prebuilt core, and a stale one silently
    fails unrelated suites.
  • No PaperStore.claimFit() ownership seam was added. It would be dead code
    until @joint/react-plus opts in, and the structural lookup covers the
    scroller today. If a second viewport owner ever appears, that is the point to
    add one.
  • useFitToContent is deliberately not exported. The prop covers <Paper>,
    including the external-paper form, and usePaper().fitToContent() covers
    imperative use; exporting the hook later is a one-line change.
  • No pan-only mode: mode: 'zoom' with minScale: 1, maxScale: 1 is
    centerContent.

Screenshots (if appropriate):

Not attached — the example story (Examples/Fit to content) demonstrates each
preset interactively.

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