Open History to see the last commit
1import { useEffect, useRef } from "react";
2import type { PhoneViewer } from "@t3tools/client-runtime/device/phone-viewer";
3import type {
4 DeviceAccessorySource,
5 DeviceModelSource,
6} from "@t3tools/client-runtime/device/model";
7import type { DeviceShapeProfile } from "@t3tools/client-runtime/device/shape-profile";
8import type { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";
9import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
10import { mountDeviceViewer } from "./mountDeviceViewer";
11
12const loadPhoneViewer = () => import("@t3tools/client-runtime/device/phone-viewer");
13
14export function DevicePhoneViewport(props: {
15 readonly profile: DeviceShapeProfile;
16 readonly model: DeviceModelSource | null;
17 readonly accessory: DeviceAccessorySource | null;
18 readonly source: HTMLCanvasElement;
19 readonly onFrameListener: (listener: (() => void) | null) => void;
20 readonly onResetReady: (reset: (() => void) | null) => void;
21 readonly onInputCancel: (cancel: (() => void) | null) => void;
22 readonly client: DeviceStreamClient;
23 readonly screen: DeviceScreenSize | null;
24 readonly foldAngle: number | null;
25 readonly onUnavailable: () => void;
26}) {
27 const viewerRef = useRef<PhoneViewer | null>(null);
28 const interactionRef = useRef<ReturnType<typeof createPhoneInteraction> | null>(null);
29 const screenRef = useRef(props.screen);
30 const foldAngleRef = useRef(props.foldAngle);
31 const profileRef = useRef(props.profile);
32 const modelRef = useRef(props.model);
33 const accessoryRef = useRef(props.accessory);
34 const { source, onFrameListener, client, onInputCancel, onResetReady, onUnavailable } = props;
35
36 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes the fold angle into the imperative 3D phone viewer
37 useEffect(() => {
38 foldAngleRef.current = props.foldAngle;
39 viewerRef.current?.setFoldAngle(props.foldAngle);
40 }, [props.foldAngle]);
41
42 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes screen, profile, model and accessory changes into the imperative 3D phone viewer
43 useEffect(() => {
44 screenRef.current = props.screen;
45 profileRef.current = props.profile;
46 modelRef.current = props.model;
47 accessoryRef.current = props.accessory;
48 interactionRef.current?.end();
49 viewerRef.current?.setModel(props.model);
50 viewerRef.current?.setAccessory(props.accessory);
51 viewerRef.current?.setScreen(props.screen, props.profile);
52 }, [props.screen, props.profile, props.model, props.accessory]);
53
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 loadPhoneViewer().then(
61 ({ createPhoneViewer }) =>
62 () =>
63 createPhoneViewer({
64 canvas,
65 source,
66 onUnavailable,
67 onModelError: (cause) => console.warn("Device 3D asset could not load", cause),
68 profile: profileRef.current,
69 model: modelRef.current,
70 accessory: accessoryRef.current,
71 foldAngle: foldAngleRef.current,
72 }),
73 ),
74 touch: (phase, point) => client.sendTouch(phase, point.x, point.y),
75 viewerRef,
76 interactionRef,
77 onInputCancel,
78 onResetReady,
79 onFrameListener,
80 onUnavailable,
81 ready: ({ viewer }) => {
82 viewer.setScreen(screenRef.current);
83 return { frame: viewer.frameUpdated };
84 },
85 });
86 };
87
88 const point = (event: React.PointerEvent<HTMLCanvasElement>) => {
89 const rect = event.currentTarget.getBoundingClientRect();
90 return {
91 x: (event.clientX - rect.left) / rect.width,
92 y: (event.clientY - rect.top) / rect.height,
93 };
94 };
95 return (
96 <div className="absolute inset-0">
97 <div className="absolute inset-0">
98 <div
99 aria-hidden
100 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"
101 />
102 <canvas
103 ref={mountViewer}
104 aria-label="Interactive 3D device. Drag the screen to interact. Drag outside it or swipe with two fingers to turn."
105 className="size-full touch-none"
106 onPointerDown={(event) => {
107 if (event.button !== 0) return;
108 if (!interactionRef.current?.begin(event.pointerId, point(event), event.altKey)) return;
109 event.preventDefault();
110 event.currentTarget.setPointerCapture(event.pointerId);
111 (event.currentTarget.closest('[role="application"]') as HTMLElement | null)?.focus();
112 }}
113 onPointerMove={(event) => interactionRef.current?.move(event.pointerId, point(event))}
114 onPointerUp={(event) => {
115 interactionRef.current?.move(event.pointerId, point(event));
116 interactionRef.current?.end(event.pointerId);
117 }}
118 onPointerCancel={(event) => interactionRef.current?.end(event.pointerId)}
119 onLostPointerCapture={(event) => interactionRef.current?.end(event.pointerId)}
120 />
121 </div>
122 </div>
123 );
124}