@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.
- package/README.md +192 -0
- package/dist/guides/guide-article.d.ts +13 -2
- package/dist/guides/guide-article.d.ts.map +1 -1
- package/dist/guides/guide-article.js +5 -3
- package/dist/guides/guide-article.js.map +1 -1
- package/dist/guides/guide-figure-zoom.d.ts +62 -0
- package/dist/guides/guide-figure-zoom.d.ts.map +1 -0
- package/dist/guides/guide-figure-zoom.js +272 -0
- package/dist/guides/guide-figure-zoom.js.map +1 -0
- package/dist/guides/guide-figure.d.ts +21 -0
- package/dist/guides/guide-figure.d.ts.map +1 -0
- package/dist/guides/guide-figure.js +19 -0
- package/dist/guides/guide-figure.js.map +1 -0
- package/dist/guides/index.d.ts +2 -0
- package/dist/guides/index.d.ts.map +1 -1
- package/dist/guides/index.js +2 -0
- package/dist/guides/index.js.map +1 -1
- package/dist/guides/library.d.ts +13 -0
- package/dist/guides/library.d.ts.map +1 -1
- package/dist/guides/library.js +8 -0
- package/dist/guides/library.js.map +1 -1
- package/dist/health/health.d.ts +65 -0
- package/dist/health/health.d.ts.map +1 -0
- package/dist/health/health.js +93 -0
- package/dist/health/health.js.map +1 -0
- package/dist/health/index.d.ts +2 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +2 -0
- package/dist/health/index.js.map +1 -0
- package/dist/markdown/figure.d.ts +44 -0
- package/dist/markdown/figure.d.ts.map +1 -0
- package/dist/markdown/figure.js +56 -0
- package/dist/markdown/figure.js.map +1 -0
- package/dist/markdown/figures.d.ts +94 -0
- package/dist/markdown/figures.d.ts.map +1 -0
- package/dist/markdown/figures.js +126 -0
- package/dist/markdown/figures.js.map +1 -0
- package/dist/markdown/guide.d.ts +15 -3
- package/dist/markdown/guide.d.ts.map +1 -1
- package/dist/markdown/guide.js +20 -3
- package/dist/markdown/guide.js.map +1 -1
- package/dist/markdown/index.d.ts +2 -0
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +2 -0
- package/dist/markdown/index.js.map +1 -1
- 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
|
+

|
|
674
|
+
|
|
675
|
+

|
|
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 (`` and
|
|
22
|
+
* `` 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
|
-
*
|
|
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;
|
|
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
|
-
*
|
|
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;
|
|
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
|