Quellcode durchsuchen

web: window drag/resize now survives an <iframe> (pointer capture)

The recipes window is an iframe; a drag that crossed it died on the first
pointermove — the iframe became the event target, so the parent document never
saw another pointermove and the gesture froze where it entered the iframe. For
a log window opened on top of the recipes window that meant it could only be
shrunk, never grown (reported 2026-10-21), and a titlebar drag could not cross
the panel either.

drag() now captures the pointer on the element that received the pointerdown
(setPointerCapture, released on pointerup/pointercancel) and filters events by
pointerId. Regression covered in ui.playwright.mjs section 16: the log window's
corner handle and titlebar are dragged *into* the iframe area and the geometry
must follow the full delta.
Alexander Korolev vor 1 Tag
Ursprung
Commit
882625898f
2 geänderte Dateien mit 67 neuen und 8 gelöschten Zeilen
  1. 20 8
      src/web/ui.html
  2. 47 0
      src/web/ui.playwright.mjs

+ 20 - 8
src/web/ui.html

@@ -696,8 +696,7 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
     front(win);
     const ox = win.offsetLeft, oy = win.offsetTop; // baseline — read ONCE
     drag(
-      e.clientX,
-      e.clientY,
+      e,
       (dx, dy) => {
         win.style.left = Math.max(0, ox + dx) + "px";
         win.style.top = Math.max(0, oy + dy) + "px";
@@ -710,8 +709,7 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
     front(win);
     const ow = win.offsetWidth, oh = win.offsetHeight; // baseline — read ONCE
     drag(
-      e.clientX,
-      e.clientY,
+      e,
       (dx, dy) => {
         win.style.width = Math.max(240, ow + dx) + "px";
         win.style.height = Math.max(140, oh + dy) + "px";
@@ -751,16 +749,30 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
   return handle;
 }
 
-function drag(sx, sy, move, done) {
-  const st = { x: sx, y: sy };
-  const mv = (e) => move(e.clientX - st.x, e.clientY - st.y);
-  const up = () => {
+// Pointer-drag with POINTER CAPTURE on the element that received the
+// pointerdown. Without it a drag that crosses another window's <iframe>
+// (the recipes panel is one) dies on the first move: the iframe becomes the
+// event target and the parent document never sees pointermove again — the
+// log window opened ON TOP of the recipes window could only be shrunk, never
+// grown (2026-10-21). Capture retargets every move back to the dragged
+// element, so the drag survives the iframe and leaving the browser window.
+function drag(e0, move, done) {
+  const st = { x: e0.clientX, y: e0.clientY };
+  const el = e0.currentTarget;
+  const pid = e0.pointerId;
+  const mv = (e) => { if (e.pointerId === pid) move(e.clientX - st.x, e.clientY - st.y); };
+  const up = (e) => {
+    if (e && e.pointerId !== pid) return;
     removeEventListener("pointermove", mv);
     removeEventListener("pointerup", up);
+    removeEventListener("pointercancel", up);
+    try { if (el && el.hasPointerCapture?.(pid)) el.releasePointerCapture(pid); } catch {}
     if (done) done();
   };
+  try { el?.setPointerCapture?.(pid); } catch {}
   addEventListener("pointermove", mv);
   addEventListener("pointerup", up);
+  addEventListener("pointercancel", up);
 }
 
 function renderDock() {

+ 47 - 0
src/web/ui.playwright.mjs

@@ -700,6 +700,53 @@ ok("layout: maximized recipes window restored maximized", back.recipesMax && bac
   ok("topo: mode choice persisted", await page.evaluate(() => localStorage.getItem("sm121-topobus") === "0"));
 }
 
+// --- 16. resize across an <iframe>: pointer capture, not window listeners --------
+// A drag that crosses the recipes window's iframe used to die on the first
+// move — the iframe became the event target and the parent document never saw
+// pointermove again, so a log window opened on top of the recipes window could
+// only be shrunk, never grown (2026-10-21). Pointer capture on the dragged
+// element fixes it; this check drags the handle INTO the iframe area.
+await page.evaluate(() => openLog("fake", "n0", "deepseek-v4-flash"));
+await page.waitForSelector(".logwin", { timeout: 10000 });
+const rzWin = page.locator(".logwin").last();
+await rzWin.evaluate((w) => { w.style.left = "60px"; w.style.top = "120px"; w.style.width = "420px"; w.style.height = "260px"; });
+const rzBefore = await rzWin.boundingBox();
+const rzh = await rzWin.locator(".win-resize").boundingBox();
+await page.mouse.move(rzh.x + rzh.width / 2, rzh.y + rzh.height / 2);
+await page.mouse.down();
+// +300/+200 puts the pointer well inside the recipes iframe behind it
+for (let i = 1; i <= 6; i++) {
+  await page.mouse.move(rzh.x + rzh.width / 2 + i * 50, rzh.y + rzh.height / 2 + i * 33);
+  await page.waitForTimeout(40);
+}
+await page.mouse.up();
+await page.waitForTimeout(150);
+const rzAfter = await rzWin.boundingBox();
+ok(
+  "resize grows through the recipes iframe (pointer capture)",
+  // sub-pixel layout rounding: bounds are fractional, the drag math is integer
+  Math.abs(rzAfter.width - (rzBefore.width + 300)) < 12 && Math.abs(rzAfter.height - (rzBefore.height + 198)) < 12,
+  `dw=${(rzAfter.width - rzBefore.width).toFixed(1)} dh=${(rzAfter.height - rzBefore.height).toFixed(1)}`,
+);
+// and the same for a titlebar drag that ends over the iframe
+const mbBefore = await rzWin.boundingBox();
+const mbBar = await rzWin.locator(".win-bar").boundingBox();
+await page.mouse.move(mbBar.x + 60, mbBar.y + 6);
+await page.mouse.down();
+for (let i = 1; i <= 5; i++) {
+  await page.mouse.move(mbBar.x + 60 + i * 30, mbBar.y + 6 + i * 20);
+  await page.waitForTimeout(40);
+}
+await page.mouse.up();
+await page.waitForTimeout(150);
+const mbAfter = await rzWin.boundingBox();
+ok(
+  "titlebar drag survives the recipes iframe",
+  Math.abs(mbAfter.x - (mbBefore.x + 150)) < 12 && Math.abs(mbAfter.y - (mbBefore.y + 100)) < 12,
+  `dx=${(mbAfter.x - mbBefore.x).toFixed(1)} dy=${(mbAfter.y - mbBefore.y).toFixed(1)}`,
+);
+await rzWin.locator(".win-bar .wbtn[data-act=close]").click();
+
 // --- no page JS errors across the whole flow -------------------------------------
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));