react-native-model-viewer-webview 0.1.0

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.
@@ -0,0 +1,103 @@
1
+ import { useMemo } from "react";
2
+
3
+ import { WebView } from "react-native-webview";
4
+ import type { WebViewMessageEvent, WebViewProps } from "react-native-webview";
5
+
6
+ import {
7
+ buildModelViewerHtml,
8
+ isModelViewerErrorStatus,
9
+ MODEL_VIEWER_LOADED_EVENT,
10
+ parseModelViewerMessage,
11
+ } from "./model-viewer-html";
12
+ import type { ModelViewerHtmlOptions, ModelViewerStatus } from "./model-viewer-html";
13
+ import { resolveModelSourceUri } from "./model-source";
14
+ import type { ModelSource } from "./model-source";
15
+
16
+ export type ModelViewerWebViewProps = Omit<WebViewProps, "onMessage" | "source"> & {
17
+ htmlOptions?: Omit<ModelViewerHtmlOptions, "modelUri">;
18
+ modelSource?: ModelSource;
19
+ modelUri?: string;
20
+ onModelError?: (status: ModelViewerStatus, event: WebViewMessageEvent) => void;
21
+ onModelLoaded?: (status: ModelViewerStatus, event: WebViewMessageEvent) => void;
22
+ onStatus?: (status: ModelViewerStatus, event: WebViewMessageEvent) => void;
23
+ webViewBaseUrl?: string;
24
+ };
25
+
26
+ export function ModelViewerWebView({
27
+ allowFileAccess = true,
28
+ allowFileAccessFromFileURLs = true,
29
+ allowUniversalAccessFromFileURLs = true,
30
+ androidLayerType = "hardware",
31
+ bounces = false,
32
+ domStorageEnabled = true,
33
+ htmlOptions,
34
+ javaScriptEnabled = true,
35
+ mixedContentMode = "always",
36
+ modelSource,
37
+ modelUri,
38
+ onModelError,
39
+ onModelLoaded,
40
+ onStatus,
41
+ originWhitelist = ["*"],
42
+ scrollEnabled = false,
43
+ setSupportMultipleWindows = false,
44
+ webViewBaseUrl = "https://localhost/",
45
+ ...webViewProps
46
+ }: ModelViewerWebViewProps) {
47
+ const resolvedModelUri = useMemo(() => {
48
+ if (modelSource !== undefined) {
49
+ return resolveModelSourceUri(modelSource);
50
+ }
51
+
52
+ if (modelUri !== undefined) {
53
+ return modelUri;
54
+ }
55
+
56
+ throw new Error("ModelViewerWebView requires modelSource or modelUri.");
57
+ }, [modelSource, modelUri]);
58
+ const html = useMemo(
59
+ () =>
60
+ buildModelViewerHtml({
61
+ ...htmlOptions,
62
+ modelUri: resolvedModelUri,
63
+ }),
64
+ [htmlOptions, resolvedModelUri],
65
+ );
66
+
67
+ function handleMessage(event: WebViewMessageEvent) {
68
+ const status = parseModelViewerMessage(event.nativeEvent.data);
69
+
70
+ onStatus?.(status, event);
71
+
72
+ if (status.type === MODEL_VIEWER_LOADED_EVENT) {
73
+ onModelLoaded?.(status, event);
74
+ return;
75
+ }
76
+
77
+ if (isModelViewerErrorStatus(status)) {
78
+ onModelError?.(status, event);
79
+ }
80
+ }
81
+
82
+ return (
83
+ <WebView
84
+ allowFileAccess={allowFileAccess}
85
+ allowFileAccessFromFileURLs={allowFileAccessFromFileURLs}
86
+ allowUniversalAccessFromFileURLs={allowUniversalAccessFromFileURLs}
87
+ androidLayerType={androidLayerType}
88
+ bounces={bounces}
89
+ domStorageEnabled={domStorageEnabled}
90
+ javaScriptEnabled={javaScriptEnabled}
91
+ mixedContentMode={mixedContentMode}
92
+ onMessage={handleMessage}
93
+ originWhitelist={originWhitelist}
94
+ scrollEnabled={scrollEnabled}
95
+ setSupportMultipleWindows={setSupportMultipleWindows}
96
+ source={{
97
+ baseUrl: webViewBaseUrl,
98
+ html,
99
+ }}
100
+ {...webViewProps}
101
+ />
102
+ );
103
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export {
2
+ ModelViewerWebView,
3
+ type ModelViewerWebViewProps,
4
+ } from "./ModelViewerWebView";
5
+ export {
6
+ buildModelViewerHtml,
7
+ DEFAULT_MODEL_VIEWER_SCRIPT_URL,
8
+ isModelViewerErrorStatus,
9
+ MODEL_VIEWER_DOM_READY_EVENT,
10
+ MODEL_VIEWER_LOADED_EVENT,
11
+ MODEL_VIEWER_MODEL_ERROR_EVENT,
12
+ MODEL_VIEWER_PAGE_ERROR_EVENT,
13
+ parseModelViewerMessage,
14
+ type ModelViewerHtmlOptions,
15
+ type ModelViewerStatus,
16
+ } from "./model-viewer-html";
17
+ export {
18
+ resolveModelSourceUri,
19
+ type ModelSource,
20
+ } from "./model-source";
@@ -0,0 +1,39 @@
1
+ import { Image } from "react-native";
2
+ import type { ImageSourcePropType } from "react-native";
3
+
4
+ export type ModelSource =
5
+ | string
6
+ | number
7
+ | {
8
+ localUri?: null | string;
9
+ uri?: null | string;
10
+ };
11
+
12
+ export function resolveModelSourceUri(modelSource: ModelSource): string {
13
+ if (typeof modelSource === "string") {
14
+ return modelSource;
15
+ }
16
+
17
+ if (
18
+ typeof modelSource === "object" &&
19
+ modelSource.localUri &&
20
+ modelSource.localUri.length > 0
21
+ ) {
22
+ return modelSource.localUri;
23
+ }
24
+
25
+ if (
26
+ typeof modelSource === "object" &&
27
+ modelSource.uri &&
28
+ modelSource.uri.length > 0
29
+ ) {
30
+ return modelSource.uri;
31
+ }
32
+
33
+ const resolved = Image.resolveAssetSource(modelSource as ImageSourcePropType);
34
+ if (resolved?.uri) {
35
+ return resolved.uri;
36
+ }
37
+
38
+ throw new Error("Unable to resolve model source URI.");
39
+ }
@@ -0,0 +1,207 @@
1
+ export const DEFAULT_MODEL_VIEWER_SCRIPT_URL =
2
+ "https://ajax.googleapis.com/ajax/libs/model-viewer/4.2.0/model-viewer.min.js";
3
+
4
+ export const MODEL_VIEWER_DOM_READY_EVENT = "dom-ready";
5
+ export const MODEL_VIEWER_LOADED_EVENT = "model-loaded";
6
+ export const MODEL_VIEWER_MODEL_ERROR_EVENT = "model-error";
7
+ export const MODEL_VIEWER_PAGE_ERROR_EVENT = "page-error";
8
+
9
+ export type ModelViewerStatus = {
10
+ message?: string;
11
+ rawData?: string;
12
+ type: string;
13
+ };
14
+
15
+ export type ModelViewerHtmlOptions = {
16
+ additionalAttributes?: Record<string, boolean | number | string | null | undefined>;
17
+ autoRotate?: boolean;
18
+ autoRotateDelay?: number | string;
19
+ backgroundColor?: string;
20
+ cameraControls?: boolean;
21
+ cameraOrbit?: string;
22
+ disablePan?: boolean;
23
+ exposure?: number | string;
24
+ interactionPrompt?: "auto" | "none";
25
+ maxCameraOrbit?: string;
26
+ minCameraOrbit?: string;
27
+ modelUri: string;
28
+ modelViewerScript?: string;
29
+ modelViewerScriptUrl?: string;
30
+ posterColor?: string;
31
+ rotationPerSecond?: string;
32
+ shadowIntensity?: number | string;
33
+ };
34
+
35
+ export function buildModelViewerHtml(options: ModelViewerHtmlOptions) {
36
+ const backgroundColor = sanitizeCssColor(options.backgroundColor ?? "#ffffff");
37
+ const posterColor = sanitizeCssColor(options.posterColor ?? backgroundColor);
38
+ const modelViewerScriptTag = getModelViewerScriptTag(options);
39
+ const attributes = [
40
+ htmlAttribute("src", options.modelUri),
41
+ htmlAttribute("camera-controls", options.cameraControls ?? true),
42
+ htmlAttribute("auto-rotate", options.autoRotate ?? false),
43
+ htmlAttribute("auto-rotate-delay", options.autoRotateDelay),
44
+ htmlAttribute("rotation-per-second", options.rotationPerSecond),
45
+ htmlAttribute("interaction-prompt", options.interactionPrompt ?? "none"),
46
+ htmlAttribute("camera-orbit", options.cameraOrbit),
47
+ htmlAttribute("min-camera-orbit", options.minCameraOrbit),
48
+ htmlAttribute("max-camera-orbit", options.maxCameraOrbit),
49
+ htmlAttribute("shadow-intensity", options.shadowIntensity),
50
+ htmlAttribute("exposure", options.exposure),
51
+ htmlAttribute("disable-pan", options.disablePan ?? false),
52
+ ...Object.entries(options.additionalAttributes ?? {}).map(([name, value]) =>
53
+ htmlAttribute(name, value),
54
+ ),
55
+ ].join("");
56
+
57
+ return `<!doctype html>
58
+ <html>
59
+ <head>
60
+ <meta charset="utf-8" />
61
+ <meta
62
+ name="viewport"
63
+ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
64
+ />
65
+ ${modelViewerScriptTag}
66
+ <script>
67
+ function postStatus(type, message) {
68
+ window.ReactNativeWebView &&
69
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type, message }));
70
+ }
71
+
72
+ window.addEventListener("error", function (event) {
73
+ postStatus("${MODEL_VIEWER_PAGE_ERROR_EVENT}", event.message || "Page error");
74
+ });
75
+
76
+ window.addEventListener("unhandledrejection", function (event) {
77
+ postStatus("${MODEL_VIEWER_PAGE_ERROR_EVENT}", String(event.reason || "Unhandled rejection"));
78
+ });
79
+
80
+ window.addEventListener("DOMContentLoaded", function () {
81
+ var viewer = document.querySelector("model-viewer");
82
+ postStatus("${MODEL_VIEWER_DOM_READY_EVENT}", "Model viewer DOM ready");
83
+
84
+ if (!viewer) {
85
+ postStatus("${MODEL_VIEWER_MODEL_ERROR_EVENT}", "model-viewer element was not created");
86
+ return;
87
+ }
88
+
89
+ viewer.addEventListener("load", function () {
90
+ postStatus("${MODEL_VIEWER_LOADED_EVENT}", "3D model loaded");
91
+ });
92
+
93
+ viewer.addEventListener("error", function (event) {
94
+ var detail = event.detail || {};
95
+ var sourceError = detail.sourceError || {};
96
+ postStatus(
97
+ "${MODEL_VIEWER_MODEL_ERROR_EVENT}",
98
+ sourceError.message || detail.message || "3D model failed to load"
99
+ );
100
+ });
101
+ });
102
+ </script>
103
+ <style>
104
+ html,
105
+ body {
106
+ background: ${backgroundColor};
107
+ height: 100%;
108
+ margin: 0;
109
+ overflow: hidden;
110
+ width: 100%;
111
+ }
112
+
113
+ model-viewer {
114
+ --poster-color: ${posterColor};
115
+ background: ${backgroundColor};
116
+ height: 100%;
117
+ width: 100%;
118
+ }
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <model-viewer${attributes}></model-viewer>
123
+ </body>
124
+ </html>`;
125
+ }
126
+
127
+ export function parseModelViewerMessage(data: string): ModelViewerStatus {
128
+ try {
129
+ const parsed = JSON.parse(data) as { message?: unknown; type?: unknown };
130
+
131
+ if (parsed && typeof parsed.type === "string") {
132
+ return {
133
+ message: typeof parsed.message === "string" ? parsed.message : undefined,
134
+ rawData: data,
135
+ type: parsed.type,
136
+ };
137
+ }
138
+ } catch {
139
+ return {
140
+ message: data,
141
+ rawData: data,
142
+ type: MODEL_VIEWER_PAGE_ERROR_EVENT,
143
+ };
144
+ }
145
+
146
+ return {
147
+ message: data,
148
+ rawData: data,
149
+ type: MODEL_VIEWER_PAGE_ERROR_EVENT,
150
+ };
151
+ }
152
+
153
+ export function isModelViewerErrorStatus(status: ModelViewerStatus) {
154
+ return status.type.endsWith("error");
155
+ }
156
+
157
+ function getModelViewerScriptTag(options: ModelViewerHtmlOptions) {
158
+ if (options.modelViewerScript) {
159
+ return `<script type="module">${escapeScriptContent(options.modelViewerScript)}</script>`;
160
+ }
161
+
162
+ const scriptUrl = options.modelViewerScriptUrl ?? DEFAULT_MODEL_VIEWER_SCRIPT_URL;
163
+ return `<script type="module" src="${escapeHtmlAttribute(scriptUrl)}"></script>`;
164
+ }
165
+
166
+ function htmlAttribute(
167
+ name: string,
168
+ value: boolean | number | string | null | undefined,
169
+ ) {
170
+ if (!isSafeAttributeName(name)) {
171
+ return "";
172
+ }
173
+
174
+ if (value === false || value === null || value === undefined) {
175
+ return "";
176
+ }
177
+
178
+ if (value === true) {
179
+ return ` ${name}`;
180
+ }
181
+
182
+ return ` ${name}="${escapeHtmlAttribute(String(value))}"`;
183
+ }
184
+
185
+ function isSafeAttributeName(name: string) {
186
+ return /^[a-zA-Z][\w:.-]*$/.test(name);
187
+ }
188
+
189
+ function escapeHtmlAttribute(value: string) {
190
+ return value
191
+ .replace(/&/g, "&amp;")
192
+ .replace(/"/g, "&quot;")
193
+ .replace(/</g, "&lt;")
194
+ .replace(/>/g, "&gt;");
195
+ }
196
+
197
+ function escapeScriptContent(value: string) {
198
+ return value.replace(/<\/script/gi, "<\\/script");
199
+ }
200
+
201
+ function sanitizeCssColor(value: string) {
202
+ if (/^[#(),.%\w\s-]+$/.test(value)) {
203
+ return value;
204
+ }
205
+
206
+ return "#ffffff";
207
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "declaration": true,
4
+ "emitDeclarationOnly": true,
5
+ "jsx": "react-jsx",
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "skipLibCheck": true,
9
+ "strict": true,
10
+ "target": "ES2020"
11
+ },
12
+ "include": ["src"]
13
+ }