drawnui-react 0.1.0-preview.1 → 0.1.0-preview.3

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 CHANGED
@@ -1,10 +1,12 @@
1
1
  # DrawnUi.React
2
2
 
3
- Prototype of the [DrawnUi](https://drawnui.net) engine rewritten in TypeScript on top of
3
+ [DrawnUi](https://drawnui.net) engine ptototype for React, TypeScript on top of
4
4
  [CanvasKit](https://skia.org/docs/user/modules/canvaskit/) (Skia for the browser), composed with React
5
5
  through a custom `react-reconciler` renderer.
6
6
 
7
- Goal: the same API surface and semantics as DrawnUi (.NET) — same control names, same PascalCase
7
+ Check out latest playbook: [helloreact.drawnui.net](https://helloreact.drawnui.net/) 👈
8
+
9
+ *Work in progress*: the goal is same API surface and semantics as DrawnUi (.NET) — same control names, same PascalCase
8
10
  property names, same measure/arrange/paint contract — so knowledge and docs transfer 1:1.
9
11
 
10
12
  ```tsx
@@ -20,17 +22,31 @@ await Super.UseDrawnUi()
20
22
  </Canvas>
21
23
  ```
22
24
 
23
- ## Layout
25
+ ## Install
26
+
27
+ ```
28
+ npm i drawnui-react@preview react react-dom
29
+ ```
24
30
 
25
- - `src/` — the library, imported by samples as `drawnui-react` (React tags + all engine types) or `drawnui-react/core` (engine only).
26
- - `src/core` — `Super` (startup, CanvasKit, fonts), `SkiaControl` (measure/arrange/render/gestures), `Canvas` (host, surface, frame loop, input), animators, value types.
27
- - `src/controls` — `SkiaLayout` (+ `SkiaStack`/`SkiaRow`/`SkiaLayer`), `SkiaLabel`, `SkiaHotspot`, `SkiaButton`.
28
- - `src/react` — reconciler host config + typed JSX tags + `<Canvas>` bridge component.
29
- - `samples/demo/` — the deployed demo: root menu + pages (`pages/ImagesPage.tsx`, `SvgPage.tsx`, `CellsPage.tsx` with `ContactCell.ts`) navigated by the React-level `SkiaShell`.
30
- - `samples/<name>/` — one folder per sample: `index.html`, `main.tsx`, two-line `vite.config.ts` (`defineSample`). Shared assets (fonts) in `samples/public`.
31
- - `dev/build-samples.mjs` — builds every sample into `dist/<name>/` + a `dist/index.html` list; used by the Pages workflow.
31
+ `drawnui-react` = React tags + every engine type, `drawnui-react/core` = the engine only. Ships ES modules + `.d.ts`;
32
+ CanvasKit's `.wasm` is referenced with a `?url` import, so use Vite (or any bundler that understands `?url`) and
33
+ put your fonts under `public/fonts`. Preview releases carry the `preview` dist-tag (the first publish also became `latest`, as npm always does).
32
34
 
33
- What is intentionally missing: see [SKIPPED.md](SKIPPED.md).
35
+ ## Run
36
+
37
+ ```
38
+ npm install
39
+ npm run dev # samples/demo at http://localhost:5173
40
+ npx vite samples/<name> # any other sample
41
+ npm run build # typecheck + build all samples into dist/<name>/
42
+ ```
43
+
44
+ ## Skills for AI agents
45
+
46
+ `skills/drawnui-react/SKILL.md` teaches an agent this library (install, startup, composition rules, scroll / shell
47
+ extras, caching, effects, accessibility). Save it under `~/.claude/skills/drawnui-react/SKILL.md` or fetch it from
48
+ the demo site: https://helloreact.drawnui.net/skills/drawnui-react/SKILL.md — `llms.txt` / `llms-full.txt` at the
49
+ site root point to it. Pair it with the DrawnUI framework skill from https://drawnui.net/llms.txt.
34
50
 
35
51
  ## Where React ends and DrawnUi begins
36
52
 
@@ -49,6 +65,25 @@ accessibility all live in the engine and would work identically under Vue, Svelt
49
65
  That is also why the demo pages describe DrawnUi features, not React ones: the same pages are meant to be reused as
50
66
  the showcase for other frameworks on this engine.
51
67
 
68
+ ## Context menu (right click)
69
+
70
+ A right click on the canvas normally opens the browser's own menu ("Save image as…"). Every mouse button goes
71
+ through the gesture pipeline as in DrawnUi.Net (`e.Parameters.Event.Pointer.Button` says which), so a right click is
72
+ a `Tapped` with `Button` `"Right"` for games and custom controls; a handler that should act on the primary button only
73
+ checks it. To take the browser menu request, handle `ContextMenu` on any control, exactly like `Tapped`:
74
+
75
+ ```tsx
76
+ <SkiaShape Tapped={(s, e) => { if ((e.Parameters.Event.Pointer?.Button ?? "Left") === "Left") open(); }} />
77
+ <SkiaShape ContextMenu={(sender, e) => { openMyMenu(e.Location); return true; }} />
78
+ <Canvas ContextMenu={(canvas, e) => true /* nothing hit: swallow the browser menu everywhere */} />
79
+ ```
80
+
81
+ The request is routed like a tap: deepest visible control under the point first (through transforms), then its
82
+ parents, then the `Canvas` prop. The first handler returning `true` takes it and the browser menu is suppressed;
83
+ with no handler the browser menu shows as before. `e` carries `Location` (points on the canvas), `Pixels`, `Local`
84
+ (pixels inside the handling control), `Source` (`"mouse"`, `"touch"` for a long press, `"keyboard"` for the Menu
85
+ key) and `Native` (the DOM event, for modifiers). Web-only: DrawnUi.Net has no mouse buttons.
86
+
52
87
  ## Accessibility
53
88
 
54
89
  Same model as DrawnUi.Blazor: the `<canvas>` is `aria-hidden`, an invisible DOM overlay mirrors every
@@ -68,25 +103,48 @@ label readable and every button focusable without touching each control.
68
103
  The overlay has `pointer-events: none`, so hover and all pointer gestures still reach the canvas — the
69
104
  Blazor "accessible control loses hover" limitation does not apply.
70
105
 
71
- ## Run
106
+ Text selection (React extension, opt-in): `AccessibilityTextSelectable` on a `SkiaLabel` renders its laid-out lines
107
+ as real, invisible DOM text in the overlay — one span per drawn line, in the same font (registered fonts are also
108
+ installed as CSS `FontFace`s) and stretched to the drawn line width — with pointer events on. The browser then
109
+ selects, copies (Ctrl+C / context menu) and reads it like an HTML paragraph, and the wheel over it still scrolls the
110
+ drawn content. It is off by default and must stay off on anything gesture-driven (buttons, carousels, drawers): the
111
+ selectable text owns the pointer, so taps and pans under it never reach the drawn control. Everything else in the
112
+ overlay is `user-select: none`, so select-all only highlights opted-in text.
72
113
 
73
- ```
74
- npm install
75
- npm run dev # samples/demo at http://localhost:5173
76
- npx vite samples/<name> # any other sample
77
- npm run build # typecheck + build all samples into dist/<name>/
78
- ```
114
+ ## Crawlers and AI agents: static HTML from the accessibility tree
79
115
 
80
- ## Publishing
116
+ A drawn app serves `<div id="root"></div>`; a crawler that does not run JavaScript sees nothing. The
117
+ `drawnUiStatic` Vite plugin fixes that at build time, from the app itself:
81
118
 
82
- Every push to `master` runs `.github/workflows/deploy.yml`: build `samples/demo`, deploy `dist/demo`
83
- to the Cloudflare Pages project `helloreact-drawnui` → **https://helloreact.drawnui.net**.
84
-
85
- Repository secrets used by the workflow:
86
-
87
- | Secret | What it is | Where to get it |
88
- |---|---|---|
89
- | `CLOUDFLARE_API_TOKEN` | Cloudflare API token with **Account → Cloudflare Pages → Edit** (deploy needs nothing else). | dash.cloudflare.com → My Profile → API Tokens → Create Token → "Edit Cloudflare Workers" template or custom with the Pages permission; copy the value once. Set with `gh secret set CLOUDFLARE_API_TOKEN --repo DrawnUi/DrawnUi.React` (paste the value on stdin). |
90
- | `CLOUDFLARE_ACCOUNT_ID` | The Cloudflare account that owns the Pages project. | dash.cloudflare.com → any zone → Overview → right column "Account ID", or `npx wrangler whoami`. `gh secret set CLOUDFLARE_ACCOUNT_ID --repo DrawnUi/DrawnUi.React`. |
119
+ ```ts
120
+ // vite.config.ts
121
+ import { drawnUiStatic } from "drawnui-react/vite";
122
+ export default defineConfig({ plugins: [react(), drawnUiStatic()] });
123
+ ```
91
124
 
92
- Adding another published sample = one more `wrangler pages deploy dist/<name> --project-name <project>` step and a Pages project + custom domain for it.
125
+ After `vite build` it serves the build, opens it in headless Chrome (`playwright-core`, your dev dependency; GitHub's
126
+ ubuntu runners ship a Chrome), lets the engine draw, reads the accessibility snapshot of the root page and of every
127
+ page a root button opens (or the `routes` you list), and writes ordinary visible HTML into `dist/index.html`: a
128
+ heading is a heading, a button is a link to the page it opened, a label is a paragraph. The overlay's own markup
129
+ (transparent text over the canvas) is never copied — in a static file it would read as hidden text. Nothing is
130
+ hand-written, so it cannot drift from what the canvas draws.
131
+
132
+ Where it lands: right after the mount element (`#root`), or wherever you put `<!-- drawnui-static -->` in
133
+ `index.html`. With the mount element filling the viewport that is below the fold: a visitor sees the app area
134
+ loading, the text is a scroll away, genuinely visible, nothing collapsed or hidden. The first `<Canvas>` that mounts
135
+ removes it once its engine has drawn frame 1 (one `querySelectorAll`, nothing in the frame loop, nothing to pay for
136
+ an app built without the plugin). If the app never boots it stays. The mount element keeps its size, so nothing
137
+ above the fold moves; a page that scrolled only because of the block loses its scrollbar (classic scrollbars give
138
+ the canvas those ~15 px back — `html { scrollbar-gutter: stable }` keeps the gutter if you prefer). If the mount
139
+ element is a box inside a longer page, put the marker at the end of the body so your own content does not move.
140
+ Look: a default stylesheet ships with the block (inherits your `body` font and color, translucent surfaces); override
141
+ by styling `.drawnui-static` or pass `render: { style: false }`.
142
+
143
+ Two things to know. Check it with `curl` or view-source, not DevTools after boot. And crawlers that do not run
144
+ JavaScript (GPTBot, ClaudeBot, CCBot, link previews) read this HTML, while Googlebot renders JavaScript and sees the
145
+ booted page, i.e. the accessibility overlay; both come from the same tree, and a control without an
146
+ `AccessibilityRole` is invisible to both.
147
+
148
+ ## Development
149
+
150
+ Repo layout, build scripts, skill sync and how the demo / npm package are published: [dev/DEVELOPMENT.md](dev/DEVELOPMENT.md).
package/SKIPPED.md CHANGED
@@ -7,10 +7,10 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
7
7
 
8
8
  | Area | Status | Notes |
9
9
  |---|---|---|
10
- | `UseCache` | partial | `None`, `Operations` (SkPicture replay), `Image` (offscreen surface snapshot, GPU-backed on WebGL, nearest-sampled blit). `ImageDoubleBuffered` keeps `RenderObjectPrevious` and draws it when a new cache cannot be produced yet, `DrawPlaceholder(ctx)` hook when nothing exists (recording itself is synchronous — no background thread, same as DrawnUi.Blazor with `CanUseCacheDoubleBuffering = false`). `GPU`, `ImageComposite`, `ImageCompositeGPU` accepted but resolve to `Image` (no per-child composite, no dedicated GPU path). `Super.CacheEnabled`, `RenderObject`/`CachedObject`, `InvalidateCache`, `DestroyRenderingObject`, `Canvas.DisposeObject` (deleted after flush). Defaults per C#: `SkiaLabel`/`SkiaSvg` Operations, everything else None. Not ported: `CacheSharing`, `AllowCaching`, `RenderObjectPrevious` fallback, offscreen bake threads, `DrawPlaceholder`, cache validity by GRContext. |
10
+ | `UseCache` | partial | `None`, `Operations` / `OperationsFull` (SkPicture replay), `Image` (offscreen surface snapshot, GPU-backed on WebGL, nearest-sampled blit), `ImageDoubleBuffered` (`RenderObjectPrevious` kept and drawn when a new cache cannot be produced yet, `DrawPlaceholder(ctx)` hook when there is none; recording itself is synchronous — no background thread, same as DrawnUi.Blazor with `CanUseCacheDoubleBuffering = false`), `ImageComposite` / `ImageCompositeGPU` (per-child composite, see the `UseCache="ImageComposite"` row below). `GPU` resolves to `Image` and `ImageCompositeGPU` to `ImageComposite`: no separate GPU-only path. `Super.CacheEnabled`, `RenderObject`/`CachedObject`, `InvalidateCache`, `DestroyRenderingObject`, `Canvas.DisposeObject` (deleted after flush). Defaults per C#: `SkiaLabel`/`SkiaSvg` Operations, everything else None. Not ported: `CacheSharing`, `AllowCaching`, offscreen bake threads, cache validity by GRContext. |
11
11
  | Incremental invalidation | partial | `Measure` returns the previous size when `!NeedMeasure` and constraints+scale are unchanged; `Update()`/`InvalidateMeasure()` bubble up and stale every ancestor cache; `Repaint()` keeps caches. Arrange still runs per frame for the whole tree; no dirty regions, no `DirtyChildrenTracker`. Post-animators on a control nested INSIDE a cached ancestor are drawn only when that ancestor re-records. |
12
- | Gestures core: `Canvas.Gestures` (Disabled/Enabled/Lock), `ProcessGestures(args, apply)`, `ConsumeGestures`, `Tapped`/`ChildTapped`, `InputTransparent`, `BlockGesturesBelow`, `LockChildrenGestures`, `HitBoxAuto`/`HitIsInside`/`IsGestureForChild`, `SkiaGesturesParameters`/`GestureEventProcessingInfo`/`SkiaGesturesInfo`/`ControlTappedEventArgs` | ported | Pointer events -> per-pointer `OnTouchAction` state machine (port of DrawnUi.Blazor) -> Down/Panning/Tapped/Up, queued and processed at frame start. Tap slop = 16pt like AppoMobi TouchEffect. |
13
- | Gestures: `LongPressing`, `Pointer` (hover), multi-touch pinch, velocity, `SoftLock`, `AddGestures` attached props, `OnGestures` delegate, transform-aware mapping (`HasTransform`, cache offsets `TranslateInputCoords`) | skipped | Enum members exist for parity, never produced. `ChildOffset` is always zero (no caches/transforms yet). |
12
+ | Gestures core: `Canvas.Gestures` (Disabled/Enabled/Lock), `ProcessGestures(args, apply)`, `ConsumeGestures`, `Tapped`/`ChildTapped`, `InputTransparent`, `BlockGesturesBelow`, `LockChildrenGestures`, `HitBoxAuto`/`HitIsInside`/`IsGestureForChild`, `SkiaGesturesParameters`/`GestureEventProcessingInfo`/`SkiaGesturesInfo`/`ControlTappedEventArgs` | ported | Pointer events -> per-pointer `OnTouchAction` state machine (port of DrawnUi.Blazor) -> Down/Panning/Tapped/Up, queued and processed at frame start. Tap slop = 16pt like AppoMobi TouchEffect. Every mouse button is delivered (`Event.Pointer`: `Button`, `ButtonNumber`, `DeviceType`, `PressedButtons`, the AppoMobi.Gestures PointerData): a right click is Down / Up / Tapped with `Button` "Right" plus a `ContextMenu`; handlers that must be primary-only check `e.Parameters.Event.Pointer?.Button`. |
13
+ | Gestures: `LongPressing`, `Pointer` (hover), multi-touch pinch, `SoftLock`, `AddGestures` attached props, `OnGestures` delegate | skipped | The enum members exist for parity and are never produced; there is no pinch member at all. Ported instead of skipped: velocity (`Gestures.ts` fills `Distance.Velocity` per move, the scroll / snapping physics run on it) and transform-aware mapping — a child receives the point through the inverse `RenderTransformMatrix` (`TransformPointToLocalSpace`), so rotated / scaled / translated controls hit-test correctly. `ChildOffset` stays zero because that inverse matrix replaces the C# cache-offset math (`TranslateInputCoords`), not because transforms are missing. `Wheel` IS produced (`Canvas.onWheel`). |
14
14
  | C# multi-subscriber `event`s | changed shape | One callback per event prop (`Tapped={fn}`), same names. `Command*` (ICommand) variants not ported. |
15
15
  | `Opacity`, `TranslationX/Y`, `Rotation`, `ScaleX/Y` (+`Scale`), `SkewX/Y`, `AnchorX/Y`, `HasTransform`, `RenderTransformMatrix`, `TransformPointToLocalSpace` | ported | Same matrix order as C# `ApplyTransforms` (pivot → rotation → scale/skew → translation), opacity = `saveLayer` alpha over the subtree, gestures mapped through the inverse matrix (`IsGestureForChild`, child receives the local point), accessibility rect = transformed bounds. Animations: `AnimateAsync`, `AnimateRangeAsync`, `FadeToAsync`, `ScaleToAsync`, `TranslateToAsync`, `RotateToAsync` as Promises with `AbortSignal` (one running per kind, like the C# per-property cancellation). Not ported: `RotationX/Y/Z`, `TranslationZ`, `Perspective1/2`, `CameraAngle`, `LinkTransforms`, `AddTranslationX/Y`, `UseTranslation*` overrides, `CustomizeLayerPaint`, `SkipRenderingOutOfBounds`. |
16
16
  | `Left` / `Top` | ported (as a translate) | Same paint-time offset in points, but folded into the render matrix (a `canvas.translate`, hit-testing follows) for every control, cached or not; C# applies them only when blitting a cache. |
@@ -29,8 +29,8 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
29
29
  | `HorizontalFillRatio`/`VerticalFillRatio` | ported | Applied at arrange like C# `DefineAvailableSize` (the Fill box is scaled, alignment stays inside the full box); measure is not affected (C# passes `useModifiers=false` there too). |
30
30
  | `ZIndex` | ported | Static children of a layout are drawn in ZIndex order (stable for equal values), gestures go to the top-most first; the sorted list is cached until a child or a ZIndex changes (C# `GetOrderedSubviews`). Templated cells keep index order. |
31
31
  | `FillGradient`, `SkiaShape.StrokeGradient` | ported | `SkiaGradient` as a plain object: `Type` Linear (Start/End ratios or CSS `Angle`), Circular / Conical (radius = half the smaller side), Oval (radial scaled to the box), Sweep (around the center, `Value1`/`Value2` angles), `ColorPositions`, `TileMode`, `Light` (< 1 darker, > 1 lighter — HSL lightness, C# uses `MakeDarker/MakeLighter`, exact curve unverified), `Opacity`, `BlendMode`; C# `SetupGradient` paint setup (white base); shaders cached per gradient object and rect (C# `Version` cache: a new React literal replaces the old shaders). `SkiaLabel.FillGradient` paints the glyphs (`GradientByLines` default true = per line bounds, false = the text block) and the background only when `BackgroundColor` is set, like the C# label override. `SkiaLabel` text stroke (`StrokeColor`, `StrokeWidth` points, `StrokeGradient`) and drop shadow (`DropShadowColor`, `DropShadowSize`, `DropShadowOffsetX/Y`) in the C# order shadow → stroke → fill, measurement inflated by stroke × 2 and shadow size + offset like C#. Not ported: `Background` MAUI brushes, `FillBlendMode`. |
32
- | `SkiaShell` | partial (React-level) | `Routes` (route -> JSX page factory), `GoToAsync(route, animated)` / `GoBackAsync(animated)` / `PopToRootAsync` with the SkiaViewSwitcher slide (`PagesAnimationSpeed` 200 ms, pushed pages opaque over the page below, lower pages hidden when idle), `NavigationStack`, `CanGoBack`, `Route`, nav bar with Back + title, `useShell()`; `OpenPopupAsync(content, { animated, closeWhenBackgroundTapped, showOverlay, backgroundColor })` (C# PopupWrapper: dimmed backdrop, scale 0.5→1 + fade over `PopupsAnimationSpeed`, tap outside the content closes), `ClosePopupAsync`, `CloseAllPopups`; `PushModalAsync(content, { useGestures, animated, freezeBackground })` (C# ModalWrapper: full-screen `SkiaDrawer` FromBottom HeaderSize 0, drag-to-close with `useGestures`), `PopModalAsync`; `ShowToast(text | content, ms)` (C# layout: bottom banner, slide + fade 300/250 ms, `ShellDefaults.Toast*`), `CloseAllToasts`; `PopupsCount`/`ModalsCount`/`ToastsCount`; `ShellDefaults` = the C# statics (`PopupBackgroundColor`, `PopupsBackgroundBlur`, `PopupsAnimationSpeed`, `PopupsCancelAnimationsAfterMs`, `ZIndex*`, `Toast*`, tab colors); popups/modals sit on a `SkiaBackdrop` (blur + tint); `Tabs` (C# SkiaViewSwitcher tabs: bottom tab bar, per-tab page stacks, `SelectedTab`, `SelectTabAsync`, `PopTabToRootAsync`); `GoBackAsync` follows C# `GoBackDefault` (top popup, then modal, then page); React extension `UseBrowserHistory` (default true): pages in the URL hash `#/a/b`, one history entry per page / popup / modal, browser Back unwinds in the same order, deep links restore the page stack. Not ported: engine-level shell / `NavigationLayout` view switcher, frozen-screenshot backgrounds, `pixelsScaleInFrom`, `Navigating`/`Navigated`/`RouteChanged` events, `RegisterRoute` by type with arguments, `IHandleGoBack`, insets, `PushAsync(page instance)`; `AnimateTabs` + `TabsAnimationSpeed` (150) ported with the C# SelectLeftTab/SelectRightTab slide (0.75 width, fade, back-ease 0.55) — only the tab roots animate, pages pushed inside the leaving tab drop instantly. |
33
- | Styles / `ConfigureStyles` | skipped | |
32
+ | `SkiaShell` | partial (React-level) | `Routes` (route -> JSX page factory), `GoToAsync(route, animated)` / `GoBackAsync(animated)` / `PopToRootAsync` with the SkiaViewSwitcher slide (`PagesAnimationSpeed` 200 ms, pushed pages opaque over the page below, lower pages hidden when idle), `NavigationStack`, `CanGoBack`, `Route`, nav bar with Back + title, `useShell()`; `OpenPopupAsync(content, { animated, closeWhenBackgroundTapped, showOverlay, backgroundColor })` (C# PopupWrapper: dimmed backdrop, scale 0.5→1 + fade over `PopupsAnimationSpeed`, tap outside the content closes), `ClosePopupAsync`, `CloseAllPopups`; `PushModalAsync(content, { useGestures, animated, freezeBackground })` (C# ModalWrapper: full-screen `SkiaDrawer` FromBottom HeaderSize 0, drag-to-close with `useGestures`), `PopModalAsync`; `ShowToast(text | content, ms)` (C# layout: bottom banner, slide + fade 300/250 ms, `ShellDefaults.Toast*`), `CloseAllToasts`; `PopupsCount`/`ModalsCount`/`ToastsCount`; `ShellDefaults` = the C# statics (`PopupBackgroundColor`, `PopupsBackgroundBlur`, `PopupsAnimationSpeed`, `PopupsCancelAnimationsAfterMs`, `ZIndex*`, `Toast*`, tab colors); popups/modals sit on a `SkiaBackdrop` (blur + tint); `Tabs` (C# SkiaViewSwitcher tabs: bottom tab bar, per-tab page stacks, `SelectedTab`, `SelectTabAsync`, `PopTabToRootAsync`); `GoBackAsync` follows C# `GoBackDefault` (top popup, then modal, then page); React extension `UseBrowserHistory` (default true): pages in the URL hash `#/a/b`, one history entry per page / popup / modal, browser Back unwinds in the same order, deep links restore the page stack. Not ported: engine-level shell / `NavigationLayout` view switcher, frozen-screenshot backgrounds, `pixelsScaleInFrom`, `Navigating`/`Navigated`/`RouteChanged` events, `RegisterRoute` by type with arguments, `IHandleGoBack`, insets, `PushAsync(page instance)`; `AnimateTabs` + `TabsAnimationSpeed` (150) ported with the C# SelectLeftTab/SelectRightTab slide (0.75 width, fade, back-ease 0.55) — only the tab roots animate, pages pushed inside the leaving tab drop instantly. Browser history: a `popstate` at the same depth with another hash (a plain `<a href="#/route">` link, a typed hash) rebuilds the stack like a forward navigation. |
33
+ | Styles / `ConfigureStyles` | ported | `Super.UseDrawnUi().ConfigureStyles(styles => styles.AddStyle({ TargetType, ApplyToDerivedTypes, Setters }))`, the C# `Style` shape with the `Setter` list written as a plain object (`Setters: { FontFamily: "FontText" }`) because controls hold plain properties instead of BindableProperty. Per-class resolution cached, base-type styles applied furthest-first so the nearest wins, exact type last (C# `ResolveStylesForType`). Applied by the reconciler between `new` and the JSX props, so props override; a control built in code-behind is styled on its first `Measure` and keeps values that differ from its class defaults (the React stand-in for C# `ExplicitPropertiesSet`, which needs BindableProperty). Not ported: `BasedOn`, `Class`, `Triggers`, `Behaviors`, the per-control `Style` property, `ConditionalStyle`. |
34
34
  | Animators core: `AnimatorBase` / `SkiaValueAnimator` / `RenderingAnimator` (`IOverlayEffect`), `Canvas.RegisterAnimator`/`AnimatingControls`, `PostAnimators` + `ExecutePostAnimators`, `Easing` (Linear/Cubic*) | ported | Frame-driven: a running animator keeps frames coming, idle canvas draws nothing. |
35
35
  | Touch feedback: `AnimationTapped="Ripple"` + `TouchEffectColor` + `AnimationTappedSpeed` on any control, `SkiaButton.ApplyEffect="Ripple"` (on Down), `PlayRippleAnimation`, `RippleAnimator`, `ClipEffects`/`CreateClip` | ported | Same numbers as C#: 500ms CubicIn, radius 300pt, opacity 0.20 fading over 1.15x progress. |
36
36
  | `Shimmer` touch animation, `ShimmerAnimator`, `ClippedEffectsWith`, `TransformView`, `DelayCallbackMs`, `removePrevious`, `Pause`/`Resume`, `UseInterpolator`, spring/deceleration timing, `AnimateExtensions` (`FadeToAsync`, `TranslateToAsync`, ...) | skipped | |
@@ -48,9 +48,11 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
48
48
  |---|---|---|
49
49
  | `RenderingMode` | partial | `Accelerated` (WebGL) with automatic fallback to software; read once at first surface creation. |
50
50
  | `Gestures` param | ported | Enabled applies `touch-action:none; user-select:none`; Lock also blocks `touchmove` default. |
51
+ | Mouse cursor over interactive controls (Blazor `Canvas.razor` `.xaml-a11y-interactive { cursor: pointer }`) | ported (differently) | The overlay here is `pointer-events: none`, so `Canvas.UpdateCursor` hit-tests mouse moves with no active pointer against the accessibility snapshot rects (`CanInteract` = has `Tapped`, buttons, links) and asks each hit control `WantsPointerCursor(x, y)` (default `AccessibilityCanInteract`; `SkiaLabel` also answers for its tappable `TextSpan`s), setting the canvas element's `cursor` only when the answer changes. Touch pointers do nothing. Engine hover (`IsHovered` / `OnHover`, `TouchActionResult.Pointer`) stays skipped, see Gestures. |
52
+ | `ContextMenu` (right click / long press / Menu key, React extension) | ported (web-only) | `SkiaControl.ContextMenu` / `Canvas.ContextMenu` handlers with `ContextMenuEventArgs` (`Location` points, `Pixels`, `Local` px in the control, `Source`, `Native`); the DOM `contextmenu` event is routed like a tap (`ProcessContextMenu`: visible non-transparent children top-most first through `TransformPointToLocalSpace`, then the control, then the canvas). `true` = handled, browser menu suppressed; nothing handled = browser menu. Not in DrawnUi.Net. |
51
53
  | Keyboard, focus (`FocusedChild`) | skipped | |
52
- | FPS / rendering stats (`Super.EnableRenderingStats`, `SkiaLabelFps`) | skipped | |
53
- | Insets / safe areas | skipped | N/A in browser for now. |
54
+ | Rendering stats | partial | `Canvas.FPS` (frames drawn over the last second) and `Canvas.FrameTime` (ms of the last measure + arrange + render, GPU flush excluded) are computed every frame and reachable from React through the `<Canvas>` ref. Not ported: the `SkiaLabelFps` control, `Super.EnableRenderingStats`. |
55
+ | Insets / safe areas | ported | `Super.Insets` measures `env(safe-area-inset-*)` off a probe element (once, and again on resize / orientation), `Super.OnInsetsChanged` notifies; `SkiaShell` pads its nav and tab bars with them. Not ported: the C# per-page `IgnoreSafeArea` / MAUI inset plumbing. |
54
56
 
55
57
  ## Controls
56
58
 
@@ -90,7 +92,7 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
90
92
  |---|---|---|
91
93
  | `Super.UseDrawnUi().ConfigureFonts(f => f.AddFont(src, alias)).BuildAsync()` | ported | Same shape as DrawnUi.Net/OpenTK. |
92
94
  | `AddFont(src, alias, weight)`, font fallback, emoji | skipped | |
93
- | `PreloadAssets`, `ConfigureStyles` | skipped | |
95
+ | `PreloadAssets` | skipped | `ConfigureStyles` is ported, see the Engine table. |
94
96
 
95
97
  ## React layer
96
98
 
@@ -99,9 +101,10 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
99
101
  | Custom `react-reconciler` renderer, PascalCase props mapped 1:1 to control properties | ported | |
100
102
  | `SkiaToggle` / `SkiaSwitch` / `SkiaCheckbox` / `SkiaRadioButton` | ported | Same names: `IsToggled`, `Toggled`, `DefaultValue`, `IsAnimated`, `RespondsToGestures`, `ColorThumbOn/Off`, `ColorFrameOn/Off` (+ `ColorCheckOn`), `ControlStyle` with the Default / Cupertino / Material / Material3 / Windows looks and geometry of the C# style builders (`Frame`/`Thumb`, `FrameOff`/`FrameOn`/`ViewCheckOn`, `On` ring + `Text`), thumb animated with `TranslateToAsync` (`AnimationSpeed` 200 ms), radio groups by `GroupName` or parent. Not ported: `CommandToggled`/`CommandTapped`, hover (`CheckHovered`), `TransformView` hotspot (the toggle itself takes the tap), `Platform` style resolves from the browser UA. |
101
103
  | `SkiaProgress` / `SkiaSlider` | ported (painted, not composed) | `Value`/`Min`/`Max`, `TrackColor`/`ProgressColor`; slider `Min`/`Max`/`Step`/`Start`/`End`/`EnableRange`/`RangeMin`/`SliderHeight`/`ThumbColor`/`TrackColor`/`TrackSelectedColor`/`ClickOnTrailEnabled`/`StartChanged`/`EndChanged`, `StartThumbX`/`EndThumbX`, the C# position<->value math (`SliderHeight` = thumb box, `Step` rounding, `RangeMin` clamps), drag + click-on-trail + per-style looks (track heights, thumb sizes, palettes, Material3 gap + stop dot). Track/trail/thumbs are painted directly instead of child shapes (`SliderTrail`/`SliderThumb` not exposed). Not ported: `Orientation` Vertical, `Invert`, `ValueStringFormat`/`MinMaxStringFormat` + `StartDesc`/`EndDesc`, `IgnoreWrongDirection`, `AvailableWidthAdjustment`, `Trail*Offset`, `Cupertino*` sizing props, hover. |
102
- | `SkiaButton.ControlStyle` | ported | Cupertino / Material / Material3 / Windows accent, corner radius, font size/weight and minimum height from the C# style builders, applied only when the user left `BackgroundColor`/`CornerRadius`(8)/`FontSize`(15)/`MinimumHeightRequest` unset; default look is now the C# Crimson accent. Not ported: `Background` brushes, pressed-state shadow changes. |
104
+ | `SkiaButton.ControlStyle` | ported | Cupertino / Material / Material3 / Windows accent, corner radius, font size/weight and minimum content size from the C# style builders, applied only when the user left `BackgroundColor`/`CornerRadius`(8)/`FontSize`(15)/`MinimumWidthRequest`/`MinimumHeightRequest` unset; default look is now the C# Crimson accent. `SetDefaultMinimumContentSize` is mirrored for BOTH axes (every C# style passes width 100, heights 41/36/40/40/32) and for the default `Unset` style too, skipped on a `Fill` axis or when a `WidthRequest`/`HeightRequest` is set, so short captions clamp to 100pt as in C#; the C# value adds `Margins` because it compares against a margin-inclusive size, here Measure applies it margin-exclusive and adds the margin after. Not ported: `Background` brushes, pressed-state shadow changes. |
103
105
  | `SkiaButton.FontFamilyFallback` | React extension | Pass-through to the inner label so icon-glyph buttons render with `AddSymbols()` faces. |
104
106
  | Accessibility (`ISkiaAccessibilityNode` + `SkiaAccessibilityManager` + Blazor ARIA overlay) | ported | `AccessibilityRole/Label/Hint/CanInteract/IsPressed/Live`, `Aria` constants, `IsAccessibilityElement`, `GetAccessibilityPixelRect`, `NotifyAccessibility`, `OnAccessibilityActivated` (synthetic Tapped), `OnAccessibilityFocused`, snapshot rate-limited by `MinUpdateIntervalMs`, top-left reading order, `SkiaButton` hides its inner label. Extensions: per-class `DefaultAccessibilityRole`, label/interaction defaults derived from text / `Tapped`, `pointer-events:none` overlay, detached/off-canvas nodes pruned at rebuild (no explicit unregister on removal). Not ported: `WithAccessibility*` fluent helpers, `FocusChanged` event consumers, UIA/AT-SPI (browser only), `aria-level` for headings. |
107
+ | Static HTML for crawlers (`drawnui-react/vite` `drawnUiStatic()`, React extension) | ported | Build-time only: after `vite build` the plugin serves the build, opens it in headless Chrome (`playwright-core`, optional peer), reads the accessibility snapshot of the root page and of every page a root button opens (or `routes`), and writes visible semantic HTML (heading → heading, button → `<a href="#/route">`, text → paragraph, other controls → list items; `img` and `presentation` dropped) after the mount element or at `<!-- drawnui-static -->`, with `DEFAULT_STATIC_STYLE` (`render.style` to override / disable). Runtime: the first `<Canvas>` mount removes every `[data-drawnui-static]` element right after its engine drew frame 1 (`removeStaticContent`, one querySelectorAll, nothing in the frame loop). Not in DrawnUi.Net (Blazor sites hand-write static SEO content, see the drawnui-blazor notes). Not done: per-route documents (pages live in the hash, one URL), `<title>` / meta per page, sitemap entries. |
105
108
  | React context bridging across the `<Canvas>` boundary | skipped | Contexts from the DOM tree are not visible inside the drawn tree. |
106
109
  | Refs to engine controls from JSX | skipped | `getPublicInstance` returns the control, `ref` not wired/typed. |
107
110
  | Fluent code-behind API (`.Assign`, `.OnTapped`, `.ObserveProperty`) | skipped | Engine classes are plain TS classes; React is the composition layer. |
@@ -44,7 +44,10 @@ export declare class SkiaButton extends SkiaLayout {
44
44
  /** The button's own BackgroundColor/CornerRadius/Stroke are the frame's; the button itself paints nothing. */
45
45
  protected PaintBackground(): void;
46
46
  get UsingControlStyle(): ResolvedControlStyle;
47
- /** C# Create*StyleContent defaults: accent, corner radius, font size/weight, minimum content size. */
47
+ /**
48
+ * C# Create*StyleContent defaults: accent, corner radius, font size/weight, minimum content size.
49
+ * Every C# style calls SetDefaultMinimumContentSize(100, minH), so the width floor is 100 everywhere.
50
+ */
48
51
  private static Look;
49
52
  protected MeasureAbsolute(w: number, h: number, scale: number): ScaledSize;
50
53
  /** The frame sits under the padding box: arrange children in the full rect, the label centered inside padding. */
@@ -49,6 +49,11 @@ export class SkiaButton extends SkiaLayout {
49
49
  this.label.VerticalOptions = "Center";
50
50
  this.AddSubView(this.frame);
51
51
  this.AddSubView(this.label);
52
+ // C# ButtonLabel is styled when the button builds its content, and ApplyProperties then overwrites FontFamily with
53
+ // the button's own (empty by default) value, which the style no longer touches. Same order here: style the caption
54
+ // now, MeasureAbsolute below pushes SkiaButton.FontFamily over it every measure.
55
+ this.frame.ApplyInitialStyles(true);
56
+ this.label.ApplyInitialStyles(true);
52
57
  }
53
58
  /** Set to `Aria.RoleButton` to expose every button (React extension). */
54
59
  static DefaultAccessibilityRole;
@@ -57,14 +62,17 @@ export class SkiaButton extends SkiaLayout {
57
62
  /** The button's own BackgroundColor/CornerRadius/Stroke are the frame's; the button itself paints nothing. */
58
63
  PaintBackground() { }
59
64
  get UsingControlStyle() { return ResolveControlStyle(this.ControlStyle); }
60
- /** C# Create*StyleContent defaults: accent, corner radius, font size/weight, minimum content size. */
65
+ /**
66
+ * C# Create*StyleContent defaults: accent, corner radius, font size/weight, minimum content size.
67
+ * Every C# style calls SetDefaultMinimumContentSize(100, minH), so the width floor is 100 everywhere.
68
+ */
61
69
  static Look(s) {
62
70
  switch (s) {
63
- case "Cupertino": return { bg: "#007AFF", radius: 8, font: 17, weight: 600, minH: 36, shadow: new SkiaShadow({ X: 0, Y: 1, Blur: 2, Opacity: 0.2, Color: Colors.Black }) };
64
- case "Material": return { bg: "#2196F3", radius: 4, font: 14, weight: 0, minH: 40, shadow: new SkiaShadow({ X: 0, Y: 2, Blur: 4, Opacity: 0.3, Color: Colors.Black }) };
65
- case "Material3": return { bg: "#6750A4", radius: 20, font: 14, weight: 0, minH: 40 };
66
- case "Windows": return { bg: "#0078D7", radius: 4, font: 15, weight: 500, minH: 32, shadow: new SkiaShadow({ X: 0, Y: 1, Blur: 1, Opacity: 0.2, Color: Colors.Black }) };
67
- default: return { bg: "#DC143C", radius: 8, font: 15, weight: 0, minH: 41 };
71
+ case "Cupertino": return { bg: "#007AFF", radius: 8, font: 17, weight: 600, minW: 100, minH: 36, shadow: new SkiaShadow({ X: 0, Y: 1, Blur: 2, Opacity: 0.2, Color: Colors.Black }) };
72
+ case "Material": return { bg: "#2196F3", radius: 4, font: 14, weight: 0, minW: 100, minH: 40, shadow: new SkiaShadow({ X: 0, Y: 2, Blur: 4, Opacity: 0.3, Color: Colors.Black }) };
73
+ case "Material3": return { bg: "#6750A4", radius: 20, font: 14, weight: 0, minW: 100, minH: 40 };
74
+ case "Windows": return { bg: "#0078D7", radius: 4, font: 15, weight: 500, minW: 100, minH: 32, shadow: new SkiaShadow({ X: 0, Y: 1, Blur: 1, Opacity: 0.2, Color: Colors.Black }) };
75
+ default: return { bg: "#DC143C", radius: 8, font: 15, weight: 0, minW: 100, minH: 41 };
68
76
  }
69
77
  }
70
78
  MeasureAbsolute(w, h, scale) {
@@ -75,7 +83,12 @@ export class SkiaButton extends SkiaLayout {
75
83
  this.frame.StrokeWidth = this.StrokeWidth;
76
84
  if (this.frame.Shadows[0] !== look.shadow)
77
85
  this.frame.Shadows = look.shadow ? [look.shadow] : [];
78
- if (this.MinimumHeightRequest < 0 && this.UsingControlStyle !== "Unset")
86
+ // C# SetDefaultMinimumContentSize(100, minH) from the style builder: a style default only where the app left the
87
+ // size unset, skipped on a Fill axis. C# adds Margins because it compares the minimum against a margin-inclusive
88
+ // size; here Measure applies it to the margin-excluded size and adds the margin after, so the plain value is used.
89
+ if (this.WidthRequest < 0 && this.MinimumWidthRequest < 0 && this.HorizontalOptions !== "Fill")
90
+ this.MinimumWidthRequest = look.minW;
91
+ if (this.HeightRequest < 0 && this.MinimumHeightRequest < 0 && this.VerticalOptions !== "Fill")
79
92
  this.MinimumHeightRequest = look.minH;
80
93
  this.label.Text = this.Text;
81
94
  this.label.TextColor = this.TextColor;
@@ -155,6 +155,8 @@ export declare class SkiaLabel extends SkiaControl {
155
155
  protected FillGradientPaintsBackground(): boolean;
156
156
  protected Paint(ctx: DrawingContext): void;
157
157
  ProcessGestures(args: SkiaGesturesParameters, apply: GestureEventProcessingInfo): SkiaControl | null;
158
+ /** Pointer cursor over a tappable span (the label itself may not be tappable). */
159
+ WantsPointerCursor(x: number, y: number): boolean;
158
160
  /** Return null to not consume the tap. */
159
161
  protected OnSpanTapped(span: TextSpan, args: SkiaGesturesParameters, apply: GestureEventProcessingInfo): SkiaControl | null;
160
162
  }
@@ -572,6 +572,15 @@ export class SkiaLabel extends SkiaControl {
572
572
  }
573
573
  return super.ProcessGestures(args, apply);
574
574
  }
575
+ /** Pointer cursor over a tappable span (the label itself may not be tappable). */
576
+ WantsPointerCursor(x, y) {
577
+ if (super.WantsPointerCursor(x, y))
578
+ return true;
579
+ for (const span of this.Spans)
580
+ if (span.HasTapHandler && span.HitIsInside(x, y))
581
+ return true;
582
+ return false;
583
+ }
575
584
  /** Return null to not consume the tap. */
576
585
  OnSpanTapped(span, args, apply) {
577
586
  span.FireTap(new ControlTappedEventArgs(this, args, apply));
@@ -3,7 +3,7 @@ import type { SkiaControl } from "./SkiaControl";
3
3
  import type { AnimatorBase } from "./Animators";
4
4
  import { SkiaAccessibilityManager } from "./Accessibility";
5
5
  import { type Color, type RenderingModeType } from "./Types";
6
- import { type GesturesMode, SkiaGesturesParameters, TouchActionEventArgs } from "./Gestures";
6
+ import { ContextMenuEventArgs, type GesturesMode, SkiaGesturesParameters, TouchActionEventArgs } from "./Gestures";
7
7
  /**
8
8
  * Mirrors DrawnUi Canvas (DrawnView): hosts one Content control on an HTML canvas element,
9
9
  * owns RenderingScale (devicePixelRatio), the surface, the on-demand frame loop and raw input.
@@ -66,6 +66,18 @@ export declare class Canvas {
66
66
  FrameIndex: number;
67
67
  /** On-screen surface (SkiaBackdrop snapshots it). */
68
68
  get Surface(): Surface | undefined;
69
+ /**
70
+ * A picture of what is on screen right now, as PNG bytes (C# DrawnView.TakeScreenShot).
71
+ *
72
+ * The on-screen canvas cannot simply be read: an accelerated surface lives in a WebGL drawing
73
+ * buffer the browser clears after compositing, so both `canvas.toDataURL()` and a snapshot of
74
+ * the live surface come back blank. The content is therefore drawn once more into an offscreen
75
+ * RASTER surface, which can be read back anywhere.
76
+ *
77
+ * This is a still picture, not a frame of the loop: animators are not ticked and the frame
78
+ * counters do not move, so taking one never changes what the next real frame shows.
79
+ */
80
+ TakeScreenShot(): Uint8Array | null;
69
81
  Dispose(): void;
70
82
  private gestures;
71
83
  get Gestures(): GesturesMode;
@@ -76,6 +88,19 @@ export declare class Canvas {
76
88
  private readonly pendingGestures;
77
89
  private readonly onPointer;
78
90
  private readonly preventTouch;
91
+ /** Called when no control handled a context-menu request; return true to suppress the browser's canvas menu. */
92
+ ContextMenu?: (sender: Canvas, e: ContextMenuEventArgs) => boolean | void;
93
+ /** DOM contextmenu (right click / long press / Menu key) -> ContextMenuEventArgs routed through the tree like a tap. */
94
+ private readonly onContextMenu;
95
+ private cursorPointer;
96
+ /**
97
+ * DrawnUi.Blazor shows `cursor: pointer` over interactive controls through its overlay elements; here the overlay
98
+ * is pointer-events:none, so the mouse position is tested against the accessibility snapshot (the accessible
99
+ * controls' rects in points, already sorted and rate-limited), each hit control answering WantsPointerCursor
100
+ * (itself tappable, or a tappable span of a label), and the canvas element's cursor is switched only when the
101
+ * answer changes. Mouse moves only, no work without a mouse and none in the frame loop.
102
+ */
103
+ private UpdateCursor;
79
104
  /** Mouse wheel -> TouchActionResult.Wheel (page scroll suppressed while gestures are enabled). */
80
105
  private readonly onWheel;
81
106
  private AttachInput;
@@ -1,7 +1,7 @@
1
1
  import { Super } from "./Super";
2
2
  import { SkiaAccessibilityManager } from "./Accessibility";
3
3
  import { Colors, SKRect } from "./Types";
4
- import { GestureEventProcessingInfo, SKPoint, SkiaGesturesParameters, TouchActionEventArgs, } from "./Gestures";
4
+ import { ContextMenuEventArgs, GestureEventProcessingInfo, PointerData, SKPoint, SkiaGesturesParameters, TouchActionEventArgs, } from "./Gestures";
5
5
  /**
6
6
  * Mirrors DrawnUi Canvas (DrawnView): hosts one Content control on an HTML canvas element,
7
7
  * owns RenderingScale (devicePixelRatio), the surface, the on-demand frame loop and raw input.
@@ -179,6 +179,50 @@ export class Canvas {
179
179
  FrameIndex = 0;
180
180
  /** On-screen surface (SkiaBackdrop snapshots it). */
181
181
  get Surface() { return this.surface; }
182
+ /**
183
+ * A picture of what is on screen right now, as PNG bytes (C# DrawnView.TakeScreenShot).
184
+ *
185
+ * The on-screen canvas cannot simply be read: an accelerated surface lives in a WebGL drawing
186
+ * buffer the browser clears after compositing, so both `canvas.toDataURL()` and a snapshot of
187
+ * the live surface come back blank. The content is therefore drawn once more into an offscreen
188
+ * RASTER surface, which can be read back anywhere.
189
+ *
190
+ * This is a still picture, not a frame of the loop: animators are not ticked and the frame
191
+ * counters do not move, so taking one never changes what the next real frame shows.
192
+ */
193
+ TakeScreenShot() {
194
+ const CK = Super.CK;
195
+ const w = this.Element.width, h = this.Element.height;
196
+ if (!CK || !w || !h)
197
+ return null;
198
+ const surface = CK.MakeSurface(w, h);
199
+ if (!surface)
200
+ return null;
201
+ try {
202
+ const canvas = surface.getCanvas();
203
+ canvas.clear(Super.ParseColor(this.BackgroundColor));
204
+ const root = this.content;
205
+ if (root) {
206
+ const scale = this.RenderingScale;
207
+ root.Measure(w, h, scale);
208
+ root.Arrange(new SKRect(0, 0, w, h), root.WidthRequest, root.HeightRequest, scale);
209
+ root.Render({ Context: { Canvas: canvas, Surface: surface }, Destination: new SKRect(0, 0, w, h), Scale: scale });
210
+ }
211
+ surface.flush();
212
+ const image = surface.makeImageSnapshot();
213
+ if (!image)
214
+ return null;
215
+ try {
216
+ return image.encodeToBytes();
217
+ }
218
+ finally {
219
+ image.delete();
220
+ }
221
+ }
222
+ finally {
223
+ surface.delete();
224
+ }
225
+ }
182
226
  Dispose() {
183
227
  this.disposed = true;
184
228
  this.Gestures = "Disabled";
@@ -216,8 +260,13 @@ export class Canvas {
216
260
  e.type === "pointercancel" ? "Cancelled" : undefined;
217
261
  if (!type)
218
262
  return;
219
- if (type === "Moved" && !this.activeTouchIds.has(e.pointerId))
220
- return; // hover not ported (TouchActionResult.Pointer)
263
+ if (type === "Moved" && !this.activeTouchIds.has(e.pointerId)) {
264
+ if (e.pointerType === "mouse")
265
+ this.UpdateCursor(e.offsetX, e.offsetY);
266
+ return;
267
+ } // hover not ported (TouchActionResult.Pointer)
268
+ if ((type === "Released" || type === "Cancelled") && !this.activeTouchIds.has(e.pointerId))
269
+ return; // Up of a pointer that never pressed here
221
270
  // Capture so Up outside the element still arrives; throws for synthetic events (tests) — harmless.
222
271
  if (type === "Pressed") {
223
272
  try {
@@ -231,9 +280,59 @@ export class Canvas {
231
280
  args.Type = type;
232
281
  args.Scale = this.RenderingScale;
233
282
  args.Location = new SKPoint((e.clientX - rect.left) * this.RenderingScale, (e.clientY - rect.top) * this.RenderingScale);
283
+ // every mouse button is delivered with its PointerData (AppoMobi.Gestures): a right click is a Down / Up / Tapped
284
+ // with Button "Right" for games and custom controls, and a ContextMenu on top
285
+ const pd = new PointerData();
286
+ pd.Button = e.button === 0 ? "Left" : e.button === 1 ? "Middle" : e.button === 2 ? "Right" : e.button === 3 ? "XButton1" : e.button === 4 ? "XButton2" : "Extended";
287
+ pd.ButtonNumber = e.button === 1 ? 3 : e.button === 2 ? 2 : e.button + 1;
288
+ pd.DeviceType = e.pointerType === "touch" ? "Touch" : e.pointerType === "pen" ? "Pen" : "Mouse";
289
+ pd.PressedButtons = e.buttons;
290
+ args.Pointer = pd;
234
291
  this.OnTouchAction(args);
235
292
  };
236
293
  preventTouch = (e) => e.preventDefault();
294
+ /** Called when no control handled a context-menu request; return true to suppress the browser's canvas menu. */
295
+ ContextMenu;
296
+ /** DOM contextmenu (right click / long press / Menu key) -> ContextMenuEventArgs routed through the tree like a tap. */
297
+ onContextMenu = (e) => {
298
+ const rect = this.Element.getBoundingClientRect();
299
+ const scale = this.RenderingScale;
300
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
301
+ const pointerType = e.pointerType;
302
+ const source = pointerType === "touch" || pointerType === "pen" ? "touch" : pointerType === "mouse" || e.button === 2 ? "mouse" : "keyboard";
303
+ const args = new ContextMenuEventArgs(new SKPoint(x, y), new SKPoint(x * scale, y * scale), source, e);
304
+ let handled = this.content?.ProcessContextMenu(args.Pixels, args) ?? false;
305
+ if (!handled && this.ContextMenu)
306
+ handled = this.ContextMenu(this, args) === true;
307
+ if (handled)
308
+ e.preventDefault();
309
+ };
310
+ cursorPointer = false;
311
+ /**
312
+ * DrawnUi.Blazor shows `cursor: pointer` over interactive controls through its overlay elements; here the overlay
313
+ * is pointer-events:none, so the mouse position is tested against the accessibility snapshot (the accessible
314
+ * controls' rects in points, already sorted and rate-limited), each hit control answering WantsPointerCursor
315
+ * (itself tappable, or a tappable span of a label), and the canvas element's cursor is switched only when the
316
+ * answer changes. Mouse moves only, no work without a mouse and none in the frame loop.
317
+ */
318
+ UpdateCursor(x, y) {
319
+ let hit = false;
320
+ const scale = this.RenderingScale;
321
+ for (const n of this.AccessibilityManager.Snapshot) {
322
+ if (x < n.Rect.Left || x >= n.Rect.Right || y < n.Rect.Top || y >= n.Rect.Bottom)
323
+ continue;
324
+ // the control decides (whole control, or a tappable span of a label); point in pixels relative to the control's
325
+ // origin, taken from the snapshot rect (already carries the scroll / cache offset; a rotated control gets its bbox)
326
+ if (n.Source.WantsPointerCursor((x - n.Rect.Left) * scale, (y - n.Rect.Top) * scale)) {
327
+ hit = true;
328
+ break;
329
+ }
330
+ }
331
+ if (hit !== this.cursorPointer) {
332
+ this.cursorPointer = hit;
333
+ this.Element.style.cursor = hit ? "pointer" : "";
334
+ }
335
+ }
237
336
  /** Mouse wheel -> TouchActionResult.Wheel (page scroll suppressed while gestures are enabled). */
238
337
  onWheel = (e) => {
239
338
  e.preventDefault();
@@ -253,6 +352,7 @@ export class Canvas {
253
352
  el.style.userSelect = "none";
254
353
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
255
354
  el.addEventListener(t, this.onPointer);
355
+ el.addEventListener("contextmenu", this.onContextMenu);
256
356
  el.addEventListener("wheel", this.onWheel, { passive: false });
257
357
  if (this.gestures === "Lock")
258
358
  el.addEventListener("touchmove", this.preventTouch, { passive: false });
@@ -263,6 +363,7 @@ export class Canvas {
263
363
  el.style.userSelect = "";
264
364
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
265
365
  el.removeEventListener(t, this.onPointer);
366
+ el.removeEventListener("contextmenu", this.onContextMenu);
266
367
  el.removeEventListener("wheel", this.onWheel);
267
368
  el.removeEventListener("touchmove", this.preventTouch);
268
369
  this.activeTouchIds.clear();
@@ -9,7 +9,7 @@ export declare class SKPoint {
9
9
  }
10
10
  /** Raw platform action (subset of TouchActionType). */
11
11
  export type TouchActionType = "Pressed" | "Moved" | "Released" | "Cancelled" | "Wheel" | "Pointer";
12
- /** Recognized gesture (TouchActionResult). LongPressing/Wheel/Pointer/Touch declared for parity, not produced yet. */
12
+ /** Recognized gesture (TouchActionResult). LongPressing/Pointer/Touch declared for parity, not produced yet. */
13
13
  export type TouchActionResult = "Touch" | "Down" | "Up" | "Tapped" | "LongPressing" | "Panning" | "Wheel" | "Pointer";
14
14
  export type GesturesMode = "Disabled" | "Enabled" | "Lock";
15
15
  export type LockTouch = "Disabled" | "Enabled" | "PassNone" | "PassTap" | "PassTapAndLongPress";
@@ -21,9 +21,23 @@ export declare class DistanceInfo {
21
21
  Total: SKPoint;
22
22
  Velocity: SKPoint;
23
23
  }
24
+ /** AppoMobi.Gestures MouseButton: which button pressed / released (DOM button 0..4). */
25
+ export type MouseButton = "Left" | "Middle" | "Right" | "XButton1" | "XButton2" | "Extended";
26
+ export type PointerDeviceType = "Mouse" | "Touch" | "Pen";
27
+ /** AppoMobi.Gestures PointerData: the device and button behind a Down / Up / Tapped (every button is delivered). */
28
+ export declare class PointerData {
29
+ Button: MouseButton;
30
+ /** 1 = Left, 2 = Right, 3 = Middle, 4+ = extended, like AppoMobi.Gestures. */
31
+ ButtonNumber: number;
32
+ DeviceType: PointerDeviceType;
33
+ /** DOM `buttons` bitmask of the buttons held (1 left, 2 right, 4 middle, 8 back, 16 forward). */
34
+ PressedButtons: number;
35
+ }
24
36
  /** AppoMobi.Gestures TouchActionEventArgs. */
25
37
  export declare class TouchActionEventArgs {
26
38
  Id: number;
39
+ /** Device and button of this event (mouse, pen, touch); undefined for wheel. */
40
+ Pointer?: PointerData;
27
41
  Type: TouchActionType;
28
42
  /** Pixels. */
29
43
  Location: SKPoint;
@@ -66,6 +80,33 @@ export declare class SkiaGesturesInfo {
66
80
  constructor(Args: SkiaGesturesParameters, Info: GestureEventProcessingInfo);
67
81
  }
68
82
  /** DrawnUi ControlTappedEventArgs. */
83
+ /** Where a context-menu request came from: right click, long press (Android fires contextmenu), or the keyboard Menu key. */
84
+ export type ContextMenuSource = "mouse" | "touch" | "keyboard";
85
+ /**
86
+ * Arguments of SkiaControl.ContextMenu / Canvas.ContextMenu: a browser `contextmenu` request on the canvas (right
87
+ * click, long press on touch, the Menu key). Handlers return true to take it: the browser's own menu ("Save image")
88
+ * is then suppressed; otherwise it shows as usual.
89
+ */
90
+ export declare class ContextMenuEventArgs {
91
+ /** Point on the canvas, in points (CSS px). */
92
+ Location: SKPoint;
93
+ /** Same point in pixels (canvas space). */
94
+ Pixels: SKPoint;
95
+ Source: ContextMenuSource;
96
+ /** The DOM event: modifiers, target, preventDefault if you need it yourself. */
97
+ Native: MouseEvent;
98
+ /** Deepest control under the point that had a ContextMenu handler (set while routing). */
99
+ Control?: SkiaControl;
100
+ /** Point inside Control, in pixels relative to its DrawingRect origin (set while routing). */
101
+ Local: SKPoint;
102
+ constructor(
103
+ /** Point on the canvas, in points (CSS px). */
104
+ Location: SKPoint,
105
+ /** Same point in pixels (canvas space). */
106
+ Pixels: SKPoint, Source: ContextMenuSource,
107
+ /** The DOM event: modifiers, target, preventDefault if you need it yourself. */
108
+ Native: MouseEvent);
109
+ }
69
110
  export declare class ControlTappedEventArgs {
70
111
  Control: SkiaControl;
71
112
  Parameters: SkiaGesturesParameters;