1- import { useEffect , useRef , type RefObject } from "react" ;
1+ import { useEffect , useRef } from "react" ;
22import type { DuoViewer } from "@t3tools/client-runtime/device/duo-viewer" ;
33import type { DeviceModelSource } from "@t3tools/client-runtime/device/model" ;
4- import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction" ;
4+ import type { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction" ;
55import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame" ;
66import type { DeviceScreenSize , DeviceStreamClient } from "@t3tools/client-runtime/device/stream" ;
77import { createDuoPinch } from "@t3tools/client-runtime/device/duo-control" ;
8- import { bindPhoneTrackpad } from "./phoneTrackpad" ;
9- import { observeResize } from "../../lib/observeResize" ;
8+ import { mountDeviceViewer } from "./mountDeviceViewer" ;
109
1110const loadDuoViewer = ( ) => import ( "@t3tools/client-runtime/device/duo-viewer" ) ;
1211
1312export function DeviceDuoViewport ( props : {
1413 readonly model : DeviceModelSource ;
15- readonly source : RefObject < HTMLCanvasElement | null > ;
14+ readonly source : HTMLCanvasElement ;
1615 readonly onFrameListener : ( listener : ( ( ) => void ) | null ) => void ;
1716 readonly onResetReady : ( reset : ( ( ) => void ) | null ) => void ;
1817 readonly onInputCancel : ( cancel : ( ( ) => void ) | null ) => void ;
19- readonly client : RefObject < DeviceStreamClient | null > ;
18+ readonly client : DeviceStreamClient ;
2019 readonly screen : DeviceScreenSize | null ;
2120 readonly hingePreview : number | null ;
22- readonly controlError : string | null ;
21+ readonly onControlRejectedListener : ( listener : ( ( ) => void ) | null ) => void ;
2322 readonly onUnavailable : ( ) => void ;
2423} ) {
25- const hostRef = useRef < HTMLDivElement | null > ( null ) ;
26- const canvasRef = useRef < HTMLCanvasElement | null > ( null ) ;
27- const viewerRef = useRef < DuoViewer | null > ( null ) ;
24+ const viewerRef = useRef <
25+ ( DuoViewer & { readonly sources : Record < 1 | 3 , HTMLCanvasElement > } ) | null
26+ > ( null ) ;
2827 const interactionRef = useRef < ReturnType < typeof createPhoneInteraction > | null > ( null ) ;
2928 const pinchRef = useRef < ReturnType < typeof createDuoPinch > | null > ( null ) ;
30- const trackpadRef = useRef < ReturnType < typeof bindPhoneTrackpad > | null > ( null ) ;
3129 const screenRef = useRef ( props . screen ) ;
3230 const modelRef = useRef ( props . model ) ;
3331 const previewRef = useRef ( props . hingePreview ) ;
34- const { source, client, onInputCancel, onResetReady, onUnavailable, onFrameListener } = props ;
32+ const {
33+ source,
34+ client,
35+ onInputCancel,
36+ onControlRejectedListener,
37+ onResetReady,
38+ onUnavailable,
39+ onFrameListener,
40+ } = props ;
3541
3642 useEffect ( ( ) => {
3743 screenRef . current = props . screen ;
@@ -40,84 +46,60 @@ export function DeviceDuoViewport(props: {
4046 viewerRef . current ?. setScreen ( props . screen ) ;
4147 } , [ props . screen , props . model ] ) ;
4248
43- useEffect ( ( ) => {
44- if ( props . controlError || ! props . screen ) {
45- trackpadRef . current ?. cancel ( ) ;
46- viewerRef . current ?. rejectOrientation ( ) ;
47- }
48- } , [ props . controlError , props . screen ] ) ;
49-
5049 useEffect ( ( ) => {
5150 previewRef . current = props . hingePreview ;
5251 if ( ! pinchRef . current ?. active ) viewerRef . current ?. setHingePreview ( props . hingePreview ) ;
5352 } , [ props . hingePreview ] ) ;
5453
55- useEffect ( ( ) => {
56- const host = hostRef . current ;
57- const canvas = canvasRef . current ;
58- const decoded = source . current ;
59- if ( ! host || ! canvas || ! decoded ) return ;
60- let disposed = false ;
61- let trackpad : ReturnType < typeof bindPhoneTrackpad > | null = null ;
62- let stopTrackpadEnd : ( ( ) => void ) | undefined ;
63- const resize = ( ) => {
64- const { width, height } = host . getBoundingClientRect ( ) ;
65- viewerRef . current ?. resize ( width , height , window . devicePixelRatio ) ;
66- } ;
67- const stopObserving = observeResize ( host , resize ) ;
68- const blur = ( ) => {
69- interactionRef . current ?. end ( ) ;
70- trackpad ?. cancel ( ) ;
71- viewerRef . current ?. cancelInput ( ) ;
72- } ;
73- onInputCancel ( blur ) ;
74- window . addEventListener ( "blur" , blur ) ;
75- void loadDuoViewer ( )
76- . then ( ( { createDuoViewer } ) => {
77- if ( disposed ) return ;
78- const sources = {
79- 1 : document . createElement ( "canvas" ) ,
80- 3 : document . createElement ( "canvas" ) ,
81- } ;
82- const viewer = createDuoViewer ( {
83- canvas,
84- sources,
85- onUnavailable,
86- onPanelRequested : ( panel ) =>
87- client . current ?. controlDuo ( {
88- control : "physical" ,
89- value : panel === 1 ? "facedown" : "faceup" ,
90- } ) ,
91- onOrientationRequested : ( value ) =>
92- client . current ?. controlDuo ( { control : "orientation" , value } ) ,
93- onModelError : ( cause ) => console . warn ( "Device 3D asset could not load" , cause ) ,
94- model : modelRef . current ,
54+ // The 3D viewer lives as long as its canvas; later prop changes are pushed into it above.
55+ const mountViewer = ( canvas : HTMLCanvasElement | null ) => {
56+ const host = canvas ?. parentElement ;
57+ if ( ! canvas || ! host ) return ;
58+ return mountDeviceViewer ( canvas , host , {
59+ load : ( ) =>
60+ loadDuoViewer ( ) . then ( ( { createDuoViewer } ) => ( ) => {
61+ const sources = {
62+ 1 : document . createElement ( "canvas" ) ,
63+ 3 : document . createElement ( "canvas" ) ,
64+ } ;
65+ const viewer = createDuoViewer ( {
66+ canvas,
67+ sources,
68+ onUnavailable,
69+ onPanelRequested : ( panel ) =>
70+ client . controlDuo ( {
71+ control : "physical" ,
72+ value : panel === 1 ? "facedown" : "faceup" ,
73+ } ) ,
74+ onOrientationRequested : ( value ) => client . controlDuo ( { control : "orientation" , value } ) ,
75+ onModelError : ( cause ) => console . warn ( "Device 3D asset could not load" , cause ) ,
76+ model : modelRef . current ,
77+ } ) ;
78+ return { ...viewer , sources } ;
79+ } ) ,
80+ touch : ( phase , point ) => client . sendRawTouch ( phase , point . x , point . y ) ,
81+ viewerRef,
82+ interactionRef,
83+ onInputCancel,
84+ onResetReady,
85+ onFrameListener,
86+ onUnavailable,
87+ cancelInput : ( viewer ) => viewer . cancelInput ( ) ,
88+ ready : ( { viewer, cancelTrackpad } ) => {
89+ onControlRejectedListener ( ( ) => {
90+ cancelTrackpad ( ) ;
91+ viewer . rejectOrientation ( ) ;
9592 } ) ;
96- viewerRef . current = viewer ;
97- onResetReady ( viewer . resetPose ) ;
9893 viewer . setScreen ( screenRef . current ) ;
99- const primaryFrame = ( ) => {
100- const panel = screenRef . current ?. screenId ;
101- if ( panel === 1 || panel === 3 ) viewer . frameUpdated ( panel , decoded ) ;
102- } ;
103- onFrameListener ( primaryFrame ) ;
104- primaryFrame ( ) ;
10594 viewer . setHingePreview ( previewRef . current ) ;
106- client . current ?. setDuoPanels ( {
95+ client . setDuoPanels ( {
10796 onScreen ( next ) {
10897 screenRef . current = next ;
10998 interactionRef . current ?. end ( ) ;
11099 viewer . setScreen ( next ) ;
111100 } ,
112- cover : createCanvasFrameSink ( sources [ 1 ] , ( ) => viewer . frameUpdated ( 1 ) ) ,
113- inner : createCanvasFrameSink ( sources [ 3 ] , ( ) => viewer . frameUpdated ( 3 ) ) ,
114- } ) ;
115- interactionRef . current = createPhoneInteraction ( {
116- screenPoint : ( point , captured ) => viewer . screenPoint ( point . x , point . y , captured ) ,
117- touch : ( phase , point ) => client . current ?. sendRawTouch ( phase , point . x , point . y ) ,
118- orbit : viewer . orbit ,
119- zoomBy : ( ) => { } ,
120- onInteractionActive : viewer . setInteractionActive ,
101+ cover : createCanvasFrameSink ( viewer . sources [ 1 ] , ( ) => viewer . frameUpdated ( 1 ) ) ,
102+ inner : createCanvasFrameSink ( viewer . sources [ 3 ] , ( ) => viewer . frameUpdated ( 3 ) ) ,
121103 } ) ;
122104 const pinch = createDuoPinch ( {
123105 angle : ( ) =>
@@ -127,43 +109,32 @@ export function DeviceDuoViewport(props: {
127109 contains : ( x , y ) => ! ! screenRef . current && viewer . beginHinge ( x , y ) ,
128110 change : ( angle ) => {
129111 viewer . setHingePreview ( angle ?? previewRef . current , true ) ;
130- if ( angle !== null ) client . current ?. controlDuo ( { control : "angle" , value : angle } ) ;
112+ if ( angle !== null ) client . controlDuo ( { control : "angle" , value : angle } ) ;
131113 } ,
132114 } ) ;
133115 pinchRef . current = pinch ;
134- trackpad = bindPhoneTrackpad ( canvas , interactionRef . current , {
135- begin ( x , y ) {
136- interactionRef . current ?. end ( ) ;
137- return pinch . begin ( x , y ) ;
116+ return {
117+ frame : ( ) => {
118+ const panel = screenRef . current ?. screenId ;
119+ if ( panel === 1 || panel === 3 ) viewer . frameUpdated ( panel , source ) ;
138120 } ,
139- move : pinch . move ,
140- end : pinch . end ,
141- } ) ;
142- trackpadRef . current = trackpad ;
143- stopTrackpadEnd = window . desktopBridge ?. onTrackpadScrollEnd ?.( ( ) => trackpad ?. endOrbit ( ) ) ;
144- resize ( ) ;
145- } )
146- . catch ( ( ) => {
147- if ( ! disposed ) onUnavailable ( ) ;
148- } ) ;
149- return ( ) => {
150- disposed = true ;
151- stopTrackpadEnd ?.( ) ;
152- trackpad ?. dispose ( ) ;
153- trackpadRef . current = null ;
154- pinchRef . current = null ;
155- interactionRef . current ?. end ( ) ;
156- interactionRef . current = null ;
157- onInputCancel ( null ) ;
158- onResetReady ( null ) ;
159- onFrameListener ( null ) ;
160- client . current ?. setDuoPanels ( null ) ;
161- stopObserving ( ) ;
162- window . removeEventListener ( "blur" , blur ) ;
163- viewerRef . current ?. dispose ( ) ;
164- viewerRef . current = null ;
165- } ;
166- } , [ onInputCancel , onResetReady , client , onUnavailable , source , onFrameListener ] ) ;
121+ pinch : {
122+ begin ( x , y ) {
123+ interactionRef . current ?. end ( ) ;
124+ return pinch . begin ( x , y ) ;
125+ } ,
126+ move : pinch . move ,
127+ end : pinch . end ,
128+ } ,
129+ dispose : ( ) => {
130+ pinchRef . current = null ;
131+ onControlRejectedListener ( null ) ;
132+ client . setDuoPanels ( null ) ;
133+ } ,
134+ } ;
135+ } ,
136+ } ) ;
137+ } ;
167138
168139 const point = ( event : React . PointerEvent < HTMLCanvasElement > ) => {
169140 const rect = event . currentTarget . getBoundingClientRect ( ) ;
@@ -174,13 +145,13 @@ export function DeviceDuoViewport(props: {
174145 } ;
175146 return (
176147 < div className = "absolute inset-0" >
177- < div ref = { hostRef } className = "absolute inset-0" >
148+ < div className = "absolute inset-0" >
178149 < div
179150 aria-hidden
180151 className = "pointer-events-none absolute bottom-[6%] left-1/2 h-5 w-2/5 -translate-x-1/2 rounded-full bg-foreground/10 blur-xl"
181152 />
182153 < canvas
183- ref = { canvasRef }
154+ ref = { mountViewer }
184155 aria-label = "Interactive 3D iPhone Duo. Drag the screen to interact. Drag outside it or swipe with two fingers to turn. Pinch over the device to open or close its hinge."
185156 className = "size-full touch-none"
186157 onPointerDown = { ( event ) => {
0 commit comments