Files
2026-07-13 13:05:14 +08:00

276 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { createRef } from "react";
import * as rerun from "@rerun-io/web-viewer";
// NOTE: We're intentionally not exposing `allow_fullscreen` and `enable_history`.
// Those features are already pretty sensitive to the environment, especially
// so in React where all normal behavior of web APIs goes out of the window.
/**
* @typedef BaseProps
* @property {string | string[]} rrd URL(s) of the `.rrd` file(s) to load.
* Changing this prop will open any new unique URLs as recordings,
* and close any URLs which are not present.
* @property {boolean} [follow_if_http] Whether to open HTTP `.rrd` sources in following mode.
* Defaults to `false`. Ignored for non-HTTP sources.
* @property {string} [width] CSS width of the viewer's parent div
* @property {string} [height] CSS height of the viewer's parent div
*
* @typedef {(
* Omit<import("@rerun-io/web-viewer").WebViewerOptions, "allow_fullscreen" | "enable_history">
* & BaseProps
* & import("./types.d.ts").ViewerEvents
* )} Props
*/
/**
* Wrapper for `WebViewer` from the `@rerun-io/web-viewer`.
*
* This component creates and manages the web viewer's `canvas` element.
*
* @extends {React.Component<Props>}
*/
export default class WebViewer extends React.Component {
/** @type {React.RefObject<HTMLDivElement>} */
#parent = /** @type {React.RefObject<HTMLDivElement>} */ (createRef());
/** @type {rerun.WebViewer} */
#handle;
/** @param {Props} props */
constructor(props) {
super(props);
this.#handle = new rerun.WebViewer();
}
componentDidMount() {
startViewer(
this.#handle,
/** @type {HTMLDivElement} */ (this.#parent.current),
() => this.props,
);
}
componentDidUpdate(/** @type {Props} */ prevProps) {
if (
keysChanged(prevProps, this.props, [
"hide_welcome_screen",
"manifest_url",
"render_backend",
])
) {
// We have to restart the viewer, because the above
// props are _startup_ options only, and we don't
// want to break that promise by setting them
// after the viewer has been started.
this.#handle.stop();
this.#handle = new rerun.WebViewer();
startViewer(
this.#handle,
/** @type {HTMLDivElement} */ (this.#parent.current),
() => this.props,
);
} else {
// We only need to diff the recordings.
if (!this.#handle.ready) {
return;
}
syncRecordings(
this.#handle,
toArray(prevProps.rrd),
toArray(this.props.rrd),
prevProps.follow_if_http,
this.props.follow_if_http,
);
}
}
componentWillUnmount() {
this.#handle.stop();
}
render() {
const { width = "640px", height = "360px" } = this.props;
return React.createElement("div", {
className: "rerun-web-viewer",
style: { width, height, position: "relative" },
ref: this.#parent,
});
}
}
/** @param {string} str */
function pascalToSnake(str) {
let out = "";
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
// AZ ?
if (code >= 65 && code <= 90) {
// if not first char, prepend underscore
if (i > 0) out += "_";
// convert to lowercase by adding 32
out += String.fromCharCode(code + 32);
} else {
// everything else (az, 09, etc.) goes straight through
out += String.fromCharCode(code);
}
}
return out;
}
/**
* @param {rerun.WebViewer} handle
* @param {HTMLElement} parent
* @param {() => Props} getProps
*/
function startViewer(handle, parent, getProps) {
const props = getProps();
const initial = toArray(props.rrd);
const initialFollowIfHttp = props.follow_if_http;
handle
.start(
initial,
parent,
{
manifest_url: props.manifest_url,
render_backend: props.render_backend,
hide_welcome_screen: props.hide_welcome_screen,
theme: props.theme,
// NOTE: `width`, `height` intentionally ignored, they will
// instead be used on the parent `div` element
width: "100%",
height: "100%",
},
{
follow_if_http: initialFollowIfHttp,
},
)
.then(() => {
if (!handle.ready) {
return;
}
const { rrd, follow_if_http } = getProps();
syncRecordings(
handle,
initial,
toArray(rrd),
initialFollowIfHttp,
follow_if_http,
);
})
.catch(() => {});
for (const key of Object.keys(props)) {
if (key.startsWith("on")) {
/** @type {any} */
const event = pascalToSnake(key.slice(2));
/** @type {any} */
const callback = /** @type {any} */ (getProps())[key];
handle.on(event, callback);
}
}
}
/**
* Return the difference between the two arrays.
*
* @param {string[]} prev
* @param {string[]} current
* @returns {{ added: string[], removed: string[] }}
*/
function diff(prev, current) {
const prevSet = new Set(prev);
const currentSet = new Set(current);
return {
added: current.filter((v) => !prevSet.has(v)),
removed: prev.filter((v) => !currentSet.has(v)),
};
}
/**
* Reconcile the currently open recordings with the latest props.
*
* @param {rerun.WebViewer} handle
* @param {string[]} prev
* @param {string[]} current
* @param {boolean | undefined} prevFollowIfHttp
* @param {boolean | undefined} followIfHttp
*/
function syncRecordings(handle, prev, current, prevFollowIfHttp, followIfHttp) {
const { added, removed } = diff(prev, current);
const reopened =
prevFollowIfHttp !== followIfHttp
? intersection(prev, current).filter(isHttpSource)
: [];
if (removed.length > 0 || reopened.length > 0) {
handle.close([...removed, ...reopened]);
}
if (added.length > 0) {
handle.open(added, { follow_if_http: followIfHttp });
}
if (reopened.length > 0) {
handle.open(reopened, { follow_if_http: followIfHttp });
}
}
/**
* Return the values present in both arrays.
*
* @param {string[]} prev
* @param {string[]} current
* @returns {string[]}
*/
function intersection(prev, current) {
const prevSet = new Set(prev);
return current.filter((v) => prevSet.has(v));
}
/**
* Returns `true` if the recording source is affected by `follow_if_http`.
*
* @param {string} url
*/
function isHttpSource(url) {
try {
const protocol = new URL(url, document.baseURI).protocol;
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}
/**
* Returns `true` if any of the `keys` changed between `prev` and `curr`.
*
* The definition of "changed" is any of removed, added, value changed.
*
* @template {Record<string, any>} T
* @param {T} prev
* @param {T} curr
* @param {(keyof T)[]} keys
*/
function keysChanged(prev, curr, keys) {
for (const key of keys) {
if (prev[key] !== curr[key]) {
return true;
}
}
return false;
}
/**
* @template T
* @param {T | T[] | undefined | null} a
* @returns {T[]}
*/
function toArray(a) {
if (a == null) return [];
return Array.isArray(a) ? a : [a];
}