@limrun/ui 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "next"
@@ -688,6 +688,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
688
688
  pointerId1: number;
689
689
  };
690
690
  const twoFingerStateRef = useRef<TwoFingerState | null>(null);
691
+ // Wheel-scroll drag state; lives for a single synthesized gesture.
692
+ const wheelStateRef = useRef<{ pos: PointerGeometry; endTimer: number } | null>(null);
693
+ // Latest wheel handlers, so the once-attached native listener and the
694
+ // pointer machinery always call the current render's closures.
695
+ const wheelCallbacksRef = useRef<{ handle: (event: WheelEvent) => void; end: () => void }>({
696
+ handle: () => {},
697
+ end: () => {},
698
+ });
691
699
 
692
700
  // Hover point for rendering two-finger indicators when Alt is held.
693
701
  // Only computed/set when Alt is held to avoid unnecessary re-renders.
@@ -789,8 +797,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
789
797
  };
790
798
 
791
799
  // Pointer ID used by inspect-driven taps. Distinct from human pointers
792
- // (-1 mouse, -2 alt-mirror) and our touch identifiers so they never
793
- // interfere with an in-progress drag.
800
+ // (-1 mouse, -2 alt-mirror), the synthesized wheel finger (-3), and our
801
+ // touch identifiers so they never interfere with an in-progress drag.
794
802
  const AX_TAP_POINTER_ID = -10;
795
803
 
796
804
  // Send a down+up tap at a viewport-space (clientX/Y) position. The point
@@ -854,6 +862,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
854
862
  // Fixed pointer IDs for Alt-simulated two-finger gestures
855
863
  const ALT_POINTER_ID_PRIMARY = -1;
856
864
  const ALT_POINTER_ID_MIRROR = -2;
865
+ // Synthesized finger for wheel-scroll drags.
866
+ const WHEEL_POINTER_ID = -3;
857
867
 
858
868
  // Helper to send a single-touch control message (used by both single-finger and Android two-finger paths)
859
869
  const sendSingleTouch = (
@@ -911,6 +921,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
911
921
 
912
922
  switch (eventType) {
913
923
  case 'down':
924
+ // A real pointer going down takes over from any synthesized
925
+ // wheel-scroll finger, so the tap isn't sent as a second finger.
926
+ if (pointerId !== WHEEL_POINTER_ID) {
927
+ wheelCallbacksRef.current.end();
928
+ }
914
929
  // For multi-touch: use ACTION_DOWN for first pointer, ACTION_POINTER_DOWN for additional pointers
915
930
  const currentPointerCount = activePointers.current.size;
916
931
  action = currentPointerCount === 0 ? AMOTION_EVENT.ACTION_DOWN : AMOTION_EVENT.ACTION_POINTER_DOWN;
@@ -1199,6 +1214,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1199
1214
  p1: { x: x1, y: y1, id: pointerId1 },
1200
1215
  });
1201
1216
 
1217
+ if (eventType === 'down') {
1218
+ // A real two-finger gesture takes over from any synthesized
1219
+ // wheel-scroll finger.
1220
+ wheelCallbacksRef.current.end();
1221
+ }
1222
+
1202
1223
  if (platform === 'ios') {
1203
1224
  // iOS: use special two-finger message (type=18)
1204
1225
  const action =
@@ -1554,6 +1575,130 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1554
1575
  }
1555
1576
  };
1556
1577
 
1578
+ // Mouse wheel / trackpad scrolling over the stream scrolls inside the
1579
+ // simulator. iOS has no scroll wheel, so we fake a finger: press down
1580
+ // where the cursor is, drag it as wheel events come in, lift when they
1581
+ // stop. If the fake finger reaches the screen edge before the user stops
1582
+ // scrolling, lift and press again to keep going.
1583
+ const WHEEL_END_QUIET_MS = 150;
1584
+ const endWheelGesture = () => {
1585
+ const state = wheelStateRef.current;
1586
+ if (!state) return;
1587
+ window.clearTimeout(state.endTimer);
1588
+ applyPointerEvent(WHEEL_POINTER_ID, 'up', state.pos);
1589
+ wheelStateRef.current = null;
1590
+ };
1591
+ const handleWheel = (event: WheelEvent) => {
1592
+ // Trackpad pinches arrive as ctrl+wheel; don't turn them into scrolls.
1593
+ if (event.ctrlKey) return;
1594
+ // If the user is already touching/dragging (or holding Alt for the
1595
+ // two-finger gesture), let that win - don't add a second fake finger.
1596
+ if (isAltHeldRef.current || twoFingerStateRef.current) return;
1597
+ const pointerCount = activePointers.current.size;
1598
+ if (pointerCount > 1 || (pointerCount === 1 && !activePointers.current.has(WHEEL_POINTER_ID))) return;
1599
+
1600
+ const ctx = computeVideoMappingContext();
1601
+ if (!ctx) return;
1602
+ // Outside the rendered video (frame, letterbox) let the page scroll.
1603
+ const relativeX = event.clientX - ctx.videoRect.left - ctx.offsetX;
1604
+ const relativeY = event.clientY - ctx.videoRect.top - ctx.offsetY;
1605
+ if (relativeX < 0 || relativeX > ctx.actualWidth || relativeY < 0 || relativeY > ctx.actualHeight) {
1606
+ return;
1607
+ }
1608
+ const anchor = mapClientPointToVideo(ctx, event.clientX, event.clientY);
1609
+ if (!anchor) return;
1610
+ event.preventDefault();
1611
+
1612
+ // deltaMode: 0=pixels, 1=lines, 2=pages. Deltas are CSS pixels; convert
1613
+ // to stream pixels so a tick scrolls the same visual distance at any
1614
+ // window size.
1615
+ const scaleX =
1616
+ (event.deltaMode === 1 ? 16
1617
+ : event.deltaMode === 2 ? ctx.actualWidth
1618
+ : 1) *
1619
+ (ctx.videoWidth / ctx.actualWidth);
1620
+ const scaleY =
1621
+ (event.deltaMode === 1 ? 16
1622
+ : event.deltaMode === 2 ? ctx.actualHeight
1623
+ : 1) *
1624
+ (ctx.videoHeight / ctx.actualHeight);
1625
+ const dx = -event.deltaX * scaleX;
1626
+ const dy = -event.deltaY * scaleY;
1627
+ const armEnd = (s: { endTimer: number }) => {
1628
+ window.clearTimeout(s.endTimer);
1629
+ s.endTimer = window.setTimeout(endWheelGesture, WHEEL_END_QUIET_MS);
1630
+ };
1631
+ if (dx === 0 && dy === 0) {
1632
+ // Some wheel events carry no movement (momentum tails); just keep
1633
+ // the gesture alive.
1634
+ if (wheelStateRef.current) armEnd(wheelStateRef.current);
1635
+ return;
1636
+ }
1637
+
1638
+ // Where to press the fake finger down: at the cursor, but nudged away
1639
+ // from the screen edges so the drag has room to travel.
1640
+ const pressPoint = (): PointerGeometry => ({
1641
+ videoWidth: anchor.videoWidth,
1642
+ videoHeight: anchor.videoHeight,
1643
+ videoX:
1644
+ Math.abs(dx) > Math.abs(dy) ?
1645
+ Math.min(Math.max(anchor.videoX, anchor.videoWidth * 0.2), anchor.videoWidth * 0.8)
1646
+ : anchor.videoX,
1647
+ videoY:
1648
+ Math.abs(dy) >= Math.abs(dx) ?
1649
+ Math.min(Math.max(anchor.videoY, anchor.videoHeight * 0.2), anchor.videoHeight * 0.8)
1650
+ : anchor.videoY,
1651
+ });
1652
+
1653
+ const advance = (p: PointerGeometry): PointerGeometry => ({
1654
+ ...p,
1655
+ videoX: Math.min(Math.max(p.videoX + dx, 0), p.videoWidth),
1656
+ videoY: Math.min(Math.max(p.videoY + dy, 0), p.videoHeight),
1657
+ });
1658
+
1659
+ let state = wheelStateRef.current;
1660
+ if (!state) {
1661
+ const start = pressPoint();
1662
+ applyPointerEvent(WHEEL_POINTER_ID, 'down', start);
1663
+ state = { pos: start, endTimer: 0 };
1664
+ wheelStateRef.current = state;
1665
+ } else {
1666
+ window.clearTimeout(state.endTimer);
1667
+ }
1668
+
1669
+ let pos = state.pos;
1670
+ let next = advance(pos);
1671
+ if (next.videoX === pos.videoX && next.videoY === pos.videoY) {
1672
+ // Finger reached the screen edge: lift it, press again with room to
1673
+ // travel, and move right away (a press that never moves would count
1674
+ // as a tap).
1675
+ applyPointerEvent(WHEEL_POINTER_ID, 'up', pos);
1676
+ pos = pressPoint();
1677
+ applyPointerEvent(WHEEL_POINTER_ID, 'down', pos);
1678
+ next = advance(pos);
1679
+ }
1680
+ applyPointerEvent(WHEEL_POINTER_ID, 'move', next);
1681
+ state.pos = next;
1682
+ armEnd(state);
1683
+ };
1684
+ useEffect(() => {
1685
+ wheelCallbacksRef.current = { handle: handleWheel, end: endWheelGesture };
1686
+ });
1687
+
1688
+ // React attaches onWheel passively, so preventDefault (needed to keep the
1689
+ // page from scrolling) requires a native non-passive listener. Cleanup
1690
+ // lifts an in-flight synthesized finger before the channel goes away.
1691
+ useEffect(() => {
1692
+ const container = containerRef.current;
1693
+ if (!container) return;
1694
+ const listener = (event: WheelEvent) => wheelCallbacksRef.current.handle(event);
1695
+ container.addEventListener('wheel', listener, { passive: false });
1696
+ return () => {
1697
+ wheelCallbacksRef.current.end();
1698
+ container.removeEventListener('wheel', listener);
1699
+ };
1700
+ }, []);
1701
+
1557
1702
  useEffect(() => {
1558
1703
  const handleKeyDown = (event: KeyboardEvent) => {
1559
1704
  if (event.key === 'Alt') {