@limrun/ui 0.17.0-rc.1 → 0.17.0-rc.3
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 +18 -3
- package/dist/{GLTFLoader-DW6wpvyR.mjs → GLTFLoader-B4nnixLv.mjs} +1 -1
- package/dist/{GLTFLoader-BKnsBFsj.js → GLTFLoader-GATLvS0U.js} +1 -1
- package/dist/components/duo-fold-motion.d.ts +1 -1
- package/dist/components/duo-view.d.ts +5 -1
- package/dist/{duo-frame-DjfA4gG-.js → duo-frame-CfBILL32.js} +2 -2
- package/dist/{duo-frame-BzyYljix.mjs → duo-frame-F3hwc09B.mjs} +406 -406
- package/dist/duo-view-BsdKpp8d.mjs +662 -0
- package/dist/duo-view-Cy8kBdg9.js +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +2 -2
- package/dist/lite.cjs +1 -1
- package/dist/lite.js +1 -1
- package/dist/{remote-control-CQjSEVDF.mjs → remote-control-BAmNHDdv.mjs} +109 -108
- package/dist/{remote-control-DVRXfCR2.js → remote-control-F3h8U5pg.js} +1 -1
- package/package.json +5 -1
- package/dist/demo.d.ts +0 -1
- package/dist/duo-view-COZg8ieG.js +0 -1
- package/dist/duo-view-o1yxIzxu.mjs +0 -654
- package/index.html +0 -199
- package/src/assets/Apple_logo_white.svg +0 -4
- package/src/assets/android_boot.gif +0 -0
- package/src/assets/android_boot.webp +0 -0
- package/src/assets/iphone16pro_black.webp +0 -0
- package/src/assets/iphone16pro_black_bg.webp +0 -0
- package/src/assets/iphone16pro_black_landscape.webp +0 -0
- package/src/assets/iphone16pro_black_landscape_bg.webp +0 -0
- package/src/assets/pixel9_black.webp +0 -0
- package/src/assets/pixel9_black_landscape.webp +0 -0
- package/src/assets/pixel_tablet_landscape.webp +0 -0
- package/src/assets/pixel_tablet_portrait.webp +0 -0
- package/src/components/device-install/index.ts +0 -1
- package/src/components/duo-button-hinge.test.tsx +0 -138
- package/src/components/duo-button-hinge.ts +0 -75
- package/src/components/duo-controls.tsx +0 -142
- package/src/components/duo-flat-frame.tsx +0 -145
- package/src/components/duo-flat-geometry.test.ts +0 -63
- package/src/components/duo-flat-geometry.ts +0 -97
- package/src/components/duo-fold-motion.test.tsx +0 -245
- package/src/components/duo-fold-motion.tsx +0 -192
- package/src/components/duo-fold-renderer.test.ts +0 -29
- package/src/components/duo-fold-renderer.ts +0 -397
- package/src/components/duo-fold-timing.test.ts +0 -44
- package/src/components/duo-fold-timing.ts +0 -9
- package/src/components/duo-frame.tsx +0 -579
- package/src/components/duo-hardware.test.ts +0 -61
- package/src/components/duo-hardware.ts +0 -61
- package/src/components/duo-model.test.ts +0 -243
- package/src/components/duo-model.ts +0 -607
- package/src/components/duo-screen-handoff.ts +0 -52
- package/src/components/duo-view.test.tsx +0 -104
- package/src/components/duo-view.tsx +0 -315
- package/src/components/inspect-overlay.css +0 -223
- package/src/components/inspect-overlay.tsx +0 -437
- package/src/components/remote-control-framed.tsx +0 -14
- package/src/components/remote-control.css +0 -441
- package/src/components/remote-control.test.ts +0 -34
- package/src/components/remote-control.tsx +0 -3989
- package/src/core/ax-fetcher.test.ts +0 -418
- package/src/core/ax-fetcher.ts +0 -387
- package/src/core/ax-tree.test.ts +0 -491
- package/src/core/ax-tree.ts +0 -416
- package/src/core/camera.test.ts +0 -155
- package/src/core/camera.ts +0 -133
- package/src/core/constants.ts +0 -344
- package/src/core/duo.test.ts +0 -142
- package/src/core/duo.ts +0 -156
- package/src/core/webrtc-messages.ts +0 -148
- package/src/demo.tsx +0 -283
- package/src/frame-assets.ts +0 -22
- package/src/hooks/index.ts +0 -1
- package/src/index.ts +0 -29
- package/src/lite.test.ts +0 -30
- package/src/lite.ts +0 -30
- package/src/vite-env.d.ts +0 -1
- package/tsconfig.json +0 -26
- package/tsconfig.node.json +0 -26
- package/vite.config.ts +0 -35
- package/vitest.config.ts +0 -24
package/index.html
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>RemoteControl Demo - @limrun/ui</title>
|
|
7
|
-
<style>
|
|
8
|
-
* {
|
|
9
|
-
margin: 0;
|
|
10
|
-
padding: 0;
|
|
11
|
-
box-sizing: border-box;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
body {
|
|
15
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
16
|
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
17
|
-
min-height: 100vh;
|
|
18
|
-
padding: 20px;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
#root {
|
|
22
|
-
display: flex;
|
|
23
|
-
flex-direction: column;
|
|
24
|
-
gap: 20px;
|
|
25
|
-
max-width: 1400px;
|
|
26
|
-
margin: 0 auto;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
.header {
|
|
30
|
-
text-align: center;
|
|
31
|
-
color: white;
|
|
32
|
-
padding: 20px;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
.header h1 {
|
|
36
|
-
font-size: 2.5rem;
|
|
37
|
-
margin-bottom: 10px;
|
|
38
|
-
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
.header p {
|
|
42
|
-
font-size: 1.1rem;
|
|
43
|
-
opacity: 0.9;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
.demo-container {
|
|
47
|
-
background: white;
|
|
48
|
-
border-radius: 20px;
|
|
49
|
-
padding: 30px;
|
|
50
|
-
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
.controls {
|
|
54
|
-
display: flex;
|
|
55
|
-
flex-direction: column;
|
|
56
|
-
gap: 15px;
|
|
57
|
-
margin-bottom: 30px;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
.control-group {
|
|
61
|
-
display: flex;
|
|
62
|
-
flex-direction: column;
|
|
63
|
-
gap: 8px;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
.control-group label {
|
|
67
|
-
font-weight: 600;
|
|
68
|
-
font-size: 0.9rem;
|
|
69
|
-
color: #374151;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
.control-group input,
|
|
73
|
-
.control-group select {
|
|
74
|
-
padding: 10px 15px;
|
|
75
|
-
border: 2px solid #e5e7eb;
|
|
76
|
-
border-radius: 8px;
|
|
77
|
-
font-size: 0.95rem;
|
|
78
|
-
transition: border-color 0.2s;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
.control-group input:focus,
|
|
82
|
-
.control-group select:focus {
|
|
83
|
-
outline: none;
|
|
84
|
-
border-color: #667eea;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
.button-group {
|
|
88
|
-
display: flex;
|
|
89
|
-
gap: 10px;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
button {
|
|
93
|
-
padding: 12px 24px;
|
|
94
|
-
border: none;
|
|
95
|
-
border-radius: 8px;
|
|
96
|
-
font-size: 1rem;
|
|
97
|
-
font-weight: 600;
|
|
98
|
-
cursor: pointer;
|
|
99
|
-
transition: all 0.2s;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
button.primary {
|
|
103
|
-
background: #667eea;
|
|
104
|
-
color: white;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
button.primary:hover {
|
|
108
|
-
background: #5568d3;
|
|
109
|
-
transform: translateY(-1px);
|
|
110
|
-
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
button.secondary {
|
|
114
|
-
background: #e5e7eb;
|
|
115
|
-
color: #374151;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
button.secondary:hover {
|
|
119
|
-
background: #d1d5db;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
button:disabled {
|
|
123
|
-
opacity: 0.5;
|
|
124
|
-
cursor: not-allowed;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
.device-preview {
|
|
128
|
-
display: flex;
|
|
129
|
-
gap: 30px;
|
|
130
|
-
justify-content: center;
|
|
131
|
-
flex-wrap: wrap;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
.preview-item {
|
|
135
|
-
flex: 1 1 420px;
|
|
136
|
-
max-width: 520px;
|
|
137
|
-
height: min(75vh, 900px);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
.preview-item h3 {
|
|
141
|
-
text-align: center;
|
|
142
|
-
margin-bottom: 15px;
|
|
143
|
-
font-size: 1.2rem;
|
|
144
|
-
color: #374151;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
.device-wrapper {
|
|
148
|
-
height: 100%;
|
|
149
|
-
width: 100%;
|
|
150
|
-
background: #f9fafb;
|
|
151
|
-
border-radius: 12px;
|
|
152
|
-
overflow: hidden;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
@media (max-width: 640px) {
|
|
156
|
-
body {
|
|
157
|
-
padding: 12px;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
.demo-container {
|
|
161
|
-
padding: 16px;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.button-group {
|
|
165
|
-
flex-direction: column;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
.preview-item {
|
|
169
|
-
max-width: 100%;
|
|
170
|
-
height: 70vh;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
.info-box {
|
|
175
|
-
background: #fef3c7;
|
|
176
|
-
border: 2px solid #fbbf24;
|
|
177
|
-
border-radius: 8px;
|
|
178
|
-
padding: 15px;
|
|
179
|
-
margin-bottom: 20px;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
.info-box h4 {
|
|
183
|
-
color: #92400e;
|
|
184
|
-
margin-bottom: 8px;
|
|
185
|
-
font-size: 0.95rem;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
.info-box p {
|
|
189
|
-
color: #78350f;
|
|
190
|
-
font-size: 0.9rem;
|
|
191
|
-
line-height: 1.5;
|
|
192
|
-
}
|
|
193
|
-
</style>
|
|
194
|
-
</head>
|
|
195
|
-
<body>
|
|
196
|
-
<div id="root"></div>
|
|
197
|
-
<script type="module" src="/src/demo.tsx"></script>
|
|
198
|
-
</body>
|
|
199
|
-
</html>
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="842.32007" height="1000.0001">
|
|
3
|
-
<path fill="#fff" d="M824.66636 779.30363c-15.12299 34.93724-33.02368 67.09674-53.7638 96.66374-28.27076 40.3074-51.4182 68.2078-69.25717 83.7012-27.65347 25.4313-57.2822 38.4556-89.00964 39.1963-22.77708 0-50.24539-6.4813-82.21973-19.629-32.07926-13.0861-61.55985-19.5673-88.51583-19.5673-28.27075 0-58.59083 6.4812-91.02193 19.5673-32.48053 13.1477-58.64639 19.9994-78.65196 20.6784-30.42501 1.29623-60.75123-12.0985-91.02193-40.2457-19.32039-16.8514-43.48632-45.7394-72.43607-86.6641-31.060778-43.7024-56.597041-94.37983-76.602609-152.15586C10.740416 658.44309 0 598.01283 0 539.50845c0-67.01648 14.481044-124.8172 43.486336-173.25401C66.28194 327.34823 96.60818 296.6578 134.5638 274.1276c37.95566-22.53016 78.96676-34.01129 123.1321-34.74585 24.16591 0 55.85633 7.47508 95.23784 22.166 39.27042 14.74029 64.48571 22.21538 75.54091 22.21538 8.26518 0 36.27668-8.7405 83.7629-26.16587 44.90607-16.16001 82.80614-22.85118 113.85458-20.21546 84.13326 6.78992 147.34122 39.95559 189.37699 99.70686-75.24463 45.59122-112.46573 109.4473-111.72502 191.36456.67899 63.8067 23.82643 116.90384 69.31888 159.06309 20.61664 19.56727 43.64066 34.69027 69.2571 45.4307-5.55531 16.11062-11.41933 31.54225-17.65372 46.35662zM631.70926 20.0057c0 50.01141-18.27108 96.70693-54.6897 139.92782-43.94932 51.38118-97.10817 81.07162-154.75459 76.38659-.73454-5.99983-1.16045-12.31444-1.16045-18.95003 0-48.01091 20.9006-99.39207 58.01678-141.40314 18.53027-21.27094 42.09746-38.95744 70.67685-53.0663C578.3158 9.00229 605.2903 1.31621 630.65988 0c.74076 6.68575 1.04938 13.37191 1.04938 20.00505z"/>
|
|
4
|
-
</svg>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import React, { act } from 'react';
|
|
2
|
-
import { createRoot } from 'react-dom/client';
|
|
3
|
-
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
4
|
-
import { DuoControls } from './duo-controls';
|
|
5
|
-
import { useDuoButtonHinge } from './duo-button-hinge';
|
|
6
|
-
|
|
7
|
-
let now = 0;
|
|
8
|
-
let nextId = 0;
|
|
9
|
-
const callbacks = new Map<number, FrameRequestCallback>();
|
|
10
|
-
const host = document.createElement('div');
|
|
11
|
-
let root: ReturnType<typeof createRoot>;
|
|
12
|
-
let hinge: ReturnType<typeof useDuoButtonHinge>;
|
|
13
|
-
const send = vi.fn<(angle: number) => Promise<void>>();
|
|
14
|
-
const failed = vi.fn();
|
|
15
|
-
function View({ nativeAngle = 0 }: { nativeAngle?: number }) {
|
|
16
|
-
hinge = useDuoButtonHinge(nativeAngle, send, failed);
|
|
17
|
-
return (
|
|
18
|
-
<DuoControls
|
|
19
|
-
angle={hinge.angle}
|
|
20
|
-
changeAngle={hinge.changeAngle}
|
|
21
|
-
interacting={hinge.interacting}
|
|
22
|
-
rotate={() => {}}
|
|
23
|
-
onFold={hinge.fold}
|
|
24
|
-
foldTarget={hinge.target}
|
|
25
|
-
onHingeDrag={hinge.cancelFold}
|
|
26
|
-
/>
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
beforeEach(() => {
|
|
30
|
-
now = nextId = 0;
|
|
31
|
-
callbacks.clear();
|
|
32
|
-
send.mockReset().mockResolvedValue(undefined);
|
|
33
|
-
failed.mockReset();
|
|
34
|
-
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
35
|
-
vi.spyOn(performance, 'now').mockImplementation(() => now);
|
|
36
|
-
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
37
|
-
callbacks.set(++nextId, callback);
|
|
38
|
-
return nextId;
|
|
39
|
-
});
|
|
40
|
-
vi.stubGlobal('cancelAnimationFrame', (id: number) => callbacks.delete(id));
|
|
41
|
-
root = createRoot(host);
|
|
42
|
-
});
|
|
43
|
-
afterEach(async () => {
|
|
44
|
-
await act(async () => root.unmount());
|
|
45
|
-
expect(callbacks.size).toBe(0);
|
|
46
|
-
vi.restoreAllMocks();
|
|
47
|
-
vi.unstubAllGlobals();
|
|
48
|
-
});
|
|
49
|
-
async function advance(duration: number) {
|
|
50
|
-
const until = now + duration;
|
|
51
|
-
while (now < until) {
|
|
52
|
-
now = Math.min(until, now + 1000 / 60);
|
|
53
|
-
const frame = [...callbacks.values()];
|
|
54
|
-
callbacks.clear();
|
|
55
|
-
await act(async () => frame.forEach((callback) => callback(now)));
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
async function clickFold() {
|
|
59
|
-
await act(async () => host.querySelector<HTMLButtonElement>('.rc-duo-fold')!.click());
|
|
60
|
-
}
|
|
61
|
-
for (const from of [0, 180]) {
|
|
62
|
-
it(`sends intermediate native angles from ${from} and the exact endpoint`, async () => {
|
|
63
|
-
await act(async () => root.render(<View nativeAngle={from} />));
|
|
64
|
-
await clickFold();
|
|
65
|
-
expect(send).not.toHaveBeenCalled();
|
|
66
|
-
expect(host.querySelector('.rc-duo-fold')!.textContent).toBe(from === 0 ? 'Fold' : 'Unfold');
|
|
67
|
-
await advance(2700);
|
|
68
|
-
const angles = send.mock.calls.map(([angle]) => angle);
|
|
69
|
-
expect(angles.length).toBeGreaterThan(15);
|
|
70
|
-
expect(angles.length).toBeLessThanOrEqual(77);
|
|
71
|
-
expect(angles[0]).toBeGreaterThan(0);
|
|
72
|
-
expect(angles[0]).toBeLessThan(180);
|
|
73
|
-
expect(angles.at(-1)).toBe(180 - from);
|
|
74
|
-
expect(
|
|
75
|
-
angles.every((angle, i) => i === 0 || (from === 0 ? angle >= angles[i - 1]! : angle <= angles[i - 1]!)),
|
|
76
|
-
).toBe(true);
|
|
77
|
-
expect(hinge.interacting.current).toBe(false);
|
|
78
|
-
expect(hinge.motion.current).toBeUndefined();
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
it('reverses from its current pose and lets a slider change take over', async () => {
|
|
82
|
-
await act(async () => root.render(<View />));
|
|
83
|
-
await clickFold();
|
|
84
|
-
await advance(200);
|
|
85
|
-
const previous = hinge.angle;
|
|
86
|
-
await clickFold();
|
|
87
|
-
expect(hinge.motion.current?.from).toBeCloseTo(previous, 8);
|
|
88
|
-
expect(hinge.target).toBe(0);
|
|
89
|
-
await advance(200);
|
|
90
|
-
expect(hinge.angle).toBeLessThan(previous);
|
|
91
|
-
await act(async () => hinge.changeAngle(110));
|
|
92
|
-
const count = send.mock.calls.length;
|
|
93
|
-
await advance(1500);
|
|
94
|
-
expect(send).toHaveBeenLastCalledWith(110);
|
|
95
|
-
expect(send).toHaveBeenCalledTimes(count);
|
|
96
|
-
});
|
|
97
|
-
it('coalesces a slow native connection without losing the endpoint', async () => {
|
|
98
|
-
let release!: () => void;
|
|
99
|
-
send.mockImplementationOnce(
|
|
100
|
-
() =>
|
|
101
|
-
new Promise<void>((resolve) => {
|
|
102
|
-
release = resolve;
|
|
103
|
-
}),
|
|
104
|
-
);
|
|
105
|
-
await act(async () => root.render(<View />));
|
|
106
|
-
await clickFold();
|
|
107
|
-
await advance(2700);
|
|
108
|
-
expect(send).toHaveBeenCalledTimes(1);
|
|
109
|
-
await act(async () => release());
|
|
110
|
-
expect(send).toHaveBeenCalledTimes(2);
|
|
111
|
-
expect(send).toHaveBeenLastCalledWith(180);
|
|
112
|
-
});
|
|
113
|
-
it('stops animation on a native failure and on unmount', async () => {
|
|
114
|
-
send.mockRejectedValueOnce(new Error('HID failed'));
|
|
115
|
-
await act(async () => root.render(<View />));
|
|
116
|
-
await clickFold();
|
|
117
|
-
await advance(100);
|
|
118
|
-
expect(hinge.motion.current).toBeUndefined();
|
|
119
|
-
expect(hinge.angle).toBe(0);
|
|
120
|
-
expect(failed).toHaveBeenLastCalledWith('Error: HID failed');
|
|
121
|
-
await clickFold();
|
|
122
|
-
await advance(100);
|
|
123
|
-
await act(async () => root.render(null));
|
|
124
|
-
const count = send.mock.calls.length;
|
|
125
|
-
await advance(2700);
|
|
126
|
-
expect(send).toHaveBeenCalledTimes(count);
|
|
127
|
-
});
|
|
128
|
-
it('keeps the default 2D button as one immediate endpoint command', async () => {
|
|
129
|
-
const changeAngle = vi.fn();
|
|
130
|
-
await act(async () =>
|
|
131
|
-
root.render(
|
|
132
|
-
<DuoControls angle={0} changeAngle={changeAngle} interacting={{ current: false }} rotate={() => {}} />,
|
|
133
|
-
),
|
|
134
|
-
);
|
|
135
|
-
await clickFold();
|
|
136
|
-
expect(changeAngle).toHaveBeenCalledExactlyOnceWith(180);
|
|
137
|
-
expect(callbacks.size).toBe(0);
|
|
138
|
-
});
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { useDuoHinge } from './duo-controls';
|
|
3
|
-
import { duoFoldAngle } from './duo-fold-timing';
|
|
4
|
-
|
|
5
|
-
type Fold = { from: number; target: number; startedAt: number };
|
|
6
|
-
|
|
7
|
-
/** Drive button folds through the slider's native angle queue. */
|
|
8
|
-
export function useDuoButtonHinge(
|
|
9
|
-
nativeAngle: number,
|
|
10
|
-
send: (angle: number) => Promise<void>,
|
|
11
|
-
failed: (error: string | undefined) => void,
|
|
12
|
-
) {
|
|
13
|
-
const motion = useRef<Fold | undefined>(undefined);
|
|
14
|
-
const request = useRef(0);
|
|
15
|
-
const [target, setTarget] = useState<number>();
|
|
16
|
-
const hinge = useDuoHinge(nativeAngle, send, (reason) => {
|
|
17
|
-
if (reason !== undefined) cancelFold();
|
|
18
|
-
failed(reason);
|
|
19
|
-
});
|
|
20
|
-
const latest = useRef(hinge);
|
|
21
|
-
latest.current = hinge;
|
|
22
|
-
const cancelFold = () => {
|
|
23
|
-
if (!motion.current) return;
|
|
24
|
-
cancelAnimationFrame(request.current);
|
|
25
|
-
motion.current = undefined;
|
|
26
|
-
hinge.interacting.current = false;
|
|
27
|
-
setTarget(undefined);
|
|
28
|
-
};
|
|
29
|
-
const fold = () => {
|
|
30
|
-
const now = performance.now();
|
|
31
|
-
const previous = motion.current;
|
|
32
|
-
const from =
|
|
33
|
-
previous ?
|
|
34
|
-
duoFoldAngle(previous.from, previous.target, (now - previous.startedAt) / 1000)
|
|
35
|
-
: hinge.angle;
|
|
36
|
-
const to = (previous?.target ?? hinge.angle) < 90 ? 180 : 0;
|
|
37
|
-
cancelFold();
|
|
38
|
-
const next = { from, target: to, startedAt: now };
|
|
39
|
-
motion.current = next;
|
|
40
|
-
hinge.interacting.current = true;
|
|
41
|
-
setTarget(to);
|
|
42
|
-
let lastSent = -Infinity;
|
|
43
|
-
const tick = (time: number) => {
|
|
44
|
-
if (motion.current !== next) return;
|
|
45
|
-
const angle = duoFoldAngle(from, to, (time - now) / 1000);
|
|
46
|
-
// Native HID updates are bounded; the 3D pose uses the same clock at display refresh rate.
|
|
47
|
-
if (time - lastSent >= 1000 / 30 || angle === to) {
|
|
48
|
-
lastSent = time;
|
|
49
|
-
latest.current.changeAngle(angle);
|
|
50
|
-
}
|
|
51
|
-
if (angle === to) cancelFold();
|
|
52
|
-
else request.current = requestAnimationFrame(tick);
|
|
53
|
-
};
|
|
54
|
-
request.current = requestAnimationFrame(tick);
|
|
55
|
-
};
|
|
56
|
-
useEffect(
|
|
57
|
-
() => () => {
|
|
58
|
-
cancelAnimationFrame(request.current);
|
|
59
|
-
motion.current = undefined;
|
|
60
|
-
latest.current.interacting.current = false;
|
|
61
|
-
},
|
|
62
|
-
[],
|
|
63
|
-
);
|
|
64
|
-
return {
|
|
65
|
-
...hinge,
|
|
66
|
-
motion,
|
|
67
|
-
target,
|
|
68
|
-
fold,
|
|
69
|
-
cancelFold,
|
|
70
|
-
changeAngle: (value: number) => {
|
|
71
|
-
cancelFold();
|
|
72
|
-
hinge.changeAngle(value);
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
2
|
-
import { HingeSender, type DuoButton } from '../core/duo';
|
|
3
|
-
|
|
4
|
-
export function useDuoHinge(
|
|
5
|
-
nativeAngle: number,
|
|
6
|
-
send: (angle: number) => Promise<void>,
|
|
7
|
-
failed: (error: string | undefined) => void,
|
|
8
|
-
) {
|
|
9
|
-
const [angle, setAngle] = useState(nativeAngle);
|
|
10
|
-
const latest = useRef({ nativeAngle, send, failed });
|
|
11
|
-
latest.current = { nativeAngle, send, failed };
|
|
12
|
-
const pending = useRef<number | undefined>(undefined);
|
|
13
|
-
const interacting = useRef(false);
|
|
14
|
-
const sender = useRef<HingeSender | null>(null);
|
|
15
|
-
useEffect(() => {
|
|
16
|
-
sender.current = new HingeSender(
|
|
17
|
-
async (value) => {
|
|
18
|
-
await latest.current.send(value);
|
|
19
|
-
if (pending.current === value) pending.current = undefined;
|
|
20
|
-
},
|
|
21
|
-
(error) => {
|
|
22
|
-
pending.current = undefined;
|
|
23
|
-
latest.current.failed(String(error));
|
|
24
|
-
setAngle(latest.current.nativeAngle);
|
|
25
|
-
},
|
|
26
|
-
);
|
|
27
|
-
return () => sender.current?.stop();
|
|
28
|
-
}, []);
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
if (!interacting.current && pending.current === undefined) setAngle(nativeAngle);
|
|
31
|
-
}, [nativeAngle]);
|
|
32
|
-
const changeAngle = (value: number) => {
|
|
33
|
-
latest.current.failed(undefined);
|
|
34
|
-
pending.current = value;
|
|
35
|
-
setAngle(value);
|
|
36
|
-
sender.current?.set(value);
|
|
37
|
-
};
|
|
38
|
-
return { angle, changeAngle, interacting };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Both renderers use the same native fold and rotation controls. */
|
|
42
|
-
export function DuoControls({
|
|
43
|
-
angle,
|
|
44
|
-
changeAngle,
|
|
45
|
-
interacting,
|
|
46
|
-
rotate,
|
|
47
|
-
onFold,
|
|
48
|
-
foldTarget,
|
|
49
|
-
onHingeDrag,
|
|
50
|
-
children,
|
|
51
|
-
}: {
|
|
52
|
-
angle: number;
|
|
53
|
-
changeAngle: (value: number) => void;
|
|
54
|
-
interacting: React.MutableRefObject<boolean>;
|
|
55
|
-
rotate: () => void;
|
|
56
|
-
onFold?: () => void;
|
|
57
|
-
foldTarget?: number;
|
|
58
|
-
onHingeDrag?: () => void;
|
|
59
|
-
children?: React.ReactNode;
|
|
60
|
-
}) {
|
|
61
|
-
const id = useId();
|
|
62
|
-
return (
|
|
63
|
-
<div className="rc-duo-controls" onPointerDown={(event) => event.stopPropagation()}>
|
|
64
|
-
<div className="rc-duo-hinge">
|
|
65
|
-
<button className="rc-duo-fold" onClick={onFold ?? (() => changeAngle(angle < 90 ? 180 : 0))}>
|
|
66
|
-
{(foldTarget ?? angle) < 90 ? 'Unfold' : 'Fold'}
|
|
67
|
-
</button>
|
|
68
|
-
<label htmlFor={id}>Hinge</label>
|
|
69
|
-
<input
|
|
70
|
-
id={id}
|
|
71
|
-
type="range"
|
|
72
|
-
min={0}
|
|
73
|
-
max={180}
|
|
74
|
-
step={1}
|
|
75
|
-
value={angle}
|
|
76
|
-
onPointerDown={(event) => {
|
|
77
|
-
onHingeDrag?.();
|
|
78
|
-
interacting.current = true;
|
|
79
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
80
|
-
}}
|
|
81
|
-
onPointerUp={() => {
|
|
82
|
-
interacting.current = false;
|
|
83
|
-
}}
|
|
84
|
-
onPointerCancel={() => {
|
|
85
|
-
interacting.current = false;
|
|
86
|
-
}}
|
|
87
|
-
onLostPointerCapture={() => {
|
|
88
|
-
interacting.current = false;
|
|
89
|
-
}}
|
|
90
|
-
onChange={(event) => changeAngle(Number(event.target.value))}
|
|
91
|
-
/>
|
|
92
|
-
<output>{Math.round(angle)}°</output>
|
|
93
|
-
<button className="rc-duo-rotate" aria-label="Rotate device" title="Rotate device" onClick={rotate}>
|
|
94
|
-
<svg
|
|
95
|
-
viewBox="0 0 24 24"
|
|
96
|
-
width="18"
|
|
97
|
-
height="18"
|
|
98
|
-
fill="none"
|
|
99
|
-
stroke="currentColor"
|
|
100
|
-
strokeWidth="1.6"
|
|
101
|
-
aria-hidden="true"
|
|
102
|
-
>
|
|
103
|
-
<rect x="7" y="3" width="10" height="18" rx="2" />
|
|
104
|
-
<path d="M3 9a9 9 0 0 1 3-5M3 4v5h5M21 15a9 9 0 0 1-3 5m3 0v-5h-5" />
|
|
105
|
-
</svg>
|
|
106
|
-
</button>
|
|
107
|
-
</div>
|
|
108
|
-
{children && (
|
|
109
|
-
<div className="rc-duo-views" aria-label="Device view">
|
|
110
|
-
{children}
|
|
111
|
-
</div>
|
|
112
|
-
)}
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function DuoHardwareIcon({ button }: { button: DuoButton }) {
|
|
118
|
-
return (
|
|
119
|
-
<svg
|
|
120
|
-
viewBox="0 0 24 24"
|
|
121
|
-
width="24"
|
|
122
|
-
height="24"
|
|
123
|
-
fill="none"
|
|
124
|
-
stroke="currentColor"
|
|
125
|
-
strokeWidth="1.7"
|
|
126
|
-
strokeLinecap="round"
|
|
127
|
-
strokeLinejoin="round"
|
|
128
|
-
aria-hidden="true"
|
|
129
|
-
>
|
|
130
|
-
{button === 'side' ?
|
|
131
|
-
<>
|
|
132
|
-
<rect x="6" y="10" width="12" height="11" rx="2" />
|
|
133
|
-
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
|
134
|
-
</>
|
|
135
|
-
: <>
|
|
136
|
-
<path d="M3 9h4l5-5v16l-5-5H3zM17 12h5" />
|
|
137
|
-
{button === 'volumeUp' && <path d="M19.5 9.5v5" />}
|
|
138
|
-
</>
|
|
139
|
-
}
|
|
140
|
-
</svg>
|
|
141
|
-
);
|
|
142
|
-
}
|