@connextar/house 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +192 -0
  2. package/dist/guides/guide-article.d.ts +13 -2
  3. package/dist/guides/guide-article.d.ts.map +1 -1
  4. package/dist/guides/guide-article.js +5 -3
  5. package/dist/guides/guide-article.js.map +1 -1
  6. package/dist/guides/guide-figure-zoom.d.ts +62 -0
  7. package/dist/guides/guide-figure-zoom.d.ts.map +1 -0
  8. package/dist/guides/guide-figure-zoom.js +272 -0
  9. package/dist/guides/guide-figure-zoom.js.map +1 -0
  10. package/dist/guides/guide-figure.d.ts +21 -0
  11. package/dist/guides/guide-figure.d.ts.map +1 -0
  12. package/dist/guides/guide-figure.js +19 -0
  13. package/dist/guides/guide-figure.js.map +1 -0
  14. package/dist/guides/index.d.ts +2 -0
  15. package/dist/guides/index.d.ts.map +1 -1
  16. package/dist/guides/index.js +2 -0
  17. package/dist/guides/index.js.map +1 -1
  18. package/dist/guides/library.d.ts +13 -0
  19. package/dist/guides/library.d.ts.map +1 -1
  20. package/dist/guides/library.js +8 -0
  21. package/dist/guides/library.js.map +1 -1
  22. package/dist/health/health.d.ts +65 -0
  23. package/dist/health/health.d.ts.map +1 -0
  24. package/dist/health/health.js +93 -0
  25. package/dist/health/health.js.map +1 -0
  26. package/dist/health/index.d.ts +2 -0
  27. package/dist/health/index.d.ts.map +1 -0
  28. package/dist/health/index.js +2 -0
  29. package/dist/health/index.js.map +1 -0
  30. package/dist/markdown/figure.d.ts +44 -0
  31. package/dist/markdown/figure.d.ts.map +1 -0
  32. package/dist/markdown/figure.js +56 -0
  33. package/dist/markdown/figure.js.map +1 -0
  34. package/dist/markdown/figures.d.ts +94 -0
  35. package/dist/markdown/figures.d.ts.map +1 -0
  36. package/dist/markdown/figures.js +126 -0
  37. package/dist/markdown/figures.js.map +1 -0
  38. package/dist/markdown/guide.d.ts +15 -3
  39. package/dist/markdown/guide.d.ts.map +1 -1
  40. package/dist/markdown/guide.js +20 -3
  41. package/dist/markdown/guide.js.map +1 -1
  42. package/dist/markdown/index.d.ts +2 -0
  43. package/dist/markdown/index.d.ts.map +1 -1
  44. package/dist/markdown/index.js +2 -0
  45. package/dist/markdown/index.js.map +1 -1
  46. package/package.json +5 -1
package/README.md CHANGED
@@ -666,6 +666,34 @@ export function Markdown({ content }: { content: string }) {
666
666
 
667
667
  `classNames` replaces the look of any element for an app without theme tokens.
668
668
 
669
+ **Pictures.** A guide names a screenshot or a diagram on a line of its own, with a
670
+ blank line either side:
671
+
672
+ ```md
673
+ ![The calls log, newest call first, with the ID-checked column](/guide-images/calls-log.webp "Every call, newest first")
674
+
675
+ ![A caller reaches the line, then the assistant answers or hands over](diagram:call-routing)
676
+ ```
677
+
678
+ `GuideMarkdown` draws one only when it is in the app's registry, `figures` — a
679
+ screenshot with its `width` and `height` (so the page doesn't jump as it loads) and
680
+ optionally a `dark` capture of the same size, or a diagram by name — and has alt
681
+ text; anything else is left out, so a guide can't pull an image in from elsewhere
682
+ and a typo never shows a broken image. (Before 0.18.0 such a line came out as a
683
+ `!` and a link.) A screenshot is a plain lazy `<img>`; with a `dark` capture both
684
+ are drawn and the theme's `dark:` variant shows one; without one, the light
685
+ picture is toned down a little on a dark page. A diagram registered as a component
686
+ is given the alt text as `label` for its own `<svg role="img" aria-label>`; one
687
+ registered as an element is wrapped in `role="img"`. Headings keep their ids
688
+ across pictures, and a picture ends a list.
689
+
690
+ `guideFigureProblems(body, figures, { minAltLength })` is the check an app's test
691
+ asserts empty for every guide: an unregistered picture, missing (or too short) alt
692
+ text, a picture without a blank line either side or indented as if in a list, and
693
+ an image written inside a line of text. `splitGuideFigures` is the same split as
694
+ data; `renderFigure` replaces the drawing. `GuideArticle` adds zoom — see
695
+ [Pictures and zoom](#pictures-and-zoom).
696
+
669
697
  ### `@connextar/house/guides`
670
698
 
671
699
  The frame a help centre's pages sit in, so every app's guides read and are found
@@ -706,6 +734,170 @@ export default async function GuidePage({ params }) {
706
734
  }
707
735
  ```
708
736
 
737
+ #### Pictures and zoom
738
+
739
+ Pass the registry and `GuideArticle` draws a guide's pictures
740
+ ([the Markdown](#connextarhousemarkdown)), each one a button: a "Click to enlarge"
741
+ hint in its corner and a zoom-in cursor. A click, Enter or Space opens it in a
742
+ dialog fitted to the screen (the whole screen on a phone):
743
+
744
+ - zoom with the buttons, `+` and `−`, ⌘/Ctrl + scroll (a trackpad pinch arrives as
745
+ one) or a touch pinch, from fit up to four times full size, at the pointer;
746
+ - when it is bigger than the screen, drag it, scroll it, or move it with the arrow
747
+ keys — never past its edges;
748
+ - a double-click or double-tap, or the zoom level between the buttons, switches
749
+ between fit and full size (one image pixel to one CSS pixel); `0` fits again;
750
+ - the caption and the alt text are shown under it; Escape or the close button
751
+ closes it and focus goes back to the picture. Zoom eases only without
752
+ `prefers-reduced-motion`; dragging and pinching never do.
753
+
754
+ Diagrams zoom too. They are vector, so the dialog lays the diagram out at the size
755
+ it had in the article and scales it: fit fills the screen, and "full size" is twice
756
+ the fit. `zoom={false}` draws plain pictures. `labels` changes the words (`zoomHint`,
757
+ `zoomOpen`, `zoomIn`, `zoomOut`, `zoomToggle`, `zoomLevel`, `zoomClose`,
758
+ `zoomInstructions`), `classNames.figure` the picture's parts and `zoomClassNames`
759
+ the button and dialog's (`GUIDE_ZOOM_CLASS_NAMES` lists them), all on the theme
760
+ tokens. `GuideFigure` and `GuideFigureZoom` are there for a page laid out without
761
+ `GuideArticle`. The zoom's classes appear only in this package, so the
762
+ [Tailwind `@source`](#tailwind-setup-required-for-every-component) line is what
763
+ styles it.
764
+
765
+ Adopting it (PharmaLineAI's v0.23.1 pictures, which this generalises):
766
+
767
+ 1. **Screenshots** live in `public/guide-images/`, as WebP about 1600px wide (sharp
768
+ in a 768px column on a 2× screen) and under ~150 KB each. A dark capture, if
769
+ the app makes one, sits beside it at the same size (`calls-log-dark.webp`).
770
+ 2. **The registry.** Sizes in `lib/guide-screenshots.json`, written by the capture
771
+ script, and the diagrams beside them:
772
+
773
+ ```json
774
+ {
775
+ "/guide-images/calls-log.webp": { "width": 1600, "height": 986 },
776
+ "/guide-images/overview.webp": { "width": 1600, "height": 506, "dark": "/guide-images/overview-dark.webp" }
777
+ }
778
+ ```
779
+
780
+ ```tsx
781
+ // lib/guide-figures.tsx
782
+ import type { GuideFigures } from "@connextar/house/markdown";
783
+
784
+ import { CallRouting, IdentityCheck } from "@components/guides/guide-diagrams";
785
+ import screenshots from "@lib/guide-screenshots.json";
786
+
787
+ export const GUIDE_FIGURES: GuideFigures = {
788
+ screenshots,
789
+ diagrams: { "call-routing": CallRouting, "identity-check": IdentityCheck }, // each takes { label }
790
+ };
791
+ ```
792
+
793
+ 3. **The pages** pass it: `<GuideArticle … figures={GUIDE_FIGURES} />`, on the public
794
+ and the signed-in guide pages alike. A diagram component is drawn on the server;
795
+ only what it drew crosses into the dialog.
796
+ 4. **The app's test** reads the real guides:
797
+
798
+ ```ts
799
+ for (const { slug } of GUIDES) {
800
+ expect(guideFigureProblems(body(slug), GUIDE_FIGURES, { minAltLength: 20 }), slug).toEqual([]);
801
+ }
802
+ ```
803
+
804
+ and, because the package can't see the app's disk, checks the files: every
805
+ registered screenshot (and `dark`) exists at its recorded size (the app's own
806
+ `sharp`), every file in `public/guide-images/` is registered and used, all
807
+ WebP and small.
808
+
809
+ 5. **Capturing them (optional).** PharmaLineAI's `scripts/guide-screenshots.ts` is
810
+ the pattern, and stays in the app — this package ships no Playwright. It runs
811
+ only against the local harness (it refuses any base URL that isn't
812
+ `localhost`/`127.0.0.1`), signed in as the seeded owner from the harness's
813
+ cookie jar, in headless Chromium at 1440px, 2× pixels, light mode and reduced
814
+ motion. Each shot names a page, an optional `before` step (open a call, pick a
815
+ setting), and a crop to the panel its guide talks about, found from the page
816
+ (a heading, a bordered card). It hides the dev overlay, replaces seed text a
817
+ public page mustn't show (a real brand in a seed name), puts back any seed data
818
+ it changed for a shot in a `finally`, resizes to 1600px WebP with sharp, and
819
+ writes the sizes to `lib/guide-screenshots.json`. A dark set is the same run
820
+ with `colorScheme: "dark"` writing `-dark.webp` files and each entry's `dark`.
821
+
822
+ PharmaLineAI's own `components/guides/guide-article.tsx` and the split in
823
+ `lib/guide-figures.ts` go; its diagrams and its JSON stay, and its list of diagram
824
+ names becomes the `diagrams` record.
825
+
826
+ ### `@connextar/house/health`
827
+
828
+ A liveness check that says which release is running, so a deploy can be confirmed
829
+ from outside: `curl https://app.example/healthz` answers `ok 0.18.0 2c9a3cf`.
830
+ From PharmaLineAI's voice relay (v0.24.1), for every app.
831
+
832
+ - `buildInfo(version, env = process.env)` → `{ version?, commit? }`. The version
833
+ must be semver-shaped (`1.2.3`, `1.2.3-rc.1`); the commit is the first of
834
+ `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT` and `GIT_COMMIT` that is a whole
835
+ hex hash, cut to 7. Anything else is dropped, never echoed: the endpoint is
836
+ public.
837
+ - `healthText(build)` → `ok 1.2.3 abc1234` (just `ok` when nothing is known).
838
+ - `healthResponse(build, { format })` → always 200, `Cache-Control: no-store`;
839
+ `text/plain` by default, `{ "status": "ok", "version", "commit" }` for
840
+ `format: "json"`. `healthFormat(request)` picks: `?format=json|text`, then an
841
+ `Accept` that names `application/json`; a browser, curl and a host's checker get
842
+ text.
843
+ - `createHealthRoute({ version })` → `{ GET }`, the two above as a Next route
844
+ handler. Next answers `HEAD` from `GET` itself. No Next import, no package at all:
845
+ it is web `Request` and `Response`.
846
+
847
+ The whole adoption is one file, `app/healthz/route.ts` (`src/app/…` adds a `../`):
848
+
849
+ ```ts
850
+ import { createHealthRoute } from "@connextar/house/health";
851
+
852
+ // A route handler is server-only, so package.json is bundled into the server and never reaches the browser.
853
+ import pkg from "../../package.json";
854
+
855
+ export const { GET } = createHealthRoute({ version: pkg.version });
856
+ ```
857
+
858
+ (Next's default `tsconfig` has `resolveJsonModule`.) **Not**
859
+ `process.env.npm_package_version`: npm sets it only in a process npm itself
860
+ started — `node -e 'console.log(process.env.npm_package_version)'` prints
861
+ `undefined`, and the same line under `npm run` prints the version. A Vercel
862
+ function is started by Vercel's runtime, not `npm start`, so it is unset when the
863
+ route runs; it is set during `npm run build`, so it could be inlined through
864
+ `next.config`'s `env`, but only for as long as the build goes through npm. It works
865
+ on Render under `npm start`, which is why it looks reliable until it isn't.
866
+
867
+ The commit: Vercel sets `VERCEL_GIT_COMMIT_SHA` on Git deployments while "Automatically
868
+ expose System Environment Variables" is on (the default); Render sets
869
+ `RENDER_GIT_COMMIT`; anywhere else, set `GIT_COMMIT`. On Render, point the service's
870
+ Health Check Path at `/healthz`; it needs the 200 and nothing else. A plain Node
871
+ server (PharmaLineAI's relay) uses `buildInfo` and `healthText` directly.
872
+
873
+ **Let it through.** `/healthz` must answer without signing in: exclude it from the
874
+ app's `proxy.ts` (`middleware.ts` before Next 16) — e.g.
875
+ `matcher: ["/((?!healthz|_next/static|_next/image|favicon.ico).*)"]` — and from any
876
+ auth redirect, maintenance switch or locale rewrite in it. Vercel Deployment
877
+ Protection on preview deployments answers 401 before the app does; that is
878
+ expected, and production should be unprotected.
879
+
880
+ **Liveness only.** It touches no database and no provider, so it can't be slow and
881
+ a provider's outage never makes a host restart a healthy app. An app that wants a
882
+ readiness check writes its own, separately — never as the host's health check:
883
+
884
+ ```ts
885
+ // app/readyz/route.ts — the app's own; says only ready / not ready
886
+ export async function GET() {
887
+ const ok = await Promise.race([
888
+ prisma.$queryRaw`select 1`.then(
889
+ () => true,
890
+ () => false,
891
+ ),
892
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 2000)),
893
+ ]);
894
+ return new Response(ok ? "ready" : "not ready", {
895
+ status: ok ? 200 : 503,
896
+ headers: { "cache-control": "no-store", "content-type": "text/plain; charset=utf-8" },
897
+ });
898
+ }
899
+ ```
900
+
709
901
  ### `@connextar/house/trail`
710
902
 
711
903
  Where you came from, rather than where the URL says you are. A breadcrumb built
@@ -1,4 +1,6 @@
1
+ import type { GuideFigures } from "../markdown/figures.js";
1
2
  import { type GuideClassNames } from "../markdown/guide.js";
3
+ import type { GuideZoomClassNames } from "./guide-figure-zoom.js";
2
4
  import { type GuidesLabels } from "./library.js";
3
5
  export interface GuideArticleProps {
4
6
  /** The guide as `parseGuideFrontmatter` splits it. */
@@ -15,13 +17,22 @@ export interface GuideArticleProps {
15
17
  category?: string;
16
18
  labels?: Partial<GuidesLabels>;
17
19
  classNames?: Partial<GuideClassNames>;
20
+ /**
21
+ * The pictures this app's guides may show (`![alt](/guide-images/x.webp "Caption")` and
22
+ * `![alt](diagram:name)` on a line of their own). Unregistered ones are left out.
23
+ */
24
+ figures?: GuideFigures;
25
+ /** Open a picture full-size on a click, Enter or Space. Default: on. */
26
+ zoom?: boolean;
27
+ zoomClassNames?: Partial<GuideZoomClassNames>;
18
28
  }
19
29
  /**
20
30
  * One guide, laid out for reading inside `GuidesShell`: a way back to all guides, the category,
21
31
  * the title and lede, the article drawn by `GuideMarkdown`, and "On this page" — a rail that
22
32
  * follows the reader on wide screens, a collapsible list above the article on narrow ones. The
23
33
  * contents use the same heading ids the renderer gives, so `#<heading id>` links land.
24
- * Server-safe; the contents are the only client part.
34
+ * Registered pictures are drawn where the guide names them, each opening full-size.
35
+ * Server-safe; the contents and the zoom are the only client parts.
25
36
  */
26
- export declare function GuideArticle({ guide, guidesPath, indexSlug, category, labels, classNames }: GuideArticleProps): import("react").JSX.Element;
37
+ export declare function GuideArticle({ guide, guidesPath, indexSlug, category, labels, classNames, figures, zoom, zoomClassNames, }: GuideArticleProps): import("react").JSX.Element;
27
38
  //# sourceMappingURL=guide-article.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"guide-article.d.ts","sourceRoot":"","sources":["../../src/guides/guide-article.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAkC,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAE5F,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,sDAAsD;IACtD,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtD,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC;IACnB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAC/B,UAAU,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;CACvC;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,iBAAiB,+BA0B7G"}
1
+ {"version":3,"file":"guide-article.d.ts","sourceRoot":"","sources":["../../src/guides/guide-article.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAkC,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAE5F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAElE,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,sDAAsD;IACtD,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtD,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC;IACnB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAC/B,UAAU,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,wEAAwE;IACxE,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,cAAc,CAAC,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC;CAC/C;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,EAC3B,KAAK,EACL,UAAU,EACV,SAAS,EACT,QAAQ,EACR,MAAM,EACN,UAAU,EACV,OAAO,EACP,IAAI,EACJ,cAAc,GACf,EAAE,iBAAiB,+BA0CnB"}
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ChevronLeft } from "lucide-react";
3
3
  import { Link } from "../internal/next-link.js";
4
4
  import { extractHeadings, GuideMarkdown } from "../markdown/guide.js";
5
+ import { GuideFigure } from "./guide-figure.js";
5
6
  import { GuideToc } from "./guide-toc.js";
6
7
  import { GUIDES_LABELS } from "./library.js";
7
8
  /**
@@ -9,11 +10,12 @@ import { GUIDES_LABELS } from "./library.js";
9
10
  * the title and lede, the article drawn by `GuideMarkdown`, and "On this page" — a rail that
10
11
  * follows the reader on wide screens, a collapsible list above the article on narrow ones. The
11
12
  * contents use the same heading ids the renderer gives, so `#<heading id>` links land.
12
- * Server-safe; the contents are the only client part.
13
+ * Registered pictures are drawn where the guide names them, each opening full-size.
14
+ * Server-safe; the contents and the zoom are the only client parts.
13
15
  */
14
- export function GuideArticle({ guide, guidesPath, indexSlug, category, labels, classNames }) {
16
+ export function GuideArticle({ guide, guidesPath, indexSlug, category, labels, classNames, figures, zoom, zoomClassNames, }) {
15
17
  const text = { ...GUIDES_LABELS, ...labels };
16
18
  const headings = extractHeadings(guide.body);
17
- return (_jsxs("div", { className: "xl:grid xl:grid-cols-[minmax(0,1fr)_13rem] xl:gap-12", children: [_jsxs("article", { className: "max-w-3xl min-w-0", children: [_jsxs(Link, { href: guidesPath, className: "inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground", children: [_jsx(ChevronLeft, { className: "size-4", "aria-hidden": true }), " ", text.allGuides] }), category ? (_jsx("p", { className: "mt-5 text-xs font-bold tracking-[0.14em] text-primary uppercase", children: category })) : null, _jsx("h1", { className: "mt-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl", children: guide.title }), guide.lede ? _jsx("p", { className: "mt-3 text-lg text-muted-foreground", children: guide.lede }) : null, _jsx("div", { className: "mt-8", children: _jsx(GuideToc, { headings: headings, variant: "inline", labels: labels }) }), _jsx(GuideMarkdown, { content: guide.body, guidesPath: guidesPath, indexSlug: indexSlug, classNames: classNames })] }), _jsx(GuideToc, { headings: headings, variant: "rail", labels: labels })] }));
19
+ return (_jsxs("div", { className: "xl:grid xl:grid-cols-[minmax(0,1fr)_13rem] xl:gap-12", children: [_jsxs("article", { className: "max-w-3xl min-w-0", children: [_jsxs(Link, { href: guidesPath, className: "inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground", children: [_jsx(ChevronLeft, { className: "size-4", "aria-hidden": true }), " ", text.allGuides] }), category ? (_jsx("p", { className: "mt-5 text-xs font-bold tracking-[0.14em] text-primary uppercase", children: category })) : null, _jsx("h1", { className: "mt-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl", children: guide.title }), guide.lede ? _jsx("p", { className: "mt-3 text-lg text-muted-foreground", children: guide.lede }) : null, _jsx("div", { className: "mt-8", children: _jsx(GuideToc, { headings: headings, variant: "inline", labels: labels }) }), _jsx(GuideMarkdown, { content: guide.body, guidesPath: guidesPath, indexSlug: indexSlug, classNames: classNames, figures: figures, renderFigure: (figure) => (_jsx(GuideFigure, { figure: figure, figures: figures, zoom: zoom, labels: labels, classNames: classNames?.figure, zoomClassNames: zoomClassNames })) })] }), _jsx(GuideToc, { headings: headings, variant: "rail", labels: labels })] }));
18
20
  }
19
21
  //# sourceMappingURL=guide-article.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"guide-article.js","sourceRoot":"","sources":["../../src/guides/guide-article.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE3C,OAAO,EAAE,IAAI,EAAE,MAAM,0BAA0B,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,aAAa,EAAwB,MAAM,sBAAsB,CAAC;AAC5F,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAqB,MAAM,cAAc,CAAC;AAehE;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAqB;IAC5G,MAAM,IAAI,GAAG,EAAE,GAAG,aAAa,EAAE,GAAG,MAAM,EAAE,CAAC;IAC7C,MAAM,QAAQ,GAAG,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAE7C,OAAO,CACL,eAAK,SAAS,EAAC,sDAAsD,aACnE,mBAAS,SAAS,EAAC,mBAAmB,aACpC,MAAC,IAAI,IACH,IAAI,EAAE,UAAU,EAChB,SAAS,EAAC,oFAAoF,aAE9F,KAAC,WAAW,IAAC,SAAS,EAAC,QAAQ,wBAAe,OAAE,IAAI,CAAC,SAAS,IACzD,EACN,QAAQ,CAAC,CAAC,CAAC,CACV,YAAG,SAAS,EAAC,iEAAiE,YAAE,QAAQ,GAAK,CAC9F,CAAC,CAAC,CAAC,IAAI,EACR,aAAI,SAAS,EAAC,oEAAoE,YAAE,KAAK,CAAC,KAAK,GAAM,EACpG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,YAAG,SAAS,EAAC,oCAAoC,YAAE,KAAK,CAAC,IAAI,GAAK,CAAC,CAAC,CAAC,IAAI,EACvF,cAAK,SAAS,EAAC,MAAM,YACnB,KAAC,QAAQ,IAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAC,QAAQ,EAAC,MAAM,EAAE,MAAM,GAAI,GAC7D,EACN,KAAC,aAAa,IAAC,OAAO,EAAE,KAAK,CAAC,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,GAAI,IACpG,EACV,KAAC,QAAQ,IAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAC,MAAM,EAAC,MAAM,EAAE,MAAM,GAAI,IAC3D,CACP,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"guide-article.js","sourceRoot":"","sources":["../../src/guides/guide-article.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE3C,OAAO,EAAE,IAAI,EAAE,MAAM,0BAA0B,CAAC;AAEhD,OAAO,EAAE,eAAe,EAAE,aAAa,EAAwB,MAAM,sBAAsB,CAAC;AAC5F,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAqB,MAAM,cAAc,CAAC;AAuBhE;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,EAC3B,KAAK,EACL,UAAU,EACV,SAAS,EACT,QAAQ,EACR,MAAM,EACN,UAAU,EACV,OAAO,EACP,IAAI,EACJ,cAAc,GACI;IAClB,MAAM,IAAI,GAAG,EAAE,GAAG,aAAa,EAAE,GAAG,MAAM,EAAE,CAAC;IAC7C,MAAM,QAAQ,GAAG,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAE7C,OAAO,CACL,eAAK,SAAS,EAAC,sDAAsD,aACnE,mBAAS,SAAS,EAAC,mBAAmB,aACpC,MAAC,IAAI,IACH,IAAI,EAAE,UAAU,EAChB,SAAS,EAAC,oFAAoF,aAE9F,KAAC,WAAW,IAAC,SAAS,EAAC,QAAQ,wBAAe,OAAE,IAAI,CAAC,SAAS,IACzD,EACN,QAAQ,CAAC,CAAC,CAAC,CACV,YAAG,SAAS,EAAC,iEAAiE,YAAE,QAAQ,GAAK,CAC9F,CAAC,CAAC,CAAC,IAAI,EACR,aAAI,SAAS,EAAC,oEAAoE,YAAE,KAAK,CAAC,KAAK,GAAM,EACpG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,YAAG,SAAS,EAAC,oCAAoC,YAAE,KAAK,CAAC,IAAI,GAAK,CAAC,CAAC,CAAC,IAAI,EACvF,cAAK,SAAS,EAAC,MAAM,YACnB,KAAC,QAAQ,IAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAC,QAAQ,EAAC,MAAM,EAAE,MAAM,GAAI,GAC7D,EACN,KAAC,aAAa,IACZ,OAAO,EAAE,KAAK,CAAC,IAAI,EACnB,UAAU,EAAE,UAAU,EACtB,SAAS,EAAE,SAAS,EACpB,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,OAAO,EAChB,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,CACxB,KAAC,WAAW,IACV,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,UAAU,EAAE,MAAM,EAC9B,cAAc,EAAE,cAAc,GAC9B,CACH,GACD,IACM,EACV,KAAC,QAAQ,IAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAC,MAAM,EAAC,MAAM,EAAE,MAAM,GAAI,IAC3D,CACP,CAAC;AACJ,CAAC"}
@@ -0,0 +1,62 @@
1
+ import { type ReactNode } from "react";
2
+ import { type GuidesLabels } from "./library.js";
3
+ /** Every element the zoom draws. Each value replaces the default for that element. */
4
+ export interface GuideZoomClassNames {
5
+ /** The button around the picture in the article. */
6
+ trigger: string;
7
+ /** "Click to enlarge", over the picture's corner. */
8
+ hint: string;
9
+ overlay: string;
10
+ /** The dialog: the whole screen on a phone, inset on wider ones. */
11
+ content: string;
12
+ toolbar: string;
13
+ instructions: string;
14
+ button: string;
15
+ /** The zoom level, a button that switches between fit and full size. */
16
+ level: string;
17
+ /** The area the picture is zoomed and dragged in. */
18
+ stage: string;
19
+ /** Under the picture: the caption and the alt text. */
20
+ footer: string;
21
+ caption: string;
22
+ alt: string;
23
+ }
24
+ export declare const GUIDE_ZOOM_CLASS_NAMES: GuideZoomClassNames;
25
+ export interface GuideFigureZoomProps {
26
+ /** The picture as the article draws it: the button's content. */
27
+ children: ReactNode;
28
+ /** What the dialog shows, laid out at the natural size and scaled. Default: `children` again. */
29
+ full?: ReactNode;
30
+ /**
31
+ * The picture's natural size in CSS pixels — a screenshot's registered size, so "full size"
32
+ * is one image pixel to one CSS pixel. Left out (a diagram), it is measured from the article's
33
+ * picture when the dialog opens, and full size is twice the fit.
34
+ */
35
+ width?: number;
36
+ height?: number;
37
+ alt: string;
38
+ caption?: string;
39
+ labels?: Partial<GuidesLabels>;
40
+ classNames?: Partial<GuideZoomClassNames>;
41
+ }
42
+ interface Size {
43
+ w: number;
44
+ h: number;
45
+ }
46
+ /** Each button press or key multiplies or divides the zoom by this. */
47
+ export declare const ZOOM_STEP = 1.25;
48
+ /** The scale that fits `natural` inside `stage`, never past full size for a picture with a natural size. */
49
+ export declare function fitScale(natural: Size, stage: Size, capAtFull: boolean): number;
50
+ /**
51
+ * A guide's picture, opened full-size: a button around the article's picture (with a "Click to
52
+ * enlarge" hint) that opens a dialog fitting it to the screen. In there it zooms with the
53
+ * buttons, `+` and `−`, ⌘/Ctrl + scroll (a trackpad pinch) and a touch pinch; drags to pan
54
+ * when it is bigger than the screen (and scrolls, and moves with the arrow keys); a double-click
55
+ * or double-tap switches between fit and full size, and `0` fits again. Escape closes it and
56
+ * focus goes back to the picture. The caption and alt text are shown under it.
57
+ *
58
+ * Zooming eases unless the reader prefers reduced motion; dragging and pinching never do.
59
+ */
60
+ export declare function GuideFigureZoom({ children, full, width, height, alt, caption, labels, classNames, }: GuideFigureZoomProps): import("react").JSX.Element;
61
+ export {};
62
+ //# sourceMappingURL=guide-figure-zoom.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guide-figure-zoom.d.ts","sourceRoot":"","sources":["../../src/guides/guide-figure-zoom.tsx"],"names":[],"mappings":"AAIA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAGf,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AAEhE,sFAAsF;AACtF,MAAM,WAAW,mBAAmB;IAClC,oDAAoD;IACpD,OAAO,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,eAAO,MAAM,sBAAsB,EAAE,mBAiBpC,CAAC;AAEF,MAAM,WAAW,oBAAoB;IACnC,iEAAiE;IACjE,QAAQ,EAAE,SAAS,CAAC;IACpB,iGAAiG;IACjG,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAC/B,UAAU,CAAC,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAC;CAC3C;AAED,UAAU,IAAI;IACZ,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAMD,uEAAuE;AACvE,eAAO,MAAM,SAAS,OAAO,CAAC;AAS9B,4GAA4G;AAC5G,wBAAgB,QAAQ,CAAC,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAM/E;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,EAC9B,QAAQ,EACR,IAAI,EACJ,KAAK,EACL,MAAM,EACN,GAAG,EACH,OAAO,EACP,MAAM,EACN,UAAU,GACX,EAAE,oBAAoB,+BAuVtB"}
@@ -0,0 +1,272 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
4
+ import { Maximize2, X, ZoomIn, ZoomOut } from "lucide-react";
5
+ import { useCallback, useEffect, useRef, useState, } from "react";
6
+ import { cx } from "../internal/cx.js";
7
+ import { GUIDES_LABELS } from "./library.js";
8
+ export const GUIDE_ZOOM_CLASS_NAMES = {
9
+ trigger: "group relative block w-full cursor-zoom-in rounded-xl text-left focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
10
+ hint: "pointer-events-none absolute right-2 bottom-2 inline-flex items-center gap-1 rounded-full border border-border bg-background/90 px-2 py-0.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors group-hover:text-foreground group-focus-visible:text-foreground motion-reduce:transition-none",
11
+ overlay: "fixed inset-0 z-50 bg-black/70 backdrop-blur-sm",
12
+ content: "fixed inset-0 z-50 flex flex-col overflow-hidden bg-background text-foreground shadow-2xl outline-none sm:inset-4 sm:rounded-xl sm:border sm:border-border",
13
+ toolbar: "flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5 sm:px-3",
14
+ instructions: "mr-auto hidden min-w-0 truncate pl-1 text-xs text-muted-foreground sm:block",
15
+ button: "inline-flex size-10 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40",
16
+ level: "h-10 min-w-16 rounded-lg px-2 text-sm font-medium text-foreground tabular-nums hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:outline-none",
17
+ stage: "relative min-h-0 flex-1 touch-none overflow-hidden bg-muted/40 select-none",
18
+ footer: "max-h-[30%] shrink-0 overflow-y-auto border-t border-border px-4 py-3 text-sm",
19
+ caption: "font-medium text-foreground",
20
+ alt: "mt-1 text-muted-foreground",
21
+ };
22
+ /** Each button press or key multiplies or divides the zoom by this. */
23
+ export const ZOOM_STEP = 1.25;
24
+ /** The margin kept around a fitted picture, in CSS pixels, each side. */
25
+ const FIT_MARGIN = 16;
26
+ /** Used when nothing can be measured (a page not laid out yet, or a test's DOM). */
27
+ const FALLBACK_SIZE = { w: 800, h: 600 };
28
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
29
+ const near = (a, b) => Math.abs(a - b) < 0.005;
30
+ /** The scale that fits `natural` inside `stage`, never past full size for a picture with a natural size. */
31
+ export function fitScale(natural, stage, capAtFull) {
32
+ const w = stage.w - 2 * FIT_MARGIN;
33
+ const h = stage.h - 2 * FIT_MARGIN;
34
+ if (w <= 0 || h <= 0 || natural.w <= 0 || natural.h <= 0)
35
+ return 1;
36
+ const fit = Math.min(w / natural.w, h / natural.h);
37
+ return capAtFull ? Math.min(fit, 1) : fit;
38
+ }
39
+ /**
40
+ * A guide's picture, opened full-size: a button around the article's picture (with a "Click to
41
+ * enlarge" hint) that opens a dialog fitting it to the screen. In there it zooms with the
42
+ * buttons, `+` and `−`, ⌘/Ctrl + scroll (a trackpad pinch) and a touch pinch; drags to pan
43
+ * when it is bigger than the screen (and scrolls, and moves with the arrow keys); a double-click
44
+ * or double-tap switches between fit and full size, and `0` fits again. Escape closes it and
45
+ * focus goes back to the picture. The caption and alt text are shown under it.
46
+ *
47
+ * Zooming eases unless the reader prefers reduced motion; dragging and pinching never do.
48
+ */
49
+ export function GuideFigureZoom({ children, full, width, height, alt, caption, labels, classNames, }) {
50
+ const text = { ...GUIDES_LABELS, ...labels };
51
+ const c = { ...GUIDE_ZOOM_CLASS_NAMES, ...classNames };
52
+ const known = width !== undefined && height !== undefined && width > 0 && height > 0;
53
+ const [open, setOpen] = useState(false);
54
+ const [natural, setNatural] = useState(known ? { w: width, h: height } : FALLBACK_SIZE);
55
+ const [stage, setStage] = useState({ w: 0, h: 0 });
56
+ const [stageEl, setStageEl] = useState(null);
57
+ /** null: fitted, and staying fitted as the window changes size. */
58
+ const [zoom, setZoom] = useState(null);
59
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
60
+ const [gesture, setGesture] = useState(null);
61
+ const thumb = useRef(null);
62
+ const fit = fitScale(natural, stage, known);
63
+ const min = fit;
64
+ const max = Math.max(fit, 1) * 4;
65
+ const scale = clamp(zoom ?? fit, min, max);
66
+ /** Where a double-click goes from fit: full size, or twice the fit when that is already full size. */
67
+ const detail = clamp(known && fit < 1 ? 1 : fit * 2, min, max);
68
+ /** An offset that keeps the picture over the stage: centred on an axis where it fits, edge to edge where not. */
69
+ const bound = useCallback((point, s) => {
70
+ const limitX = Math.max(0, (natural.w * s - stage.w) / 2);
71
+ const limitY = Math.max(0, (natural.h * s - stage.h) / 2);
72
+ return { x: clamp(point.x, -limitX, limitX), y: clamp(point.y, -limitY, limitY) };
73
+ }, [natural, stage]);
74
+ // The latest view, for the listeners and gestures that outlive a render.
75
+ const view = useRef({ scale, offset, fit, min, max, detail, bound });
76
+ view.current = { scale, offset, fit, min, max, detail, bound };
77
+ /** Zoom to `next`, keeping the point `origin` (from the stage's centre) where it is. */
78
+ const zoomTo = useCallback((next, origin = { x: 0, y: 0 }) => {
79
+ const v = view.current;
80
+ const s = clamp(next, v.min, v.max);
81
+ const ratio = s / v.scale;
82
+ const moved = {
83
+ x: origin.x - (origin.x - v.offset.x) * ratio,
84
+ y: origin.y - (origin.y - v.offset.y) * ratio,
85
+ };
86
+ setZoom(near(s, v.fit) ? null : s);
87
+ setOffset(near(s, v.fit) ? { x: 0, y: 0 } : v.bound(moved, s));
88
+ }, []);
89
+ const panBy = useCallback((dx, dy) => {
90
+ const v = view.current;
91
+ setOffset(v.bound({ x: v.offset.x + dx, y: v.offset.y + dy }, v.scale));
92
+ }, []);
93
+ const toggle = useCallback((origin) => {
94
+ const v = view.current;
95
+ zoomTo(near(v.scale, v.fit) ? v.detail : v.fit, origin);
96
+ }, [zoomTo]);
97
+ /** A point on the screen, measured from the stage's centre. */
98
+ const fromCentre = useCallback((clientX, clientY) => {
99
+ if (!stageEl)
100
+ return { x: 0, y: 0 };
101
+ const rect = stageEl.getBoundingClientRect();
102
+ return { x: clientX - rect.left - rect.width / 2, y: clientY - rect.top - rect.height / 2 };
103
+ }, [stageEl]);
104
+ // The stage's size, kept as the window changes.
105
+ useEffect(() => {
106
+ if (!stageEl)
107
+ return;
108
+ const measure = () => {
109
+ const rect = stageEl.getBoundingClientRect();
110
+ setStage({ w: rect.width, h: rect.height });
111
+ };
112
+ measure();
113
+ window.addEventListener("resize", measure);
114
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
115
+ observer?.observe(stageEl);
116
+ return () => {
117
+ window.removeEventListener("resize", measure);
118
+ observer?.disconnect();
119
+ };
120
+ }, [stageEl]);
121
+ // ⌘/Ctrl + wheel (and a trackpad pinch, which arrives as one) zooms at the pointer; a plain wheel pans a zoomed
122
+ // picture. Not React's onWheel: that listener is passive, so it can't stop the page zooming instead.
123
+ useEffect(() => {
124
+ if (!stageEl)
125
+ return;
126
+ const onWheel = (event) => {
127
+ const v = view.current;
128
+ if (event.ctrlKey || event.metaKey) {
129
+ event.preventDefault();
130
+ const rect = stageEl.getBoundingClientRect();
131
+ const origin = { x: event.clientX - rect.left - rect.width / 2, y: event.clientY - rect.top - rect.height / 2 };
132
+ zoomTo(v.scale * Math.exp(-clamp(event.deltaY, -50, 50) / 100), origin);
133
+ }
134
+ else if (!near(v.scale, v.fit)) {
135
+ event.preventDefault();
136
+ panBy(-event.deltaX, -event.deltaY);
137
+ }
138
+ };
139
+ stageEl.addEventListener("wheel", onWheel, { passive: false });
140
+ return () => stageEl.removeEventListener("wheel", onWheel);
141
+ }, [stageEl, zoomTo, panBy]);
142
+ // Pointers on the stage: one drags, two pinch. A quick second tap (touch or pen) switches fit and full size.
143
+ const pointers = useRef(new Map());
144
+ const start = useRef(null);
145
+ const lastTap = useRef(null);
146
+ const beginGesture = () => {
147
+ const points = [...pointers.current.values()];
148
+ const [a, b] = points;
149
+ if (!a)
150
+ return;
151
+ const point = b ? { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } : a;
152
+ const distance = b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
153
+ start.current = {
154
+ scale: view.current.scale,
155
+ offset: view.current.offset,
156
+ point,
157
+ distance,
158
+ at: Date.now(),
159
+ moved: false,
160
+ };
161
+ setGesture(b ? "pinch" : "drag");
162
+ };
163
+ const onPointerDown = (event) => {
164
+ if (event.pointerType === "mouse" && event.button !== 0)
165
+ return;
166
+ event.currentTarget.setPointerCapture?.(event.pointerId);
167
+ pointers.current.set(event.pointerId, fromCentre(event.clientX, event.clientY));
168
+ if (pointers.current.size <= 2)
169
+ beginGesture();
170
+ };
171
+ const onPointerMove = (event) => {
172
+ if (!pointers.current.has(event.pointerId) || !start.current)
173
+ return;
174
+ pointers.current.set(event.pointerId, fromCentre(event.clientX, event.clientY));
175
+ const s = start.current;
176
+ const [a, b] = [...pointers.current.values()];
177
+ if (!a)
178
+ return;
179
+ if (b && s.distance > 0) {
180
+ s.moved = true;
181
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
182
+ const v = view.current;
183
+ const next = clamp((s.scale * Math.hypot(a.x - b.x, a.y - b.y)) / s.distance, v.min, v.max);
184
+ const ratio = next / s.scale;
185
+ setZoom(near(next, v.fit) ? null : next);
186
+ setOffset(v.bound({ x: mid.x - (s.point.x - s.offset.x) * ratio, y: mid.y - (s.point.y - s.offset.y) * ratio }, next));
187
+ }
188
+ else if (!b) {
189
+ const dx = a.x - s.point.x;
190
+ const dy = a.y - s.point.y;
191
+ if (Math.hypot(dx, dy) > 4)
192
+ s.moved = true;
193
+ setOffset(view.current.bound({ x: s.offset.x + dx, y: s.offset.y + dy }, view.current.scale));
194
+ }
195
+ };
196
+ const onPointerUp = (event) => {
197
+ if (!pointers.current.has(event.pointerId))
198
+ return;
199
+ const point = pointers.current.get(event.pointerId);
200
+ const wasTap = pointers.current.size === 1 && start.current && !start.current.moved && Date.now() - start.current.at < 300;
201
+ pointers.current.delete(event.pointerId);
202
+ if (pointers.current.size > 0) {
203
+ beginGesture(); // the finger left on the screen carries on as a drag
204
+ return;
205
+ }
206
+ start.current = null;
207
+ setGesture(null);
208
+ if (!wasTap || event.pointerType === "mouse")
209
+ return; // a mouse has onDoubleClick
210
+ const previous = lastTap.current;
211
+ if (previous &&
212
+ Date.now() - previous.at < 350 &&
213
+ Math.hypot(point.x - previous.point.x, point.y - previous.point.y) < 30) {
214
+ lastTap.current = null;
215
+ toggle(point);
216
+ }
217
+ else {
218
+ lastTap.current = { at: Date.now(), point };
219
+ }
220
+ };
221
+ const onKeyDown = (event) => {
222
+ if (event.metaKey || event.ctrlKey || event.altKey)
223
+ return; // ⌘/Ctrl + and − stay the browser's page zoom
224
+ const v = view.current;
225
+ const step = 48;
226
+ const keys = {
227
+ "+": () => zoomTo(v.scale * ZOOM_STEP),
228
+ "=": () => zoomTo(v.scale * ZOOM_STEP),
229
+ "-": () => zoomTo(v.scale / ZOOM_STEP),
230
+ _: () => zoomTo(v.scale / ZOOM_STEP),
231
+ "−": () => zoomTo(v.scale / ZOOM_STEP),
232
+ "0": () => zoomTo(v.fit),
233
+ ArrowLeft: () => panBy(step, 0),
234
+ ArrowRight: () => panBy(-step, 0),
235
+ ArrowUp: () => panBy(0, step),
236
+ ArrowDown: () => panBy(0, -step),
237
+ };
238
+ const action = keys[event.key];
239
+ // Arrow keys move only a zoomed picture, so they still move between the buttons' focus otherwise.
240
+ if (!action || (event.key.startsWith("Arrow") && near(v.scale, v.fit)))
241
+ return;
242
+ event.preventDefault();
243
+ action();
244
+ };
245
+ const change = (next) => {
246
+ if (next && !known) {
247
+ const rect = thumb.current?.getBoundingClientRect();
248
+ setNatural(rect && rect.width > 0 && rect.height > 0 ? { w: rect.width, h: rect.height } : FALLBACK_SIZE);
249
+ }
250
+ if (!next) {
251
+ setZoom(null);
252
+ setOffset({ x: 0, y: 0 });
253
+ setGesture(null);
254
+ pointers.current.clear();
255
+ start.current = null;
256
+ }
257
+ setOpen(next);
258
+ };
259
+ const zoomed = !near(scale, fit);
260
+ const percent = Math.round(scale * 100);
261
+ const title = caption || alt;
262
+ return (_jsxs(DialogPrimitive.Root, { open: open, onOpenChange: change, children: [_jsx(DialogPrimitive.Trigger, { asChild: true, children: _jsxs("button", { type: "button", className: c.trigger, "aria-label": text.zoomOpen.replace("{alt}", alt), children: [_jsx("span", { ref: thumb, className: "block", children: children }), _jsxs("span", { className: c.hint, "aria-hidden": true, children: [_jsx(Maximize2, { className: "size-3" }), text.zoomHint] })] }) }), _jsxs(DialogPrimitive.Portal, { children: [_jsx(DialogPrimitive.Overlay, { className: c.overlay }), _jsxs(DialogPrimitive.Content, { className: c.content, onKeyDown: onKeyDown, ...(caption ? {} : { "aria-describedby": undefined }), onOpenAutoFocus: (event) => {
263
+ // The close button, not the first zoom button: Enter on open shouldn't zoom.
264
+ event.preventDefault();
265
+ event.currentTarget?.querySelector("[data-house-zoom-close]")?.focus();
266
+ }, children: [_jsxs("div", { className: c.toolbar, children: [_jsx("p", { className: c.instructions, children: text.zoomInstructions }), _jsx("button", { type: "button", className: cx(c.button, "ml-auto sm:ml-0"), onClick: () => zoomTo(scale / ZOOM_STEP), disabled: scale <= min + 0.001, "aria-label": text.zoomOut, title: text.zoomOut, children: _jsx(ZoomOut, { className: "size-5", "aria-hidden": true }) }), _jsxs("button", { type: "button", className: c.level, onClick: () => toggle(), "aria-label": text.zoomToggle, title: text.zoomToggle, children: [percent, "%"] }), _jsx("button", { type: "button", className: c.button, onClick: () => zoomTo(scale * ZOOM_STEP), disabled: scale >= max - 0.001, "aria-label": text.zoomIn, title: text.zoomIn, children: _jsx(ZoomIn, { className: "size-5", "aria-hidden": true }) }), _jsx(DialogPrimitive.Close, { className: c.button, "aria-label": text.zoomClose, title: text.zoomClose, "data-house-zoom-close": "", children: _jsx(X, { className: "size-5", "aria-hidden": true }) }), _jsx("span", { className: "sr-only", "aria-live": "polite", children: text.zoomLevel.replace("{percent}", String(percent)) })] }), _jsx("div", { ref: setStageEl, "data-house-zoom-stage": "", className: cx(c.stage, zoomed ? (gesture === "drag" ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"), onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, onDoubleClick: (event) => toggle(fromCentre(event.clientX, event.clientY)), children: _jsx("div", { "data-house-zoom-picture": "", "data-scale": scale.toFixed(3), className: cx("absolute top-1/2 left-1/2 origin-center", gesture ? "" : "motion-safe:transition-transform motion-safe:duration-150 motion-safe:ease-out"), style: {
267
+ width: natural.w,
268
+ height: natural.h,
269
+ transform: `translate(-50%, -50%) translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
270
+ }, children: full ?? children }) }), _jsxs("div", { className: c.footer, children: [_jsx(DialogPrimitive.Title, { className: c.caption, children: title }), caption ? _jsx(DialogPrimitive.Description, { className: c.alt, children: alt }) : null] })] })] })] }));
271
+ }
272
+ //# sourceMappingURL=guide-figure-zoom.js.map