@canvas-glide/react 0.0.0-stage → 1.0.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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vangel Trajkovski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,140 @@
1
- # Temporary Holding Version
1
+ # @canvas-glide/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React components and a hook for [canvas-glide](https://github.com/vrtexe/canvas-glide): pan, zoom and pinch-to-zoom for an image, or for any content you render, with mouse, touch, pen and wheel input.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@canvas-glide/react.svg?style=flat-square)](https://www.npmjs.com/package/@canvas-glide/react)
6
+ [![types](https://img.shields.io/npm/types/@canvas-glide/react.svg?style=flat-square)](https://github.com/vrtexe/canvas-glide-react/blob/main/src/GlideCanvas.tsx)
7
+ [![license](https://img.shields.io/npm/l/@canvas-glide/react.svg?style=flat-square)](LICENSE.md)
8
+
9
+ [Live demo](https://vrtexe.github.io/canvas-glide-react-demo/)
10
+
11
+ The package has two layers. `GlideCanvas` is a ready image viewer: give it a `Blob` and it shows the image fitted to its box, with drag to pan, wheel and pinch to zoom, and a ref for zoom buttons. `useGlideCanvas` and `Viewport` are the parts `GlideCanvas` is built from; use them to pan and zoom your own content. The package re-exports everything from `canvas-glide`, so one import covers the engine API too.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install @canvas-glide/react
17
+ ```
18
+
19
+ `canvas-glide` is a dependency and installs with it. `react` 19 is a peer dependency.
20
+
21
+ ## Image viewer
22
+
23
+ ```tsx
24
+ import { useRef, useState } from 'react';
25
+ import { GlideCanvas, Renderer, type GlideCanvasRef } from '@canvas-glide/react';
26
+
27
+ export function Viewer() {
28
+ const [file, setFile] = useState<File | null>(null);
29
+ const viewer = useRef<GlideCanvasRef>(null);
30
+
31
+ return (
32
+ <>
33
+ <input type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
34
+ <button onClick={() => viewer.current?.zoomIn()}>+</button>
35
+ <button onClick={() => viewer.current?.zoomOut()}>−</button>
36
+ <button onClick={() => viewer.current?.fit()}>Fit</button>
37
+
38
+ <div style={{ width: 800, height: 500 }}>
39
+ <GlideCanvas ref={viewer} renderer={Renderer.Image} image={file} />
40
+ </div>
41
+ </>
42
+ );
43
+ }
44
+ ```
45
+
46
+ `GlideCanvas` fills its parent, so the parent needs a size. At scale 1 the image is fitted inside the box with its aspect ratio kept, and `fit()` returns to that view. By default the image can be panned until it just leaves the box, and no further.
47
+
48
+ ## Renderers
49
+
50
+ `renderer` picks how the image is drawn. All three use the same engine and the same coordinates; a renderer calls `fit()` when its image has loaded, so switching renderers starts from the fitted view.
51
+
52
+ | Renderer | How it draws | `canvasConfig.children` |
53
+ |---|---|---|
54
+ | `Renderer.Canvas` (default) | Draws the decoded bitmap into a `<canvas>` sized in device pixels. | Not rendered. |
55
+ | `Renderer.Image` | Moves and scales an `<img>` with a CSS `transform`. | Rendered inside the transformed box, so overlays move and scale with the image. |
56
+ | `Renderer.Html` | Resizes the `<img>` to the zoomed size and scrolls its container, with no CSS transform, so the browser rasterizes the image at its displayed size. | Rendered after the `<img>` inside the scrolled container. |
57
+
58
+ ## `GlideCanvas` props
59
+
60
+ | Prop | Type | Usage |
61
+ |---|---|---|
62
+ | `image` | `Blob \| null` | The image to show. A `File` from an `<input type="file">` is a `Blob`. `null` clears the view. The image and HTML renderers load PNG, JPEG, GIF, WebP, AVIF and BMP through a `blob:` URL and every other type, such as SVG, through a `data:` URL, so a script inside an uploaded SVG cannot run in your page's origin. |
63
+ | `renderer` | `Renderer` | `Renderer.Canvas` (default), `Renderer.Image` or `Renderer.Html`. |
64
+ | `config` | engine options | The `canvas-glide` options except the two rect getters, which `GlideCanvas` supplies: `state`, `move`, `scale` and `constraints`. |
65
+ | `viewportConfig` | `ViewportOptions` | Props for the outer `<div>`; see [Viewport](#viewport). |
66
+ | `canvasConfig` | renderer options | `content` (props for the `<img>` or `<canvas>`), `container` (props for the wrapping element; for the canvas renderer, also the `<canvas>`), `children`, and `afterDraw(transform, ref)`, called after each frame. A renderer chosen at runtime cannot take `canvasConfig`. |
67
+ | `ref` | `GlideCanvasRef` | Every engine method (`zoomIn`, `zoomOut`, `zoomTo`, `panTo`, `fit`, `center`, `reset`, `getTransform`, ...) plus `viewportRef` and `canvasRef`. |
68
+
69
+ The engine methods are documented in the [canvas-glide README](https://github.com/vrtexe/canvas-glide#methods).
70
+
71
+ `GlideCanvas` redraws once per animation frame. It calls `config.state.onTransformChange` once per frame with a copy of the transform, while `onScaleChange` and `onOriginChange` run on every input event.
72
+
73
+ ## Your own content
74
+
75
+ `useGlideCanvas` creates the engine, and `Viewport` is the element that receives the input. This example pans and zooms a 1200 by 800 pixel `<div>`:
76
+
77
+ ```tsx
78
+ import { useRef } from 'react';
79
+ import { useGlideCanvas, Viewport, type ViewportRef } from '@canvas-glide/react';
80
+
81
+ export function Board() {
82
+ const viewport = useRef<ViewportRef>(null);
83
+ const content = useRef<HTMLDivElement>(null);
84
+
85
+ const glide = useGlideCanvas({
86
+ getViewportRect: () => viewport.current?.getRect() ?? null,
87
+ getCanvasRect: () => ({ x: 0, y: 0, width: 1200, height: 800 }),
88
+ state: {
89
+ onTransformChange: ({ origin, scale }) => {
90
+ if (!content.current) return;
91
+ content.current.style.transform = `translate(${-origin.x * scale}px, ${-origin.y * scale}px) scale(${scale})`;
92
+ },
93
+ },
94
+ });
95
+
96
+ return (
97
+ <Viewport ref={viewport} lib={glide} style={{ width: 800, height: 500 }}>
98
+ <div ref={content} style={{ width: 1200, height: 800, transformOrigin: '0 0' }}>
99
+ Your content
100
+ </div>
101
+ </Viewport>
102
+ );
103
+ }
104
+ ```
105
+
106
+ `useGlideCanvas` adds an origin limit that keeps the content reachable, and passing `config.constraints.origin` replaces it. The hook creates the engine once: callbacks and the rect getters always use the latest props, while `move`, `scale`, `constraints` and `state.initial` are read when the component mounts. To apply new values for those, remount the component, for example by changing its `key`.
107
+
108
+ ## Viewport
109
+
110
+ `Viewport` renders a `<div>` with `overflow: hidden`, `touch-action: none`, `user-select: none` and `overscroll-behavior: contain`, and attaches the engine's pointer handlers and a non-passive wheel listener. It accepts every `<div>` prop, and these:
111
+
112
+ | Prop | Default | Usage |
113
+ |---|---|---|
114
+ | `lib` | none | The object returned by `useGlideCanvas`. |
115
+ | `onResize` | none | Called with the `ViewportRef` when the element's size changes. |
116
+ | `pointerDownEnabled`, `pointerMoveEnabled`, `pointerUpEnabled`, `pointerCancelEnabled` | `true` | `false` leaves that pointer event to the page. |
117
+ | `wheelEnabled` | `true` | `false` removes the wheel listener, so the wheel does not zoom. |
118
+
119
+ Its ref, `ViewportRef`, has `getElement()`, `getRect()` (the element's client rectangle, read on each call), `getClientWidth()` and `getClientHeight()` in CSS pixels, and `getWidth()` and `getHeight()` in device pixels.
120
+
121
+ ## Development
122
+
123
+ ```sh
124
+ npm install
125
+ npm test
126
+ npm run build
127
+ npm run lint
128
+ ```
129
+
130
+ `npm test` runs the Vitest suite in `test/` in jsdom, with the 2D context, `createImageBitmap` and `ResizeObserver` stubbed.
131
+
132
+ `npm run build` type-checks with `tsc -b` and writes `dist/` with Vite: `canvas-glide.js` (ES module) and the type declarations. `canvas-glide`, `react` and `react/jsx-runtime` stay external.
133
+
134
+ `canvas-glide` comes from npm. To work against an unreleased engine, build it and run `npm link ../lib` (the path to a local `canvas-glide` checkout); `npm install` restores the published version.
135
+
136
+ Publishing a GitHub release runs `.github/workflows/npm-publish.yml`: it lints, tests and builds the tag, sets `package.json` on the default branch to the release version, and stages the package on npm with trusted publishing; `npm stage approve` publishes it.
137
+
138
+ ## License
139
+
140
+ MIT, see [LICENSE.md](LICENSE.md).
@@ -0,0 +1,36 @@
1
+ import { Ref } from 'react';
2
+ import { GlideCanvasInstance, GlideCanvasOptions } from './useGlideCanvas.ts';
3
+ import { ViewportRef, ViewportOptions } from './Viewport.tsx';
4
+ import { Renderer, CanvasOptions, RendererRef } from './renderer/base.tsx';
5
+ export type BaseGlideCanvasRef<R> = GlideCanvasInstance & {
6
+ viewportRef?: ViewportRef | null;
7
+ canvasRef?: R | null;
8
+ };
9
+ export type GlideCanvasRef<T extends Renderer = Renderer> = BaseGlideCanvasRef<RendererRef[T]>;
10
+ export type BaseGlideCanvasProps = {
11
+ config?: Omit<GlideCanvasOptions, "getViewportRect" | "getCanvasRect">;
12
+ viewportConfig?: ViewportOptions;
13
+ image?: Blob | null;
14
+ ref?: Ref<GlideCanvasRef>;
15
+ };
16
+ /**
17
+ * Discriminated union over `renderer`: call sites with a literal renderer get
18
+ * their exact canvasConfig checked. The last arm supports a runtime-decided
19
+ * renderer (e.g. tab switching); it cannot accept canvasConfig, because no
20
+ * single config shape fits all renderers. For fully custom rendering, compose
21
+ * the primitives directly: useGlideCanvas + Viewport + your own component.
22
+ */
23
+ export type GlideCanvasProps = (BaseGlideCanvasProps & {
24
+ renderer?: typeof Renderer.Canvas;
25
+ canvasConfig?: CanvasOptions<typeof Renderer.Canvas>;
26
+ }) | (BaseGlideCanvasProps & {
27
+ renderer: typeof Renderer.Image;
28
+ canvasConfig?: CanvasOptions<typeof Renderer.Image>;
29
+ }) | (BaseGlideCanvasProps & {
30
+ renderer: typeof Renderer.Html;
31
+ canvasConfig?: CanvasOptions<typeof Renderer.Html>;
32
+ }) | (BaseGlideCanvasProps & {
33
+ renderer: Renderer;
34
+ canvasConfig?: undefined;
35
+ });
36
+ export declare function GlideCanvas(props: GlideCanvasProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { HTMLProps, PropsWithChildren, Ref } from 'react';
2
+ import { GlideCanvasInstance } from './useGlideCanvas.ts';
3
+ export type ViewportRef = {
4
+ getElement: () => HTMLDivElement | null;
5
+ /** Width in CSS pixels. */
6
+ getClientWidth: () => number;
7
+ /** Height in CSS pixels. */
8
+ getClientHeight: () => number;
9
+ getRect: () => DOMRect | null;
10
+ /** Width in device pixels (CSS pixels × devicePixelRatio). */
11
+ getWidth: () => number;
12
+ /** Height in device pixels (CSS pixels × devicePixelRatio). */
13
+ getHeight: () => number;
14
+ };
15
+ export type ViewportOptions = Omit<HTMLProps<HTMLDivElement>, "ref"> & {
16
+ onResize?: (ref: ViewportRef) => void;
17
+ pointerDownEnabled?: boolean;
18
+ pointerMoveEnabled?: boolean;
19
+ pointerUpEnabled?: boolean;
20
+ pointerCancelEnabled?: boolean;
21
+ wheelEnabled?: boolean;
22
+ };
23
+ export type ViewportProps = ViewportOptions & {
24
+ ref?: Ref<ViewportRef>;
25
+ lib?: GlideCanvasInstance;
26
+ };
27
+ export declare function Viewport({ ref, lib, children, style, className, onResize, pointerDownEnabled, pointerMoveEnabled, pointerUpEnabled, pointerCancelEnabled, wheelEnabled, ...rest }: PropsWithChildren<ViewportProps>): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ export * from 'canvas-glide';
2
+ export * from './useGlideCanvas.ts';
3
+ export * from './Viewport.tsx';
4
+ export * from './GlideCanvas.tsx';
5
+ export * from './renderer/base.tsx';
6
+ export * from './renderer/Canvas.tsx';
7
+ export * from './renderer/HtmlCanvas.tsx';
8
+ export * from './renderer/ImageCanvas.tsx';
@@ -0,0 +1,455 @@
1
+ "use no memo";
2
+ import { constrainTo as e, copyTransform as t, createEngine as n } from "canvas-glide";
3
+ import { useEffect as r, useImperativeHandle as i, useMemo as a, useRef as o, useState as s } from "react";
4
+ import { jsx as c, jsxs as l } from "react/jsx-runtime";
5
+ export * from "canvas-glide";
6
+ function u(e) {
7
+ return (t) => {
8
+ let n = e.getViewportRect(), r = e.getCanvasRect();
9
+ return !n || !r || r.width <= 0 ? t : d(t, e.getScale(), n, r);
10
+ };
11
+ }
12
+ function d(t, n, r, i) {
13
+ let a = r.width / n, o = r.height / n;
14
+ return {
15
+ x: e(t.x, -a + 0, i.width - 0),
16
+ y: e(t.y, -o + 0, i.height - 0)
17
+ };
18
+ }
19
+ //#endregion
20
+ //#region src/useGlideCanvas.ts
21
+ function f(e) {
22
+ let t = o(null), r = o(e);
23
+ if (r.current = e, t.current === null) {
24
+ let i = u({
25
+ getViewportRect: () => r.current.getViewportRect(),
26
+ getCanvasRect: () => r.current.getCanvasRect(),
27
+ getScale: () => t.current.getScale()
28
+ });
29
+ t.current = n({
30
+ ...e,
31
+ constraints: {
32
+ ...e.constraints,
33
+ origin: e.constraints?.origin ?? {
34
+ enabled: !0,
35
+ constrain: i
36
+ }
37
+ },
38
+ getViewportRect: () => r.current.getViewportRect(),
39
+ getCanvasRect: () => r.current.getCanvasRect(),
40
+ state: {
41
+ ...e.state,
42
+ onTransformChange: (e) => r.current.state?.onTransformChange?.(e),
43
+ onScaleChange: (e) => r.current.state?.onScaleChange?.(e),
44
+ onOriginChange: (e) => r.current.state?.onOriginChange?.(e)
45
+ },
46
+ move: {
47
+ ...e.move,
48
+ onIsMovingChange: (e) => r.current.move?.onIsMovingChange?.(e)
49
+ }
50
+ });
51
+ }
52
+ let i = t.current;
53
+ return a(() => ({
54
+ ...i,
55
+ engine: i,
56
+ onPointerDown: i.event.onPointerDown,
57
+ onPointerMove: i.event.onPointerMove,
58
+ onPointerUp: i.event.onPointerUp,
59
+ onWheel: i.event.onWheel
60
+ }), [i]);
61
+ }
62
+ //#endregion
63
+ //#region src/util/event.ts
64
+ function p(e, t, n) {
65
+ if (e) return m("wheel", e, t, n);
66
+ }
67
+ function m(e, t, n, r) {
68
+ if (t) return t.addEventListener(e, n, r), () => {
69
+ t.removeEventListener(e, n, r);
70
+ };
71
+ }
72
+ //#endregion
73
+ //#region src/Viewport.tsx
74
+ var h = {
75
+ width: "100%",
76
+ height: "100%",
77
+ position: "relative",
78
+ background: "transparent",
79
+ overflow: "hidden",
80
+ userSelect: "none",
81
+ touchAction: "none",
82
+ overscrollBehavior: "contain"
83
+ };
84
+ function g({ ref: e, lib: t, children: n, style: a, className: s, onResize: l, pointerDownEnabled: u = !0, pointerMoveEnabled: d = !0, pointerUpEnabled: f = !0, pointerCancelEnabled: m = !0, wheelEnabled: g = !0, ..._ }) {
85
+ let v = o(null), y = o(l);
86
+ y.current = l;
87
+ let b = {
88
+ ...h,
89
+ ...a
90
+ };
91
+ function x() {
92
+ return v?.current ? v.current.clientWidth * (window.devicePixelRatio || 1) : 0;
93
+ }
94
+ function S() {
95
+ return v?.current ? v.current.clientHeight * (window.devicePixelRatio || 1) : 0;
96
+ }
97
+ r(() => {
98
+ if (t?.onWheel !== void 0 && g !== !1) return p(v.current, t.onWheel, { passive: !1 });
99
+ }, [t?.onWheel, g]), r(() => {
100
+ if (!v.current) return;
101
+ let e = new ResizeObserver(() => y.current?.(C.current));
102
+ return e.observe(v.current), () => e.disconnect();
103
+ }, []);
104
+ let C = o({
105
+ getElement: () => v.current,
106
+ getClientWidth: () => v.current?.clientWidth || 0,
107
+ getClientHeight: () => v.current?.clientHeight || 0,
108
+ getWidth: x,
109
+ getHeight: S,
110
+ getRect: () => v.current?.getBoundingClientRect() ?? null
111
+ });
112
+ return i(e, () => C.current), /* @__PURE__ */ c("div", {
113
+ ref: v,
114
+ className: s,
115
+ style: b,
116
+ onPointerDown: u ? t?.onPointerDown : void 0,
117
+ onPointerMove: d ? t?.onPointerMove : void 0,
118
+ onPointerUp: f ? t?.onPointerUp : void 0,
119
+ onPointerCancel: m ? t?.onPointerUp : void 0,
120
+ ..._,
121
+ children: n
122
+ });
123
+ }
124
+ //#endregion
125
+ //#region src/util/asyncMemo.ts
126
+ function _(e, t, n) {
127
+ let [i, a] = s(null);
128
+ return r(() => {
129
+ let t = !0, r = null;
130
+ return e().then((e) => {
131
+ r = e, t ? a(e) : e && (n?.(e), r = null);
132
+ }).catch(() => {
133
+ t && a(null);
134
+ }), () => {
135
+ t = !1, r &&= (n?.(r), null), a(null);
136
+ };
137
+ }, t), i;
138
+ }
139
+ //#endregion
140
+ //#region src/util/image.ts
141
+ var v = /^image\/(png|jpeg|gif|webp|avif|bmp)$/i;
142
+ function y(e) {
143
+ return e ? createImageBitmap(e) : Promise.resolve(null);
144
+ }
145
+ function b(e) {
146
+ return e ? v.test(e.type) ? Promise.resolve(URL.createObjectURL(e)) : S(e) : Promise.resolve(null);
147
+ }
148
+ function x(e) {
149
+ e?.startsWith("blob:") && URL.revokeObjectURL(e);
150
+ }
151
+ function S(e) {
152
+ return new Promise((t, n) => {
153
+ let r = new FileReader();
154
+ r.onload = () => t(r.result), r.onerror = (e) => n(e), r.readAsDataURL(e);
155
+ });
156
+ }
157
+ //#endregion
158
+ //#region src/util/useIdealScale.ts
159
+ function C(e, t) {
160
+ let n = o(1);
161
+ function r() {
162
+ let r = e?.current, i = t();
163
+ if (!r || !i?.width || !i?.height) {
164
+ n.current = 1;
165
+ return;
166
+ }
167
+ n.current = Math.min(r.getClientWidth() / i.width, r.getClientHeight() / i.height);
168
+ }
169
+ function i(e) {
170
+ let r = t();
171
+ return {
172
+ x: e?.offsetLeft || 0,
173
+ y: e?.offsetTop || 0,
174
+ width: (r?.width || 0) * n.current,
175
+ height: (r?.height || 0) * n.current
176
+ };
177
+ }
178
+ return {
179
+ idealScale: n,
180
+ updateIdealScale: r,
181
+ getImageRect: i
182
+ };
183
+ }
184
+ //#endregion
185
+ //#region src/renderer/Canvas.tsx
186
+ var w = {
187
+ position: "absolute",
188
+ top: 0,
189
+ left: 0,
190
+ height: "100%",
191
+ width: "100%"
192
+ };
193
+ function T({ viewportRef: e, ref: t, afterDraw: n, image: a, lib: l, container: u, content: d }) {
194
+ let { style: f, ref: p, ...m } = u ?? {}, { style: h, ref: g, ...v } = d ?? {}, b = {
195
+ ...w,
196
+ ...f,
197
+ ...h
198
+ }, x = _(() => y(a), [a], (e) => e?.close()), S = o(null), [T] = s(() => document.createElement("canvas")), E = o(null);
199
+ function D(e) {
200
+ e.imageSmoothingEnabled = !0, e.imageSmoothingQuality = "high";
201
+ }
202
+ r(() => {
203
+ E.current || (E.current = S?.current?.getContext("2d") || null, E.current && D(E.current));
204
+ }, []);
205
+ let { idealScale: O, updateIdealScale: k, getImageRect: A } = C(e, () => ({
206
+ width: T.width,
207
+ height: T.height
208
+ }));
209
+ r(() => {
210
+ if (!x) {
211
+ T.width = 0, T.height = 0, j();
212
+ return;
213
+ }
214
+ P(x), I(e?.current), k(), l?.fit(), M(l?.stateService.getTransform());
215
+ }, [x]);
216
+ function j() {
217
+ let e = S.current, t = E.current;
218
+ !e || !t || (t.resetTransform(), t.clearRect(0, 0, e.width, e.height));
219
+ }
220
+ function M(t) {
221
+ if (!S.current || !t || !x) return;
222
+ let r = S.current, i = t.scale, a = t.origin.x, o = t.origin.y, s = E.current;
223
+ if (!s) return;
224
+ let c = e?.current;
225
+ c && (r.width !== Math.floor(c.getWidth()) || r.height !== Math.floor(c.getHeight())) && F(c.getWidth(), c.getHeight());
226
+ let l = window.devicePixelRatio || 1;
227
+ j();
228
+ let u = i * l;
229
+ s.setTransform(u, 0, 0, u, -a * u, -o * u), s.drawImage(T, 0, 0, T.width * O.current, T.height * O.current), n?.(t, L);
230
+ }
231
+ function N() {
232
+ M(l?.stateService.getTransform());
233
+ }
234
+ function P(e) {
235
+ T.width = e.width, T.height = e.height, T.getContext("2d")?.drawImage(e, 0, 0);
236
+ }
237
+ function F(e, t) {
238
+ S.current && (S.current.width = e, S.current.height = t, E.current && D(E.current), k());
239
+ }
240
+ function I(e) {
241
+ e && (F(e.getWidth(), e.getHeight()), N());
242
+ }
243
+ let L = {
244
+ getContainerRef: () => S.current,
245
+ getContentRef: () => S.current,
246
+ draw: M,
247
+ handleViewportResize: I,
248
+ getImageRect: () => A(S.current)
249
+ };
250
+ return i(t, () => L), /* @__PURE__ */ c("canvas", {
251
+ style: b,
252
+ ref: S,
253
+ ...m,
254
+ ...v
255
+ });
256
+ }
257
+ //#endregion
258
+ //#region src/renderer/ImageCanvas.tsx
259
+ var E = {
260
+ position: "relative",
261
+ transformOrigin: "top left"
262
+ }, D = {
263
+ display: "block",
264
+ height: "100%",
265
+ width: "100%"
266
+ };
267
+ function O({ viewportRef: e, afterDraw: t, children: n, ref: a, image: u, lib: d, container: f, content: p }) {
268
+ let { style: m, ref: h, ...g } = p ?? {}, { style: v, ref: y, ...S } = f ?? {}, w = o(null), T = o(null), [O, k] = s("hidden"), A = _(() => b(u), [u], x), { idealScale: j, updateIdealScale: M, getImageRect: N } = C(e, () => T.current ? {
269
+ width: T.current.naturalWidth,
270
+ height: T.current.naturalHeight
271
+ } : null);
272
+ function P(e) {
273
+ !e || !w.current || (w.current.style.transform = `scale(${e.scale}) translate(${-e.origin.x}px, ${-e.origin.y}px)`, t?.(e, B));
274
+ }
275
+ function F() {
276
+ P(d?.stateService.getTransform());
277
+ }
278
+ function I() {
279
+ if (!w.current || !T.current) return;
280
+ let { naturalWidth: e, naturalHeight: t } = T.current;
281
+ w.current.style.width = `${e * j.current}px`, w.current.style.height = `${t * j.current}px`;
282
+ }
283
+ function L(e) {
284
+ e && (M(), I(), F());
285
+ }
286
+ function R() {
287
+ M(), I(), d?.fit(), F(), k("visible");
288
+ }
289
+ r(() => {
290
+ T.current?.complete && T.current.naturalWidth && R();
291
+ }, [A]);
292
+ let z = {
293
+ ...E,
294
+ ...v
295
+ }, B = {
296
+ getContainerRef: () => w.current,
297
+ getContentRef: () => T.current,
298
+ draw: P,
299
+ handleViewportResize: L,
300
+ getImageRect: () => N(w.current)
301
+ };
302
+ i(a, () => B);
303
+ let V = {
304
+ ...D,
305
+ visibility: O,
306
+ ...m
307
+ };
308
+ return /* @__PURE__ */ l("div", {
309
+ ref: w,
310
+ style: z,
311
+ ...S,
312
+ children: [/* @__PURE__ */ c("img", {
313
+ hidden: !A,
314
+ style: V,
315
+ ref: T,
316
+ src: A || void 0,
317
+ draggable: !1,
318
+ onLoad: R,
319
+ ...g
320
+ }), n]
321
+ });
322
+ }
323
+ //#endregion
324
+ //#region src/renderer/HtmlCanvas.tsx
325
+ function k({ viewportRef: e, afterDraw: t, children: n, ref: r, image: a, lib: s, container: u, content: d }) {
326
+ let { style: f, ref: p, ...m } = u ?? {}, { style: h, ref: g, ...v } = d ?? {}, y = o(null), S = o(null), w = _(() => b(a), [a], x), { idealScale: T, updateIdealScale: E, getImageRect: D } = C(e, () => S.current ? {
327
+ width: S.current.naturalWidth,
328
+ height: S.current.naturalHeight
329
+ } : null);
330
+ function O(n) {
331
+ let r = y.current, i = S.current, a = e?.current;
332
+ if (!n || !r || !i?.naturalWidth || !a) return;
333
+ let { scale: o, origin: s } = n, c = i.naturalWidth * T.current * o, l = i.naturalHeight * T.current * o, u = a.getClientWidth(), d = a.getClientHeight();
334
+ i.style.width = `${c}px`, i.style.height = `${l}px`, i.style.padding = `${d}px ${u}px`, r.scrollTo(u + s.x * o, d + s.y * o), t?.(n, M);
335
+ }
336
+ function k() {
337
+ O(s?.stateService.getTransform());
338
+ }
339
+ function A() {
340
+ E(), k();
341
+ }
342
+ function j() {
343
+ E(), s?.fit(), k();
344
+ }
345
+ let M = {
346
+ getContainerRef: () => y.current,
347
+ getContentRef: () => S.current,
348
+ draw: O,
349
+ handleViewportResize: A,
350
+ getImageRect: () => D(y.current)
351
+ };
352
+ i(r, () => M);
353
+ let N = {
354
+ width: "100%",
355
+ height: "100%",
356
+ overflow: "hidden",
357
+ position: "relative",
358
+ ...f
359
+ }, P = {
360
+ display: "block",
361
+ boxSizing: "content-box",
362
+ ...h
363
+ };
364
+ return /* @__PURE__ */ l("div", {
365
+ style: N,
366
+ ref: y,
367
+ ...m,
368
+ children: [/* @__PURE__ */ c("img", {
369
+ ref: S,
370
+ src: w || void 0,
371
+ style: P,
372
+ draggable: !1,
373
+ onLoad: j,
374
+ ...v
375
+ }), n]
376
+ });
377
+ }
378
+ //#endregion
379
+ //#region src/renderer/base.tsx
380
+ var A = Object.freeze({
381
+ Canvas: "canvas",
382
+ Image: "image",
383
+ Html: "html"
384
+ });
385
+ //#endregion
386
+ //#region src/GlideCanvas.tsx
387
+ function j(e) {
388
+ let n = o(null), a = o(null), l = o(null), u = (e) => {
389
+ a.current = e;
390
+ }, [d, p] = s(!1), { config: m } = e, h = f({
391
+ ...m,
392
+ getViewportRect: () => n.current?.getRect() || null,
393
+ getCanvasRect: () => a.current?.getImageRect?.() || null,
394
+ move: {
395
+ ...m?.move,
396
+ onIsMovingChange(e) {
397
+ p(e), m?.move?.onIsMovingChange?.(e);
398
+ }
399
+ },
400
+ state: {
401
+ ...m?.state,
402
+ onTransformChange() {
403
+ v();
404
+ }
405
+ }
406
+ });
407
+ function _(t) {
408
+ a.current?.handleViewportResize?.(t), e.viewportConfig?.onResize?.(t);
409
+ }
410
+ i(e.ref, () => ({
411
+ viewportRef: n.current,
412
+ canvasRef: a.current,
413
+ ...h
414
+ }));
415
+ function v() {
416
+ l.current === null && (l.current = requestAnimationFrame(() => {
417
+ l.current = null;
418
+ let e = h.stateService.getTransform();
419
+ a.current?.draw?.(e), m?.state?.onTransformChange?.(t(e));
420
+ }));
421
+ }
422
+ return r(() => () => {
423
+ l.current !== null && cancelAnimationFrame(l.current);
424
+ }, []), /* @__PURE__ */ c(g, {
425
+ ref: n,
426
+ lib: h,
427
+ ...e.viewportConfig,
428
+ style: {
429
+ cursor: d ? "grabbing" : h.isPanEnabled() ? "grab" : "default",
430
+ ...e.viewportConfig?.style
431
+ },
432
+ onResize: _,
433
+ children: e.renderer === A.Image ? /* @__PURE__ */ c(O, {
434
+ viewportRef: n,
435
+ ref: u,
436
+ lib: h,
437
+ image: e.image,
438
+ ...e.canvasConfig
439
+ }) : e.renderer === A.Html ? /* @__PURE__ */ c(k, {
440
+ viewportRef: n,
441
+ ref: u,
442
+ lib: h,
443
+ image: e.image,
444
+ ...e.canvasConfig
445
+ }) : /* @__PURE__ */ c(T, {
446
+ viewportRef: n,
447
+ ref: u,
448
+ lib: h,
449
+ image: e.image,
450
+ ...e.canvasConfig
451
+ })
452
+ });
453
+ }
454
+ //#endregion
455
+ export { T as Canvas, j as GlideCanvas, k as HtmlCanvas, O as ImageCanvas, A as Renderer, g as Viewport, f as useGlideCanvas };
@@ -0,0 +1,4 @@
1
+ import { Renderer, RendererConfig } from './base.tsx';
2
+ export type CanvasRendererProps = RendererConfig<typeof Renderer.Canvas>;
3
+ export declare function Canvas({ viewportRef, ref, afterDraw, image, lib, container, content }: CanvasRendererProps): import("react").JSX.Element;
4
+ export default Canvas;
@@ -0,0 +1,13 @@
1
+ import { Renderer, RendererConfig } from './base.tsx';
2
+ export type HtmlRendererProps = RendererConfig<typeof Renderer.Html>;
3
+ /**
4
+ * Scroll-based renderer: no CSS transforms and no <canvas>.
5
+ * Zoom resizes the <img> element itself (the browser re-rasterizes at the
6
+ * layout size, so it stays sharp — unlike transformed layers on Safari) and
7
+ * pan is a programmatic scroll of an overflow:hidden box (an element does not
8
+ * need visible scrollbars to be scrollable via scrollTo). The img's padding
9
+ * carries one viewport of slack on every side — a scroll range only exists
10
+ * where a descendant box occupies it, and padding is part of the img's border
11
+ * box — so the image can be panned fully off-screen in every direction.
12
+ */
13
+ export declare function HtmlCanvas({ viewportRef, afterDraw, children, ref, image, lib, container, content, }: HtmlRendererProps): import("react").JSX.Element;
@@ -0,0 +1,3 @@
1
+ import { Renderer, RendererConfig } from './base.tsx';
2
+ export type ImageRendererProps = RendererConfig<typeof Renderer.Image>;
3
+ export declare function ImageCanvas({ viewportRef, afterDraw, children, ref, image, lib, container, content, }: ImageRendererProps): import("react").JSX.Element;
@@ -0,0 +1,46 @@
1
+ import { Rect, Transform } from 'canvas-glide';
2
+ import { ViewportRef } from '../Viewport.tsx';
3
+ import { HTMLProps, ReactNode, Ref, RefObject } from 'react';
4
+ import { GlideCanvasInstance } from '../useGlideCanvas.ts';
5
+ export type CanvasRef<T, C> = {
6
+ getContainerRef?: () => C | null;
7
+ getContentRef?: () => T | null;
8
+ getImageRect?: () => Rect;
9
+ handleViewportResize?: (viewport: ViewportRef) => void;
10
+ draw?: (transform?: Transform) => void;
11
+ };
12
+ export type RendererComponentProps<T> = HTMLProps<T> & {
13
+ className?: string;
14
+ style?: React.CSSProperties;
15
+ };
16
+ export type RendererProps<R extends Renderer, T = unknown, C = unknown> = {
17
+ content?: RendererComponentProps<T>;
18
+ container?: RendererComponentProps<C>;
19
+ children?: ReactNode | undefined;
20
+ afterDraw?: (transform?: Transform, data?: RendererRef[R]) => void;
21
+ };
22
+ export type RendererPropsMap = {
23
+ [Renderer.Canvas]: RendererProps<typeof Renderer.Canvas, HTMLCanvasElement, HTMLCanvasElement>;
24
+ [Renderer.Image]: RendererProps<typeof Renderer.Image, HTMLImageElement, HTMLDivElement>;
25
+ [Renderer.Html]: RendererProps<typeof Renderer.Html, HTMLImageElement, HTMLDivElement>;
26
+ };
27
+ export type BaseRenderConfig<R> = {
28
+ viewportRef?: RefObject<ViewportRef | null>;
29
+ image?: Blob | null;
30
+ lib?: GlideCanvasInstance;
31
+ ref?: Ref<R>;
32
+ };
33
+ export type RendererConfig<T extends Renderer> = RendererPropsMap[T] & BaseRenderConfig<RendererRef[T]>;
34
+ export type RendererRef = {
35
+ [Renderer.Canvas]: CanvasRef<HTMLCanvasElement, HTMLCanvasElement>;
36
+ [Renderer.Image]: CanvasRef<HTMLImageElement, HTMLDivElement>;
37
+ [Renderer.Html]: CanvasRef<HTMLImageElement, HTMLDivElement>;
38
+ };
39
+ export type Renderer = (typeof Renderer)[keyof typeof Renderer];
40
+ export declare const Renderer: Readonly<{
41
+ readonly Canvas: "canvas";
42
+ readonly Image: "image";
43
+ /** Renders the image as a resized, scrolled `<img>`, not arbitrary HTML. */
44
+ readonly Html: "html";
45
+ }>;
46
+ export type CanvasOptions<T extends Renderer> = RendererPropsMap[T];
@@ -0,0 +1,18 @@
1
+ import { PointerEventHandler, WheelEventHandler as ReactWheelEventHandler } from 'react';
2
+ import { CanvasEngine, CanvasEngineOptions } from 'canvas-glide';
3
+ export type GlideCanvasOptions = CanvasEngineOptions;
4
+ export type BaseElement = Element;
5
+ export type DomEventListener<T extends BaseElement, K extends keyof E, E extends HTMLElementEventMap = HTMLElementEventMap> = (this: T, ev: E[K]) => unknown;
6
+ export type PointerDownEventHandler<T extends BaseElement> = DomEventListener<T, "pointerdown">;
7
+ export type PointerUpEventHandler<T extends BaseElement> = DomEventListener<T, "pointerup">;
8
+ export type PointerMoveEventHandler<T extends BaseElement> = DomEventListener<T, "pointermove">;
9
+ export type WheelEventHandler<T extends BaseElement> = DomEventListener<T, "wheel">;
10
+ export type GlideCanvasInstance = CanvasEngine & {
11
+ engine: CanvasEngine;
12
+ onPointerDown: PointerEventHandler<Element> & PointerDownEventHandler<Element>;
13
+ onPointerMove: PointerEventHandler<Element> & PointerMoveEventHandler<Element>;
14
+ onPointerUp: PointerEventHandler<Element> & PointerUpEventHandler<Element>;
15
+ onWheel: ReactWheelEventHandler<HTMLDivElement> & WheelEventHandler<Element>;
16
+ };
17
+ /** Creates the engine once: callbacks and rect getters follow the latest config, all other options are read at mount. */
18
+ export declare function useGlideCanvas(config: GlideCanvasOptions): GlideCanvasInstance;
@@ -0,0 +1,11 @@
1
+ import { DependencyList } from 'react';
2
+ /**
3
+ * Async-derived value tied to a dependency list.
4
+ *
5
+ * `dispose` releases a produced value when it is replaced, unmounted, or
6
+ * arrives after its generation was superseded (e.g. `(bitmap) => bitmap.close()`).
7
+ * Each effect run only ever disposes the value IT produced — never a newer
8
+ * generation's. A rejected `asyncFn` resolves the value to null instead of
9
+ * leaking an unhandled rejection.
10
+ */
11
+ export declare function useAsyncMemo<T>(asyncFn: () => Promise<T>, dependencies: DependencyList, dispose?: (value: T) => void): T | null;
@@ -0,0 +1,9 @@
1
+ import { Point, PointConstrainer, Rect } from 'canvas-glide';
2
+ export declare const CONSTRAINT_THRESHOLD = 0;
3
+ export type OriginConstrainerConfig = {
4
+ getViewportRect: () => Rect | null;
5
+ getCanvasRect: () => Rect | null;
6
+ getScale: () => number;
7
+ };
8
+ export declare function createConstrainer(config: OriginConstrainerConfig): PointConstrainer;
9
+ export declare function constrainOrigin(origin: Point, scale: number, viewport: Rect, canvas: Rect): Point;
@@ -0,0 +1,2 @@
1
+ export declare function attachWheelEvent<T extends HTMLElement>(element: T | null, handler: (e: WheelEvent) => void, options?: AddEventListenerOptions): (() => void) | undefined;
2
+ export declare function attachEvent<K extends keyof HTMLElementEventMap, T extends HTMLElement | Window>(event: K, element: T | null, handler: (this: T, ev: HTMLElementEventMap[K]) => any, options?: AddEventListenerOptions | boolean): (() => void) | undefined;
@@ -0,0 +1,3 @@
1
+ export declare function createImageBitmapSafe(blob?: Blob | null | undefined): Promise<ImageBitmap | null>;
2
+ export declare function createImageUrlSafe(blob?: Blob | null | undefined): Promise<string | null>;
3
+ export declare function revokeImageUrl(url: string | null): void;
@@ -0,0 +1,20 @@
1
+ import { RefObject } from 'react';
2
+ import { Rect } from 'canvas-glide';
3
+ import { ViewportRef } from '../Viewport.tsx';
4
+ export type ContentSize = {
5
+ width: number;
6
+ height: number;
7
+ };
8
+ /**
9
+ * Shared contain-fit state for renderers: `idealScale` maps the content's
10
+ * natural size to the viewport at engine scale 1, so engine origins are
11
+ * expressed in the same scale-1 frame for every renderer.
12
+ */
13
+ export declare function useIdealScale(viewportRef: RefObject<ViewportRef | null> | undefined, getContentSize: () => ContentSize | null): {
14
+ idealScale: RefObject<number>;
15
+ updateIdealScale: () => void;
16
+ getImageRect: (element: {
17
+ offsetLeft: number;
18
+ offsetTop: number;
19
+ } | null) => Rect;
20
+ };
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@canvas-glide/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "React components and hook for canvas-glide: pan, zoom and pinch-to-zoom for images on a canvas, an img or a scrolled view, with mouse, touch, pen and wheel input.",
5
+ "keywords": [
6
+ "react",
7
+ "react-component",
8
+ "pan-zoom",
9
+ "panzoom",
10
+ "pinch-zoom",
11
+ "pinch-to-zoom",
12
+ "zoom",
13
+ "pan",
14
+ "image-viewer",
15
+ "image-zoom",
16
+ "canvas",
17
+ "gestures",
18
+ "touch",
19
+ "typescript"
20
+ ],
21
+ "license": "MIT",
22
+ "homepage": "https://vrtexe.github.io/canvas-glide-react-demo/",
23
+ "bugs": {
24
+ "url": "https://github.com/vrtexe/canvas-glide-react/issues"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/vrtexe/canvas-glide-react.git"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "type": "module",
34
+ "scripts": {
35
+ "dev": "vite",
36
+ "build": "tsc -b && vite build",
37
+ "build:dev": "tsc -b && vite build --watch",
38
+ "lint": "oxlint",
39
+ "test": "vitest run",
40
+ "preview": "vite preview"
41
+ },
42
+ "dependencies": {
43
+ "canvas-glide": "^1.0.1"
44
+ },
45
+ "peerDependencies": {
46
+ "react": "^19.0.0",
47
+ "react-dom": "^19.0.0"
48
+ },
49
+ "main": "./dist/canvas-glide.js",
50
+ "types": "./dist/canvas-glide.d.ts",
51
+ "files": [
52
+ "dist"
53
+ ],
54
+ "exports": {
55
+ ".": {
56
+ "types": "./dist/canvas-glide.d.ts",
57
+ "default": "./dist/canvas-glide.js"
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "^24.13.3",
62
+ "@types/react": "^19.2.17",
63
+ "@types/react-dom": "^19.2.3",
64
+ "@vitejs/plugin-react": "^6.0.4",
65
+ "jsdom": "^31.0.1",
66
+ "localforage": "^1.10.0",
67
+ "oxlint": "^1.75.0",
68
+ "typescript": "~6.0.2",
69
+ "vite": "^8.2.0",
70
+ "vite-plugin-dts": "^5.0.3",
71
+ "vitest": "^5.1.0"
72
+ }
73
+ }