@limrun/ui 0.16.0 → 0.17.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +49 -0
  2. package/dist/GLTFLoader-BKnsBFsj.js +1 -0
  3. package/dist/GLTFLoader-DW6wpvyR.mjs +1679 -0
  4. package/dist/components/duo-button-hinge.d.ts +16 -0
  5. package/dist/components/duo-controls.d.ts +21 -0
  6. package/dist/components/duo-flat-frame.d.ts +7 -0
  7. package/dist/components/duo-flat-geometry.d.ts +52 -0
  8. package/dist/components/duo-fold-motion.d.ts +27 -0
  9. package/dist/components/duo-fold-renderer.d.ts +19 -0
  10. package/dist/components/duo-fold-timing.d.ts +1 -0
  11. package/dist/components/duo-frame.d.ts +16 -0
  12. package/dist/components/duo-hardware.d.ts +20 -0
  13. package/dist/components/duo-model.d.ts +19 -0
  14. package/dist/components/duo-screen-handoff.d.ts +4 -0
  15. package/dist/components/duo-view.d.ts +4 -0
  16. package/dist/components/remote-control.d.ts +5 -1
  17. package/dist/core/duo.d.ts +63 -0
  18. package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
  19. package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
  20. package/dist/duo-frame-BzyYljix.mjs +26767 -0
  21. package/dist/duo-frame-DjfA4gG-.js +4059 -0
  22. package/dist/duo-view-COZg8ieG.js +1 -0
  23. package/dist/duo-view-o1yxIzxu.mjs +654 -0
  24. package/dist/index.cjs +1 -1
  25. package/dist/index.css +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/lite.cjs +1 -1
  28. package/dist/lite.js +1 -1
  29. package/dist/remote-control-CQjSEVDF.mjs +2673 -0
  30. package/dist/remote-control-DVRXfCR2.js +1 -0
  31. package/package.json +4 -2
  32. package/src/components/duo-button-hinge.test.tsx +138 -0
  33. package/src/components/duo-button-hinge.ts +75 -0
  34. package/src/components/duo-controls.tsx +142 -0
  35. package/src/components/duo-flat-frame.tsx +145 -0
  36. package/src/components/duo-flat-geometry.test.ts +63 -0
  37. package/src/components/duo-flat-geometry.ts +97 -0
  38. package/src/components/duo-fold-motion.test.tsx +245 -0
  39. package/src/components/duo-fold-motion.tsx +192 -0
  40. package/src/components/duo-fold-renderer.test.ts +29 -0
  41. package/src/components/duo-fold-renderer.ts +397 -0
  42. package/src/components/duo-fold-timing.test.ts +44 -0
  43. package/src/components/duo-fold-timing.ts +9 -0
  44. package/src/components/duo-frame.tsx +579 -0
  45. package/src/components/duo-hardware.test.ts +61 -0
  46. package/src/components/duo-hardware.ts +61 -0
  47. package/src/components/duo-model.test.ts +243 -0
  48. package/src/components/duo-model.ts +607 -0
  49. package/src/components/duo-screen-handoff.ts +52 -0
  50. package/src/components/duo-view.test.tsx +104 -0
  51. package/src/components/duo-view.tsx +315 -0
  52. package/src/components/remote-control.css +310 -0
  53. package/src/components/remote-control.tsx +150 -14
  54. package/src/core/duo.test.ts +142 -0
  55. package/src/core/duo.ts +156 -0
  56. package/src/demo.tsx +14 -0
  57. package/dist/remote-control-BRgbD7yo.mjs +0 -2516
  58. package/dist/remote-control-VISrP1zI.js +0 -1
@@ -0,0 +1,104 @@
1
+ import React, { act, useEffect } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { afterEach, expect, it, vi } from 'vitest';
4
+ import DuoView from './duo-view';
5
+ import type { DuoFrameProps } from './duo-frame';
6
+
7
+ const renderer = vi.hoisted(() => ({ loaded: vi.fn(), released: vi.fn() }));
8
+ vi.mock('./duo-frame', () => {
9
+ renderer.loaded();
10
+ return {
11
+ default: function Frame() {
12
+ useEffect(() => () => renderer.released(), []);
13
+ return <canvas />;
14
+ },
15
+ };
16
+ });
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ vi.unstubAllGlobals();
20
+ });
21
+
22
+ it('starts without a renderer, loads it on request and releases it when returning to native video', async () => {
23
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
24
+ vi.stubGlobal(
25
+ 'ResizeObserver',
26
+ class {
27
+ observe() {}
28
+ disconnect() {}
29
+ },
30
+ );
31
+ vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
32
+ vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
33
+ const outer = document.createElement('video');
34
+ const inner = document.createElement('video');
35
+ outer.srcObject = {} as MediaStream;
36
+ inner.srcObject = {} as MediaStream;
37
+ const props: DuoFrameProps = {
38
+ outer,
39
+ inner,
40
+ state: {
41
+ angleDegrees: 0,
42
+ minAngleDegrees: 0,
43
+ maxAngleDegrees: 180,
44
+ orientation: 'portrait',
45
+ displays: [
46
+ { id: 'outer', screenId: 1, trackId: 'outer', width: 1398, height: 2034, scale: 3, orientation: 1 },
47
+ { id: 'inner', screenId: 2, trackId: 'inner', width: 2034, height: 2912, scale: 3, orientation: 3 },
48
+ ],
49
+ },
50
+ setAngle: vi.fn().mockResolvedValue(undefined),
51
+ setOrientation: vi.fn().mockResolvedValue(undefined),
52
+ button: vi.fn().mockResolvedValue(undefined),
53
+ touch: vi.fn(),
54
+ onKeyDown: vi.fn(),
55
+ onKeyUp: vi.fn(),
56
+ };
57
+ const host = document.createElement('div');
58
+ const root = createRoot(host);
59
+ const clickMode = async (label: string) => {
60
+ await act(async () => {
61
+ [...host.querySelectorAll('.rc-duo-mode button')]
62
+ .find((b) => b.textContent === label)!
63
+ .dispatchEvent(new MouseEvent('click', { bubbles: true }));
64
+ });
65
+ };
66
+ try {
67
+ await act(async () => root.render(<DuoView {...props} />));
68
+ expect(renderer.loaded).not.toHaveBeenCalled();
69
+ expect(host.querySelector('canvas')).toBeNull();
70
+ expect(host.querySelector('.rc-duo-flat-frame')).not.toBeNull();
71
+ expect(host.querySelectorAll('.rc-duo-physical')).toHaveLength(3);
72
+ expect(host.querySelectorAll('.rc-duo-hardware')).toHaveLength(3);
73
+ expect(host.querySelector('[aria-label="Rotate device"]')).not.toBeNull();
74
+ expect(host.querySelector('input[type=range]')).not.toBeNull();
75
+ expect(host.querySelector('video')!.srcObject).toBe(outer.srcObject);
76
+ const press = async (selector: string, type: string) => {
77
+ await act(async () => {
78
+ host.querySelector(selector)!.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', bubbles: true }));
79
+ });
80
+ };
81
+ await press('.rc-duo-physical[data-duo-button=side]', 'keydown');
82
+ await press('.rc-duo-physical[data-duo-button=side]', 'keyup');
83
+ await press('.rc-duo-hardware[data-duo-button=volumeUp]', 'keydown');
84
+ await press('.rc-duo-hardware[data-duo-button=volumeUp]', 'keyup');
85
+ expect(props.button).toHaveBeenNthCalledWith(1, 'side', true);
86
+ expect(props.button).toHaveBeenNthCalledWith(2, 'side', false);
87
+ expect(props.button).toHaveBeenNthCalledWith(3, 'volumeUp', true);
88
+ expect(props.button).toHaveBeenNthCalledWith(4, 'volumeUp', false);
89
+ await press('.rc-duo-hardware[data-duo-button=volumeDown]', 'keydown');
90
+ await clickMode('3D');
91
+ expect(props.button).toHaveBeenLastCalledWith('volumeDown', false);
92
+ expect(renderer.loaded).toHaveBeenCalledOnce();
93
+ expect(host.querySelector('canvas')).not.toBeNull();
94
+ await clickMode('2D');
95
+ expect(renderer.released).toHaveBeenCalledOnce();
96
+ expect(host.querySelector('canvas')).toBeNull();
97
+ expect(host.querySelector('video')!.srcObject).toBe(outer.srcObject);
98
+ expect(props.setAngle).not.toHaveBeenCalled();
99
+ await act(async () => root.render(<DuoView {...props} state={{ ...props.state, angleDegrees: 180 }} />));
100
+ expect(host.querySelector('video')!.srcObject).toBe(inner.srcObject);
101
+ } finally {
102
+ await act(async () => root.unmount());
103
+ }
104
+ });
@@ -0,0 +1,315 @@
1
+ import React, { lazy, Suspense, useEffect, useRef, useState } from 'react';
2
+ import { DUO_BUTTONS, DuoButtonSender, flatDisplayTouch, type DuoButton } from '../core/duo';
3
+ import type { DuoFrameProps } from './duo-frame';
4
+ import { DuoControls, DuoHardwareIcon, useDuoHinge } from './duo-controls';
5
+ import { DuoFlatFrame } from './duo-flat-frame';
6
+ import { flatFrameGeometry, flatHoverEdge } from './duo-flat-geometry';
7
+ import { DuoFoldMotion, useDuoFoldMotion } from './duo-fold-motion';
8
+
9
+ const DuoFrame = lazy(() => import('./duo-frame'));
10
+
11
+ /** Native video is the default; only an explicit 3D selection loads the renderer and body asset. */
12
+ export default function DuoView(props: DuoFrameProps) {
13
+ const [mode, setMode] = useState<'2d' | '3d'>('2d');
14
+ return (
15
+ <div className="rc-duo-view">
16
+ {mode === '3d' ?
17
+ <Suspense fallback={<DuoFlat {...props} />}>
18
+ <DuoFrame {...props} />
19
+ </Suspense>
20
+ : <DuoFlat {...props} />}
21
+ <div className="rc-duo-mode" role="group" aria-label="Device rendering">
22
+ <button type="button" aria-pressed={mode === '2d'} onClick={() => setMode('2d')}>
23
+ 2D
24
+ </button>
25
+ <button type="button" aria-pressed={mode === '3d'} onClick={() => setMode('3d')}>
26
+ 3D
27
+ </button>
28
+ </div>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ function DuoFlat(props: DuoFrameProps) {
34
+ const stage = useRef<HTMLDivElement>(null);
35
+ const video = useRef<HTMLVideoElement>(null);
36
+ const latest = useRef(props);
37
+ latest.current = props;
38
+ const [size, setSize] = useState({ width: 0, height: 0 });
39
+ const [error, setError] = useState<string>();
40
+ const [edge, setEdge] = useState<ReturnType<typeof flatHoverEdge>>();
41
+ const [hovered, setHovered] = useState<DuoButton>();
42
+ const [pressed, setPressed] = useState<DuoButton>();
43
+ const held = useRef<{ pointer: number; screenId: number; x: number; y: number } | undefined>(undefined);
44
+ const hardware = useRef<DuoButtonSender | null>(null);
45
+ const display = props.state.displays.find(
46
+ (d) => d.id === (props.state.angleDegrees < 90 ? 'outer' : 'inner'),
47
+ );
48
+ const source = display?.id === 'inner' ? props.inner : props.outer;
49
+ const turns =
50
+ display?.orientation === 2 ? 2
51
+ : display?.orientation === 3 ? 1
52
+ : display?.orientation === 4 ? 3
53
+ : 0;
54
+ const inner = display?.id === 'inner';
55
+ const frame = flatFrameGeometry(inner, turns, size.width, size.height);
56
+ const { bodyWidth, bodyHeight, screenWidth, screenHeight, scale } = frame;
57
+ const width = screenWidth * scale;
58
+ const height = screenHeight * scale;
59
+
60
+ useEffect(() => {
61
+ const observer = new ResizeObserver(([entry]) => {
62
+ if (entry) setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
63
+ });
64
+ if (stage.current) observer.observe(stage.current);
65
+ return () => observer.disconnect();
66
+ }, []);
67
+
68
+ useEffect(() => {
69
+ const target = video.current;
70
+ if (!target || !source) return;
71
+ const sync = () => {
72
+ if (target.srcObject !== source.srcObject) target.srcObject = source.srcObject;
73
+ void target.play().catch(() => undefined);
74
+ props.outer?.pause();
75
+ props.inner?.pause();
76
+ };
77
+ sync();
78
+ source.addEventListener('loadedmetadata', sync);
79
+ return () => {
80
+ source.removeEventListener('loadedmetadata', sync);
81
+ target.pause();
82
+ target.srcObject = null;
83
+ for (const original of [props.outer, props.inner]) void original?.play().catch(() => undefined);
84
+ };
85
+ }, [source, props.outer, props.inner]);
86
+
87
+ const {
88
+ motion,
89
+ finish: finishMotion,
90
+ prepare,
91
+ } = useDuoFoldMotion(video, inner, frame, props.state.orientation);
92
+ const { angle, changeAngle, interacting } = useDuoHinge(
93
+ props.state.angleDegrees,
94
+ async (value) => {
95
+ prepare(value >= 90);
96
+ try {
97
+ await props.setAngle(value);
98
+ } catch (reason) {
99
+ finishMotion();
100
+ throw reason;
101
+ }
102
+ },
103
+ setError,
104
+ );
105
+
106
+ const releaseTouch = () => {
107
+ if (!held.current) return;
108
+ const { screenId, x, y } = held.current;
109
+ latest.current.touch(1, screenId, x, y);
110
+ held.current = undefined;
111
+ };
112
+ useEffect(() => {
113
+ hardware.current = new DuoButtonSender(
114
+ (button, down) => latest.current.button(button, down),
115
+ (e) => setError(String(e)),
116
+ );
117
+ const release = () => {
118
+ releaseTouch();
119
+ hardware.current?.release();
120
+ setPressed(undefined);
121
+ };
122
+ window.addEventListener('blur', release);
123
+ return () => {
124
+ release();
125
+ window.removeEventListener('blur', release);
126
+ };
127
+ }, []);
128
+ useEffect(() => {
129
+ releaseTouch();
130
+ }, [display?.screenId, turns]);
131
+
132
+ const releaseButton = () => {
133
+ hardware.current?.release();
134
+ setPressed(undefined);
135
+ };
136
+ const buttonEvents = (button: DuoButton): React.ButtonHTMLAttributes<HTMLButtonElement> => ({
137
+ onPointerDown: (event) => {
138
+ if (motion || event.button !== 0) return;
139
+ event.currentTarget.setPointerCapture(event.pointerId);
140
+ hardware.current?.press(button);
141
+ setPressed(button);
142
+ },
143
+ onPointerUp: releaseButton,
144
+ onPointerCancel: releaseButton,
145
+ onLostPointerCapture: releaseButton,
146
+ onBlur: releaseButton,
147
+ onPointerEnter: () => setHovered(button),
148
+ onPointerLeave: () => setHovered(undefined),
149
+ onFocus: () => setEdge(frame.guides.find((g) => g.button === button)?.edge),
150
+ onKeyDown: (event) => {
151
+ if (motion) return;
152
+ if (event.key === 'Enter' || event.key === ' ') {
153
+ event.preventDefault();
154
+ if (!event.repeat) {
155
+ hardware.current?.press(button);
156
+ setPressed(button);
157
+ }
158
+ }
159
+ },
160
+ onKeyUp: (event) => {
161
+ if (event.key === 'Enter' || event.key === ' ') {
162
+ event.preventDefault();
163
+ releaseButton();
164
+ }
165
+ },
166
+ });
167
+ const point = (event: React.PointerEvent<HTMLDivElement>) => {
168
+ const rect = event.currentTarget.getBoundingClientRect();
169
+ return flatDisplayTouch(
170
+ (event.clientX - rect.left) / rect.width,
171
+ (event.clientY - rect.top) / rect.height,
172
+ turns,
173
+ );
174
+ };
175
+ return (
176
+ <div className="rc-duo">
177
+ <div
178
+ ref={stage}
179
+ className="rc-duo-flat-stage"
180
+ data-folding={!!motion}
181
+ aria-busy={!!motion}
182
+ onPointerMove={(event) => {
183
+ const rect = event.currentTarget.getBoundingClientRect();
184
+ setEdge(flatHoverEdge(event.clientX - rect.left, event.clientY - rect.top, frame.rect));
185
+ }}
186
+ onPointerLeave={() => {
187
+ setEdge(undefined);
188
+ setHovered(undefined);
189
+ }}
190
+ >
191
+ <div
192
+ className="rc-duo-flat-device"
193
+ data-display={inner ? 'inner' : 'outer'}
194
+ style={{
195
+ width: bodyWidth * scale,
196
+ height: bodyHeight * scale,
197
+ transform: 'translate(-50%, -50%) rotate(' + frame.turns * 90 + 'deg)',
198
+ }}
199
+ >
200
+ <DuoFlatFrame inner={inner} width={bodyWidth} height={bodyHeight} />
201
+ <div
202
+ className="rc-duo-flat-screen"
203
+ role="application"
204
+ aria-label="iPhone Duo 2D simulator"
205
+ tabIndex={0}
206
+ style={{
207
+ width,
208
+ height,
209
+ left: ((bodyWidth - screenWidth) / 2) * scale,
210
+ top: ((bodyHeight - screenHeight) / 2) * scale,
211
+ borderRadius: inner ? 5.3 * scale : [0.8, 6.6, 6.6, 0.8].map((r) => r * scale + 'px').join(' '),
212
+ }}
213
+ onKeyDown={motion ? undefined : props.onKeyDown}
214
+ onKeyUp={props.onKeyUp}
215
+ onPointerDown={(event) => {
216
+ if (motion || held.current || event.button !== 0 || !display) return;
217
+ event.currentTarget.focus({ preventScroll: true });
218
+ event.currentTarget.setPointerCapture(event.pointerId);
219
+ const p = point(event);
220
+ held.current = { pointer: event.pointerId, screenId: display.screenId, ...p };
221
+ props.touch(0, display.screenId, p.x, p.y);
222
+ }}
223
+ onPointerMove={(event) => {
224
+ if (held.current?.pointer !== event.pointerId) return;
225
+ const p = point(event);
226
+ Object.assign(held.current, p);
227
+ props.touch(2, held.current.screenId, p.x, p.y);
228
+ }}
229
+ onPointerUp={(event) => {
230
+ if (held.current?.pointer === event.pointerId) releaseTouch();
231
+ }}
232
+ onPointerCancel={releaseTouch}
233
+ onLostPointerCapture={releaseTouch}
234
+ >
235
+ <video
236
+ ref={video}
237
+ autoPlay
238
+ muted
239
+ playsInline
240
+ style={{
241
+ width: inner ? height : width,
242
+ height: inner ? width : height,
243
+ transform: 'translate(-50%, -50%) rotate(' + (inner ? 90 : 0) + 'deg)',
244
+ }}
245
+ />
246
+ </div>
247
+ {frame.buttons.map((b) => (
248
+ <button
249
+ key={b.button}
250
+ type="button"
251
+ className="rc-duo-physical"
252
+ data-duo-button={b.button}
253
+ data-edge={b.edge}
254
+ data-highlighted={hovered === b.button}
255
+ data-pressed={pressed === b.button}
256
+ aria-label={DUO_BUTTONS[b.button] + ' physical button'}
257
+ title={DUO_BUTTONS[b.button]}
258
+ style={{
259
+ left: b.x * scale,
260
+ top: b.y * scale,
261
+ width: b.edge === 'top' ? Math.max(28, b.width * scale) : 28,
262
+ height: b.edge === 'top' ? 28 : Math.max(28, b.height * scale),
263
+ }}
264
+ {...buttonEvents(b.button)}
265
+ >
266
+ <span style={{ width: Math.max(2, b.width * scale), height: Math.max(2, b.height * scale) }} />
267
+ </button>
268
+ ))}
269
+ </div>
270
+ {frame.guides.map((g) => (
271
+ <button
272
+ key={g.button}
273
+ type="button"
274
+ className="rc-duo-hardware"
275
+ data-duo-button={g.button}
276
+ data-visible={edge === g.edge}
277
+ data-pressed={pressed === g.button}
278
+ aria-label={DUO_BUTTONS[g.button]}
279
+ title={DUO_BUTTONS[g.button]}
280
+ style={
281
+ {
282
+ left: g.targetX,
283
+ top: g.targetY,
284
+ '--duo-hardware-icon-size': `${frame.iconSize}px`,
285
+ '--duo-hardware-icon-x': `${g.x - g.targetX}px`,
286
+ '--duo-hardware-icon-y': `${g.y - g.targetY}px`,
287
+ } as React.CSSProperties
288
+ }
289
+ {...buttonEvents(g.button)}
290
+ >
291
+ <DuoHardwareIcon button={g.button} />
292
+ </button>
293
+ ))}
294
+ {motion && <DuoFoldMotion motion={motion} onFinish={finishMotion} />}
295
+ </div>
296
+ <DuoControls
297
+ angle={angle}
298
+ changeAngle={changeAngle}
299
+ interacting={interacting}
300
+ rotate={() => {
301
+ void props
302
+ .setOrientation(props.state.orientation === 'portrait' ? 'landscape-left' : 'portrait')
303
+ .catch((e) => setError(String(e)));
304
+ }}
305
+ >
306
+ <span className="rc-duo-view-label">Front view</span>
307
+ </DuoControls>
308
+ {error && (
309
+ <div role="alert" className="rc-duo-error">
310
+ {error}
311
+ </div>
312
+ )}
313
+ </div>
314
+ );
315
+ }