|
1 | 1 | # Deep Zoom for Blazor |
2 | 2 |
|
3 | | -In Blazor WebAssembly, you compose `DeepZoomController` and `SKGestureTracker` yourself — there is no single wrapper component like the MAUI `SKDeepZoomView`. This gives you full control over the rendering loop, pointer event wiring, and layout. |
| 3 | +Use `SKDeepZoomController` with a plain `SKCanvasView` to render Deep Zoom images in Blazor WebAssembly. There is no custom component — the page wires the services directly to the canvas. |
4 | 4 |
|
5 | 5 | ## Quick Start |
6 | 6 |
|
7 | 7 | ```razor |
8 | 8 | @page "/deepzoom" |
9 | 9 | @implements IDisposable |
10 | 10 | @inject HttpClient Http |
11 | | -@using SkiaSharp.Extended |
12 | 11 | @using SkiaSharp.Extended.DeepZoom |
13 | 12 |
|
14 | | -<div @onpointerdown="OnPointerDown" |
15 | | - @onpointermove="OnPointerMove" |
16 | | - @onpointerup="OnPointerUp" |
17 | | - @onpointercancel="OnPointerCancel" |
18 | | - @onwheel="OnWheel" |
19 | | - @onwheel:preventDefault |
20 | | - style="touch-action: none; cursor: grab; border: 1px solid #ccc; user-select: none;"> |
21 | | - <SKCanvasView @ref="_canvas" OnPaintSurface="OnPaintSurface" |
22 | | - style="width: 100%; height: 600px;" /> |
23 | | -</div> |
| 13 | +<SKCanvasView @ref="_canvas" |
| 14 | + OnPaintSurface="OnPaintSurface" |
| 15 | + style="width: 100%; height: 600px; border: 1px solid #ccc;" /> |
24 | 16 |
|
25 | 17 | @code { |
26 | 18 | private SKCanvasView? _canvas; |
27 | | - private DeepZoomController? _controller; |
28 | | - private bool _suppressSync; |
29 | | - private float _displayScale = 1f; |
30 | | -
|
31 | | - private readonly SKGestureTracker _tracker = new(new SKGestureTrackerOptions |
32 | | - { |
33 | | - IsRotateEnabled = false, |
34 | | - IsDoubleTapZoomEnabled = true, |
35 | | - IsScrollZoomEnabled = true, |
36 | | - IsFlingEnabled = true, |
37 | | - MinScale = 1f, |
38 | | - MaxScale = 32f, |
39 | | - }); |
40 | | -
|
41 | | - protected override void OnInitialized() |
42 | | - { |
43 | | - _tracker.TransformChanged += OnTransformChanged; |
44 | | - } |
| 19 | + private SKDeepZoomController? _controller; |
45 | 20 |
|
46 | 21 | protected override async Task OnAfterRenderAsync(bool firstRender) |
47 | 22 | { |
48 | 23 | if (!firstRender) return; |
49 | 24 |
|
50 | | - _controller = new DeepZoomController(); |
51 | | - _controller.ImageOpenSucceeded += (_, _) => _tracker.Reset(); |
52 | | - _controller.InvalidateRequired += (_, _) => InvokeAsync(() => _canvas?.Invalidate()); |
| 25 | + _controller = new SKDeepZoomController(); |
| 26 | + _controller.InvalidateRequired += OnInvalidateRequired; |
53 | 27 |
|
54 | | - var xml = await Http.GetStringAsync("deepzoom/image.dzi"); |
55 | | - var baseUrl = new Uri(Http.BaseAddress!, "deepzoom/image_files/").ToString(); |
56 | | - var tileSource = DziTileSource.Parse(xml, baseUrl); |
57 | | - _controller.Load(tileSource, new HttpTileFetcher(new HttpClient())); |
| 28 | + var xml = await Http.GetStringAsync("deepzoom/image.dzi"); |
| 29 | + var baseUrl = new Uri(Http.BaseAddress!, "deepzoom/image_files/").ToString(); |
| 30 | + var tileSource = SKDeepZoomImageSource.Parse(xml, baseUrl); |
58 | 31 |
|
59 | | - await InvokeAsync(StateHasChanged); |
60 | | - } |
61 | | -
|
62 | | - // ── Tracker → Viewport sync ──────────────────────────────────────── |
| 32 | + _controller.Load(tileSource, new SKDeepZoomHttpTileFetcher(new HttpClient())); |
63 | 33 |
|
64 | | - private void OnTransformChanged(object? sender, EventArgs e) |
65 | | - { |
66 | | - if (_suppressSync || _controller == null) return; |
67 | | - var controlW = _controller.Viewport.ControlWidth; |
68 | | - if (controlW <= 0) { InvokeAsync(() => _canvas?.Invalidate()); return; } |
69 | | -
|
70 | | - // Translate tracker (scale + pixel offset) → deep zoom viewport |
71 | | - double scale = _tracker.Scale; |
72 | | - _controller.Viewport.ViewportWidth = 1.0 / scale; |
73 | | - _controller.Viewport.ViewportOriginX = -_tracker.Offset.X / (controlW * scale); |
74 | | - _controller.Viewport.ViewportOriginY = -_tracker.Offset.Y / (controlW * scale); |
75 | | - _controller.Viewport.Constrain(); |
76 | | -
|
77 | | - // Sync tracker back to the (possibly constrained) viewport |
78 | | - var vp = _controller.Viewport; |
79 | | - float ns = (float)(1.0 / vp.ViewportWidth); |
80 | | - _suppressSync = true; |
81 | | - _tracker.SetTransform(ns, 0f, new SKPoint( |
82 | | - (float)(-vp.ViewportOriginX * controlW * ns), |
83 | | - (float)(-vp.ViewportOriginY * controlW * ns))); |
84 | | - _suppressSync = false; |
85 | | -
|
86 | | - InvokeAsync(() => _canvas?.Invalidate()); |
| 34 | + await InvokeAsync(StateHasChanged); |
87 | 35 | } |
88 | 36 |
|
89 | | - // ── Rendering ────────────────────────────────────────────────────── |
90 | | -
|
91 | 37 | private void OnPaintSurface(SKPaintSurfaceEventArgs e) |
92 | 38 | { |
93 | 39 | if (_controller == null) return; |
94 | | - _displayScale = e.Info.Height / 600f; // CSS height |
| 40 | +
|
95 | 41 | _controller.SetControlSize(e.Info.Width, e.Info.Height); |
96 | 42 | _controller.Update(); |
97 | | - e.Surface.Canvas.Clear(SKColors.White); |
98 | 43 | _controller.Render(e.Surface.Canvas); |
99 | 44 | } |
100 | 45 |
|
101 | | - // ── Pointer events ───────────────────────────────────────────────── |
102 | | -
|
103 | | - private SKPoint ToCanvas(PointerEventArgs e) => |
104 | | - new((float)e.OffsetX * _displayScale, (float)e.OffsetY * _displayScale); |
105 | | -
|
106 | | - private void OnPointerDown(PointerEventArgs e) => |
107 | | - _tracker.ProcessTouchDown(e.PointerId, ToCanvas(e), e.PointerType == "mouse"); |
108 | | - private void OnPointerMove(PointerEventArgs e) => |
109 | | - _tracker.ProcessTouchMove(e.PointerId, ToCanvas(e), e.Buttons > 0 || e.PointerType != "mouse"); |
110 | | - private void OnPointerUp(PointerEventArgs e) => |
111 | | - _tracker.ProcessTouchUp(e.PointerId, ToCanvas(e), e.PointerType == "mouse"); |
112 | | - private void OnPointerCancel(PointerEventArgs e) => |
113 | | - _tracker.ProcessTouchCancel(e.PointerId); |
114 | | - private void OnWheel(WheelEventArgs e) => |
115 | | - _tracker.ProcessMouseWheel( |
116 | | - new((float)e.OffsetX * _displayScale, (float)e.OffsetY * _displayScale), |
117 | | - 0, e.DeltaY < 0 ? 1f : -1f); |
| 46 | + private void OnInvalidateRequired(object? sender, EventArgs e) |
| 47 | + => InvokeAsync(() => _canvas?.Invalidate()); |
118 | 48 |
|
119 | 49 | public void Dispose() |
120 | 50 | { |
121 | | - _tracker.TransformChanged -= OnTransformChanged; |
122 | | - _tracker.Dispose(); |
123 | | - _controller?.Dispose(); |
| 51 | + if (_controller != null) |
| 52 | + { |
| 53 | + _controller.InvalidateRequired -= OnInvalidateRequired; |
| 54 | + _controller.Dispose(); |
| 55 | + } |
124 | 56 | } |
125 | 57 | } |
126 | 58 | ``` |
127 | 59 |
|
128 | | -## How It Works |
129 | | - |
130 | | -The pattern is the same one used inside `SKDeepZoomView` on MAUI — you're just doing the wiring yourself: |
| 60 | +## Serving Tile Assets |
131 | 61 |
|
132 | | -1. **Pointer events** are routed to `SKGestureTracker` via `ProcessTouchDown/Move/Up` and `ProcessMouseWheel`. |
133 | | -2. The tracker detects gestures and animates fling/zoom. On each frame it fires **`TransformChanged`**. |
134 | | -3. In your `TransformChanged` handler you translate the tracker's `Scale` and `Offset` into the controller's viewport coordinates, call `Viewport.Constrain()`, then sync the constrained result back to the tracker. |
135 | | -4. Call `_canvas.Invalidate()` to schedule a repaint, which calls `controller.Update()` and `controller.Render()`. |
| 62 | +Place `.dzi` and tile folder under `wwwroot`. In the project file, reference them as content: |
136 | 63 |
|
137 | | -### Coordinate Translation |
138 | | - |
139 | | -The tracker works in pixel-space (scale factor + pixel offset), while the deep zoom viewport works in normalised image-space (viewport width 0–1 + origin). The translation is: |
140 | | - |
141 | | -```csharp |
142 | | -// Tracker → Viewport |
143 | | -viewportWidth = 1.0 / scale |
144 | | -viewportOriginX = -offset.X / (controlWidth * scale) |
145 | | -viewportOriginY = -offset.Y / (controlWidth * scale) |
146 | | - |
147 | | -// Viewport → Tracker (inverse) |
148 | | -scale = 1.0 / viewportWidth |
149 | | -offsetX = -viewportOriginX * controlWidth * scale |
150 | | -offsetY = -viewportOriginY * controlWidth * scale |
| 64 | +```xml |
| 65 | +<ItemGroup> |
| 66 | + <Content Include="wwwroot\deepzoom\image.dzi" /> |
| 67 | + <Content Include="wwwroot\deepzoom\image_files\**" /> |
| 68 | +</ItemGroup> |
151 | 69 | ``` |
152 | 70 |
|
153 | | -Both X and Y use `controlWidth` (not height) because deep zoom normalises coordinates to the image width. |
154 | | - |
155 | | -### The `_suppressSync` Flag |
| 71 | +The `SKDeepZoomHttpTileFetcher` fetches each tile URL via `HttpClient`; tile URLs are constructed automatically from the base URL you supply to `SKDeepZoomImageSource.Parse`. |
156 | 72 |
|
157 | | -When you call `_tracker.SetTransform(...)` to sync the constrained viewport back, the tracker fires `TransformChanged` again. The `_suppressSync` flag prevents this from re-entering your handler. Since Blazor component code runs on a single synchronisation context, this is safe without locks. |
| 73 | +## Rendering Behaviour |
158 | 74 |
|
159 | | -## Display Scale |
160 | | - |
161 | | -Blazor pointer events report coordinates in CSS pixels, but the SkiaSharp canvas renders in physical (device) pixels. You need to scale pointer coordinates: |
162 | | - |
163 | | -```csharp |
164 | | -// Compute scale: physical canvas height / expected CSS height |
165 | | -_displayScale = canvasInfo.Height / 600f; // 600 = your CSS height in px |
166 | | -
|
167 | | -// Apply to every pointer event |
168 | | -var canvasPoint = new SKPoint((float)e.OffsetX * _displayScale, (float)e.OffsetY * _displayScale); |
169 | | -``` |
170 | | - |
171 | | -## Programmatic Navigation |
172 | | - |
173 | | -```csharp |
174 | | -// Zoom in 2× at the center (animated via tracker) |
175 | | -var cx = (float)(_controller.Viewport.ControlWidth / 2); |
176 | | -var cy = (float)(_controller.Viewport.ControlHeight / 2); |
177 | | -_tracker.ZoomTo(2f, new SKPoint(cx, cy)); |
178 | | - |
179 | | -// Reset to full image |
180 | | -_tracker.Reset(); |
181 | | -``` |
182 | | - |
183 | | -## Using SKDeepZoomView |
184 | | - |
185 | | -The Blazor sample includes a reusable `SKDeepZoomView` component that encapsulates the gesture tracker, deep zoom controller, and pointer-event wiring: |
186 | | - |
187 | | -```razor |
188 | | -<SKDeepZoomView @ref="_deepZoomView" |
189 | | - TileSource="@_tileSource" |
190 | | - Fetcher="@_fetcher" |
191 | | - ShowTileBorders="@showBorders" |
192 | | - ShowDebugStats="@showStats" |
193 | | - Style="height: 600px; border: 1px solid #ccc;" /> |
194 | | -
|
195 | | -@code { |
196 | | - private SKDeepZoomView? _deepZoomView; |
197 | | - private ISKDeepZoomTileSource? _tileSource; |
198 | | - private ISKDeepZoomTileFetcher? _fetcher; |
199 | | -
|
200 | | - // Call Reset(), ZoomIn(), ZoomOut() on the @ref |
201 | | - private void OnReset() => _deepZoomView?.Reset(); |
202 | | -} |
203 | | -``` |
| 75 | +- **Fit and center**: On load the controller calls `FitToView()` so the full image is visible and centered in the canvas. Neither cropping nor distortion occurs. |
| 76 | +- **Tile resolution**: `Update()` selects the pyramid level whose tile size best matches the physical pixel dimensions of the canvas. Only visible tiles are requested. |
| 77 | +- **Tile blending**: While high-resolution tiles are in-flight, parent-level tiles are upscaled and drawn as placeholders. |
204 | 78 |
|
205 | | -`SKDeepZoomView` handles all pointer event routing, gesture animation via `SKGestureTracker`, and tile loading via `SKDeepZoomController`. Pass a tile source and fetcher to load an image. |
| 79 | +## Related |
206 | 80 |
|
207 | | -## Next Steps |
| 81 | +- [Deep Zoom overview](deep-zoom.md) — architecture, services, and API reference |
| 82 | +- [Deep Zoom for MAUI](deep-zoom-maui.md) — .NET MAUI integration |
208 | 83 |
|
209 | | -- [Deep Zoom Core](deep-zoom.md) — Core classes shared by MAUI and Blazor |
210 | | -- [Deep Zoom for MAUI](deep-zoom-maui.md) — MAUI `SKDeepZoomView` control |
211 | | -- [Gestures](gestures.md) — SKGestureTracker documentation |
212 | | -- [Animation](animation.md) — Animation utilities used by the gesture system |
213 | | -- [API Reference — DeepZoomController](xref:SkiaSharp.Extended.DeepZoom.DeepZoomController) |
214 | | -- [Blazor Sample](https://github.com/mono/SkiaSharp.Extended/tree/main/samples/SkiaSharpDemo.Blazor/Pages/DeepZoom.razor) |
0 commit comments