@limrun/ui 0.17.0-rc.1 → 0.17.0-rc.2

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.
Files changed (83) hide show
  1. package/README.md +18 -3
  2. package/dist/{GLTFLoader-DW6wpvyR.mjs → GLTFLoader-Cami_vza.mjs} +1 -1
  3. package/dist/{GLTFLoader-BKnsBFsj.js → GLTFLoader-DeCLxW_q.js} +1 -1
  4. package/dist/components/duo-fold-motion.d.ts +1 -1
  5. package/dist/components/duo-loading.d.ts +8 -0
  6. package/dist/components/duo-view.d.ts +6 -1
  7. package/dist/{duo-frame-BzyYljix.mjs → duo-frame-Cmw4p2uN.mjs} +414 -414
  8. package/dist/{duo-frame-DjfA4gG-.js → duo-frame-iMtrvx_a.js} +18 -18
  9. package/dist/duo-view-BAh-w2Kz.js +1 -0
  10. package/dist/duo-view-D9Ae00rj.mjs +512 -0
  11. package/dist/index.cjs +1 -1
  12. package/dist/index.css +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/lite.cjs +1 -1
  15. package/dist/lite.js +1 -1
  16. package/dist/remote-control-B2jZpf3c.js +1 -0
  17. package/dist/remote-control-DiddxS8l.mjs +2883 -0
  18. package/package.json +5 -1
  19. package/dist/demo.d.ts +0 -1
  20. package/dist/duo-view-COZg8ieG.js +0 -1
  21. package/dist/duo-view-o1yxIzxu.mjs +0 -654
  22. package/dist/remote-control-CQjSEVDF.mjs +0 -2673
  23. package/dist/remote-control-DVRXfCR2.js +0 -1
  24. package/index.html +0 -199
  25. package/src/assets/Apple_logo_white.svg +0 -4
  26. package/src/assets/android_boot.gif +0 -0
  27. package/src/assets/android_boot.webp +0 -0
  28. package/src/assets/iphone16pro_black.webp +0 -0
  29. package/src/assets/iphone16pro_black_bg.webp +0 -0
  30. package/src/assets/iphone16pro_black_landscape.webp +0 -0
  31. package/src/assets/iphone16pro_black_landscape_bg.webp +0 -0
  32. package/src/assets/pixel9_black.webp +0 -0
  33. package/src/assets/pixel9_black_landscape.webp +0 -0
  34. package/src/assets/pixel_tablet_landscape.webp +0 -0
  35. package/src/assets/pixel_tablet_portrait.webp +0 -0
  36. package/src/components/device-install/index.ts +0 -1
  37. package/src/components/duo-button-hinge.test.tsx +0 -138
  38. package/src/components/duo-button-hinge.ts +0 -75
  39. package/src/components/duo-controls.tsx +0 -142
  40. package/src/components/duo-flat-frame.tsx +0 -145
  41. package/src/components/duo-flat-geometry.test.ts +0 -63
  42. package/src/components/duo-flat-geometry.ts +0 -97
  43. package/src/components/duo-fold-motion.test.tsx +0 -245
  44. package/src/components/duo-fold-motion.tsx +0 -192
  45. package/src/components/duo-fold-renderer.test.ts +0 -29
  46. package/src/components/duo-fold-renderer.ts +0 -397
  47. package/src/components/duo-fold-timing.test.ts +0 -44
  48. package/src/components/duo-fold-timing.ts +0 -9
  49. package/src/components/duo-frame.tsx +0 -579
  50. package/src/components/duo-hardware.test.ts +0 -61
  51. package/src/components/duo-hardware.ts +0 -61
  52. package/src/components/duo-model.test.ts +0 -243
  53. package/src/components/duo-model.ts +0 -607
  54. package/src/components/duo-screen-handoff.ts +0 -52
  55. package/src/components/duo-view.test.tsx +0 -104
  56. package/src/components/duo-view.tsx +0 -315
  57. package/src/components/inspect-overlay.css +0 -223
  58. package/src/components/inspect-overlay.tsx +0 -437
  59. package/src/components/remote-control-framed.tsx +0 -14
  60. package/src/components/remote-control.css +0 -441
  61. package/src/components/remote-control.test.ts +0 -34
  62. package/src/components/remote-control.tsx +0 -3989
  63. package/src/core/ax-fetcher.test.ts +0 -418
  64. package/src/core/ax-fetcher.ts +0 -387
  65. package/src/core/ax-tree.test.ts +0 -491
  66. package/src/core/ax-tree.ts +0 -416
  67. package/src/core/camera.test.ts +0 -155
  68. package/src/core/camera.ts +0 -133
  69. package/src/core/constants.ts +0 -344
  70. package/src/core/duo.test.ts +0 -142
  71. package/src/core/duo.ts +0 -156
  72. package/src/core/webrtc-messages.ts +0 -148
  73. package/src/demo.tsx +0 -283
  74. package/src/frame-assets.ts +0 -22
  75. package/src/hooks/index.ts +0 -1
  76. package/src/index.ts +0 -29
  77. package/src/lite.test.ts +0 -30
  78. package/src/lite.ts +0 -30
  79. package/src/vite-env.d.ts +0 -1
  80. package/tsconfig.json +0 -26
  81. package/tsconfig.node.json +0 -26
  82. package/vite.config.ts +0 -35
  83. package/vitest.config.ts +0 -24
@@ -1,579 +0,0 @@
1
- import React, { useEffect, useRef, useState } from 'react';
2
- import * as THREE from 'three';
3
- import { createDuoModel } from './duo-model';
4
- import { duoFoldAngle } from './duo-fold-timing';
5
- import { hardwareLayout, hardwareHoverEdge } from './duo-hardware';
6
- import { DuoControls, DuoHardwareIcon } from './duo-controls';
7
- import { useDuoButtonHinge } from './duo-button-hinge';
8
- import {
9
- panelTouch,
10
- duoPointerMode,
11
- DuoButtonSender,
12
- DUO_BUTTONS,
13
- type DuoButton,
14
- type DuoState,
15
- type DuoOrientation,
16
- } from '../core/duo';
17
-
18
- export interface DuoFrameProps {
19
- modelUrl?: string;
20
- outer: HTMLVideoElement | null;
21
- inner: HTMLVideoElement | null;
22
- state: DuoState;
23
- setAngle: (degrees: number) => Promise<void>;
24
- setOrientation: (orientation: DuoOrientation) => Promise<void>;
25
- button: (button: DuoButton, down: boolean) => Promise<void>;
26
- touch: (action: number, screenId: number, x: number, y: number) => void;
27
- onKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
28
- onKeyUp: React.KeyboardEventHandler<HTMLDivElement>;
29
- }
30
-
31
- type View = 'front' | 'book' | 'table' | 'back';
32
-
33
- /** A procedural titanium frame. Each glass panel carries its own native video texture. */
34
- export default function DuoFrame(props: DuoFrameProps) {
35
- const host = useRef<HTMLDivElement>(null);
36
- const hardwareNodes = useRef(new Map<DuoButton, HTMLButtonElement>());
37
- const latest = useRef(props);
38
- latest.current = props;
39
- const [error, setError] = useState<string>();
40
- const { angle, changeAngle, interacting, motion, target, fold, cancelFold } = useDuoButtonHinge(
41
- props.state.angleDegrees,
42
- props.setAngle,
43
- setError,
44
- );
45
- const angleRef = useRef(angle);
46
- angleRef.current = angle;
47
- const [view, setView] = useState<View>('front');
48
- const viewRevision = useRef(0);
49
- const viewRef = useRef(view);
50
- viewRef.current = view;
51
- const [positionLocked, setPositionLocked] = useState(true);
52
- const lockedRef = useRef(positionLocked);
53
- lockedRef.current = positionLocked;
54
- const invalidateFrame = useRef<() => void>(() => undefined);
55
-
56
- useEffect(() => {
57
- invalidateFrame.current();
58
- }, [angle, target, view, positionLocked, props.state.orientation]);
59
-
60
- useEffect(() => {
61
- const container = host.current;
62
- if (!container || !props.outer || !props.inner) return;
63
- let renderer: THREE.WebGLRenderer;
64
- try {
65
- renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
66
- } catch {
67
- setError('3D rendering is unavailable in this browser. Enable WebGL to use the folding frame.');
68
- return;
69
- }
70
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
71
- renderer.outputColorSpace = THREE.SRGBColorSpace;
72
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
73
- renderer.toneMappingExposure = 1;
74
- renderer.setClearColor(0x000000, 0);
75
- container.appendChild(renderer.domElement);
76
- const scene = new THREE.Scene();
77
- const camera = new THREE.OrthographicCamera(-150, 150, 150, -150, 1, 2000);
78
- let aspect = 1;
79
- camera.position.set(0, 0, 340);
80
- const root = new THREE.Group();
81
- // Continuous light-to-dark studio sweeps give broad metal faces reflections at every viewing angle.
82
- const studioWidth = 512;
83
- const studioHeight = 256;
84
- const studioPixels = new Float32Array(studioWidth * studioHeight * 4);
85
- for (let y = 0; y < studioHeight; y++) {
86
- const latitude = ((y + 0.5) / studioHeight - 0.5) * Math.PI;
87
- for (let x = 0; x < studioWidth; x++) {
88
- const longitude = ((x + 0.5) / studioWidth) * Math.PI * 2;
89
- const sweep = Math.pow(0.5 + 0.5 * Math.sin(4 * longitude + 1.8 * Math.sin(latitude)), 3);
90
- const fill = Math.pow(0.5 + 0.5 * Math.cos(2 * longitude - 3 * latitude), 3);
91
- const light = 0.12 + 3.2 * sweep + 0.8 * fill;
92
- const offset = (y * studioWidth + x) * 4;
93
- studioPixels.set([light, light, light, 1], offset);
94
- }
95
- }
96
- const studio = new THREE.DataTexture(
97
- studioPixels,
98
- studioWidth,
99
- studioHeight,
100
- THREE.RGBAFormat,
101
- THREE.FloatType,
102
- );
103
- studio.mapping = THREE.EquirectangularReflectionMapping;
104
- studio.needsUpdate = true;
105
- const pmrem = new THREE.PMREMGenerator(renderer);
106
- const environmentMap = pmrem.fromEquirectangular(studio);
107
- scene.environment = environmentMap.texture;
108
- scene.environmentIntensity = 0.4;
109
- studio.dispose();
110
- pmrem.dispose();
111
- scene.add(new THREE.HemisphereLight(0xffffff, 0xc1c8d1, 1.1));
112
- const key = new THREE.DirectionalLight(0xffffff, 2.4);
113
- key.position.set(-100, 180, 260);
114
- scene.add(key);
115
- const rim = new THREE.DirectionalLight(0xe4edff, 0.8);
116
- rim.position.set(200, -40, -200);
117
- scene.add(rim);
118
- const outerTexture = new THREE.VideoTexture(props.outer);
119
- const innerTexture = new THREE.VideoTexture(props.inner);
120
- for (const texture of [outerTexture, innerTexture]) {
121
- texture.colorSpace = THREE.SRGBColorSpace;
122
- texture.minFilter = THREE.LinearFilter;
123
- texture.magFilter = THREE.LinearFilter;
124
- }
125
-
126
- const model = createDuoModel(outerTexture, innerTexture, environmentMap.texture);
127
- const { device } = model;
128
- if (props.modelUrl)
129
- void model
130
- .loadAppearance(props.modelUrl)
131
- .then(() => invalidate())
132
- .catch((reason) => setError(String(reason)));
133
- const buttons = new DuoButtonSender(
134
- (button, down) => latest.current.button(button, down),
135
- (reason) => setError(String(reason)),
136
- );
137
- root.add(device);
138
- scene.add(root);
139
-
140
- const ray = new THREE.Raycaster();
141
- const pointer = new THREE.Vector2();
142
- let drag:
143
- | {
144
- pointer: number;
145
- button?: DuoButton;
146
- screenId?: number;
147
- x: number;
148
- y: number;
149
- clientX: number;
150
- clientY: number;
151
- }
152
- | undefined;
153
- let yaw = 0,
154
- pitch = 0;
155
- let previousViewRevision = viewRevision.current;
156
- let renderedAngle = angleRef.current;
157
- let foldFrom = renderedAngle;
158
- let foldTarget = renderedAngle;
159
- let foldStartedAt = 0;
160
- let foldDragging = false;
161
- let renderedYaw = 0;
162
- let animation = 0;
163
- let visible = true;
164
- let disposed = false;
165
- const invalidate = () => {
166
- if (!animation && visible && !document.hidden && !disposed) animation = requestAnimationFrame(animate);
167
- };
168
- invalidateFrame.current = invalidate;
169
- let lastTime = 0;
170
- let layout: ReturnType<typeof hardwareLayout> | undefined;
171
- let hoveredEdge: ReturnType<typeof hardwareHoverEdge>;
172
- const iconButton = (target: EventTarget | null) =>
173
- target instanceof Element ? target.closest<HTMLButtonElement>('[data-duo-button]') : null;
174
- const showEdge = (edge: typeof hoveredEdge) => {
175
- hoveredEdge = edge;
176
- for (const guide of layout?.guides ?? []) {
177
- const element = hardwareNodes.current.get(guide.button);
178
- if (element) element.dataset.visible = String(guide.edge === edge);
179
- }
180
- };
181
- const pick = (event: PointerEvent) => {
182
- const icon = iconButton(event.target);
183
- if (icon) return { button: icon.dataset.duoButton as DuoButton };
184
- const rect = renderer.domElement.getBoundingClientRect();
185
- pointer.set(
186
- ((event.clientX - rect.left) / rect.width) * 2 - 1,
187
- 1 - ((event.clientY - rect.top) / rect.height) * 2,
188
- );
189
- ray.setFromCamera(pointer, camera);
190
- const intersection = model.pick(ray);
191
- const button = intersection?.object.userData['button'] as DuoButton | undefined;
192
- if (button) return { button };
193
- if (!intersection?.object.userData['display']) return undefined;
194
- if (!intersection.uv) return undefined;
195
- const display = latest.current.state.displays.find(
196
- (d) => d.id === intersection.object.userData['display'],
197
- );
198
- if (!display) return undefined;
199
- return { screenId: display.screenId, ...panelTouch(intersection.uv.x, intersection.uv.y) };
200
- };
201
- const down = (event: PointerEvent) => {
202
- invalidate();
203
- if (drag || event.button !== 0) return;
204
- if (!iconButton(event.target)) container.focus({ preventScroll: true });
205
- const picked = pick(event);
206
- const hardware = picked && 'button' in picked ? picked.button : undefined;
207
- const mode = duoPointerMode(lockedRef.current, !!picked && !hardware, event.altKey);
208
- if (mode === 'ignore' && !hardware) return;
209
- const hit = mode === 'touch' && picked && 'screenId' in picked ? picked : undefined;
210
- drag = {
211
- pointer: event.pointerId,
212
- button: hardware,
213
- ...hit,
214
- x: hit?.x ?? 0,
215
- y: hit?.y ?? 0,
216
- clientX: event.clientX,
217
- clientY: event.clientY,
218
- };
219
- container.setPointerCapture(event.pointerId);
220
- if (hardware) {
221
- setError(undefined);
222
- buttons.press(hardware);
223
- model.highlightButton(hardware, true);
224
- } else if (hit) latest.current.touch(0, hit.screenId, hit.x, hit.y);
225
- };
226
- const move = (event: PointerEvent) => {
227
- invalidate();
228
- if (!drag) {
229
- const hit = pick(event);
230
- const button = hit && 'button' in hit ? hit.button : undefined;
231
- const rect = container.getBoundingClientRect();
232
- showEdge(
233
- button ?
234
- layout?.guides.find((guide) => guide.button === button)?.edge
235
- : layout && hardwareHoverEdge(event.clientX - rect.left, event.clientY - rect.top, layout.rect),
236
- );
237
- renderer.domElement.style.cursor = button ? 'pointer' : '';
238
- renderer.domElement.title = button ? DUO_BUTTONS[button] : '';
239
- model.highlightButton(button);
240
- return;
241
- }
242
- if (drag.pointer !== event.pointerId || drag.button) return;
243
- if (drag.screenId) {
244
- const hit = pick(event);
245
- if (hit && 'screenId' in hit && hit.screenId === drag.screenId) {
246
- drag.x = hit.x;
247
- drag.y = hit.y;
248
- latest.current.touch(2, hit.screenId, hit.x, hit.y);
249
- }
250
- } else if (!lockedRef.current) {
251
- yaw += (event.clientX - drag.clientX) * 0.008;
252
- pitch = THREE.MathUtils.clamp(pitch + (event.clientY - drag.clientY) * 0.008, -1.4, 1.4);
253
- }
254
- drag.clientX = event.clientX;
255
- drag.clientY = event.clientY;
256
- };
257
- const up = (event?: PointerEvent) => {
258
- invalidate();
259
- if (!drag || (event && drag.pointer !== event.pointerId)) return;
260
- if (drag.button) {
261
- buttons.release();
262
- model.highlightButton();
263
- } else if (drag.screenId) latest.current.touch(1, drag.screenId, drag.x, drag.y);
264
- drag = undefined;
265
- if (event) move(event);
266
- };
267
- const leave = () => {
268
- invalidate();
269
- if (!drag) {
270
- model.highlightButton();
271
- showEdge(undefined);
272
- }
273
- };
274
- const keyDown = (event: KeyboardEvent) => {
275
- invalidate();
276
- const icon = iconButton(event.target);
277
- if (!icon) return;
278
- event.stopPropagation();
279
- if (event.code !== 'Space' && event.code !== 'Enter') return;
280
- event.preventDefault();
281
- if (event.repeat) return;
282
- const button = icon.dataset.duoButton as DuoButton;
283
- buttons.press(button);
284
- model.highlightButton(button, true);
285
- icon.dataset.pressed = 'true';
286
- };
287
- const keyUp = (event: KeyboardEvent) => {
288
- invalidate();
289
- const icon = iconButton(event.target);
290
- if (!icon) return;
291
- event.stopPropagation();
292
- if (event.code !== 'Space' && event.code !== 'Enter') return;
293
- event.preventDefault();
294
- buttons.release();
295
- model.highlightButton(icon.dataset.duoButton as DuoButton);
296
- icon.dataset.pressed = 'false';
297
- };
298
- const focus = (event: FocusEvent) => {
299
- const icon = iconButton(event.target);
300
- if (icon) showEdge(layout?.guides.find((guide) => guide.button === icon.dataset.duoButton)?.edge);
301
- };
302
- const blur = () => {
303
- invalidate();
304
- up();
305
- buttons.release();
306
- model.highlightButton();
307
- showEdge(undefined);
308
- hardwareNodes.current.forEach((element) => {
309
- element.dataset.pressed = 'false';
310
- });
311
- };
312
- const focusOut = (event: FocusEvent) => {
313
- if (!container.contains(event.relatedTarget as Node | null)) blur();
314
- else if (iconButton(event.target)) {
315
- buttons.release();
316
- model.highlightButton();
317
- }
318
- };
319
- container.addEventListener('pointerleave', leave);
320
- container.addEventListener('pointerdown', down);
321
- container.addEventListener('pointermove', move);
322
- container.addEventListener('pointerup', up);
323
- container.addEventListener('pointercancel', up);
324
- container.addEventListener('lostpointercapture', up);
325
- container.addEventListener('keydown', keyDown);
326
- container.addEventListener('keyup', keyUp);
327
- container.addEventListener('focusin', focus);
328
- container.addEventListener('focusout', focusOut);
329
- window.addEventListener('blur', blur);
330
- const resize = () => {
331
- const w = container.clientWidth,
332
- h = container.clientHeight;
333
- if (!w || !h) return;
334
- aspect = w / h;
335
-
336
- camera.updateProjectionMatrix();
337
- renderer.setSize(w, h, false);
338
- invalidate();
339
- };
340
- const observer = new ResizeObserver(resize);
341
- observer.observe(container);
342
- const bounds = new THREE.Box3();
343
- const center = new THREE.Vector3();
344
- const extent = new THREE.Vector3();
345
- const animate = (time: number) => {
346
- animation = 0;
347
- if (disposed || !visible || document.hidden) return;
348
- const dt = Math.min((time - lastTime) / 1000, 0.05);
349
- lastTime = time;
350
- if (viewRevision.current !== previousViewRevision) {
351
- yaw = 0;
352
- pitch = 0;
353
- previousViewRevision = viewRevision.current;
354
- }
355
- if (foldTarget !== angleRef.current) {
356
- foldFrom = renderedAngle;
357
- foldTarget = angleRef.current;
358
- foldStartedAt = time;
359
- foldDragging = interacting.current;
360
- }
361
- const buttonFold = motion.current;
362
- renderedAngle =
363
- buttonFold ? duoFoldAngle(buttonFold.from, buttonFold.target, (time - buttonFold.startedAt) / 1000)
364
- : foldDragging ? THREE.MathUtils.damp(renderedAngle, foldTarget, 18, dt)
365
- : duoFoldAngle(foldFrom, foldTarget, (time - foldStartedAt) / 1000);
366
- if (Math.abs(renderedAngle - angleRef.current) < 0.001) renderedAngle = angleRef.current;
367
- model.setAngle(renderedAngle);
368
- // Closed view presents the cover; intermediate poses retain depth and true occlusion.
369
- const closed = 1 - THREE.MathUtils.smoothstep(renderedAngle, 15, 110);
370
- const baseYaw = (closed * Math.PI) / 2 + (viewRef.current === 'back' ? Math.PI : 0);
371
- renderedYaw = THREE.MathUtils.damp(renderedYaw, baseYaw, 12, dt);
372
- if (Math.abs(renderedYaw - baseYaw) < 0.001) renderedYaw = baseYaw;
373
- // Camera orbit stays independent of the native device orientation.
374
- root.rotation.set(
375
- pitch +
376
- (viewRef.current === 'table' ? -0.35
377
- : viewRef.current === 'book' ? 0.2
378
- : 0),
379
- yaw + (viewRef.current === 'book' ? -0.2 : 0),
380
- 0,
381
- );
382
- // Apply screen rotation after turning the device over so the cover remains upright.
383
- device.rotation.set(
384
- 0,
385
- renderedYaw,
386
- { portrait: 0, pud: Math.PI, 'landscape-left': Math.PI / 2, 'landscape-right': -Math.PI / 2 }[
387
- latest.current.state.orientation
388
- ] ?? 0,
389
- 'ZYX',
390
- );
391
- root.position.set(0, 0, 0);
392
- root.updateMatrixWorld(true);
393
- model.getBounds(bounds).getCenter(center);
394
- bounds.getSize(extent);
395
- root.position.copy(center).negate();
396
- // Parallel projection keeps the far leaf's buttons visible when the device is closed.
397
- const width = container.clientWidth;
398
- const height = container.clientHeight;
399
- const usableWidth = Math.max(0.4, 1 - 108 / width);
400
- const usableHeight = Math.min(0.8, Math.max(0.35, 1 - 108 / height));
401
- const halfHeight = Math.max(extent.y / (2 * usableHeight), extent.x / (2 * aspect * usableWidth));
402
- camera.top = THREE.MathUtils.damp(camera.top, halfHeight, 14, dt);
403
- if (Math.abs(camera.top - halfHeight) < 0.001) camera.top = halfHeight;
404
- camera.bottom = -camera.top;
405
- camera.left = -camera.top * aspect;
406
- camera.right = camera.top * aspect;
407
- camera.updateProjectionMatrix();
408
-
409
- const buttonsMoving = model.animateButtons(dt);
410
- renderer.render(scene, camera);
411
- model.getBounds(bounds);
412
- layout = hardwareLayout(model.buttonAnchors(), bounds, camera, width, height);
413
- for (const guide of layout.guides) {
414
- const element = hardwareNodes.current.get(guide.button);
415
- if (!element) continue;
416
- element.style.left = `${guide.x}px`;
417
- element.style.top = `${guide.y}px`;
418
- element.style.setProperty('--duo-hardware-icon-size', `${layout.iconSize}px`);
419
- element.dataset.visible = String(guide.edge === hoveredEdge);
420
- }
421
- if (
422
- motion.current ||
423
- renderedAngle !== angleRef.current ||
424
- renderedYaw !== baseYaw ||
425
- camera.top !== halfHeight ||
426
- buttonsMoving
427
- )
428
- invalidate();
429
- };
430
- const videoCallbacks = new Map<HTMLVideoElement, number>();
431
- for (const video of [props.outer, props.inner]) {
432
- const frame = () => {
433
- if (disposed) return;
434
- invalidate();
435
- videoCallbacks.set(video, video.requestVideoFrameCallback(frame));
436
- };
437
- if ('requestVideoFrameCallback' in video)
438
- videoCallbacks.set(video, video.requestVideoFrameCallback(frame));
439
- }
440
- const fallback =
441
- !('requestVideoFrameCallback' in props.outer) ? window.setInterval(invalidate, 1000 / 30) : undefined;
442
- const intersection = new IntersectionObserver(([entry]) => {
443
- visible = !!entry?.isIntersecting;
444
- invalidate();
445
- });
446
- intersection.observe(container);
447
- document.addEventListener('visibilitychange', invalidate);
448
- resize();
449
- return () => {
450
- disposed = true;
451
- invalidateFrame.current = () => undefined;
452
- intersection.disconnect();
453
- document.removeEventListener('visibilitychange', invalidate);
454
- for (const [video, id] of videoCallbacks) video.cancelVideoFrameCallback(id);
455
- clearInterval(fallback);
456
- up();
457
- buttons.release();
458
- cancelAnimationFrame(animation);
459
- observer.disconnect();
460
- window.removeEventListener('blur', blur);
461
- container.removeEventListener('pointerleave', leave);
462
- container.removeEventListener('pointerdown', down);
463
- container.removeEventListener('pointermove', move);
464
- container.removeEventListener('pointerup', up);
465
- container.removeEventListener('pointercancel', up);
466
- container.removeEventListener('lostpointercapture', up);
467
- container.removeEventListener('keydown', keyDown);
468
- container.removeEventListener('keyup', keyUp);
469
- container.removeEventListener('focusin', focus);
470
- container.removeEventListener('focusout', focusOut);
471
- renderer.dispose();
472
- renderer.forceContextLoss();
473
- model.dispose();
474
- outerTexture.dispose();
475
- innerTexture.dispose();
476
- environmentMap.dispose();
477
- renderer.domElement.remove();
478
- };
479
- }, [props.outer, props.inner, props.modelUrl]);
480
-
481
- return (
482
- <div className="rc-duo">
483
- <div
484
- ref={host}
485
- className="rc-duo-stage"
486
- data-position-locked={positionLocked}
487
- tabIndex={0}
488
- role="application"
489
- aria-label="iPhone Duo simulator. Touch the screen to control iOS. Unlock position to rotate the view."
490
- onKeyDown={props.onKeyDown}
491
- onKeyUp={props.onKeyUp}
492
- >
493
- {(Object.keys(DUO_BUTTONS) as DuoButton[]).map((button) => (
494
- <button
495
- key={button}
496
- ref={(element) => {
497
- if (element) hardwareNodes.current.set(button, element);
498
- else hardwareNodes.current.delete(button);
499
- }}
500
- type="button"
501
- className="rc-duo-hardware"
502
- data-duo-button={button}
503
- data-visible="false"
504
- aria-label={DUO_BUTTONS[button]}
505
- title={DUO_BUTTONS[button]}
506
- >
507
- <DuoHardwareIcon button={button} />
508
- </button>
509
- ))}
510
- </div>
511
- <DuoControls
512
- angle={angle}
513
- onFold={fold}
514
- foldTarget={target}
515
- onHingeDrag={cancelFold}
516
- changeAngle={changeAngle}
517
- interacting={interacting}
518
- rotate={() => {
519
- void props
520
- .setOrientation(props.state.orientation === 'portrait' ? 'landscape-left' : 'portrait')
521
- .catch((reason) => setError(String(reason)));
522
- }}
523
- >
524
- {(['front', 'book', 'table', 'back'] as const).map((value) => (
525
- <button
526
- key={value}
527
- aria-pressed={view === value}
528
- onClick={() => {
529
- viewRevision.current++;
530
- setView(value);
531
- invalidateFrame.current();
532
- if (value === 'book') {
533
- void props
534
- .setOrientation('portrait')
535
- .then(() => changeAngle(110))
536
- .catch((reason) => setError(String(reason)));
537
- }
538
- if (value === 'table') {
539
- void props
540
- .setOrientation('landscape-left')
541
- .then(() => changeAngle(110))
542
- .catch((reason) => setError(String(reason)));
543
- }
544
- }}
545
- >
546
- {value === 'table' ? 'Laptop view' : value[0]!.toUpperCase() + value.slice(1)}
547
- </button>
548
- ))}
549
- <button
550
- className="rc-duo-lock"
551
- aria-label="Lock position"
552
- aria-pressed={positionLocked}
553
- title={
554
- positionLocked ?
555
- 'Position is locked. Screen touches still control iOS.'
556
- : 'Drag the frame or background to rotate. Alt-drag rotates from the screen.'
557
- }
558
- onClick={() => setPositionLocked(!positionLocked)}
559
- >
560
- <svg width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
561
- <rect x="4" y="9" width="12" height="9" rx="2" stroke="currentColor" strokeWidth="1.6" />
562
- <path
563
- d={positionLocked ? 'M6 9V6a4 4 0 0 1 8 0v3' : 'M6 9V6a4 4 0 0 1 8 0'}
564
- stroke="currentColor"
565
- strokeWidth="1.6"
566
- strokeLinecap="round"
567
- />
568
- </svg>
569
- {positionLocked ? 'Position locked' : 'Lock position'}
570
- </button>
571
- </DuoControls>
572
- {error && (
573
- <div role="alert" className="rc-duo-error">
574
- {error}
575
- </div>
576
- )}
577
- </div>
578
- );
579
- }
@@ -1,61 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import * as THREE from 'three';
3
- import { createDuoModel } from './duo-model';
4
- import { hardwareHoverEdge, hardwareLayout } from './duo-hardware';
5
- import { hardwareIconSize } from './duo-flat-geometry';
6
-
7
- describe('Duo edge hints', () => {
8
- it.each([0, 110, 180])('keeps icon targets outside the screen at %s degrees', (angle) => {
9
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
10
- model.setAngle(angle);
11
- model.device.rotation.y = angle === 0 ? Math.PI / 2 : 0;
12
- model.device.updateMatrixWorld(true);
13
- const camera = new THREE.OrthographicCamera(-120, 120, 120, -120, 1, 1000);
14
- camera.position.z = 340;
15
- camera.updateMatrixWorld(true);
16
- const { guides, rect, iconSize } = hardwareLayout(
17
- model.buttonAnchors(),
18
- model.getBounds(new THREE.Box3()),
19
- camera,
20
- 500,
21
- 500,
22
- );
23
- expect(guides).toHaveLength(3);
24
- expect(iconSize).toBe(hardwareIconSize(rect));
25
- for (const guide of guides) {
26
- expect(rect.containsPoint(new THREE.Vector2(guide.x, guide.y))).toBe(false);
27
- expect(hardwareHoverEdge(guide.x, guide.y, rect)).toBe(guide.edge);
28
- }
29
- expect(guides.filter((g) => g.button !== 'side').every((g) => g.edge === 'top')).toBe(true);
30
- const volume = guides.filter((guide) => guide.button !== 'side');
31
- expect(Math.abs(volume[0]!.x - volume[1]!.x)).toBeGreaterThanOrEqual(48);
32
- model.dispose();
33
- });
34
-
35
- it('keeps the edge visible while crossing from the device to its icon', () => {
36
- const rect = new THREE.Box2(new THREE.Vector2(100, 100), new THREE.Vector2(400, 600));
37
- for (const y of [104, 100, 90, 80, 72]) expect(hardwareHoverEdge(250, y, rect)).toBe('top');
38
- expect(hardwareHoverEdge(250, 350, rect)).toBeUndefined();
39
- expect(hardwareHoverEdge(250, 40, rect)).toBeUndefined();
40
- expect(hardwareHoverEdge(428, 250, rect)).toBe('right');
41
- });
42
-
43
- it('moves hints with native screen orientation', () => {
44
- const model = createDuoModel(new THREE.Texture(), new THREE.Texture());
45
- model.device.rotation.z = Math.PI / 2;
46
- model.device.updateMatrixWorld(true);
47
- const camera = new THREE.OrthographicCamera(-120, 120, 120, -120, 1, 1000);
48
- camera.position.z = 340;
49
- camera.updateMatrixWorld(true);
50
- const { guides } = hardwareLayout(
51
- model.buttonAnchors(),
52
- model.getBounds(new THREE.Box3()),
53
- camera,
54
- 500,
55
- 500,
56
- );
57
- expect(guides.filter((g) => g.button !== 'side').every((g) => g.edge === 'left')).toBe(true);
58
- expect(guides.find((g) => g.button === 'side')?.edge).toBe('top');
59
- model.dispose();
60
- });
61
- });