@desklink/react-native 0.4.0 → 0.5.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.
@@ -1,6 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import React from 'react';
3
3
  import TestRenderer from 'react-test-renderer';
4
+ import { pictureBox } from '../test/picture-box.mjs';
4
5
 
5
6
  /** `act` refuses to flush state updates unless React is told this is a test. */
6
7
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -57,14 +58,16 @@ const required: string[] = [];
57
58
 
58
59
  const BOUNDS = { width: 390, height: 844 };
59
60
  const DESKTOP = { width: 1280, height: 720 };
60
- // fit = min(390/1280, 844/720); the picture is 390 wide, centred vertically.
61
+ // Most of this spec works on the whole desktop (`whole: true`, as after
62
+ // `fitToView()`): fit = min(390/1280, 844/720); the picture is 390 wide,
63
+ // centred vertically.
61
64
  const SCALE = Math.min(BOUNDS.width / DESKTOP.width, BOUNDS.height / DESKTOP.height);
62
65
  const ORIGIN_Y = (BOUNDS.height - DESKTOP.height * SCALE) / 2;
63
66
 
64
67
  type Touch = { locationX: number; locationY: number; timestamp?: number };
65
68
  function touch(x: number, y: number, timestamp?: number): Touch { return { locationX: x, locationY: y, timestamp }; }
66
69
 
67
- async function openView(options: { gestures?: 'desktop' | 'browser' | 'device'; accessibilityLabel?: string; native?: boolean } = {}) {
70
+ async function openView(options: { gestures?: 'desktop' | 'browser' | 'device'; accessibilityLabel?: string; native?: boolean; whole?: boolean; desktop?: { width: number; height: number }; bounds?: { width: number; height: number }; insets?: { top: number; left: number; bottom: number; right: number } } = {}) {
68
71
  nativeInput.installed = options.native === true;
69
72
  const { desktopInputEnabled, nativeDesklink } = await import('./native.ios');
70
73
  const { DesktopView } = await import('./DesktopView.ios');
@@ -79,7 +82,7 @@ async function openView(options: { gestures?: 'desktop' | 'browser' | 'device';
79
82
  let renderer: TestRenderer.ReactTestRenderer | null = null;
80
83
  TestRenderer.act(() => {
81
84
  renderer = TestRenderer.create(
82
- <DesktopView sessionId={id} gestures={options.gestures} accessibilityLabel={options.accessibilityLabel} />,
85
+ <DesktopView sessionId={id} gestures={options.gestures} accessibilityLabel={options.accessibilityLabel} insets={options.insets} />,
83
86
  );
84
87
  });
85
88
  const outer = () => renderer!.root.findByType(options.native ? 'DesklinkInput' as never : 'View').props as {
@@ -90,10 +93,11 @@ async function openView(options: { gestures?: 'desktop' | 'browser' | 'device';
90
93
  [key: string]: unknown;
91
94
  };
92
95
  TestRenderer.act(() => {
93
- outer().onLayout({ nativeEvent: { layout: { ...BOUNDS } } });
94
- nativeDesklink.setSurfaceSize(id, DESKTOP.width, DESKTOP.height);
96
+ outer().onLayout({ nativeEvent: { layout: { ...(options.bounds ?? BOUNDS) } } });
97
+ nativeDesklink.setSurfaceSize(id, (options.desktop ?? DESKTOP).width, (options.desktop ?? DESKTOP).height);
95
98
  peer.ontrack!({ track: { kind: 'video' }, streams: [] });
96
99
  });
100
+ if (options.whole ?? true) TestRenderer.act(() => { nativeDesklink.fitToView(id); });
97
101
  const grant = (at: Touch[]) => TestRenderer.act(() => { outer().onResponderGrant({ nativeEvent: { touches: at } }); });
98
102
  const move = (at: Touch[]) => TestRenderer.act(() => { outer().onResponderMove({ nativeEvent: { touches: at } }); });
99
103
  const release = (at: Touch[] = []) => TestRenderer.act(() => { outer().onResponderRelease({ nativeEvent: { touches: at } }); });
@@ -130,6 +134,124 @@ beforeEach(() => {
130
134
  afterEach(() => { vi.unstubAllGlobals(); });
131
135
 
132
136
  describe('DesktopView.ios parity with DesktopView.kt', () => {
137
+ it('fits portrait width, then zooms and pans without distorting the desktop', async () => {
138
+ const { sent, rtcStyle, grant, move, release } = await openView({ whole: false });
139
+ expect(rtcStyle()).toMatchObject({ left: 0, top: 0, width: BOUNDS.width });
140
+ expect(rtcStyle().height).toBeCloseTo(DESKTOP.height * SCALE, 5);
141
+ grant([touch(100, 100)]);
142
+ move([touch(100, 100), touch(140, 100)]);
143
+ move([touch(80, 100), touch(160, 100)]);
144
+ release();
145
+ const before = rtcStyle();
146
+ expect(before.width / before.height).toBeCloseTo(DESKTOP.width / DESKTOP.height, 5);
147
+ grant([touch(260, 400)]);
148
+ move([touch(200, 400)]);
149
+ release();
150
+ expect(sent.filter((message) => message.kind === 'pointer')).toEqual([]);
151
+ expect(rtcStyle().left).toBeCloseTo(before.left - 60, 5);
152
+ const at = rtcStyle();
153
+ grant([touch(100, 300)]);
154
+ release();
155
+ expect(sent.filter((message) => message.kind === 'pointer')).toMatchObject([
156
+ { kind: 'pointer', phase: 'down', x: Math.floor((100 - at.left) / (SCALE * 2)), y: Math.floor((300 - at.top) / (SCALE * 2)), button: 1 },
157
+ { kind: 'pointer', phase: 'up', x: Math.floor((100 - at.left) / (SCALE * 2)), y: Math.floor((300 - at.top) / (SCALE * 2)), button: 1 },
158
+ ]);
159
+ });
160
+
161
+ it('pans a tall portrait desktop to both vertical edges at fit-width', async () => {
162
+ const insets = { top: 47, left: 0, bottom: 21, right: 0 };
163
+ const { rtcStyle, grant, move, release } = await openView({ whole: false, insets, desktop: { width: 720, height: 1800 } });
164
+ expect(rtcStyle().width).toBeCloseTo(390, 5);
165
+ expect(rtcStyle().top).toBeCloseTo(47, 5);
166
+ grant([touch(200, 400)]);
167
+ move([touch(200, -2000)]);
168
+ release();
169
+ expect(rtcStyle().top + rtcStyle().height).toBeCloseTo(823, 5);
170
+ grant([touch(200, 400)]);
171
+ move([touch(200, 2000)]);
172
+ release();
173
+ expect(rtcStyle().top).toBeCloseTo(47, 5);
174
+ });
175
+
176
+ it('starts again at the top-left corner after a rotation, and keeps the whole desktop when the same size is reported again', async () => {
177
+ const { id, nativeDesklink, outer, rtcStyle, grant, move, release } = await openView({ whole: false });
178
+ grant([touch(260, 400)]);
179
+ move([touch(200, 400)]);
180
+ release();
181
+ expect(rtcStyle().left).toBeCloseTo(0, 5);
182
+ const layout = (bounds: { width: number; height: number }) => TestRenderer.act(() => {
183
+ (outer().onLayout as (event: unknown) => void)({ nativeEvent: { layout: bounds } });
184
+ });
185
+ layout({ width: BOUNDS.height, height: BOUNDS.width });
186
+ // Landscape crops the bottom, never the top.
187
+ expect(rtcStyle().top).toBeCloseTo(0, 5);
188
+ expect(rtcStyle().left).toBeCloseTo(0, 5);
189
+ layout(BOUNDS);
190
+ expect(rtcStyle().left).toBeCloseTo(0, 5);
191
+ TestRenderer.act(() => { nativeDesklink.fitToView(id); });
192
+ TestRenderer.act(() => { nativeDesklink.setSurfaceSize(id, DESKTOP.width, DESKTOP.height); });
193
+ expect(rtcStyle().width).toBeCloseTo(BOUNDS.width, 5);
194
+ });
195
+
196
+ it('resets both default offsets on equal-scale layout and inset changes', async () => {
197
+ const { DesktopView } = await import('./DesktopView.ios');
198
+ const insets = { top: 0, left: 47, bottom: 21, right: 47 };
199
+ const { id, renderer, outer, rtcStyle, grant, move, release } = await openView({ whole: false, bounds: { width: 844, height: 390 }, insets });
200
+ const pan = () => {
201
+ grant([touch(400, 200)]);
202
+ move([touch(340, 140)]);
203
+ release();
204
+ };
205
+ const width = rtcStyle().width;
206
+ pan();
207
+ expect(rtcStyle().left).toBeLessThan(47);
208
+ expect(rtcStyle().top).toBeLessThan(0);
209
+ TestRenderer.act(() => { outer().onLayout({ nativeEvent: { layout: { width: 844, height: 400 } } }); });
210
+ expect(rtcStyle()).toMatchObject({ left: 47, top: 0, width });
211
+ pan();
212
+ TestRenderer.act(() => { renderer.update(<DesktopView sessionId={id} insets={{ top: 10, left: 50, right: 44, bottom: 21 }} />); });
213
+ expect(rtcStyle()).toMatchObject({ left: 50, top: 10, width });
214
+ });
215
+
216
+ it('starts and pans inside the insets while the picture still reaches under them', async () => {
217
+ const landscape = { width: 844, height: 390 };
218
+ const insets = { top: 0, left: 47, bottom: 21, right: 47 };
219
+ const { rtcStyle, grant, move, release } = await openView({ whole: false, bounds: landscape, insets });
220
+ const fill = Math.max(landscape.width / DESKTOP.width, landscape.height / DESKTOP.height);
221
+ expect(rtcStyle().width).toBeCloseTo(DESKTOP.width * fill, 5);
222
+ // The desktop's top-left corner shows just inside the cutout's side.
223
+ expect(rtcStyle().left).toBeCloseTo(47, 5);
224
+ expect(rtcStyle().top).toBeCloseTo(0, 5);
225
+ // Panned all the way, the desktop's right and bottom edges reach the
226
+ // uncovered part's edges, not the screen's.
227
+ grant([touch(600, 300)]);
228
+ move([touch(-2000, -2000)]);
229
+ release();
230
+ expect(rtcStyle().left + rtcStyle().width).toBeCloseTo(landscape.width - 47, 5);
231
+ expect(rtcStyle().top + rtcStyle().height).toBeCloseTo(landscape.height - 21, 5);
232
+ for (const x of [-2000, 2000]) for (const y of [-2000, 2000]) {
233
+ grant([touch(400, 200)]);
234
+ move([touch(x, y)]);
235
+ release();
236
+ const box = rtcStyle();
237
+ const width = Math.min(landscape.width, box.left + box.width) - Math.max(0, box.left);
238
+ const height = Math.min(landscape.height, box.top + box.height) - Math.max(0, box.top);
239
+ expect(1 - width * height / (landscape.width * landscape.height)).toBeLessThan(0.15);
240
+ expect(box.width / box.height).toBeCloseTo(DESKTOP.width / DESKTOP.height, 5);
241
+ }
242
+
243
+ });
244
+
245
+ it('leaves no black bars on a landscape phone, and fitToView shows the whole desktop', async () => {
246
+ const landscape = { width: 844, height: 390 };
247
+ const { id, nativeDesklink, rtcStyle } = await openView({ whole: false, bounds: landscape });
248
+ expect(rtcStyle().width).toBeCloseTo(landscape.width, 5);
249
+ expect(rtcStyle().left).toBeCloseTo(0, 5);
250
+ TestRenderer.act(() => { nativeDesklink.fitToView(id); });
251
+ expect(rtcStyle().height).toBeCloseTo(landscape.height, 5);
252
+ expect(rtcStyle().width).toBeCloseTo(DESKTOP.width * landscape.height / DESKTOP.height, 5);
253
+ });
254
+
133
255
  it('ignores a one-finger drag that starts in the letterbox', async () => {
134
256
  const { sent, grant, move, release } = await openView();
135
257
  // y = 10 is above the fitted picture (it starts at ORIGIN_Y).
@@ -144,11 +266,29 @@ describe('DesktopView.ios parity with DesktopView.kt', () => {
144
266
  expect(sent.filter((message) => message.kind === 'pointer' && message.phase === 'move')).not.toEqual([]);
145
267
  });
146
268
 
269
+ it('carries a one-finger drag from the moment the picture exists, in portrait', async () => {
270
+ // The state a session starts in: the picture at its default fill, before
271
+ // anything has been zoomed. In portrait that fill is the fit, so the
272
+ // picture has nowhere to pan to and the finger belongs to the desktop.
273
+ const { sent, rtcStyle, grant, move, release } = await openView({ whole: false });
274
+ const before = rtcStyle();
275
+ grant([touch(100, 100)]);
276
+ move([touch(160, 120)]);
277
+ move([touch(220, 140)]);
278
+ release();
279
+ const moves = sent.filter((message) => message.kind === 'pointer' && message.phase === 'move');
280
+ expect(moves).toHaveLength(2);
281
+ expect(moves[1].x).toBeGreaterThan(moves[0].x);
282
+ expect(moves[1].y).toBeGreaterThan(moves[0].y);
283
+ expect(rtcStyle()).toEqual(before);
284
+ });
285
+
147
286
  it('zooms around the pinch focus and re-clamps after zooming out', async () => {
148
287
  const { rtcStyle, grant, move } = await openView();
149
288
  grant([touch(100, 400)]);
150
289
  move([touch(100, 400), touch(140, 400)]);
151
- move([touch(80, 400), touch(160, 400)]);
290
+ // A finger's pinch spreads a few points per move, each step under the slop.
291
+ for (let step = 2; step <= 20; step += 2) move([touch(100 - step, 400), touch(140 + step, 400)]);
152
292
  expect(rtcStyle().width).toBeCloseTo(DESKTOP.width * SCALE * 2, 0);
153
293
  // The desktop point under the focus (120, 400) stays under it.
154
294
  expect(rtcStyle().left).toBeCloseTo(-120, 0);
@@ -362,3 +502,28 @@ describe('DesktopView.ios hardware keyboard and pointer', () => {
362
502
  expect(wheels[1].dy).toBeCloseTo(0.01 / SCALE / 120, 5);
363
503
  });
364
504
  });
505
+
506
+
507
+ describe('iOS proof picture bounds', () => {
508
+ it('measures contiguous paper without including the disconnected home indicator', () => {
509
+ for (const density of [1, 3]) {
510
+ const png = { width: 390 * density, height: 844 * density, data: new Uint8Array(390 * 844 * density * density * 4) };
511
+ const paper = (left: number, top: number, right: number, bottom: number) => {
512
+ for (let y = top * density; y < bottom * density; y++) for (let x = left * density; x < right * density; x++) {
513
+ const i = (y * png.width + x) * 4;
514
+ png.data.set([251, 250, 247, 255], i);
515
+ }
516
+ };
517
+ paper(0, 47, 390, 266);
518
+ paper(125, 825, 265, 830);
519
+ expect(pictureBox(png, 390)).toEqual({ top: 47, left: 0, bottom: 266 });
520
+ png.data.fill(0);
521
+ paper(0, 203, 390, 642);
522
+ paper(125, 825, 265, 830);
523
+ expect(pictureBox(png, 390)).toEqual({ top: 203, left: 0, bottom: 642 });
524
+ png.data.fill(0);
525
+ paper(47, 0, 390, 844);
526
+ expect(pictureBox(png, 390)).toEqual({ top: 0, left: 47, bottom: 844 });
527
+ }
528
+ });
529
+ });
@@ -16,6 +16,8 @@ const MIN_WHEEL_STEP = 0.05;
16
16
  const MAX_WHEEL_STEP = 10;
17
17
  /** Desktop pixels one wheel detent stands for. */
18
18
  const PIXELS_PER_DETENT = 120;
19
+ /** The closest the picture can be zoomed: view points per desktop pixel. */
20
+ const MAX_SCALE = 2.5;
19
21
  /** Where the cursor mark's tip sits inside the mark, in points. */
20
22
  const CURSOR_HOTSPOT = 3;
21
23
 
@@ -62,13 +64,18 @@ function loadRTCView(): React.ComponentType<Record<string, unknown>> | null {
62
64
  }
63
65
  }
64
66
 
65
- export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
67
+ /** An offset that the edge clamp turns into the desktop's top-left corner. */
68
+ const TOP_LEFT: Point = { x: Infinity, y: Infinity };
69
+
70
+ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop', onPictureFrame }: DesktopViewProps) {
66
71
  const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
67
72
  const [bounds, setBounds] = React.useState({ width: 0, height: 0 });
68
73
  const [size, setSize] = React.useState(() => getDesktopSize(sessionId) ?? { width: 0, height: 0 });
69
74
  const [keyboardHeight, setKeyboardHeight] = React.useState(0);
70
- const [zoom, setZoom] = React.useState(1);
71
- const [offset, setOffset] = React.useState<Point>({ x: 0, y: 0 });
75
+ /** Zoom over the whole-desktop fit; null fills the view, the default. */
76
+ const [zoom, setZoom] = React.useState<number | null>(null);
77
+ /** From the middle of the uncovered part of the view; clamped to the picture's edges when shown. */
78
+ const [offset, setOffset] = React.useState<Point>(TOP_LEFT);
72
79
  const [cursor, setCursor] = React.useState<Point | null>(null);
73
80
  const [keyboardText, setKeyboardText] = React.useState('');
74
81
  const keyboardTextRef = React.useRef('');
@@ -123,13 +130,28 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
123
130
  });
124
131
  }, [sessionId]);
125
132
 
126
- const visibleHeight = Math.max(1, bounds.height - (keyboardHeight ? keyboardHeight + keyboardClearance : 0));
127
- const fit = size.width && size.height ? Math.min(bounds.width / size.width, visibleHeight / size.height) : 1;
128
- const scale = fit * zoom;
133
+ // The uncovered part of the view: inside what the system covers, and above the keyboard.
134
+ const top = insets?.top ?? 0;
135
+ const left = insets?.left ?? 0;
136
+ const safeWidth = Math.max(1, bounds.width - left - (insets?.right ?? 0));
137
+ const safeHeight = Math.max(1, bounds.height - top - (insets?.bottom ?? 0));
138
+ const visibleBottom = Math.min(top + safeHeight, bounds.height - (keyboardHeight ? keyboardHeight + keyboardClearance : 0));
139
+ const visibleHeight = Math.max(1, visibleBottom - top);
140
+ const fit = size.width && size.height ? Math.min(safeWidth / size.width, visibleHeight / size.height) : 1;
141
+ const defaultScale = bounds.width <= bounds.height
142
+ ? safeWidth / size.width
143
+ : Math.max(bounds.width / size.width, (bounds.height - top) / size.height);
144
+ const fill = size.width && size.height ? Math.max(fit, Math.min(MAX_SCALE, defaultScale)) : 1;
145
+ const scale = zoom === null ? fill : fit * zoom;
146
+ /** The zoom over the whole-desktop fit the picture is at now. */
147
+ const zoomed = fit > 0 ? scale / fit : 1;
148
+ // A new desktop size fills the view again, and a new fill (a rotation)
149
+ // starts again, at the desktop's top-left corner, where a page's first words are.
150
+ React.useEffect(() => { setZoom(null); setOffset(TOP_LEFT); }, [size.width, size.height]);
151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
152
+ React.useEffect(() => { if (zoom === null) setOffset(TOP_LEFT); }, [fill, bounds.width, bounds.height, top, left, insets?.right, insets?.bottom]);
129
153
  const pictureWidth = size.width * scale;
130
154
  const pictureHeight = size.height * scale;
131
- const originX = (bounds.width - pictureWidth) / 2 + offset.x;
132
- const originY = (visibleHeight - pictureHeight) / 2 + offset.y;
133
155
  const point = (x: number, y: number, clamp = false): Point | null => {
134
156
  if (!size.width || !size.height || !scale) return null;
135
157
  const px = Math.floor((x - originX) / scale);
@@ -139,10 +161,24 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
139
161
  };
140
162
  /** Hold the offset to the picture's edges at a zoom level. */
141
163
  const clampOffset = (x: number, y: number, atScale: number = scale): Point => {
142
- const maxX = Math.max(0, (size.width * atScale - bounds.width) / 2);
164
+ const maxX = Math.max(0, (size.width * atScale - safeWidth) / 2);
143
165
  const maxY = Math.max(0, (size.height * atScale - visibleHeight) / 2);
144
166
  return { x: Math.max(-maxX, Math.min(maxX, x)), y: Math.max(-maxY, Math.min(maxY, y)) };
145
167
  };
168
+ // A rotation or the keyboard can leave the offset past the picture's new edges.
169
+ const shown = clampOffset(offset.x, offset.y);
170
+ const originX = left + (safeWidth - pictureWidth) / 2 + shown.x;
171
+ const originY = top + (zoom === null && pictureHeight <= visibleHeight ? 0 : (visibleHeight - pictureHeight) / 2) + shown.y;
172
+ // An application's controls can sit under the picture instead of over it,
173
+ // so it needs to know where the picture ended. Reported only when the
174
+ // picture moved: the callback sets state, and a fresh callback each render
175
+ // would otherwise loop.
176
+ const frame = React.useRef({ top: -1, height: -1 });
177
+ React.useEffect(() => {
178
+ if (frame.current.top === originY && frame.current.height === pictureHeight) return;
179
+ frame.current = { top: originY, height: pictureHeight };
180
+ onPictureFrame?.(frame.current);
181
+ }, [onPictureFrame, originY, pictureHeight]);
146
182
  const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
147
183
  const flushWheel = (force: boolean) => {
148
184
  const { x, y } = wheel.current;
@@ -186,7 +222,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
186
222
  // second one, and one finger alone is ignored. A zoomed picture pans
187
223
  // from anywhere, clamped to its edges.
188
224
  const onPicture = point(at.x, at.y) !== null;
189
- gesture.current = { stamp, start: at, last: at, time: Date.now(), mode: onPicture || zoom > 1 ? 'pending' : 'letterbox', span: 0, focus: at };
225
+ gesture.current = { stamp, start: at, last: at, time: Date.now(), mode: onPicture || zoomed > 1.001 ? 'pending' : 'letterbox', span: 0, focus: at };
190
226
  stopTimer();
191
227
  longPress.current = setTimeout(() => {
192
228
  if (gesture.current?.mode === 'pending' && point(at.x, at.y)) gesture.current.mode = 'armed';
@@ -223,16 +259,16 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
223
259
  }
224
260
  }
225
261
  if (g.mode === 'pinch' && g.span) {
226
- const maxZoom = fit > 0 ? 2.5 / fit : 2.5;
227
- const next = Math.max(1, Math.min(maxZoom, zoom * distance / g.span));
228
- const applied = zoom > 0 ? next / zoom : 1;
262
+ const maxZoom = fit > 0 ? Math.max(1, MAX_SCALE / fit) : MAX_SCALE;
263
+ const next = Math.max(1, Math.min(maxZoom, zoomed * distance / g.span));
264
+ const applied = zoomed > 0 ? next / zoomed : 1;
229
265
  // The desktop point under the focus stays under it while the
230
266
  // offset follows the fingers, then everything clamps back.
231
267
  const nextScale = fit * next;
232
- const ox = (bounds.width - size.width * scale) / 2 + offset.x;
233
- const oy = (visibleHeight - size.height * scale) / 2 + offset.y;
234
- const baseX = (bounds.width - size.width * nextScale) / 2;
235
- const baseY = (visibleHeight - size.height * nextScale) / 2;
268
+ const ox = originX;
269
+ const oy = originY;
270
+ const baseX = left + (safeWidth - size.width * nextScale) / 2;
271
+ const baseY = top + (visibleHeight - size.height * nextScale) / 2;
236
272
  setZoom(next);
237
273
  setOffset(clampOffset(
238
274
  center.x - (center.x - ox) * applied - baseX + (center.x - g.focus.x),
@@ -245,7 +281,10 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
245
281
  wheel.current.y += -(center.y - g.focus.y) / scale / PIXELS_PER_DETENT;
246
282
  flushWheel(false);
247
283
  }
248
- g.span = distance; g.focus = center; g.last = center;
284
+ // Undecided, the spread is measured from where the fingers landed:
285
+ // a slow pinch crosses the slop over many small moves.
286
+ if (g.mode !== 'two') g.span = distance;
287
+ g.focus = center; g.last = center;
249
288
  return;
250
289
  }
251
290
  if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'letterbox' || g.mode === 'two' || g.mode === 'pinch' || (g.mode === 'scroll' && gestures !== 'browser')) return;
@@ -258,7 +297,13 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
258
297
  g.mode = 'scroll';
259
298
  const under = point(at.x, at.y);
260
299
  if (under) pointer('move', under);
261
- } else g.mode = zoom > 1 ? 'pan' : 'hover';
300
+ } else {
301
+ // A whole desktop has nowhere to move to, so the finger moves the
302
+ // desktop's pointer instead. It is the zoom over the fit, not the
303
+ // default fill, that says there is room to pan: a fill that landed
304
+ // on the fit has none.
305
+ g.mode = zoomed > 1.001 ? 'pan' : 'hover';
306
+ }
262
307
  }
263
308
  if (g.mode === 'scroll') {
264
309
  // Content follows the finger: moving it up scrolls the page down.
@@ -271,7 +316,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
271
316
  if (g.mode === 'pan') {
272
317
  const dx = at.x - g.last.x;
273
318
  const dy = at.y - g.last.y;
274
- setOffset((current) => clampOffset(current.x + dx, current.y + dy));
319
+ setOffset((current) => { const from = clampOffset(current.x, current.y); return clampOffset(from.x + dx, from.y + dy); });
275
320
  }
276
321
  if (g.mode === 'armed' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
277
322
  const from = point(g.start.x, g.start.y, true);
@@ -5,6 +5,9 @@ import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
5
5
  import { desktopAvailable } from './native';
6
6
  import type { GestureProfile } from './protocol';
7
7
 
8
+ /** Room at each edge of the view, in points; a missing edge is 0. */
9
+ export type EdgeInsets = { top?: number; left?: number; bottom?: number; right?: number };
10
+
8
11
  export interface DesktopViewProps {
9
12
  /** The session handle from `useDesktopSession`. */
10
13
  sessionId: string | null;
@@ -19,11 +22,26 @@ export interface DesktopViewProps {
19
22
  * following the keyboard as it moves.
20
23
  */
21
24
  keyboardClearance?: number;
25
+ /**
26
+ * What the system covers at each edge of the view — status and navigation
27
+ * bars, a camera cutout, the home indicator — in points, as a safe-area
28
+ * library reports it. The picture still reaches under these edges, but it
29
+ * starts and pans inside them: its top-left corner first shows just inside,
30
+ * and every edge of the desktop can be brought into the uncovered part.
31
+ */
32
+ insets?: EdgeInsets;
22
33
  /**
23
34
  * Which touch meaning the surface uses: a desktop pointer, a browser page
24
35
  * (one finger scrolls), or a device screen (one finger presses and drags).
25
36
  */
26
37
  gestures?: GestureProfile;
38
+ /**
39
+ * Where the picture ended, in the view's own points, whenever it moves.
40
+ * A wide desktop on a tall screen leaves a band under the picture; this is
41
+ * how an application lays its own controls into that band instead of
42
+ * guessing the desktop's aspect.
43
+ */
44
+ onPictureFrame?: (frame: { top: number; height: number }) => void;
27
45
  }
28
46
 
29
47
  interface NativeSurfaceProps {
@@ -32,6 +50,8 @@ interface NativeSurfaceProps {
32
50
  accessible?: boolean;
33
51
  accessibilityLabel?: string;
34
52
  keyboardClearance?: number;
53
+ /** `insets` as top, left, bottom, right. */
54
+ insets?: number[];
35
55
  gestures?: GestureProfile;
36
56
  }
37
57
 
@@ -49,7 +69,7 @@ const NativeSurface: React.ComponentType<NativeSurfaceProps> | null = desktopAva
49
69
  * belong to the application, which mounts this wherever it wants the desktop
50
70
  * to appear.
51
71
  */
52
- export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
72
+ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, insets, gestures = 'desktop' }: DesktopViewProps) {
53
73
  if (NativeSurface == null || sessionId == null) {
54
74
  return (
55
75
  <View style={[styles.surface, style]}>
@@ -57,7 +77,8 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
57
77
  </View>
58
78
  );
59
79
  }
60
- return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance} gestures={gestures} />;
80
+ return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance}
81
+ insets={[insets?.top ?? 0, insets?.left ?? 0, insets?.bottom ?? 0, insets?.right ?? 0]} gestures={gestures} />;
61
82
  }
62
83
 
63
84
  const styles = StyleSheet.create({
@@ -3,6 +3,9 @@ import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
 
4
4
  import type { GestureProfile } from './protocol';
5
5
 
6
+ /** Room at each edge of the view, in points; a missing edge is 0. */
7
+ export type EdgeInsets = { top?: number; left?: number; bottom?: number; right?: number };
8
+
6
9
  export interface DesktopViewProps {
7
10
  /** The session handle from `useDesktopSession`. */
8
11
  sessionId: string | null;
@@ -16,6 +19,15 @@ export interface DesktopViewProps {
16
19
  * controls, in points. While the keyboard is up the picture sits above both.
17
20
  */
18
21
  keyboardClearance?: number;
22
+ /**
23
+ * What the system covers at each edge of the view — status and navigation
24
+ * bars, a camera cutout, the home indicator — in points, as a safe-area
25
+ * library reports it. The picture still reaches under these edges, but it
26
+ * starts and pans inside them: its top-left corner first shows just inside,
27
+ * and every edge of the desktop can be brought into the uncovered part.
28
+ */
29
+ /** The browser surface fits its own box and ignores this. */
30
+ insets?: EdgeInsets;
19
31
  /**
20
32
  * Which touch meaning the surface uses: a desktop pointer, a browser page
21
33
  * (one finger scrolls), or a device screen (one finger presses and drags).
@@ -0,0 +1,77 @@
1
+ import * as React from 'react';
2
+ import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ import type { StickyModifier, StickyModifiers, StickyState } from './stickyModifiers';
5
+
6
+ export interface ModifierKeysProps {
7
+ /** The session's `modifiers`. */
8
+ modifiers: StickyModifiers;
9
+ /** The session's `tapModifier`. */
10
+ onTap: (name: StickyModifier) => void;
11
+ style?: StyleProp<ViewStyle>;
12
+ /**
13
+ * Tablet targets: the keys share the width and take the height the row is
14
+ * given, instead of sitting at 44 pt in a row of a phone's. A row shorter
15
+ * than the keys' minimum keeps the minimum.
16
+ */
17
+ grow?: boolean;
18
+ }
19
+
20
+ const LABELS: Record<StickyModifier, string> = { Control: 'Ctrl', Shift: 'Shift', Alt: 'Alt', Meta: 'Meta' };
21
+ /** What a screen reader says for each state; "once" is the latch the user sees. */
22
+ export const STATE_NAMES: Record<StickyState, string> = { off: 'off', once: 'latched', lock: 'locked' };
23
+
24
+ /**
25
+ * The sticky modifier keys as a row the user can read at a glance.
26
+ *
27
+ * Off is an outlined dark key, latched is a filled key, and locked is a
28
+ * filled key with a white rim and a bar under its label, so the three differ
29
+ * in shape as well as colour. The row carries its own backing, so it reads
30
+ * the same over a light desktop as over a dark one.
31
+ */
32
+ export function ModifierKeys({ modifiers, onTap, style, grow }: ModifierKeysProps) {
33
+ return (
34
+ <View style={[styles.row, grow && styles.grown, style]}>
35
+ {(['Control', 'Shift', 'Alt', 'Meta'] as const).map((name) => {
36
+ const state = modifiers[name];
37
+ return (
38
+ <Pressable
39
+ key={name}
40
+ testID={`desklink-key-${name}`}
41
+ accessibilityRole="button"
42
+ accessibilityLabel={LABELS[name]}
43
+ accessibilityState={{ selected: state !== 'off' }}
44
+ accessibilityValue={{ text: STATE_NAMES[state] }}
45
+ onPress={() => onTap(name)}
46
+ style={[styles.key, grow && styles.keyGrown, state === 'off' ? styles.off : styles.on, state === 'lock' && styles.lock]}
47
+ >
48
+ <Text style={state === 'off' ? styles.offLabel : styles.onLabel}>{LABELS[name]}</Text>
49
+ <View style={[styles.bar, state === 'lock' && styles.barShown]} />
50
+ </Pressable>
51
+ );
52
+ })}
53
+ </View>
54
+ );
55
+ }
56
+
57
+ export const KEY_COLORS = {
58
+ backing: 'rgba(22, 23, 26, 0.94)',
59
+ off: '#2b2d33',
60
+ offLabel: '#c9ccd3',
61
+ on: '#2563eb',
62
+ onLabel: '#ffffff',
63
+ };
64
+
65
+ const styles = StyleSheet.create({
66
+ row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 8, backgroundColor: KEY_COLORS.backing },
67
+ grown: { width: '100%', flexGrow: 1, maxHeight: 160 },
68
+ key: { minWidth: 56, maxWidth: '100%', minHeight: 44, paddingHorizontal: 12, borderRadius: 8, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
69
+ keyGrown: { flexGrow: 1, minHeight: 96, paddingHorizontal: 24 },
70
+ off: { backgroundColor: KEY_COLORS.off, borderColor: '#4b4e57' },
71
+ on: { backgroundColor: KEY_COLORS.on, borderColor: KEY_COLORS.on },
72
+ lock: { borderColor: '#ffffff' },
73
+ offLabel: { color: KEY_COLORS.offLabel, fontSize: 15, fontWeight: '500' },
74
+ onLabel: { color: KEY_COLORS.onLabel, fontSize: 15, fontWeight: '700' },
75
+ bar: { marginTop: 3, width: 18, height: 3, borderRadius: 2, backgroundColor: 'transparent' },
76
+ barShown: { backgroundColor: '#ffffff' },
77
+ });