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

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,23 @@ 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…"). Only the primary mouse
71
+ button starts a gesture, as in DrawnUi.Blazor, so a right click never fires `Tapped`; to take it, handle
72
+ `ContextMenu` on any control, exactly like `Tapped`:
73
+
74
+ ```tsx
75
+ <SkiaShape ContextMenu={(sender, e) => { openMyMenu(e.Location); return true; }} />
76
+ <Canvas ContextMenu={(canvas, e) => true /* nothing hit: swallow the browser menu everywhere */} />
77
+ ```
78
+
79
+ The request is routed like a tap: deepest visible control under the point first (through transforms), then its
80
+ parents, then the `Canvas` prop. The first handler returning `true` takes it and the browser menu is suppressed;
81
+ with no handler the browser menu shows as before. `e` carries `Location` (points on the canvas), `Pixels`, `Local`
82
+ (pixels inside the handling control), `Source` (`"mouse"`, `"touch"` for a long press, `"keyboard"` for the Menu
83
+ key) and `Native` (the DOM event, for modifiers). Web-only: DrawnUi.Net has no mouse buttons.
84
+
52
85
  ## Accessibility
53
86
 
54
87
  Same model as DrawnUi.Blazor: the `<canvas>` is `aria-hidden`, an invisible DOM overlay mirrors every
@@ -68,25 +101,48 @@ label readable and every button focusable without touching each control.
68
101
  The overlay has `pointer-events: none`, so hover and all pointer gestures still reach the canvas — the
69
102
  Blazor "accessible control loses hover" limitation does not apply.
70
103
 
71
- ## Run
104
+ Text selection (React extension, opt-in): `AccessibilityTextSelectable` on a `SkiaLabel` renders its laid-out lines
105
+ as real, invisible DOM text in the overlay — one span per drawn line, in the same font (registered fonts are also
106
+ installed as CSS `FontFace`s) and stretched to the drawn line width — with pointer events on. The browser then
107
+ selects, copies (Ctrl+C / context menu) and reads it like an HTML paragraph, and the wheel over it still scrolls the
108
+ drawn content. It is off by default and must stay off on anything gesture-driven (buttons, carousels, drawers): the
109
+ selectable text owns the pointer, so taps and pans under it never reach the drawn control. Everything else in the
110
+ overlay is `user-select: none`, so select-all only highlights opted-in text.
72
111
 
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
- ```
112
+ ## Crawlers and AI agents: static HTML from the accessibility tree
79
113
 
80
- ## Publishing
114
+ A drawn app serves `<div id="root"></div>`; a crawler that does not run JavaScript sees nothing. The
115
+ `drawnUiStatic` Vite plugin fixes that at build time, from the app itself:
81
116
 
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`. |
117
+ ```ts
118
+ // vite.config.ts
119
+ import { drawnUiStatic } from "drawnui-react/vite";
120
+ export default defineConfig({ plugins: [react(), drawnUiStatic()] });
121
+ ```
91
122
 
92
- Adding another published sample = one more `wrangler pages deploy dist/<name> --project-name <project>` step and a Pages project + custom domain for it.
123
+ After `vite build` it serves the build, opens it in headless Chrome (`playwright-core`, your dev dependency; GitHub's
124
+ ubuntu runners ship a Chrome), lets the engine draw, reads the accessibility snapshot of the root page and of every
125
+ page a root button opens (or the `routes` you list), and writes ordinary visible HTML into `dist/index.html`: a
126
+ heading is a heading, a button is a link to the page it opened, a label is a paragraph. The overlay's own markup
127
+ (transparent text over the canvas) is never copied — in a static file it would read as hidden text. Nothing is
128
+ hand-written, so it cannot drift from what the canvas draws.
129
+
130
+ Where it lands: right after the mount element (`#root`), or wherever you put `<!-- drawnui-static -->` in
131
+ `index.html`. With the mount element filling the viewport that is below the fold: a visitor sees the app area
132
+ loading, the text is a scroll away, genuinely visible, nothing collapsed or hidden. The first `<Canvas>` that mounts
133
+ removes it once its engine has drawn frame 1 (one `querySelectorAll`, nothing in the frame loop, nothing to pay for
134
+ an app built without the plugin). If the app never boots it stays. The mount element keeps its size, so nothing
135
+ above the fold moves; a page that scrolled only because of the block loses its scrollbar (classic scrollbars give
136
+ the canvas those ~15 px back — `html { scrollbar-gutter: stable }` keeps the gutter if you prefer). If the mount
137
+ element is a box inside a longer page, put the marker at the end of the body so your own content does not move.
138
+ Look: a default stylesheet ships with the block (inherits your `body` font and color, translucent surfaces); override
139
+ by styling `.drawnui-static` or pass `render: { style: false }`.
140
+
141
+ Two things to know. Check it with `curl` or view-source, not DevTools after boot. And crawlers that do not run
142
+ JavaScript (GPTBot, ClaudeBot, CCBot, link previews) read this HTML, while Googlebot renders JavaScript and sees the
143
+ booted page, i.e. the accessibility overlay; both come from the same tree, and a control without an
144
+ `AccessibilityRole` is invisible to both.
145
+
146
+ ## Development
147
+
148
+ 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. Only the primary mouse button starts a gesture (right / middle clicks are ignored, like DrawnUi.Blazor). |
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,7 +29,7 @@ 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. |
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
33
  | Styles / `ConfigureStyles` | skipped | |
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. |
@@ -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
 
@@ -102,6 +104,7 @@ DrawnUi name and semantics; nothing here is a redesign, only an omission.
102
104
  | `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. |
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. |
@@ -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, 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,17 @@ 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
+ // only the primary mouse button is a touch (DrawnUi.Blazor): right / middle buttons never start a gesture, and
264
+ // their Up / Cancel (pointer never pressed here) is ignored too, so a right click cannot end as a Tapped
265
+ if (type === "Pressed" && e.pointerType === "mouse" && e.button !== 0)
266
+ return;
267
+ if (type === "Moved" && !this.activeTouchIds.has(e.pointerId)) {
268
+ if (e.pointerType === "mouse")
269
+ this.UpdateCursor(e.offsetX, e.offsetY);
270
+ return;
271
+ } // hover not ported (TouchActionResult.Pointer)
272
+ if ((type === "Released" || type === "Cancelled") && !this.activeTouchIds.has(e.pointerId))
273
+ return;
221
274
  // Capture so Up outside the element still arrives; throws for synthetic events (tests) — harmless.
222
275
  if (type === "Pressed") {
223
276
  try {
@@ -234,6 +287,48 @@ export class Canvas {
234
287
  this.OnTouchAction(args);
235
288
  };
236
289
  preventTouch = (e) => e.preventDefault();
290
+ /** Called when no control handled a context-menu request; return true to suppress the browser's canvas menu. */
291
+ ContextMenu;
292
+ /** DOM contextmenu (right click / long press / Menu key) -> ContextMenuEventArgs routed through the tree like a tap. */
293
+ onContextMenu = (e) => {
294
+ const rect = this.Element.getBoundingClientRect();
295
+ const scale = this.RenderingScale;
296
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
297
+ const pointerType = e.pointerType;
298
+ const source = pointerType === "touch" || pointerType === "pen" ? "touch" : pointerType === "mouse" || e.button === 2 ? "mouse" : "keyboard";
299
+ const args = new ContextMenuEventArgs(new SKPoint(x, y), new SKPoint(x * scale, y * scale), source, e);
300
+ let handled = this.content?.ProcessContextMenu(args.Pixels, args) ?? false;
301
+ if (!handled && this.ContextMenu)
302
+ handled = this.ContextMenu(this, args) === true;
303
+ if (handled)
304
+ e.preventDefault();
305
+ };
306
+ cursorPointer = false;
307
+ /**
308
+ * DrawnUi.Blazor shows `cursor: pointer` over interactive controls through its overlay elements; here the overlay
309
+ * is pointer-events:none, so the mouse position is tested against the accessibility snapshot (the accessible
310
+ * controls' rects in points, already sorted and rate-limited), each hit control answering WantsPointerCursor
311
+ * (itself tappable, or a tappable span of a label), and the canvas element's cursor is switched only when the
312
+ * answer changes. Mouse moves only, no work without a mouse and none in the frame loop.
313
+ */
314
+ UpdateCursor(x, y) {
315
+ let hit = false;
316
+ const scale = this.RenderingScale;
317
+ for (const n of this.AccessibilityManager.Snapshot) {
318
+ if (x < n.Rect.Left || x >= n.Rect.Right || y < n.Rect.Top || y >= n.Rect.Bottom)
319
+ continue;
320
+ // the control decides (whole control, or a tappable span of a label); point in pixels relative to the control's
321
+ // origin, taken from the snapshot rect (already carries the scroll / cache offset; a rotated control gets its bbox)
322
+ if (n.Source.WantsPointerCursor((x - n.Rect.Left) * scale, (y - n.Rect.Top) * scale)) {
323
+ hit = true;
324
+ break;
325
+ }
326
+ }
327
+ if (hit !== this.cursorPointer) {
328
+ this.cursorPointer = hit;
329
+ this.Element.style.cursor = hit ? "pointer" : "";
330
+ }
331
+ }
237
332
  /** Mouse wheel -> TouchActionResult.Wheel (page scroll suppressed while gestures are enabled). */
238
333
  onWheel = (e) => {
239
334
  e.preventDefault();
@@ -253,6 +348,7 @@ export class Canvas {
253
348
  el.style.userSelect = "none";
254
349
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
255
350
  el.addEventListener(t, this.onPointer);
351
+ el.addEventListener("contextmenu", this.onContextMenu);
256
352
  el.addEventListener("wheel", this.onWheel, { passive: false });
257
353
  if (this.gestures === "Lock")
258
354
  el.addEventListener("touchmove", this.preventTouch, { passive: false });
@@ -263,6 +359,7 @@ export class Canvas {
263
359
  el.style.userSelect = "";
264
360
  for (const t of ["pointerdown", "pointermove", "pointerup", "pointercancel"])
265
361
  el.removeEventListener(t, this.onPointer);
362
+ el.removeEventListener("contextmenu", this.onContextMenu);
266
363
  el.removeEventListener("wheel", this.onWheel);
267
364
  el.removeEventListener("touchmove", this.preventTouch);
268
365
  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";
@@ -66,6 +66,33 @@ export declare class SkiaGesturesInfo {
66
66
  constructor(Args: SkiaGesturesParameters, Info: GestureEventProcessingInfo);
67
67
  }
68
68
  /** DrawnUi ControlTappedEventArgs. */
69
+ /** Where a context-menu request came from: right click, long press (Android fires contextmenu), or the keyboard Menu key. */
70
+ export type ContextMenuSource = "mouse" | "touch" | "keyboard";
71
+ /**
72
+ * Arguments of SkiaControl.ContextMenu / Canvas.ContextMenu: a browser `contextmenu` request on the canvas (right
73
+ * click, long press on touch, the Menu key). Handlers return true to take it: the browser's own menu ("Save image")
74
+ * is then suppressed; otherwise it shows as usual.
75
+ */
76
+ export declare class ContextMenuEventArgs {
77
+ /** Point on the canvas, in points (CSS px). */
78
+ Location: SKPoint;
79
+ /** Same point in pixels (canvas space). */
80
+ Pixels: SKPoint;
81
+ Source: ContextMenuSource;
82
+ /** The DOM event: modifiers, target, preventDefault if you need it yourself. */
83
+ Native: MouseEvent;
84
+ /** Deepest control under the point that had a ContextMenu handler (set while routing). */
85
+ Control?: SkiaControl;
86
+ /** Point inside Control, in pixels relative to its DrawingRect origin (set while routing). */
87
+ Local: SKPoint;
88
+ constructor(
89
+ /** Point on the canvas, in points (CSS px). */
90
+ Location: SKPoint,
91
+ /** Same point in pixels (canvas space). */
92
+ Pixels: SKPoint, Source: ContextMenuSource,
93
+ /** The DOM event: modifiers, target, preventDefault if you need it yourself. */
94
+ Native: MouseEvent);
95
+ }
69
96
  export declare class ControlTappedEventArgs {
70
97
  Control: SkiaControl;
71
98
  Parameters: SkiaGesturesParameters;
@@ -91,7 +91,33 @@ export class SkiaGesturesInfo {
91
91
  this.Info = Info;
92
92
  }
93
93
  }
94
- /** DrawnUi ControlTappedEventArgs. */
94
+ /**
95
+ * Arguments of SkiaControl.ContextMenu / Canvas.ContextMenu: a browser `contextmenu` request on the canvas (right
96
+ * click, long press on touch, the Menu key). Handlers return true to take it: the browser's own menu ("Save image")
97
+ * is then suppressed; otherwise it shows as usual.
98
+ */
99
+ export class ContextMenuEventArgs {
100
+ Location;
101
+ Pixels;
102
+ Source;
103
+ Native;
104
+ /** Deepest control under the point that had a ContextMenu handler (set while routing). */
105
+ Control;
106
+ /** Point inside Control, in pixels relative to its DrawingRect origin (set while routing). */
107
+ Local = SKPoint.Empty;
108
+ constructor(
109
+ /** Point on the canvas, in points (CSS px). */
110
+ Location,
111
+ /** Same point in pixels (canvas space). */
112
+ Pixels, Source,
113
+ /** The DOM event: modifiers, target, preventDefault if you need it yourself. */
114
+ Native) {
115
+ this.Location = Location;
116
+ this.Pixels = Pixels;
117
+ this.Source = Source;
118
+ this.Native = Native;
119
+ }
120
+ }
95
121
  export class ControlTappedEventArgs {
96
122
  Control;
97
123
  Parameters;
@@ -3,7 +3,7 @@ import { type Color, type LayoutOptions, SKRect, ScaledSize, type SkiaCacheType,
3
3
  import { type IOverlayEffect } from "./Animators";
4
4
  import { Easing } from "./Easing";
5
5
  import type { Canvas } from "./Canvas";
6
- import { ControlTappedEventArgs, GestureEventProcessingInfo, type LockTouch, SKPoint, SkiaGesturesInfo, SkiaGesturesParameters } from "./Gestures";
6
+ import { ContextMenuEventArgs, ControlTappedEventArgs, GestureEventProcessingInfo, type LockTouch, SKPoint, SkiaGesturesInfo, SkiaGesturesParameters } from "./Gestures";
7
7
  import { type CachedTexture, type IPostRendererEffect, type SkiaEffect } from "./SkiaEffect";
8
8
  /** Mirrors DrawnUi DrawingContext: ctx.Context.Canvas / Surface, ctx.Destination (pixels), ctx.Scale. */
9
9
  export interface DrawingContext {
@@ -180,6 +180,12 @@ export declare class SkiaControl {
180
180
  /** Clip overlay effects to the control's shape (CreateClip). */
181
181
  ClipEffects: boolean;
182
182
  Tapped?: (sender: SkiaControl, e: ControlTappedEventArgs) => void;
183
+ /**
184
+ * Context-menu request over this control (right click, long press on touch, Menu key). Return true to handle it:
185
+ * the browser's own canvas menu is suppressed. Routed deepest child first, then parents, then Canvas.ContextMenu.
186
+ * Not in DrawnUi.Net (a web concept).
187
+ */
188
+ ContextMenu?: (sender: SkiaControl, e: ContextMenuEventArgs) => boolean | void;
183
189
  ChildTapped?: (sender: SkiaControl, e: ControlTappedEventArgs) => void;
184
190
  /** Raw gesture hook: set e.Consumed = true to stop propagation (not for Up). */
185
191
  ConsumeGestures?: (sender: SkiaControl, e: SkiaGesturesInfo) => void;
@@ -235,6 +241,11 @@ export declare class SkiaControl {
235
241
  get AccessibilityCanInteract(): boolean;
236
242
  set AccessibilityCanInteract(v: boolean);
237
243
  protected DefaultAccessibilityCanInteract(): boolean;
244
+ /**
245
+ * Whether the mouse at this point (pixels, relative to DrawingRect's origin) is over something tappable, for the
246
+ * host's pointer cursor. Default: the control as a whole (AccessibilityCanInteract); SkiaLabel adds its tappable spans.
247
+ */
248
+ WantsPointerCursor(_x: number, _y: number): boolean;
238
249
  /** aria-pressed for toggles; undefined = not a toggle. */
239
250
  get AccessibilityIsPressed(): boolean | undefined;
240
251
  set AccessibilityIsPressed(v: boolean | undefined);
@@ -370,6 +381,11 @@ export declare class SkiaControl {
370
381
  get HitBoxAuto(): SKRect;
371
382
  HitIsInside(x: number, y: number): boolean;
372
383
  IsGestureForChild(child: SkiaControl, point: SKPoint): boolean;
384
+ /**
385
+ * Routes a context-menu request like a tap: visible, non-transparent children under the point first (top-most
386
+ * first, each in its own transformed space), then this control's ContextMenu handler. True = handled.
387
+ */
388
+ ProcessContextMenu(point: SKPoint, e: ContextMenuEventArgs): boolean;
373
389
  /** Children that may receive gestures, top-most LAST (layouts return their Views). */
374
390
  protected GetGestureListeners(): readonly SkiaControl[];
375
391
  private static readonly NoListeners;
@@ -238,6 +238,12 @@ export class SkiaControl {
238
238
  ClipEffects = true;
239
239
  // ---- gesture events (single handler each; C# events map to one callback prop) ----
240
240
  Tapped;
241
+ /**
242
+ * Context-menu request over this control (right click, long press on touch, Menu key). Return true to handle it:
243
+ * the browser's own canvas menu is suppressed. Routed deepest child first, then parents, then Canvas.ContextMenu.
244
+ * Not in DrawnUi.Net (a web concept).
245
+ */
246
+ ContextMenu;
241
247
  ChildTapped;
242
248
  /** Raw gesture hook: set e.Consumed = true to stop propagation (not for Up). */
243
249
  ConsumeGestures;
@@ -388,6 +394,11 @@ export class SkiaControl {
388
394
  this.AccessibilityChanged();
389
395
  } }
390
396
  DefaultAccessibilityCanInteract() { return !!this.Tapped; }
397
+ /**
398
+ * Whether the mouse at this point (pixels, relative to DrawingRect's origin) is over something tappable, for the
399
+ * host's pointer cursor. Default: the control as a whole (AccessibilityCanInteract); SkiaLabel adds its tappable spans.
400
+ */
401
+ WantsPointerCursor(_x, _y) { return this.AccessibilityCanInteract; }
391
402
  /** aria-pressed for toggles; undefined = not a toggle. */
392
403
  get AccessibilityIsPressed() { return this.accessibilityIsPressed; }
393
404
  set AccessibilityIsPressed(v) { if (this.accessibilityIsPressed !== v) {
@@ -1079,6 +1090,25 @@ export class SkiaControl {
1079
1090
  const local = child.TransformPointToLocalSpace(point);
1080
1091
  return child.HitIsInside(local.X, local.Y);
1081
1092
  }
1093
+ /**
1094
+ * Routes a context-menu request like a tap: visible, non-transparent children under the point first (top-most
1095
+ * first, each in its own transformed space), then this control's ContextMenu handler. True = handled.
1096
+ */
1097
+ ProcessContextMenu(point, e) {
1098
+ const listeners = this.GetGestureListeners();
1099
+ for (let i = listeners.length - 1; i >= 0; i--) {
1100
+ const listener = listeners[i];
1101
+ if (!listener.IsVisible || listener.InputTransparent || !this.IsGestureForChild(listener, point))
1102
+ continue;
1103
+ if (listener.ProcessContextMenu(listener.TransformPointToLocalSpace(point), e))
1104
+ return true;
1105
+ }
1106
+ if (!this.ContextMenu)
1107
+ return false;
1108
+ e.Control = this;
1109
+ e.Local = new SKPoint(point.X - this.DrawingRect.Left, point.Y - this.DrawingRect.Top);
1110
+ return this.ContextMenu(this, e) === true;
1111
+ }
1082
1112
  /** Children that may receive gestures, top-most LAST (layouts return their Views). */
1083
1113
  GetGestureListeners() { return SkiaControl.NoListeners; }
1084
1114
  static NoListeners = [];
@@ -134,9 +134,14 @@ export type DrawImageAlignment = "Start" | "Center" | "End";
134
134
  /** MAUI/DrawnUi ScrollOrientation. */
135
135
  export type ScrollOrientation = "Vertical" | "Horizontal" | "Both" | "Neither";
136
136
  /**
137
- * DrawnUi SkiaCacheType. Operations = recorded draw commands (SkPicture) replayed each frame;
137
+ * DrawnUi SkiaCacheType. Operations / OperationsFull = recorded draw commands (SkPicture) replayed each frame.
138
138
  * Image = offscreen surface snapshot blitted each frame (GPU-backed when the canvas is WebGL).
139
- * GPU / ImageDoubleBuffered / ImageComposite / ImageCompositeGPU are accepted and currently resolve to Image.
139
+ * ImageDoubleBuffered = an Image cache that keeps the previous one and draws it — or DrawPlaceholder when there is
140
+ * none — while a new one cannot be produced yet.
141
+ * ImageComposite / ImageCompositeGPU = an Image cache whose offscreen surface survives between records: when only
142
+ * children changed, their old and new bounds plus the siblings those overlap are erased and only those children are
143
+ * painted again.
144
+ * GPU resolves to Image and ImageCompositeGPU to ImageComposite; the browser has no separate GPU-only path.
140
145
  */
141
146
  export type SkiaCacheType = "None" | "Operations" | "OperationsFull" | "Image" | "ImageDoubleBuffered" | "ImageComposite" | "ImageCompositeGPU" | "GPU";
142
147
  /** DrawnUi GradientType (Conical accepted, drawn as Circular). */