rerun-io--rerun
276 行
7.1 KiB
JavaScript
276 行
7.1 KiB
JavaScript
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);
|
|
|
|
// A–Z ?
|
|
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 (a–z, 0–9, 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];
|
|
}
|