From 76356ed975567a323439cfbf3d2bd2103a29b4e9 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Tue, 16 Jun 2026 20:12:22 +0000 Subject: [PATCH 1/5] feat: proxy-bridge directive + touch drag recovery for prereview --external - lvt-fx:proxy-bridge: relays the proxied page's nav (-> setProxyURL) and scroll (-> pin-layer transform) from the injected beacon; locates a region by posting a focus message into the iframe (navigate + scroll). Re-applies the pin-layer transform on every render (morphdom-wipe recovery). - region-select gains a data-surface="page" branch (regionRectFromBox): converts a drawn box to document fractions via beacon scroll/doc metrics. - attachBoxDragSelect: on touch pointers, finalize the region on pointercancel / lostpointercapture instead of discarding (iOS long-drag recovery); mouse/pen unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- dom/directives.ts | 252 ++++++++++++++++++++++++++++++++- livetemplate-client.ts | 8 ++ tests/directives.test.ts | 298 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 554 insertions(+), 4 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index f803cb2..8c37cda 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -1660,6 +1660,15 @@ function attachBoxDragSelect( let startClientX = 0; let startClientY = 0; let pointerId = -1; + // pointerType + lastMove let us recover a TOUCH drag that iOS Safari + // cancels mid-gesture. iOS fires pointercancel / yanks pointer capture on + // long, fast touch-drags (it re-interprets them as a scroll/swipe), which + // would otherwise discard a large region the user deliberately drew. For + // touch we finalize WITH the last-known position instead of dropping it; + // mouse/pen keep the strict discard. (The area threshold in finalize still + // rejects an accidental tiny box, so a stray cancel can't create a region.) + let pointerType = ""; + let lastMove: PointerEvent | null = null; // Capture the parent at pointerdown time so a server diff that moves // the host to a NEW parent mid-drag doesn't split the drag across // two positioning contexts. updateOverlay positions against this @@ -1703,6 +1712,7 @@ function attachBoxDragSelect( pointerId = -1; dragParent = null; startRect = null; + lastMove = null; try { el.releasePointerCapture(capturedPointerId); } catch { @@ -1820,6 +1830,8 @@ function attachBoxDragSelect( startClientX = e.clientX; startClientY = e.clientY; pointerId = e.pointerId; + pointerType = e.pointerType; + lastMove = e; dragParent = parent; startRect = el.getBoundingClientRect(); let captureOk = false; @@ -1915,6 +1927,7 @@ function attachBoxDragSelect( finalize(null, false); return; } + lastMove = e; updateOverlay(e); }; @@ -1929,7 +1942,9 @@ function attachBoxDragSelect( const onPointerCancel = (e: PointerEvent) => { if (e.pointerId !== pointerId) return; - finalize(e, false); + // iOS cancels long touch-drags it re-reads as a swipe; keep the region + // the user drew rather than dropping it (mouse/pen still discard). + finalize(e, pointerType === "touch"); }; // lostpointercapture handles the rare case where the platform yanks // capture (OS gesture, another setPointerCapture call). Guard on @@ -1937,7 +1952,11 @@ function attachBoxDragSelect( // DIFFERENT pointer on the same element, and we mustn't cancel // our in-progress drag because of an unrelated release. const onLostCapture = (e: PointerEvent) => { - if (e.pointerId === pointerId) finalize(null, false); + if (e.pointerId !== pointerId) return; + // Same touch-recovery as pointercancel: finalize from the last move so a + // capture yank mid-touch-drag keeps the region instead of dropping it. + const m = lastMove; + finalize(pointerType === "touch" ? m : null, pointerType === "touch" && m !== null); }; el.addEventListener("pointerdown", onPointerDown); @@ -2068,6 +2087,178 @@ export function teardownRegionSelectForRoot(rootElement: Element): void { } } +// proxyBridgeArmed mirrors the area/region-select singletons: one entry per +// armed `lvt-fx:proxy-bridge` element, swept on disconnect. sync runs on every +// render to re-apply the pin-layer transform morphdom wipes + relay a "locate +// this annotation" request to the iframe. +type ProxyBridgeEntry = BoxDragEntry & { sync: () => void }; +const proxyBridgeArmed = new Map(); + +/** + * Apply proxy-bridge directives — the `--external` live-site bridge. + * `lvt-fx:proxy-bridge=""` on the preview stage (the element + * wrapping the proxied `