Fix active-pane click via document-capture pointerdown

Root cause: xterm.js attaches its own pointerdown handler inside the
terminal and calls e.stopPropagation(), which prevents the .leaf
div's onpointerdown from firing for any click landing inside the
terminal body. That's why clicking pane bodies never moved the blue
active border — the event simply never reached our handler.

Fix: register a document-level CAPTURE-phase pointerdown listener
in App.svelte. Capture fires before xterm.js's bubble-phase handler
runs (and before it can stop propagation), so we always see the
click. The handler walks up via Element.closest('[data-leaf-id]')
to find which pane was clicked, then calls orch.setActive.

- LeafPane.svelte: add data-leaf-id={leaf.id} attribute so the
  document handler can identify the clicked pane.
- App.svelte: $effect attaches document.addEventListener('pointerdown',
  ..., true) and cleans up on teardown.
- Keep the per-leaf onpointerdown as a redundant backup for clicks
  on toolbar buttons (which sit outside the xterm subtree). Cheap
  + idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 14:35:59 +01:00
parent e871ee8e6e
commit 96a9180f3b
41 changed files with 85 additions and 2 deletions

0
dev.err Normal file
View file

BIN
dev.log Normal file

Binary file not shown.

BIN
screen0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
screen1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
screen2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
shot01-initial.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
shot02-clicked-pane1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
shot03-f12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
shot07-devtools-attempt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
shot08-after-hmr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
shot09-after-reload.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
shot10-after-restart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
shot12-clicked-bcast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot13-back-to-pane1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
shot14-pane1-bcast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot15-palette.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
shot16-pane1-body.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot17-capture-fix-test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot18-restart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot20-pane2-body.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
shot21-baseline.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
shot22-pane1-body.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
shot23-pane2-body.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
shot24-pane1-again.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot25-slow-pane2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot26-slow-pane1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot27-slow-pane2-again.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot28-fresh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot29-click1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot30-click2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
shot31-click3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View file

@ -184,6 +184,26 @@
return () => window.removeEventListener("keydown", onKey, true);
});
// ---- Document-level active-pane detector --------------------------------
// xterm.js calls `stopPropagation` on pointerdown inside terminals, so a
// per-leaf `onpointerdown` never fires for body clicks. A document-level
// CAPTURE-phase listener fires before xterm.js can intercept, then finds
// the nearest `data-leaf-id` ancestor to know which pane was clicked.
// Toolbar buttons also pass through (they're outside the xterm container,
// their own onclick still fires in the bubble phase afterwards).
$effect(() => {
function onAnyPointerDown(e: PointerEvent) {
const t = e.target as Element | null;
if (!t) return;
const leafEl = t.closest("[data-leaf-id]");
if (!leafEl) return;
const id = leafEl.getAttribute("data-leaf-id");
if (id) orch.setActive(id);
}
document.addEventListener("pointerdown", onAnyPointerDown, true);
return () => document.removeEventListener("pointerdown", onAnyPointerDown, true);
});
// ---- preset layouts ------------------------------------------------------
function applyPreset(make: (d: { distro?: string }) => TreeNode) {
const count = leafCount(tree);

View file

@ -103,9 +103,11 @@
if (active) focusTrigger += 1;
});
// Backup setActive for toolbar clicks (which can't reach the document
// capture listener if a bubble-phase handler stops propagation). Cheap;
// idempotent if the document listener also fired.
function onPaneClick() {
console.log("[tiletopia] pane click:", leaf.id, "currentlyActive:", active);
if (!active) orch.setActive(leaf.id);
orch.setActive(leaf.id);
}
// ---- pane id registration ------------------------------------------------
@ -121,6 +123,7 @@
class:broadcasting={leaf.broadcast}
role="group"
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
data-leaf-id={leaf.id}
onpointerdown={onPaneClick}
>
<div class="pane-toolbar">

60
tilescript.ps1 Normal file
View file

@ -0,0 +1,60 @@
# Helpers for driving the tiletopia window from PowerShell.
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Runtime.InteropServices;
public class W {
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint dwData, int extraInfo);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
}
'@
function Get-TileWindow {
$p = Get-Process tiletopia -ErrorAction Stop | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1
if (-not $p) { throw "no tiletopia window" }
return $p
}
function Bring-ToFront {
$p = Get-TileWindow
[W]::ShowWindow($p.MainWindowHandle, 9) | Out-Null # SW_RESTORE
[W]::SetForegroundWindow($p.MainWindowHandle) | Out-Null
Start-Sleep -Milliseconds 300
$r = New-Object W+RECT
[W]::GetWindowRect($p.MainWindowHandle, [ref] $r) | Out-Null
return $r
}
function Save-Shot([string]$path) {
$r = Bring-ToFront
$w = $r.Right - $r.Left
$h = $r.Bottom - $r.Top
$bmp = New-Object System.Drawing.Bitmap $w, $h
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.CopyFromScreen($r.Left, $r.Top, 0, 0, (New-Object System.Drawing.Size $w, $h))
$bmp.Save($path)
$g.Dispose()
$bmp.Dispose()
Write-Host ("saved " + $path + " (" + $w + "x" + $h + " at " + $r.Left + "," + $r.Top + ")")
return @{ x = $r.Left; y = $r.Top; w = $w; h = $h }
}
# Click at *window-relative* coordinates (so we don't depend on the window's
# screen position from one run to the next).
function Click-Win([int]$relX, [int]$relY) {
$r = Bring-ToFront
$absX = $r.Left + $relX
$absY = $r.Top + $relY
[W]::SetCursorPos($absX, $absY) | Out-Null
Start-Sleep -Milliseconds 80
[W]::mouse_event(0x0002, 0, 0, 0, 0) # LEFTDOWN
Start-Sleep -Milliseconds 30
[W]::mouse_event(0x0004, 0, 0, 0, 0) # LEFTUP
Start-Sleep -Milliseconds 120
Write-Host ("clicked window-rel " + $relX + "," + $relY + " (abs " + $absX + "," + $absY + ")")
}

BIN
tiletopia-window.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB