@limrun/ui 0.15.1 → 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 (66) hide show
  1. package/README.md +59 -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-framed.d.ts +2 -0
  17. package/dist/components/remote-control.d.ts +24 -1
  18. package/dist/core/duo.d.ts +63 -0
  19. package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
  20. package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
  21. package/dist/duo-frame-BzyYljix.mjs +26767 -0
  22. package/dist/duo-frame-DjfA4gG-.js +4059 -0
  23. package/dist/duo-view-COZg8ieG.js +1 -0
  24. package/dist/duo-view-o1yxIzxu.mjs +654 -0
  25. package/dist/frame-assets.d.ts +2 -0
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.css +1 -1
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +25 -2495
  30. package/dist/lite.cjs +1 -0
  31. package/dist/lite.d.ts +6 -0
  32. package/dist/lite.js +13 -0
  33. package/dist/remote-control-CQjSEVDF.mjs +2673 -0
  34. package/dist/remote-control-DVRXfCR2.js +1 -0
  35. package/package.json +13 -3
  36. package/src/components/duo-button-hinge.test.tsx +138 -0
  37. package/src/components/duo-button-hinge.ts +75 -0
  38. package/src/components/duo-controls.tsx +142 -0
  39. package/src/components/duo-flat-frame.tsx +145 -0
  40. package/src/components/duo-flat-geometry.test.ts +63 -0
  41. package/src/components/duo-flat-geometry.ts +97 -0
  42. package/src/components/duo-fold-motion.test.tsx +245 -0
  43. package/src/components/duo-fold-motion.tsx +192 -0
  44. package/src/components/duo-fold-renderer.test.ts +29 -0
  45. package/src/components/duo-fold-renderer.ts +397 -0
  46. package/src/components/duo-fold-timing.test.ts +44 -0
  47. package/src/components/duo-fold-timing.ts +9 -0
  48. package/src/components/duo-frame.tsx +579 -0
  49. package/src/components/duo-hardware.test.ts +61 -0
  50. package/src/components/duo-hardware.ts +61 -0
  51. package/src/components/duo-model.test.ts +243 -0
  52. package/src/components/duo-model.ts +607 -0
  53. package/src/components/duo-screen-handoff.ts +52 -0
  54. package/src/components/duo-view.test.tsx +104 -0
  55. package/src/components/duo-view.tsx +315 -0
  56. package/src/components/remote-control-framed.tsx +14 -0
  57. package/src/components/remote-control.css +310 -0
  58. package/src/components/remote-control.tsx +259 -59
  59. package/src/core/duo.test.ts +142 -0
  60. package/src/core/duo.ts +156 -0
  61. package/src/demo.tsx +16 -1
  62. package/src/frame-assets.ts +22 -0
  63. package/src/index.ts +5 -1
  64. package/src/lite.test.ts +30 -0
  65. package/src/lite.ts +30 -0
  66. package/vite.config.ts +5 -0
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ import { forwardRef } from 'react';
2
+ import {
3
+ RemoteControl as RemoteControlBase,
4
+ type RemoteControlHandle,
5
+ type RemoteControlProps,
6
+ } from './remote-control';
7
+ import { defaultRemoteControlAssets } from '../frame-assets';
8
+
9
+ // The default export: RemoteControl with Limrun's device frames unless the
10
+ // caller supplies its own assets.
11
+ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>((props, ref) => (
12
+ <RemoteControlBase ref={ref} assets={defaultRemoteControlAssets} {...props} />
13
+ ));
14
+ RemoteControl.displayName = 'RemoteControl';