@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.
- package/README.md +81 -13
- package/android/build.gradle +13 -1
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +15 -0
- package/android/src/main/java/expo/modules/desklink/DesktopSession.kt +27 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +77 -16
- package/package.json +1 -1
- package/src/ClipboardConfirmation.tsx +57 -0
- package/src/DesktopView.ios.spec.tsx +171 -6
- package/src/DesktopView.ios.tsx +65 -20
- package/src/DesktopView.tsx +23 -2
- package/src/DesktopView.web.tsx +12 -0
- package/src/ModifierKeys.tsx +77 -0
- package/src/controls.spec.tsx +205 -0
- package/src/index.ts +4 -1
- package/src/native.ios.ts +5 -0
- package/src/native.ts +2 -1
- package/src/native.web.ts +5 -0
- package/src/protocol.ts +2 -1
- package/src/useDesktopSession.ts +38 -3
- package/src/videoStats.ts +16 -0
|
@@ -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
|
-
//
|
|
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
|
|
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
|
+
});
|
package/src/DesktopView.ios.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
const [
|
|
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
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
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 -
|
|
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 ||
|
|
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 ?
|
|
227
|
-
const next = Math.max(1, Math.min(maxZoom,
|
|
228
|
-
const applied =
|
|
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 =
|
|
233
|
-
const oy =
|
|
234
|
-
const baseX = (
|
|
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
|
-
|
|
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
|
|
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,
|
|
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);
|
package/src/DesktopView.tsx
CHANGED
|
@@ -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}
|
|
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({
|
package/src/DesktopView.web.tsx
CHANGED
|
@@ -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
|
+
});
|