@limrun/ui 0.4.2 → 0.5.0
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/dist/core/constants.d.ts +1 -0
- package/dist/core/webrtc-messages.d.ts +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +771 -456
- package/package.json +1 -1
- package/src/components/remote-control.css +18 -0
- package/src/components/remote-control.tsx +669 -117
- package/src/core/constants.ts +2 -0
- package/src/core/webrtc-messages.ts +49 -1
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
createTouchControlMessage,
|
|
15
15
|
createInjectKeycodeMessage,
|
|
16
16
|
createSetClipboardMessage,
|
|
17
|
+
createTwoFingerTouchControlMessage,
|
|
17
18
|
} from '../core/webrtc-messages';
|
|
18
19
|
|
|
19
20
|
declare global {
|
|
@@ -81,6 +82,14 @@ const debugWarn = (...args: any[]) => {
|
|
|
81
82
|
}
|
|
82
83
|
};
|
|
83
84
|
|
|
85
|
+
const motionActionToString = (action: number): string => {
|
|
86
|
+
// AMOTION_EVENT is a constants object; find the matching ACTION_* key if present
|
|
87
|
+
const match = Object.entries(AMOTION_EVENT).find(
|
|
88
|
+
([key, value]) => key.startsWith('ACTION_') && value === action,
|
|
89
|
+
);
|
|
90
|
+
return match?.[0] ?? String(action);
|
|
91
|
+
};
|
|
92
|
+
|
|
84
93
|
type DevicePlatform = 'ios' | 'android';
|
|
85
94
|
|
|
86
95
|
const detectPlatform = (url: string): DevicePlatform => {
|
|
@@ -170,6 +179,7 @@ function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number
|
|
|
170
179
|
|
|
171
180
|
export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>(
|
|
172
181
|
({ className, url, token, sessionId: propSessionId, openUrl, showFrame = true }: RemoteControlProps, ref) => {
|
|
182
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
173
183
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
174
184
|
const frameRef = useRef<HTMLImageElement>(null);
|
|
175
185
|
const [videoLoaded, setVideoLoaded] = useState(false);
|
|
@@ -184,10 +194,46 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
184
194
|
>(new Map());
|
|
185
195
|
const pendingScreenshotRejectersRef = useRef<Map<string, (reason?: any) => void>>(new Map());
|
|
186
196
|
|
|
187
|
-
// Map to track active pointers
|
|
197
|
+
// Map to track active pointers for real touch/mouse single-finger events.
|
|
188
198
|
// Key: pointerId (-1 for mouse, touch.identifier for touch), Value: { x: number, y: number }
|
|
189
199
|
const activePointers = useRef<Map<number, { x: number; y: number }>>(new Map());
|
|
190
200
|
|
|
201
|
+
// Alt/Option modifier state for pinch emulation.
|
|
202
|
+
// We use a ref as the source of truth (for synchronous event handler access)
|
|
203
|
+
// and state only to trigger re-renders for the visual indicators.
|
|
204
|
+
const isAltHeldRef = useRef(false);
|
|
205
|
+
const [isAltHeld, setIsAltHeld] = useState(false);
|
|
206
|
+
|
|
207
|
+
// State for any two-finger gesture (Alt+mouse simulated or real two-finger touch).
|
|
208
|
+
// Tracks positions, video size, source, and pointer IDs (for Android protocol).
|
|
209
|
+
type TwoFingerState = {
|
|
210
|
+
finger0: { x: number; y: number };
|
|
211
|
+
finger1: { x: number; y: number };
|
|
212
|
+
videoSize: { width: number; height: number };
|
|
213
|
+
// Track source so we know when to clear (Alt release vs touch end)
|
|
214
|
+
source: 'alt-mouse' | 'real-touch';
|
|
215
|
+
// Pointer IDs for Android (real touch.identifier or simulated -1/-2)
|
|
216
|
+
pointerId0: number;
|
|
217
|
+
pointerId1: number;
|
|
218
|
+
};
|
|
219
|
+
const twoFingerStateRef = useRef<TwoFingerState | null>(null);
|
|
220
|
+
|
|
221
|
+
// Hover point for rendering two-finger indicators when Alt is held.
|
|
222
|
+
// Only computed/set when Alt is held to avoid unnecessary re-renders.
|
|
223
|
+
type HoverPoint = {
|
|
224
|
+
containerX: number;
|
|
225
|
+
containerY: number;
|
|
226
|
+
mirrorContainerX: number;
|
|
227
|
+
mirrorContainerY: number;
|
|
228
|
+
videoX: number;
|
|
229
|
+
videoY: number;
|
|
230
|
+
mirrorVideoX: number;
|
|
231
|
+
mirrorVideoY: number;
|
|
232
|
+
videoWidth: number;
|
|
233
|
+
videoHeight: number;
|
|
234
|
+
};
|
|
235
|
+
const [hoverPoint, setHoverPoint] = useState<HoverPoint | null>(null);
|
|
236
|
+
|
|
191
237
|
const sessionId = useMemo(
|
|
192
238
|
() =>
|
|
193
239
|
propSessionId ||
|
|
@@ -210,130 +256,449 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
210
256
|
dataChannelRef.current.send(data);
|
|
211
257
|
};
|
|
212
258
|
|
|
213
|
-
//
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
259
|
+
// Fixed pointer IDs for Alt-simulated two-finger gestures
|
|
260
|
+
const ALT_POINTER_ID_PRIMARY = -1;
|
|
261
|
+
const ALT_POINTER_ID_MIRROR = -2;
|
|
262
|
+
|
|
263
|
+
// Helper to send a single-touch control message (used by both single-finger and Android two-finger paths)
|
|
264
|
+
const sendSingleTouch = (
|
|
265
|
+
action: number,
|
|
266
|
+
pointerId: number,
|
|
267
|
+
videoWidth: number,
|
|
268
|
+
videoHeight: number,
|
|
269
|
+
x: number,
|
|
270
|
+
y: number,
|
|
271
|
+
) => {
|
|
272
|
+
const message = createTouchControlMessage(
|
|
273
|
+
action,
|
|
274
|
+
pointerId,
|
|
275
|
+
videoWidth,
|
|
276
|
+
videoHeight,
|
|
277
|
+
x,
|
|
278
|
+
y,
|
|
279
|
+
1.0, // pressure
|
|
280
|
+
AMOTION_EVENT.BUTTON_PRIMARY,
|
|
281
|
+
AMOTION_EVENT.BUTTON_PRIMARY,
|
|
282
|
+
);
|
|
283
|
+
if (message) {
|
|
284
|
+
debugLog('[rc-touch] sendSingleTouch', {
|
|
285
|
+
action,
|
|
286
|
+
actionName: motionActionToString(action),
|
|
287
|
+
pointerId,
|
|
288
|
+
x,
|
|
289
|
+
y,
|
|
290
|
+
video: { width: videoWidth, height: videoHeight },
|
|
291
|
+
});
|
|
292
|
+
sendBinaryControlMessage(message);
|
|
220
293
|
}
|
|
294
|
+
};
|
|
221
295
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const processPointer = (
|
|
231
|
-
pointerId: number,
|
|
232
|
-
clientX: number,
|
|
233
|
-
clientY: number,
|
|
234
|
-
eventType: 'down' | 'move' | 'up' | 'cancel',
|
|
235
|
-
) => {
|
|
236
|
-
// --- Start: Coordinate Calculation ---
|
|
237
|
-
const displayWidth = rect.width;
|
|
238
|
-
const displayHeight = rect.height;
|
|
239
|
-
const videoAspectRatio = videoWidth / videoHeight;
|
|
240
|
-
const containerAspectRatio = displayWidth / displayHeight;
|
|
241
|
-
let actualWidth = displayWidth;
|
|
242
|
-
let actualHeight = displayHeight;
|
|
243
|
-
if (videoAspectRatio > containerAspectRatio) {
|
|
244
|
-
actualHeight = displayWidth / videoAspectRatio;
|
|
245
|
-
} else {
|
|
246
|
-
actualWidth = displayHeight * videoAspectRatio;
|
|
247
|
-
}
|
|
248
|
-
const offsetX = (displayWidth - actualWidth) / 2;
|
|
249
|
-
const offsetY = (displayHeight - actualHeight) / 2;
|
|
250
|
-
const relativeX = clientX - rect.left - offsetX;
|
|
251
|
-
const relativeY = clientY - rect.top - offsetY;
|
|
252
|
-
const isInside =
|
|
253
|
-
relativeX >= 0 && relativeX <= actualWidth && relativeY >= 0 && relativeY <= actualHeight;
|
|
254
|
-
|
|
255
|
-
let videoX = 0;
|
|
256
|
-
let videoY = 0;
|
|
257
|
-
if (isInside) {
|
|
258
|
-
videoX = Math.max(0, Math.min(videoWidth, (relativeX / actualWidth) * videoWidth));
|
|
259
|
-
videoY = Math.max(0, Math.min(videoHeight, (relativeY / actualHeight) * videoHeight));
|
|
260
|
-
}
|
|
261
|
-
// --- End: Coordinate Calculation ---
|
|
296
|
+
// Minimal geometry for single-finger touch events (no mirror/container coords needed).
|
|
297
|
+
type PointerGeometry = {
|
|
298
|
+
inside: boolean;
|
|
299
|
+
videoX: number;
|
|
300
|
+
videoY: number;
|
|
301
|
+
videoWidth: number;
|
|
302
|
+
videoHeight: number;
|
|
303
|
+
};
|
|
262
304
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
305
|
+
const applyPointerEvent = (
|
|
306
|
+
pointerId: number,
|
|
307
|
+
eventType: 'down' | 'move' | 'up' | 'cancel',
|
|
308
|
+
geometry: PointerGeometry | null,
|
|
309
|
+
) => {
|
|
310
|
+
if (!geometry) return;
|
|
311
|
+
const { inside: isInside, videoX, videoY, videoWidth, videoHeight } = geometry;
|
|
312
|
+
|
|
313
|
+
let action: number | null = null;
|
|
314
|
+
let positionToSend: { x: number; y: number } | null = null;
|
|
315
|
+
let pressure = 1.0; // Default pressure
|
|
316
|
+
const buttons = AMOTION_EVENT.BUTTON_PRIMARY; // Assume primary button
|
|
317
|
+
|
|
318
|
+
switch (eventType) {
|
|
319
|
+
case 'down':
|
|
320
|
+
if (isInside) {
|
|
321
|
+
// For multi-touch: use ACTION_DOWN for first pointer, ACTION_POINTER_DOWN for additional pointers
|
|
322
|
+
const currentPointerCount = activePointers.current.size;
|
|
323
|
+
action =
|
|
324
|
+
currentPointerCount === 0
|
|
325
|
+
? AMOTION_EVENT.ACTION_DOWN
|
|
326
|
+
: AMOTION_EVENT.ACTION_POINTER_DOWN;
|
|
327
|
+
positionToSend = { x: videoX, y: videoY };
|
|
328
|
+
activePointers.current.set(pointerId, positionToSend);
|
|
329
|
+
if (pointerId === -1) {
|
|
330
|
+
// Focus on mouse down
|
|
331
|
+
videoRef.current?.focus();
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
// If the initial down event is outside, ignore it for this pointer
|
|
335
|
+
activePointers.current.delete(pointerId);
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
267
338
|
|
|
268
|
-
|
|
269
|
-
|
|
339
|
+
case 'move':
|
|
340
|
+
if (activePointers.current.has(pointerId)) {
|
|
270
341
|
if (isInside) {
|
|
271
|
-
action = AMOTION_EVENT.
|
|
342
|
+
action = AMOTION_EVENT.ACTION_MOVE;
|
|
272
343
|
positionToSend = { x: videoX, y: videoY };
|
|
344
|
+
// Update the last known position for this active pointer
|
|
273
345
|
activePointers.current.set(pointerId, positionToSend);
|
|
274
|
-
if (pointerId === -1) {
|
|
275
|
-
// Focus on mouse down
|
|
276
|
-
videoRef.current?.focus();
|
|
277
|
-
}
|
|
278
346
|
} else {
|
|
279
|
-
//
|
|
280
|
-
activePointers.current.delete(pointerId);
|
|
347
|
+
// Moved outside while active - do nothing, UP/CANCEL will use last known pos
|
|
281
348
|
}
|
|
282
|
-
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
283
351
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
activePointers.current.set(pointerId, positionToSend);
|
|
291
|
-
} else {
|
|
292
|
-
// Moved outside while active - do nothing, UP/CANCEL will use last known pos
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
break;
|
|
352
|
+
case 'up':
|
|
353
|
+
case 'cancel': // Treat cancel like up, but use ACTION_CANCEL
|
|
354
|
+
if (activePointers.current.has(pointerId)) {
|
|
355
|
+
// IMPORTANT: Send the UP/CANCEL at the *last known position* inside the video
|
|
356
|
+
positionToSend = activePointers.current.get(pointerId)!;
|
|
357
|
+
activePointers.current.delete(pointerId); // Remove pointer as it's no longer active
|
|
296
358
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
359
|
+
if (eventType === 'cancel') {
|
|
360
|
+
action = AMOTION_EVENT.ACTION_CANCEL;
|
|
361
|
+
} else {
|
|
362
|
+
// For multi-touch: use ACTION_UP for last pointer, ACTION_POINTER_UP for non-last pointers
|
|
363
|
+
const remainingPointerCount = activePointers.current.size;
|
|
364
|
+
action =
|
|
365
|
+
remainingPointerCount === 0
|
|
366
|
+
? AMOTION_EVENT.ACTION_UP
|
|
367
|
+
: AMOTION_EVENT.ACTION_POINTER_UP;
|
|
304
368
|
}
|
|
305
|
-
|
|
306
|
-
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
307
372
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
action,
|
|
373
|
+
// Send message if action and position determined
|
|
374
|
+
if (action !== null && positionToSend !== null) {
|
|
375
|
+
debugLog('[rc-touch][mouse->touch] sending', {
|
|
312
376
|
pointerId,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
377
|
+
eventType,
|
|
378
|
+
action,
|
|
379
|
+
actionName: motionActionToString(action),
|
|
380
|
+
isInside,
|
|
381
|
+
positionToSend,
|
|
382
|
+
video: { width: videoWidth, height: videoHeight },
|
|
383
|
+
altHeld: isAltHeldRef.current,
|
|
384
|
+
activePointersAfter: Array.from(activePointers.current.entries()).map(([id, pos]) => ({
|
|
385
|
+
id,
|
|
386
|
+
x: pos.x,
|
|
387
|
+
y: pos.y,
|
|
388
|
+
})),
|
|
389
|
+
});
|
|
390
|
+
const message = createTouchControlMessage(
|
|
391
|
+
action,
|
|
392
|
+
pointerId,
|
|
393
|
+
videoWidth,
|
|
394
|
+
videoHeight,
|
|
395
|
+
positionToSend.x,
|
|
396
|
+
positionToSend.y,
|
|
397
|
+
pressure,
|
|
398
|
+
buttons,
|
|
399
|
+
buttons,
|
|
400
|
+
);
|
|
401
|
+
if (message) {
|
|
402
|
+
debugLog('[rc-touch][mouse->touch] buffer', {
|
|
403
|
+
pointerId,
|
|
404
|
+
actionName: motionActionToString(action),
|
|
405
|
+
byteLength: message.byteLength,
|
|
406
|
+
});
|
|
407
|
+
sendBinaryControlMessage(message);
|
|
408
|
+
}
|
|
409
|
+
} else if (eventType === 'up' || eventType === 'cancel') {
|
|
410
|
+
// Clean up map just in case if 'down' was outside and 'up'/'cancel' is triggered
|
|
411
|
+
activePointers.current.delete(pointerId);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// Update Alt modifier state. Only iOS Simulator uses Indigo modifier injection.
|
|
416
|
+
const updateAltHeld = (nextHeld: boolean) => {
|
|
417
|
+
if (isAltHeldRef.current === nextHeld) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
isAltHeldRef.current = nextHeld;
|
|
421
|
+
setIsAltHeld(nextHeld);
|
|
422
|
+
|
|
423
|
+
// Clear hover point when Alt is released to hide indicators immediately.
|
|
424
|
+
if (!nextHeld) {
|
|
425
|
+
setHoverPoint(null);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
debugLog('[rc-touch][alt] updateAltHeld', {
|
|
429
|
+
nextHeld,
|
|
430
|
+
activePointerIds: Array.from(activePointers.current.keys()),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// iOS Simulator pinch (Option/Alt+drag) behavior depends on the Option modifier being
|
|
434
|
+
// active on the Indigo HID side. Send Alt key down/up immediately on toggle so the
|
|
435
|
+
// sequence matches Simulator.app (Alt down -> mouse down/drag -> mouse up -> Alt up).
|
|
436
|
+
// This is iOS-specific; Android doesn't use this modifier injection.
|
|
437
|
+
if (platform === 'ios' && dataChannelRef.current && dataChannelRef.current.readyState === 'open') {
|
|
438
|
+
const action = nextHeld ? ANDROID_KEYS.ACTION_DOWN : ANDROID_KEYS.ACTION_UP;
|
|
439
|
+
const message = createInjectKeycodeMessage(action, ANDROID_KEYS.KEYCODE_ALT_LEFT, 0, ANDROID_KEYS.META_NONE);
|
|
440
|
+
debugLog('[rc-touch][alt] sending Indigo modifier keycode', {
|
|
441
|
+
action,
|
|
442
|
+
keycode: ANDROID_KEYS.KEYCODE_ALT_LEFT,
|
|
443
|
+
});
|
|
444
|
+
if (message) {
|
|
445
|
+
sendBinaryControlMessage(message);
|
|
327
446
|
}
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// Mapping context computed once per DOM event, then reused for each pointer.
|
|
451
|
+
type VideoMappingContext = {
|
|
452
|
+
videoWidth: number;
|
|
453
|
+
videoHeight: number;
|
|
454
|
+
videoRect: DOMRect;
|
|
455
|
+
containerRect: DOMRect;
|
|
456
|
+
actualWidth: number;
|
|
457
|
+
actualHeight: number;
|
|
458
|
+
offsetX: number;
|
|
459
|
+
offsetY: number;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// Compute mapping context from current video/container state (once per event).
|
|
463
|
+
const computeVideoMappingContext = (): VideoMappingContext | null => {
|
|
464
|
+
const video = videoRef.current;
|
|
465
|
+
const container = containerRef.current;
|
|
466
|
+
if (!video || !container) return null;
|
|
467
|
+
|
|
468
|
+
const videoWidth = video.videoWidth;
|
|
469
|
+
const videoHeight = video.videoHeight;
|
|
470
|
+
if (!videoWidth || !videoHeight) return null;
|
|
471
|
+
|
|
472
|
+
const videoRect = video.getBoundingClientRect();
|
|
473
|
+
const containerRect = container.getBoundingClientRect();
|
|
474
|
+
|
|
475
|
+
const displayWidth = videoRect.width;
|
|
476
|
+
const displayHeight = videoRect.height;
|
|
477
|
+
const videoAspectRatio = videoWidth / videoHeight;
|
|
478
|
+
const containerAspectRatio = displayWidth / displayHeight;
|
|
479
|
+
|
|
480
|
+
let actualWidth = displayWidth;
|
|
481
|
+
let actualHeight = displayHeight;
|
|
482
|
+
if (videoAspectRatio > containerAspectRatio) {
|
|
483
|
+
actualHeight = displayWidth / videoAspectRatio;
|
|
484
|
+
} else {
|
|
485
|
+
actualWidth = displayHeight * videoAspectRatio;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const offsetX = (displayWidth - actualWidth) / 2;
|
|
489
|
+
const offsetY = (displayHeight - actualHeight) / 2;
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
videoWidth,
|
|
493
|
+
videoHeight,
|
|
494
|
+
videoRect,
|
|
495
|
+
containerRect,
|
|
496
|
+
actualWidth,
|
|
497
|
+
actualHeight,
|
|
498
|
+
offsetX,
|
|
499
|
+
offsetY,
|
|
500
|
+
};
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// Map a client point to video coordinates using a pre-computed context.
|
|
504
|
+
// Returns null if outside the video content area or context is missing.
|
|
505
|
+
const mapClientPointToVideo = (
|
|
506
|
+
ctx: VideoMappingContext,
|
|
507
|
+
clientX: number,
|
|
508
|
+
clientY: number,
|
|
509
|
+
): PointerGeometry | null => {
|
|
510
|
+
const relativeX = clientX - ctx.videoRect.left - ctx.offsetX;
|
|
511
|
+
const relativeY = clientY - ctx.videoRect.top - ctx.offsetY;
|
|
512
|
+
|
|
513
|
+
const isInside =
|
|
514
|
+
relativeX >= 0 && relativeX <= ctx.actualWidth &&
|
|
515
|
+
relativeY >= 0 && relativeY <= ctx.actualHeight;
|
|
516
|
+
|
|
517
|
+
if (!isInside) {
|
|
518
|
+
return {
|
|
519
|
+
inside: false,
|
|
520
|
+
videoX: 0,
|
|
521
|
+
videoY: 0,
|
|
522
|
+
videoWidth: ctx.videoWidth,
|
|
523
|
+
videoHeight: ctx.videoHeight,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const videoX = Math.max(0, Math.min(ctx.videoWidth, (relativeX / ctx.actualWidth) * ctx.videoWidth));
|
|
528
|
+
const videoY = Math.max(0, Math.min(ctx.videoHeight, (relativeY / ctx.actualHeight) * ctx.videoHeight));
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
inside: true,
|
|
532
|
+
videoX,
|
|
533
|
+
videoY,
|
|
534
|
+
videoWidth: ctx.videoWidth,
|
|
535
|
+
videoHeight: ctx.videoHeight,
|
|
328
536
|
};
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// Compute full hover point with mirror/container coordinates (for Alt indicator rendering).
|
|
540
|
+
const computeFullHoverPoint = (
|
|
541
|
+
ctx: VideoMappingContext,
|
|
542
|
+
clientX: number,
|
|
543
|
+
clientY: number,
|
|
544
|
+
): HoverPoint | null => {
|
|
545
|
+
const relativeX = clientX - ctx.videoRect.left - ctx.offsetX;
|
|
546
|
+
const relativeY = clientY - ctx.videoRect.top - ctx.offsetY;
|
|
547
|
+
|
|
548
|
+
const isInside =
|
|
549
|
+
relativeX >= 0 && relativeX <= ctx.actualWidth &&
|
|
550
|
+
relativeY >= 0 && relativeY <= ctx.actualHeight;
|
|
551
|
+
|
|
552
|
+
if (!isInside) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const videoX = Math.max(0, Math.min(ctx.videoWidth, (relativeX / ctx.actualWidth) * ctx.videoWidth));
|
|
557
|
+
const videoY = Math.max(0, Math.min(ctx.videoHeight, (relativeY / ctx.actualHeight) * ctx.videoHeight));
|
|
558
|
+
const mirrorVideoX = ctx.videoWidth - videoX;
|
|
559
|
+
const mirrorVideoY = ctx.videoHeight - videoY;
|
|
560
|
+
|
|
561
|
+
const contentLeft = ctx.videoRect.left + ctx.offsetX;
|
|
562
|
+
const contentTop = ctx.videoRect.top + ctx.offsetY;
|
|
563
|
+
const containerX = contentLeft - ctx.containerRect.left + relativeX;
|
|
564
|
+
const containerY = contentTop - ctx.containerRect.top + relativeY;
|
|
565
|
+
const mirrorContainerX = contentLeft - ctx.containerRect.left + (ctx.actualWidth - relativeX);
|
|
566
|
+
const mirrorContainerY = contentTop - ctx.containerRect.top + (ctx.actualHeight - relativeY);
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
containerX,
|
|
570
|
+
containerY,
|
|
571
|
+
mirrorContainerX,
|
|
572
|
+
mirrorContainerY,
|
|
573
|
+
videoX,
|
|
574
|
+
videoY,
|
|
575
|
+
mirrorVideoX,
|
|
576
|
+
mirrorVideoY,
|
|
577
|
+
videoWidth: ctx.videoWidth,
|
|
578
|
+
videoHeight: ctx.videoHeight,
|
|
579
|
+
};
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// Helper to send a two-finger touch message (iOS-specific type=18 message).
|
|
583
|
+
const sendTwoFingerMessage = (
|
|
584
|
+
action: number,
|
|
585
|
+
videoWidth: number,
|
|
586
|
+
videoHeight: number,
|
|
587
|
+
x0: number,
|
|
588
|
+
y0: number,
|
|
589
|
+
x1: number,
|
|
590
|
+
y1: number,
|
|
591
|
+
) => {
|
|
592
|
+
const msg = createTwoFingerTouchControlMessage(
|
|
593
|
+
action,
|
|
594
|
+
videoWidth,
|
|
595
|
+
videoHeight,
|
|
596
|
+
x0,
|
|
597
|
+
y0,
|
|
598
|
+
x1,
|
|
599
|
+
y1,
|
|
600
|
+
);
|
|
601
|
+
debugLog('[rc-touch2] sendTwoFingerMessage (iOS)', {
|
|
602
|
+
actionName: motionActionToString(action),
|
|
603
|
+
video: { width: videoWidth, height: videoHeight },
|
|
604
|
+
p0: { x: x0, y: y0 },
|
|
605
|
+
p1: { x: x1, y: y1 },
|
|
606
|
+
byteLength: msg.byteLength,
|
|
607
|
+
});
|
|
608
|
+
sendBinaryControlMessage(msg);
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// Generic two-finger event handler - sends platform-appropriate messages.
|
|
612
|
+
// iOS: uses special two-finger message (type=18)
|
|
613
|
+
// Android: sends two separate single-touch messages with proper action sequencing
|
|
614
|
+
const applyTwoFingerEvent = (
|
|
615
|
+
eventType: 'down' | 'move' | 'up',
|
|
616
|
+
videoWidth: number,
|
|
617
|
+
videoHeight: number,
|
|
618
|
+
x0: number,
|
|
619
|
+
y0: number,
|
|
620
|
+
x1: number,
|
|
621
|
+
y1: number,
|
|
622
|
+
pointerId0: number,
|
|
623
|
+
pointerId1: number,
|
|
624
|
+
) => {
|
|
625
|
+
debugLog('[rc-touch2] applyTwoFingerEvent', {
|
|
626
|
+
platform,
|
|
627
|
+
eventType,
|
|
628
|
+
video: { width: videoWidth, height: videoHeight },
|
|
629
|
+
p0: { x: x0, y: y0, id: pointerId0 },
|
|
630
|
+
p1: { x: x1, y: y1, id: pointerId1 },
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
if (platform === 'ios') {
|
|
634
|
+
// iOS: use special two-finger message (type=18)
|
|
635
|
+
const action = eventType === 'down' ? AMOTION_EVENT.ACTION_DOWN
|
|
636
|
+
: eventType === 'move' ? AMOTION_EVENT.ACTION_MOVE
|
|
637
|
+
: AMOTION_EVENT.ACTION_UP;
|
|
638
|
+
sendTwoFingerMessage(action, videoWidth, videoHeight, x0, y0, x1, y1);
|
|
639
|
+
} else {
|
|
640
|
+
// Android: send two separate single-touch messages with proper action codes
|
|
641
|
+
// Per scrcpy protocol, each finger is a separate INJECT_TOUCH_EVENT with unique pointerId
|
|
642
|
+
if (eventType === 'down') {
|
|
643
|
+
// First finger down (ACTION_DOWN), then second finger down (ACTION_POINTER_DOWN)
|
|
644
|
+
sendSingleTouch(AMOTION_EVENT.ACTION_DOWN, pointerId0, videoWidth, videoHeight, x0, y0);
|
|
645
|
+
sendSingleTouch(AMOTION_EVENT.ACTION_POINTER_DOWN, pointerId1, videoWidth, videoHeight, x1, y1);
|
|
646
|
+
} else if (eventType === 'move') {
|
|
647
|
+
// Both fingers move (ACTION_MOVE for each)
|
|
648
|
+
sendSingleTouch(AMOTION_EVENT.ACTION_MOVE, pointerId0, videoWidth, videoHeight, x0, y0);
|
|
649
|
+
sendSingleTouch(AMOTION_EVENT.ACTION_MOVE, pointerId1, videoWidth, videoHeight, x1, y1);
|
|
650
|
+
} else {
|
|
651
|
+
// Second finger up (ACTION_POINTER_UP), then first finger up (ACTION_UP)
|
|
652
|
+
sendSingleTouch(AMOTION_EVENT.ACTION_POINTER_UP, pointerId1, videoWidth, videoHeight, x1, y1);
|
|
653
|
+
sendSingleTouch(AMOTION_EVENT.ACTION_UP, pointerId0, videoWidth, videoHeight, x0, y0);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// Update hover point only when Alt is held (to avoid re-renders in normal path).
|
|
659
|
+
const updateHoverPoint = (ctx: VideoMappingContext, clientX: number, clientY: number) => {
|
|
660
|
+
if (!isAltHeldRef.current) {
|
|
661
|
+
// Don't compute or update when Alt isn't held
|
|
662
|
+
if (hoverPoint !== null) {
|
|
663
|
+
setHoverPoint(null);
|
|
664
|
+
}
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const fullPoint = computeFullHoverPoint(ctx, clientX, clientY);
|
|
668
|
+
setHoverPoint(fullPoint);
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
// Unified handler for both mouse and touch interactions
|
|
672
|
+
const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
|
|
673
|
+
event.preventDefault();
|
|
674
|
+
event.stopPropagation();
|
|
675
|
+
|
|
676
|
+
// Compute mapping context once per event (reused for all pointers)
|
|
677
|
+
const ctx = computeVideoMappingContext();
|
|
678
|
+
|
|
679
|
+
// Handle hover point updates for mouse events (only when Alt is held)
|
|
680
|
+
if (!('touches' in event) && ctx) {
|
|
681
|
+
if (event.type === 'mousemove') {
|
|
682
|
+
updateHoverPoint(ctx, event.clientX, event.clientY);
|
|
683
|
+
} else if (event.type === 'mouseleave') {
|
|
684
|
+
setHoverPoint(null);
|
|
685
|
+
}
|
|
686
|
+
// Note: Alt state is tracked via global keydown/keyup listeners, not event.altKey,
|
|
687
|
+
// to ensure consistent behavior across focus transitions.
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (!dataChannelRef.current || dataChannelRef.current.readyState !== 'open' || !videoRef.current || !ctx) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
329
693
|
|
|
330
694
|
// --- Event Type Handling ---
|
|
331
695
|
|
|
332
696
|
if ('touches' in event) {
|
|
333
|
-
// Touch Events
|
|
334
|
-
const
|
|
335
|
-
|
|
697
|
+
// Touch Events - handle both single-finger and two-finger gestures
|
|
698
|
+
const allTouches = event.touches; // All currently active touches
|
|
699
|
+
const changedTouches = event.changedTouches;
|
|
336
700
|
|
|
701
|
+
let eventType: 'down' | 'move' | 'up' | 'cancel';
|
|
337
702
|
switch (event.type) {
|
|
338
703
|
case 'touchstart':
|
|
339
704
|
eventType = 'down';
|
|
@@ -348,45 +713,210 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
348
713
|
eventType = 'cancel';
|
|
349
714
|
break;
|
|
350
715
|
default:
|
|
351
|
-
return;
|
|
716
|
+
return;
|
|
352
717
|
}
|
|
353
718
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
719
|
+
// Check if we have exactly 2 active touches - route to two-finger logic
|
|
720
|
+
if (allTouches.length === 2) {
|
|
721
|
+
const t0 = allTouches[0];
|
|
722
|
+
const t1 = allTouches[1];
|
|
723
|
+
const g0 = mapClientPointToVideo(ctx, t0.clientX, t0.clientY);
|
|
724
|
+
const g1 = mapClientPointToVideo(ctx, t1.clientX, t1.clientY);
|
|
725
|
+
|
|
726
|
+
if (!g0 || !g1) return;
|
|
727
|
+
|
|
728
|
+
if (!twoFingerStateRef.current) {
|
|
729
|
+
// Starting a new two-finger gesture
|
|
730
|
+
if (g0.inside && g1.inside) {
|
|
731
|
+
twoFingerStateRef.current = {
|
|
732
|
+
finger0: { x: g0.videoX, y: g0.videoY },
|
|
733
|
+
finger1: { x: g1.videoX, y: g1.videoY },
|
|
734
|
+
videoSize: { width: g0.videoWidth, height: g0.videoHeight },
|
|
735
|
+
source: 'real-touch',
|
|
736
|
+
pointerId0: t0.identifier,
|
|
737
|
+
pointerId1: t1.identifier,
|
|
738
|
+
};
|
|
739
|
+
applyTwoFingerEvent('down', g0.videoWidth, g0.videoHeight,
|
|
740
|
+
g0.videoX, g0.videoY, g1.videoX, g1.videoY,
|
|
741
|
+
t0.identifier, t1.identifier);
|
|
742
|
+
}
|
|
743
|
+
} else if (twoFingerStateRef.current.source === 'real-touch') {
|
|
744
|
+
// Continuing two-finger gesture (move)
|
|
745
|
+
if (g0.inside && g1.inside) {
|
|
746
|
+
twoFingerStateRef.current.finger0 = { x: g0.videoX, y: g0.videoY };
|
|
747
|
+
twoFingerStateRef.current.finger1 = { x: g1.videoX, y: g1.videoY };
|
|
748
|
+
applyTwoFingerEvent('move', g0.videoWidth, g0.videoHeight,
|
|
749
|
+
g0.videoX, g0.videoY, g1.videoX, g1.videoY,
|
|
750
|
+
twoFingerStateRef.current.pointerId0,
|
|
751
|
+
twoFingerStateRef.current.pointerId1);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
} else if (allTouches.length < 2 && twoFingerStateRef.current?.source === 'real-touch') {
|
|
755
|
+
// Finger lifted - end two-finger gesture using last known state
|
|
756
|
+
const state = twoFingerStateRef.current;
|
|
757
|
+
applyTwoFingerEvent('up', state.videoSize.width, state.videoSize.height,
|
|
758
|
+
state.finger0.x, state.finger0.y,
|
|
759
|
+
state.finger1.x, state.finger1.y,
|
|
760
|
+
state.pointerId0, state.pointerId1);
|
|
761
|
+
twoFingerStateRef.current = null;
|
|
762
|
+
// Don't process remaining finger - gesture ended
|
|
763
|
+
return;
|
|
764
|
+
} else if (allTouches.length > 2) {
|
|
765
|
+
// 3+ fingers - not supported, ignore
|
|
766
|
+
return;
|
|
767
|
+
} else {
|
|
768
|
+
// Single finger touch (allTouches is 0 or 1)
|
|
769
|
+
// Note: allTouches=0 happens on touchend when last finger lifts
|
|
770
|
+
const touch = changedTouches[0];
|
|
771
|
+
if (touch) {
|
|
772
|
+
const geometry = mapClientPointToVideo(ctx, touch.clientX, touch.clientY);
|
|
773
|
+
applyPointerEvent(touch.identifier, eventType, geometry);
|
|
774
|
+
}
|
|
357
775
|
}
|
|
358
776
|
} else {
|
|
359
777
|
// Mouse Events
|
|
360
|
-
const pointerId = -1; //
|
|
778
|
+
const pointerId = -1; // Primary mouse pointer
|
|
361
779
|
let eventType: 'down' | 'move' | 'up' | 'cancel' | null = null;
|
|
362
780
|
|
|
781
|
+
// Determine if we're in two-finger mode (Alt+mouse drag)
|
|
782
|
+
const inTwoFingerMode = twoFingerStateRef.current?.source === 'alt-mouse';
|
|
783
|
+
|
|
363
784
|
switch (event.type) {
|
|
364
785
|
case 'mousedown':
|
|
365
|
-
if (event.button === 0) eventType = 'down';
|
|
786
|
+
if (event.button === 0) eventType = 'down';
|
|
366
787
|
break;
|
|
367
788
|
case 'mousemove':
|
|
368
|
-
//
|
|
369
|
-
if (activePointers.current.has(pointerId)) {
|
|
789
|
+
// Process move if either in two-finger mode or has active pointer (normal drag)
|
|
790
|
+
if (inTwoFingerMode || activePointers.current.has(pointerId)) {
|
|
370
791
|
eventType = 'move';
|
|
371
792
|
}
|
|
372
793
|
break;
|
|
373
794
|
case 'mouseup':
|
|
374
|
-
if (event.button === 0) eventType = 'up';
|
|
795
|
+
if (event.button === 0) eventType = 'up';
|
|
375
796
|
break;
|
|
376
797
|
case 'mouseleave':
|
|
377
|
-
// Treat leave like up only if
|
|
378
|
-
if (activePointers.current.has(pointerId)) {
|
|
798
|
+
// Treat leave like up only if in drag/two-finger mode
|
|
799
|
+
if (inTwoFingerMode || activePointers.current.has(pointerId)) {
|
|
379
800
|
eventType = 'up';
|
|
380
801
|
}
|
|
381
802
|
break;
|
|
382
803
|
}
|
|
383
804
|
|
|
384
805
|
if (eventType) {
|
|
385
|
-
|
|
806
|
+
const geometry = mapClientPointToVideo(ctx, event.clientX, event.clientY);
|
|
807
|
+
if (!geometry) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
debugLog('[rc-touch][mouse] event', {
|
|
812
|
+
domType: event.type,
|
|
813
|
+
eventType,
|
|
814
|
+
button: event.button,
|
|
815
|
+
buttons: (event as React.MouseEvent).buttons,
|
|
816
|
+
client: { x: event.clientX, y: event.clientY },
|
|
817
|
+
altHeldRef: isAltHeldRef.current,
|
|
818
|
+
inTwoFingerMode,
|
|
819
|
+
geometry: {
|
|
820
|
+
inside: geometry.inside,
|
|
821
|
+
videoX: geometry.videoX,
|
|
822
|
+
videoY: geometry.videoY,
|
|
823
|
+
videoWidth: geometry.videoWidth,
|
|
824
|
+
videoHeight: geometry.videoHeight,
|
|
825
|
+
},
|
|
826
|
+
activePointerIds: Array.from(activePointers.current.keys()),
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// Route to two-finger (Alt+mouse) or single-finger path
|
|
830
|
+
if (isAltHeldRef.current || inTwoFingerMode) {
|
|
831
|
+
// Two-finger mode - Alt simulates second finger at mirror position
|
|
832
|
+
handleAltMouseGesture(eventType, geometry);
|
|
833
|
+
} else {
|
|
834
|
+
// Normal single-finger touch
|
|
835
|
+
applyPointerEvent(pointerId, eventType, geometry);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// Handle Alt+mouse gestures (simulated two-finger with mirror position).
|
|
842
|
+
// Works on both iOS and Android - applyTwoFingerEvent handles platform differences.
|
|
843
|
+
const handleAltMouseGesture = (
|
|
844
|
+
eventType: 'down' | 'move' | 'up' | 'cancel',
|
|
845
|
+
geometry: PointerGeometry,
|
|
846
|
+
) => {
|
|
847
|
+
const { inside, videoX, videoY, videoWidth, videoHeight } = geometry;
|
|
848
|
+
const mirrorX = videoWidth - videoX;
|
|
849
|
+
const mirrorY = videoHeight - videoY;
|
|
850
|
+
|
|
851
|
+
if (eventType === 'down') {
|
|
852
|
+
if (inside) {
|
|
853
|
+
// Start two-finger gesture
|
|
854
|
+
twoFingerStateRef.current = {
|
|
855
|
+
finger0: { x: videoX, y: videoY },
|
|
856
|
+
finger1: { x: mirrorX, y: mirrorY },
|
|
857
|
+
videoSize: { width: videoWidth, height: videoHeight },
|
|
858
|
+
source: 'alt-mouse',
|
|
859
|
+
pointerId0: ALT_POINTER_ID_PRIMARY,
|
|
860
|
+
pointerId1: ALT_POINTER_ID_MIRROR,
|
|
861
|
+
};
|
|
862
|
+
videoRef.current?.focus();
|
|
863
|
+
applyTwoFingerEvent('down', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
|
|
864
|
+
ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
|
|
386
865
|
}
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (eventType === 'move') {
|
|
870
|
+
if (twoFingerStateRef.current?.source === 'alt-mouse' && inside) {
|
|
871
|
+
// Update positions
|
|
872
|
+
twoFingerStateRef.current.finger0 = { x: videoX, y: videoY };
|
|
873
|
+
twoFingerStateRef.current.finger1 = { x: mirrorX, y: mirrorY };
|
|
874
|
+
applyTwoFingerEvent('move', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
|
|
875
|
+
ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
|
|
876
|
+
}
|
|
877
|
+
// If outside, we just don't send a move - UP will use last known position
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (eventType === 'up' || eventType === 'cancel') {
|
|
882
|
+
const state = twoFingerStateRef.current;
|
|
883
|
+
if (state?.source === 'alt-mouse') {
|
|
884
|
+
// End gesture at last known inside positions
|
|
885
|
+
const { finger0, finger1, videoSize } = state;
|
|
886
|
+
applyTwoFingerEvent('up', videoSize.width, videoSize.height,
|
|
887
|
+
finger0.x, finger0.y, finger1.x, finger1.y,
|
|
888
|
+
ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
|
|
889
|
+
twoFingerStateRef.current = null;
|
|
890
|
+
}
|
|
891
|
+
return;
|
|
387
892
|
}
|
|
388
893
|
};
|
|
389
894
|
|
|
895
|
+
useEffect(() => {
|
|
896
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
897
|
+
if (event.key === 'Alt') {
|
|
898
|
+
updateAltHeld(true);
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
const handleKeyUp = (event: KeyboardEvent) => {
|
|
902
|
+
if (event.key === 'Alt') {
|
|
903
|
+
updateAltHeld(false);
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
const handleWindowBlur = () => {
|
|
907
|
+
updateAltHeld(false);
|
|
908
|
+
};
|
|
909
|
+
// Use capture phase so these fire before handleKeyboard's stopPropagation
|
|
910
|
+
window.addEventListener('keydown', handleKeyDown, true);
|
|
911
|
+
window.addEventListener('keyup', handleKeyUp, true);
|
|
912
|
+
window.addEventListener('blur', handleWindowBlur);
|
|
913
|
+
return () => {
|
|
914
|
+
window.removeEventListener('keydown', handleKeyDown, true);
|
|
915
|
+
window.removeEventListener('keyup', handleKeyUp, true);
|
|
916
|
+
window.removeEventListener('blur', handleWindowBlur);
|
|
917
|
+
};
|
|
918
|
+
}, []);
|
|
919
|
+
|
|
390
920
|
const handleKeyboard = (event: React.KeyboardEvent) => {
|
|
391
921
|
event.preventDefault();
|
|
392
922
|
event.stopPropagation();
|
|
@@ -990,8 +1520,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
990
1520
|
},
|
|
991
1521
|
}));
|
|
992
1522
|
|
|
1523
|
+
// Show indicators when Alt is held and we have a valid hover point (null when outside)
|
|
1524
|
+
const showAltIndicators = isAltHeld && hoverPoint !== null;
|
|
1525
|
+
|
|
993
1526
|
return (
|
|
994
1527
|
<div
|
|
1528
|
+
ref={containerRef}
|
|
995
1529
|
className={clsx(
|
|
996
1530
|
'rc-container',
|
|
997
1531
|
className,
|
|
@@ -1008,12 +1542,30 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1008
1542
|
onTouchEnd={handleInteraction}
|
|
1009
1543
|
onTouchCancel={handleInteraction}
|
|
1010
1544
|
>
|
|
1545
|
+
{showAltIndicators && hoverPoint && (
|
|
1546
|
+
<>
|
|
1547
|
+
<div
|
|
1548
|
+
className="rc-touch-indicator"
|
|
1549
|
+
style={{
|
|
1550
|
+
left: `${hoverPoint.containerX}px`,
|
|
1551
|
+
top: `${hoverPoint.containerY}px`,
|
|
1552
|
+
}}
|
|
1553
|
+
/>
|
|
1554
|
+
<div
|
|
1555
|
+
className="rc-touch-indicator"
|
|
1556
|
+
style={{
|
|
1557
|
+
left: `${hoverPoint.mirrorContainerX}px`,
|
|
1558
|
+
top: `${hoverPoint.mirrorContainerY}px`,
|
|
1559
|
+
}}
|
|
1560
|
+
/>
|
|
1561
|
+
</>
|
|
1562
|
+
)}
|
|
1011
1563
|
{showFrame && (
|
|
1012
1564
|
<img
|
|
1013
1565
|
ref={frameRef}
|
|
1014
1566
|
src={isLandscape ? config.frame.imageLandscape : config.frame.image}
|
|
1015
1567
|
alt=""
|
|
1016
|
-
className={'rc-phone-frame'}
|
|
1568
|
+
className={platform === 'ios' ? clsx('rc-phone-frame', 'rc-phone-frame-ios') : 'rc-phone-frame'}
|
|
1017
1569
|
draggable={false}
|
|
1018
1570
|
/>
|
|
1019
1571
|
)}
|