|
116 | 116 | // on touch) and while a splitter is being dragged (smooth resize on touch). Issue #94. |
117 | 117 | window._tickSuspended = false; |
118 | 118 | 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; |
119 | 123 |
|
120 | 124 | function tickJS(timestamp) { |
121 | 125 | // FPS cap: skip this tick if not enough time has elapsed since the last one. |
|
158 | 162 | // the uncapped loop. The game freezes for the brief interaction, then resumes. Touch |
159 | 163 | // only: on desktop the loop doesn't starve @onclick/pointer input, so we don't want to |
160 | 164 | // 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)) { |
162 | 166 | window.requestAnimationFrame(tickJS); |
163 | 167 | return; |
164 | 168 | } |
|
183 | 187 | // touch during a running game. Issue #94. |
184 | 188 | window.setTickSuspended = function (v) { window._tickSuspended = !!v; }; |
185 | 189 |
|
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() { |
189 | 200 | 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". |
198 | 206 | ['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(function (type) { |
199 | 207 | 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; |
231 | 219 | } |
232 | 220 | }, false); |
233 | 221 | }); |
|
241 | 229 | // so if the served page is stale the splitter shows the PREVIOUS color. applyLayout |
242 | 230 | // paints it onto #splitter. Canonical (no-marker) accent is #007acc — reset to that |
243 | 231 | // 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 |
245 | 233 | var splitRatio = 0.45; |
246 | 234 | var dragging = false; |
247 | 235 | // On touch the finger hit-target is widened separately from the visible bar |
|
630 | 618 | } |
631 | 619 | window._tickInterval = targetFps > 0 ? (1000 / targetFps) : 0; |
632 | 620 |
|
633 | | - wireTouchToolbar(); |
634 | | - wireExampleModalTouchIsolation(); |
| 621 | + wireTouchInputIsolation(); |
635 | 622 |
|
636 | 623 | // ?hover=true — throttle to 2fps when mouse is not over the canvas. |
637 | 624 | // Ignored on touch devices (no hover concept; touch FPS capped separately — issue #90). |
|
0 commit comments