Skip to content

Commit e5de546

Browse files
authored
Merge pull request #108 from vchelaru/fix/mobile-touch-input-isolation
Isolate mobile touch input with one canvas predicate (#107)
2 parents 87cd3f4 + a04a70b commit e5de546

4 files changed

Lines changed: 48 additions & 76 deletions

File tree

.claude/skills/issue-workflow/SKILL.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,10 @@ user testing as fast as possible:
5555
--urls "http://0.0.0.0:60441"`, then open `http://<machine-LAN-ip>:60441` on the phone (same
5656
Wi-Fi). Use plain **HTTP** (avoids the dev-cert-trust problem); it's fine unless the specific
5757
test needs a secure context (clipboard / CacheStorage). Find the LAN IP with `Get-NetIPAddress
58-
-AddressFamily IPv4` (the Wi-Fi one, not the `172.x` WSL vEthernet). If the phone can't connect,
59-
it's almost always Windows Firewall blocking inbound 60441 — offer to add a private-profile
60-
inbound rule.
58+
-AddressFamily IPv4` (the Wi-Fi one, not the `172.x` WSL vEthernet). The user has a **permanent
59+
Windows Firewall rule** for inbound 60441, so the phone connects without any setup — do **not**
60+
mention firewall, connection troubleshooting, or Incognito in the handoff; that's all handled on
61+
their end. Just give them the URL and the marker color (below).
6162
2. **Then write concise, numbered manual steps** — what to run, what to click, and what correct
6263
behavior looks like (and the failure mode the fix addresses, so the user knows what they're
6364
confirming).
@@ -83,7 +84,7 @@ mobile testing**, bump a **deterministic** visual marker and tell the user the v
8384
below, advance to the *next* palette entry after it, and — as part of the same commit that bumps
8485
`SPLITTER_COLOR` — update that line to the color you just picked. This is the source of truth:
8586

86-
LAST MARKER: green #2f9e44
87+
LAST MARKER: purple #7048e8
8788

8889
Why a committed pointer line and not a git-history search: the shipped `SPLITTER_COLOR` is
8990
bump-then-reset (magenta on the branch, back to `#007acc` before merge), so its **net diff is
@@ -92,9 +93,9 @@ mobile testing**, bump a **deterministic** visual marker and tell the user the v
9293
pointer line has a **persistent net change** (magenta → green), so it survives *any* merge
9394
strategy and is readable at a glance with no `git log` archaeology. Keep the two in lockstep: the
9495
same commit bumps `SPLITTER_COLOR` *and* this line.
95-
- Tell the user to load in a **fresh Incognito tab** (guarantees a clean fetch) and check the
96-
splitter is the color you named **before** re-running the test. Wrong color = stale cache, not a
97-
failed fix.
96+
- **The color is the only thing to tell the user in the handoff.** State the name **and** hex; that
97+
is enough for them to confirm the served build is fresh (wrong color = stale cache, not a failed
98+
fix). They manage their own clean-fetch flow — don't instruct them on Incognito or reloading.
9899
- **Before the PR merges, reset `SPLITTER_COLOR` to the canonical accent `#007acc`** so `main`
99100
doesn't ship a random marker color.
100101

XnaFiddle.BlazorGL/Pages/Index.razor

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,29 +30,29 @@
3030
<div class="xf-toolbar" style="padding: 6px 12px; background: #2d2d2d; display: flex; align-items: center; gap: 8px; flex-shrink: 0;">
3131
@if (_isCompiling)
3232
{
33-
<button data-touch-action="TriggerStopCompilation" @onclick="StopCompilation"
33+
<button @onclick="StopCompilation"
3434
style="padding: 4px 14px; background: #e04040; color: #fff; border: none; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;">
3535
<i class="codicon codicon-debug-stop"></i>
3636
Stop
3737
</button>
3838
}
3939
else
4040
{
41-
<button data-touch-action="TriggerCompileAndRun" @onclick="CompileAndRun"
41+
<button @onclick="CompileAndRun"
4242
style="padding: 4px 14px; background: #0e639c; color: white; border: none; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;">
4343
<i class="codicon codicon-play"></i>
4444
@(_game != null ? "Restart" : "Run")
4545
</button>
4646
@if (_game != null)
4747
{
48-
<button data-touch-action="TriggerStopGame" @onclick="StopGame"
48+
<button @onclick="StopGame"
4949
style="padding: 4px 14px; background: #e04040; color: #fff; border: none; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;">
5050
<i class="codicon codicon-debug-stop"></i>
5151
Stop
5252
</button>
5353
}
5454
}
55-
<button data-touch-action="TriggerOpenExampleBrowser" @onclick="OpenExampleBrowser"
55+
<button @onclick="OpenExampleBrowser"
5656
style="padding: 4px 8px; background: @(_exampleBrowserOpen ? "#0e639c" : "#3c3c3c"); color: #d4d4d4;
5757
border: 1px solid #555; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;
5858
user-select: none; -webkit-user-select: none;">
@@ -803,7 +803,7 @@
803803
Pick an example with the Examples button, or write your own XNA-style C# code,<br/>
804804
then click the Run button to compile and run.
805805
<div style="margin-top: 16px; pointer-events: auto;">
806-
<button data-touch-action="TriggerCompileAndRun" @onclick="CompileAndRun"
806+
<button @onclick="CompileAndRun"
807807
style="padding: 6px 18px; background: #0e639c; color: white; border: none; cursor: pointer; font-size: 14px; display: inline-flex; align-items: center; gap: 6px;">
808808
<i class="codicon codicon-play"></i>
809809
Run

XnaFiddle.BlazorGL/Pages/Index.razor.cs

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -346,31 +346,15 @@ protected override async void OnAfterRender(bool firstRender)
346346
static readonly string SupportedExtensionsDisplay =
347347
string.Join(", ", SupportedAssetExtensions);
348348

349+
// Invoked from monaco-interop.js keyboard shortcuts (F5 / Ctrl+S). Not a touch bypass —
350+
// touch now uses the same @onclick path as desktop via wireTouchInputIsolation (issue #107).
349351
[JSInvokable]
350352
public void TriggerCompileAndRun()
351353
{
352354
if (!_isCompiling)
353355
CompileAndRun();
354356
}
355357

356-
[JSInvokable]
357-
public void TriggerStopGame()
358-
{
359-
StopGame();
360-
}
361-
362-
[JSInvokable]
363-
public async Task TriggerOpenExampleBrowser()
364-
{
365-
await OpenExampleBrowser();
366-
}
367-
368-
[JSInvokable]
369-
public void TriggerStopCompilation()
370-
{
371-
StopCompilation();
372-
}
373-
374358
private string GetAssetUrlsFragment()
375359
{
376360
var urls = new List<string>();

XnaFiddle.BlazorGL/wwwroot/index.html

Lines changed: 33 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@
116116
// on touch) and while a splitter is being dragged (smooth resize on touch). Issue #94.
117117
window._tickSuspended = false;
118118
window._uiDragging = false;
119+
// Set true for the duration of a touch on a non-canvas UI element (see wireTouchInputIsolation).
120+
// A transient game-step pause frees the single WASM thread to service the tap's @onclick on
121+
// touch, mirroring _tickSuspended/_uiDragging but scoped to one touch. Issue #107.
122+
window._uiTouchActive = false;
119123

120124
function tickJS(timestamp) {
121125
// FPS cap: skip this tick if not enough time has elapsed since the last one.
@@ -158,7 +162,7 @@
158162
// the uncapped loop. The game freezes for the brief interaction, then resumes. Touch
159163
// only: on desktop the loop doesn't starve @onclick/pointer input, so we don't want to
160164
// freeze the game behind the modal or during a drag there. Issue #94.
161-
if (window._touchDevice && (window._tickSuspended || window._uiDragging)) {
165+
if (window._touchDevice && (window._tickSuspended || window._uiDragging || window._uiTouchActive)) {
162166
window.requestAnimationFrame(tickJS);
163167
return;
164168
}
@@ -183,51 +187,35 @@
183187
// touch during a running game. Issue #94.
184188
window.setTickSuspended = function (v) { window._tickSuspended = !!v; };
185189

186-
// Touch toolbar: capture-phase touchend + invokeMethodAsync bypasses Blazor @onclick
187-
// when the game loop starves the dispatcher (issue #90). Buttons use data-touch-action.
188-
function wireTouchToolbar() {
190+
// Touch input isolation (issue #107). Once any game has run, KNI installs window-level touch
191+
// listeners that call event.preventDefault() UNCONDITIONALLY (Wasm.Dom Window.<ver>.js) and
192+
// never removes them — permanent for the page. On Chrome window touchend is NOT passive, so
193+
// its preventDefault kills the synthesized click for EVERY DOM element → every Blazor @onclick
194+
// dies on touch after the first Run. The old code fought this with an ALLOWLIST (per-element
195+
// data-touch-action + a #exampleModalOverlay isolator), so every new UI element was
196+
// broken-by-default. We invert it: isolate the WHOLE page and carve out ONLY the game's input
197+
// surface, the canvas. Everything non-canvas gets its touch stripped before KNI sees it, so
198+
// its click survives and @onclick works with no per-element wiring.
199+
function wireTouchInputIsolation() {
189200
if (!window._touchDevice) return;
190-
// A toolbar tap must be fully invisible to KNI's window-level touch listeners. We
191-
// trigger the action on touchend, but we ALSO stop touchstart/move/cancel from reaching
192-
// KNI. Otherwise KNI's TouchPanel records the press (touchstart) yet never the release
193-
// (we swallow touchend), leaving a dangling nativeTouchId that collides on the next tap:
194-
// "nativeTouchId already registered" — fatal under Debug.Assert in a Debug build, a
195-
// stuck phantom touch under Release. Touch events keep their initial target across the
196-
// sequence, so closest('[data-touch-action]') matches for every phase of the tap.
197-
// Issue #90.
201+
// Document-level BUBBLE phase: document sits below window in the bubble path, so
202+
// stopPropagation here strips the touch before KNI's window listeners ever fire — DOM
203+
// ordering guarantees we win. Identity check (=== theCanvas), NOT canvasHolder.contains:
204+
// overlay buttons (welcome Run, collapse handle) live inside #canvasHolder but must stay
205+
// tappable; the canvas has no children, so identity is exactly "is this the game surface".
198206
['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(function (type) {
199207
document.addEventListener(type, function (e) {
200-
var btn = e.target.closest && e.target.closest('[data-touch-action]');
201-
if (!btn) return;
202-
e.stopImmediatePropagation();
203-
if (type === 'touchend') {
204-
e.preventDefault();
205-
if (window.theInstance)
206-
window.theInstance.invokeMethodAsync(btn.getAttribute('data-touch-action'));
207-
}
208-
}, { capture: true, passive: false });
209-
});
210-
}
211-
212-
// Examples modal touch isolation (issue #94).
213-
// Once any game has run, KNI installs window-level touch listeners that call
214-
// event.preventDefault() UNCONDITIONALLY in JS, before dispatching to .NET (Wasm.Dom
215-
// Window.<ver>.js) — and they're never removed, so this is permanent for the page. On
216-
// Chrome, window touchstart/touchmove are passive so that preventDefault is ignored (the
217-
// modal's list scrolling still works), but touchend is NOT passive, so its preventDefault
218-
// IS honored and suppresses the compatibility click — so the Examples modal's plain
219-
// @onclick (× / card / category / backdrop) never fires on touch. These document-level
220-
// BUBBLE listeners fire before KNI's window-level ones (document sits below window in the
221-
// bubble path), so stopping propagation for a touch that started inside the modal keeps
222-
// KNI from ever seeing it: the click is synthesized normally and @onclick works. We stop
223-
// all four phases so KNI sees neither the press nor the release and its TouchPanel stays
224-
// balanced (a lone swallowed release would dangle a nativeTouchId — issue #90 bug 2). We
225-
// never call preventDefault, so list scrolling is unaffected.
226-
function wireExampleModalTouchIsolation() {
227-
['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(function (type) {
228-
document.addEventListener(type, function (e) {
229-
if (e.target && e.target.closest && e.target.closest('#exampleModalOverlay')) {
230-
e.stopPropagation();
208+
// Canvas touches are the game's input — let them reach KNI untouched so in-game
209+
// TouchPanel keeps working.
210+
if (e.target === document.getElementById('theCanvas')) return;
211+
// Stop all four phases so KNI sees neither press nor release and its TouchPanel
212+
// stays balanced (a lone swallowed release dangles a nativeTouchId — #90 bug 2).
213+
// Never preventDefault — that would kill page/list scrolling.
214+
e.stopPropagation();
215+
if (type === 'touchstart') {
216+
window._uiTouchActive = true; // pause the game step so the tap's @onclick isn't starved
217+
} else if (type === 'touchend' || type === 'touchcancel') {
218+
window._uiTouchActive = false;
231219
}
232220
}, false);
233221
});
@@ -241,7 +229,7 @@
241229
// so if the served page is stale the splitter shows the PREVIOUS color. applyLayout
242230
// paints it onto #splitter. Canonical (no-marker) accent is #007acc — reset to that
243231
// before merging. See the issue-workflow skill ("Build marker"). Issue #94.
244-
var SPLITTER_COLOR = '#007acc'; // canonical accent (no active marker)
232+
var SPLITTER_COLOR = '#7048e8'; // purple — mobile build marker (issue #107); reset to #007acc pre-merge
245233
var splitRatio = 0.45;
246234
var dragging = false;
247235
// On touch the finger hit-target is widened separately from the visible bar
@@ -630,8 +618,7 @@
630618
}
631619
window._tickInterval = targetFps > 0 ? (1000 / targetFps) : 0;
632620

633-
wireTouchToolbar();
634-
wireExampleModalTouchIsolation();
621+
wireTouchInputIsolation();
635622

636623
// ?hover=true — throttle to 2fps when mouse is not over the canvas.
637624
// Ignored on touch devices (no hover concept; touch FPS capped separately — issue #90).

0 commit comments

Comments
 (0)