Procházet zdrojové kódy

feat[Krpano]: VideoScene

chenlei před 11 měsíci
rodič
revize
c019c59ddc

+ 6 - 0
packages/krpano/CHANGELOG.md

@@ -1,5 +1,11 @@
 # @dage/krpano
 
+## 2.4.0
+
+### Minor Changes
+
+- - feat: VideoScene
+
 ## 2.3.0
 
 ### Minor Changes

+ 6 - 3
packages/krpano/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@dage/krpano",
-  "version": "2.3.0",
+  "version": "2.4.0",
   "description": "krpano sdk",
   "module": "build/index.js",
   "main": "build/index.js",
@@ -17,7 +17,8 @@
   },
   "peerDependencies": {
     "react": ">=18",
-    "react-dom": ">=18"
+    "react-dom": ">=18",
+    "@dage/utils": ">=1.0.0"
   },
   "devDependencies": {
     "@babel/core": "^7.22.10",
@@ -30,6 +31,8 @@
   },
   "license": "MIT",
   "dependencies": {
-    "escape-html": "^1.0.3"
+    "escape-html": "^1.0.3",
+    "mobx": "^6.13.2",
+    "mobx-react": "^9.1.1"
   }
 }

+ 93 - 93
packages/krpano/src/components/Events.tsx

@@ -1,93 +1,93 @@
-import { FC, useContext, useEffect } from "react";
-import { KrpanoRendererContext } from "../contexts/KrpanoRendererContext";
-import { EventCallback } from "../types";
-import { mapEventPropsToJSCall } from "../utils";
-
-/**
- * @see https://krpano.com/docu/xml/#events
- */
-export interface EventsConfig {
-  /** 事件名,若存在该参数则为局部事件 */
-  name?: string;
-  keep?: boolean;
-  onEnterFullscreen?: EventCallback;
-  onExitFullscreen?: EventCallback;
-  onXmlComplete?: EventCallback;
-  onPreviewComplete?: EventCallback;
-  onLoadComplete?: EventCallback;
-  onBlendComplete?: EventCallback;
-  onNewPano?: EventCallback;
-  onRemovePano?: EventCallback;
-  onNewScene?: EventCallback;
-  onXmlError?: EventCallback;
-  onLoadError?: EventCallback;
-  onKeydown?: EventCallback;
-  onKeyup?: EventCallback;
-  onClick?: EventCallback;
-  onSingleClick?: EventCallback;
-  onDoubleClick?: EventCallback;
-  onMousedown?: EventCallback;
-  onMouseup?: EventCallback;
-  onMousewheel?: EventCallback;
-  onContextmenu?: EventCallback;
-  onIdle?: EventCallback;
-  onViewChange?: EventCallback;
-  onViewChanged?: EventCallback;
-  onResize?: EventCallback;
-  onFrameBufferResize?: EventCallback;
-  /**
-   * 启动自动旋转时回调
-   */
-  onAutoRotateStart?: EventCallback;
-  /**
-   * 停止自动旋转时回调
-   */
-  onAutoRotateStop?: EventCallback;
-  /**
-   * 全景图完成一轮自动旋转时回调
-   */
-  onAutoRotateOneRound?: EventCallback;
-  /**
-   * 自动旋转状态发生改变时回调
-   */
-  onAutoRotateChange?: EventCallback;
-  onIPhoneFullscreen?: EventCallback;
-}
-
-export interface EventsProps extends EventsConfig {}
-
-const GlobalEvents = "__GlobalEvents";
-
-export const Events: FC<EventsProps> = ({ name, keep, ...EventsAttrs }) => {
-  const renderer = useContext(KrpanoRendererContext);
-  const EventSelector = `events[${name || GlobalEvents}]`;
-
-  // 在renderer上绑定回调
-  useEffect(() => {
-    renderer?.bindEvents(EventSelector, { ...EventsAttrs });
-
-    return () => {
-      renderer?.unbindEvents(EventSelector, { ...EventsAttrs });
-    };
-  }, [renderer, EventsAttrs]);
-
-  // Krpano标签上添加js call,触发事件
-  useEffect(() => {
-    renderer?.setTag(
-      "events",
-      // 全局事件直接设置
-      name || null,
-      {
-        ...mapEventPropsToJSCall(
-          { ...EventsAttrs },
-          (eventName) =>
-            `js(${renderer.name}.fire(${eventName},${EventSelector}))`
-        ),
-        keep,
-      },
-      true
-    );
-  }, [name, renderer]);
-
-  return <div className="events"></div>;
-};
+import { FC, useContext, useEffect } from "react";
+import { KrpanoRendererContext } from "../contexts/KrpanoRendererContext";
+import { EventCallback } from "../types";
+import { mapEventPropsToJSCall } from "../utils";
+
+/**
+ * @see https://krpano.com/docu/xml/#events
+ */
+export interface EventsConfig {
+  /** 事件名,若存在该参数则为局部事件 */
+  name?: string;
+  keep?: boolean;
+  onEnterFullscreen?: EventCallback;
+  onExitFullscreen?: EventCallback;
+  onXmlComplete?: EventCallback;
+  onPreviewComplete?: EventCallback;
+  onLoadComplete?: EventCallback;
+  onBlendComplete?: EventCallback;
+  onNewPano?: EventCallback;
+  onRemovePano?: EventCallback;
+  onNewScene?: EventCallback;
+  onXmlError?: EventCallback;
+  onLoadError?: EventCallback;
+  onKeydown?: EventCallback;
+  onKeyup?: EventCallback;
+  onClick?: EventCallback;
+  onSingleClick?: EventCallback;
+  onDoubleClick?: EventCallback;
+  onMousedown?: EventCallback;
+  onMouseup?: EventCallback;
+  onMousewheel?: EventCallback;
+  onContextmenu?: EventCallback;
+  onIdle?: EventCallback;
+  onViewChange?: EventCallback;
+  onViewChanged?: EventCallback;
+  onResize?: EventCallback;
+  onFrameBufferResize?: EventCallback;
+  /**
+   * 启动自动旋转时回调
+   */
+  onAutoRotateStart?: EventCallback;
+  /**
+   * 停止自动旋转时回调
+   */
+  onAutoRotateStop?: EventCallback;
+  /**
+   * 全景图完成一轮自动旋转时回调
+   */
+  onAutoRotateOneRound?: EventCallback;
+  /**
+   * 自动旋转状态发生改变时回调
+   */
+  onAutoRotateChange?: EventCallback;
+  onIPhoneFullscreen?: EventCallback;
+}
+
+export interface EventsProps extends EventsConfig {}
+
+const GlobalEvents = "__GlobalEvents";
+
+export const Events: FC<EventsProps> = ({ name, keep, ...EventsAttrs }) => {
+  const renderer = useContext(KrpanoRendererContext);
+  const EventSelector = `events[${name || GlobalEvents}]`;
+
+  // 在renderer上绑定回调
+  useEffect(() => {
+    renderer?.bindEvents(EventSelector, { ...EventsAttrs });
+
+    return () => {
+      renderer?.unbindEvents(EventSelector, { ...EventsAttrs });
+    };
+  }, [renderer, EventsAttrs]);
+
+  // Krpano标签上添加js call,触发事件
+  useEffect(() => {
+    renderer?.setTag(
+      "events",
+      // 全局事件直接设置
+      name || null,
+      {
+        ...mapEventPropsToJSCall(
+          { ...EventsAttrs },
+          (eventName) =>
+            `js(${renderer.name}.fire(${eventName},${EventSelector}))`
+        ),
+        keep,
+      },
+      true
+    );
+  }, [name, renderer]);
+
+  return <div className="events"></div>;
+};

+ 1 - 1
packages/krpano/src/components/Layer.tsx

@@ -7,7 +7,7 @@ import { KrpanoRendererContext } from "../contexts";
 export interface LayerProps {
   name: string;
   keep?: boolean;
-  type?: "image" | "text";
+  type?: "image" | "text" | "container";
   align?:
     | "lefttop"
     | "left"

+ 178 - 0
packages/krpano/src/components/VideoScene.tsx

@@ -0,0 +1,178 @@
+import { FC, useContext, useEffect } from "react";
+import { observer } from "mobx-react";
+import { KrpanoRendererContext } from "../contexts";
+import { videoSceneModel } from "../models";
+
+export interface VideoSceneProps {
+  name: string;
+  videointerfaceXmlUrl: string;
+  videoplayerUrl: string;
+  /**
+   * 视频源列表
+   * @example
+   * [{
+   *   // 分辨率
+   *   res: '1920x960',
+   *   // | 分割视频文件 url,krpano会自动根据浏览器的兼容性选择合适的视频格式播放
+   *   url: '/path/video-1920x960.mp4|/path/video-1920x960.webm',
+   *   // 封面图片 url
+   *   poster: '/path/video-1920x960-poster.jpg'
+   * }]
+   */
+  sourceList: {
+    res: string;
+    url: string;
+    poster: string;
+  }[];
+  /**
+   * 播放分辨率
+   * @example '1920x960'
+   */
+  playRes: string;
+  /**
+   * 插件属性,参数见文档
+   * @see https://krpano.com/plugins/videoplayer/#attributes
+   * @default
+   * {
+   *   loop: true,
+   *   volume: 0
+   * }
+   */
+  pluginAttrs?: {
+    loop?: boolean;
+    volume?: number;
+  };
+  /**
+   * 视图属性,参数见文档
+   * @see https://krpano.com/docu/xml/#view
+   */
+  viewAttrs?: Record<string, unknown>;
+  /**
+   * 页面可见状态发生变化回调
+   */
+  onVisibility?: () => void;
+}
+
+const DEFAULT_PLUGIN_ATTRS = {
+  loop: true,
+  volume: 0,
+};
+
+const DEFAULT_VIEW_ATTRS = {
+  hlookat: 0,
+  vlookat: 0,
+  fovtype: "DFOV",
+  fov: 120,
+  fovmin: 80,
+  fovmax: 130,
+  distortion: 0,
+};
+
+export const VideoScene: FC<VideoSceneProps> = observer(
+  ({
+    name,
+    videointerfaceXmlUrl,
+    videoplayerUrl,
+    sourceList,
+    playRes,
+    pluginAttrs,
+    viewAttrs,
+    onVisibility,
+  }) => {
+    const renderer = useContext(KrpanoRendererContext);
+    const model = videoSceneModel;
+
+    const getSourceListStr = () => {
+      let str = "";
+      sourceList.forEach((item) => {
+        str += `videointerface_addsource('${item.res}', '${item.url}', '${item.poster}');`;
+      });
+      return str;
+    };
+
+    const objectToString = (obj: Record<string, unknown>) => {
+      let stack: string[] = [];
+
+      Object.keys(obj).forEach((key) => {
+        stack.push(`${key}="${obj[key]}"`);
+      });
+
+      return stack.join(" ");
+    };
+
+    const visibilityHandler = () => {
+      if (document.visibilityState === "hidden") {
+        model.pause();
+      }
+
+      onVisibility?.();
+    };
+
+    const wechatJSReadyHandler = () => {
+      model.playing && model.play();
+    };
+
+    useEffect(() => {
+      window.addEventListener("visibilitychange", visibilityHandler);
+      document.addEventListener(
+        "WeixinJSBridgeReady",
+        wechatJSReadyHandler,
+        false
+      );
+
+      return () => {
+        window.removeEventListener("visibilitychange", visibilityHandler);
+        document.removeEventListener(
+          "WeixinJSBridgeReady",
+          wechatJSReadyHandler
+        );
+      };
+    }, []);
+
+    useEffect(() => {
+      if (!renderer) return;
+
+      const _pluginAttrs = objectToString(
+        Object.assign({}, DEFAULT_PLUGIN_ATTRS, pluginAttrs, {
+          pausedonstart: !model.playing,
+        })
+      );
+      const _viewAttrs = objectToString(
+        Object.assign({}, DEFAULT_VIEW_ATTRS, viewAttrs)
+      );
+
+      renderer.tagAction.pushSyncTag(
+        "scene",
+        {
+          name,
+        },
+        `
+          <!-- include the videoplayer interface / skin -->
+          <include url="${videointerfaceXmlUrl}" />
+
+          <!-- include the videoplayer plugin -->
+          <plugin ${_pluginAttrs} name="video"
+            url="${videoplayerUrl}"
+            onloaded="add_video_sources();"
+          />
+
+          <!-- use the videoplayer plugin as panoramic image source -->
+          <image>
+            <sphere url="plugin:video" />
+          </image>
+
+          <!-- set the default view -->
+          <view ${_viewAttrs} />
+
+          <action name="add_video_sources" >
+            ${getSourceListStr()}
+
+            videointerface_play('${playRes}');
+          </action>
+        `
+      );
+    }, [renderer]);
+
+    return <div className="video-scene" />;
+  }
+);

+ 11 - 10
packages/krpano/src/components/index.ts

@@ -1,10 +1,11 @@
-export * from "./Action";
-export * from "./Krpano";
-export * from "./Scene";
-export * from "./View";
-export * from "./HotSpot";
-export * from "./Autorotate";
-export * from "./Events";
-export * from "./Include";
-export * from "./Plugin";
-export * from "./Layer";
+export * from "./Action";
+export * from "./Krpano";
+export * from "./Scene";
+export * from "./View";
+export * from "./HotSpot";
+export * from "./Autorotate";
+export * from "./Events";
+export * from "./Include";
+export * from "./Plugin";
+export * from "./Layer";
+export * from "./VideoScene";

+ 2 - 2
packages/krpano/src/contexts/index.ts

@@ -1,2 +1,2 @@
-export * from "./CurrentSceneContext";
-export * from "./KrpanoRendererContext";
+export * from "./CurrentSceneContext";
+export * from "./KrpanoRendererContext";

+ 6 - 5
packages/krpano/src/index.ts

@@ -1,5 +1,6 @@
-export * from "./components";
-
-export * from "./contexts";
-export * from "./hooks";
-export * from "./types";
+export * from "./components";
+
+export * from "./contexts";
+export * from "./models";
+export * from "./hooks";
+export * from "./types";

+ 45 - 0
packages/krpano/src/models/VideoSceneModel.ts

@@ -0,0 +1,45 @@
+import { makeAutoObservable } from "mobx";
+import { EventBus } from "@dage/utils";
+
+export class VideoSceneModel {
+  event = new EventBus();
+
+  /**
+   * 播放中
+   * @default true
+   */
+  playing = true;
+
+  constructor() {
+    makeAutoObservable(this);
+  }
+
+  /** 播放 */
+  play() {
+    window.ReactKrpanoActionProxy?.call("plugin[video].play()");
+  }
+  /** 暂停 */
+  pause() {
+    window.ReactKrpanoActionProxy?.call("plugin[video].pause()");
+  }
+}
+
+export const videoSceneModel = new VideoSceneModel();
+
+window.onVideoSceneReady = () => {
+  videoSceneModel.event.emit("Event.videoScene.ready", undefined);
+};
+window.onVideoScenePlay = () => {
+  videoSceneModel.playing = true;
+  videoSceneModel.event.emit("Event.videoScene.play", undefined);
+};
+window.onVideoScenePaused = () => {
+  videoSceneModel.playing = false;
+  videoSceneModel.event.emit("Event.videoScene.pause", undefined);
+};
+window.onVideoSceneComplete = () => {
+  videoSceneModel.event.emit("Event.videoScene.complete", undefined);
+};
+window.onVideoSceneError = (error: string) => {
+  videoSceneModel.event.emit("Event.videoScene.error", error);
+};

+ 4 - 3
packages/krpano/src/models/index.ts

@@ -1,3 +1,4 @@
-export * from "./KrpanoActionProxy";
-export * from "./PromiseQueue";
-export * from "./TagActionProxy";
+export * from "./KrpanoActionProxy";
+export * from "./PromiseQueue";
+export * from "./TagActionProxy";
+export * from "./VideoSceneModel";

+ 14 - 0
packages/krpano/src/types.ts

@@ -11,6 +11,20 @@ declare global {
      */
     draggbleHotspotEvent?: (ath: number, atv: number) => void;
     ReactKrpanoActionProxy?: KrpanoActionProxy;
+
+    onVideoSceneReady: () => void;
+    onVideoScenePlay: () => void;
+    onVideoScenePaused: () => void;
+    onVideoSceneComplete: () => void;
+    onVideoSceneError: (err: string) => void;
+  }
+
+  interface EventMapper {
+    "Event.videoScene.ready": undefined;
+    "Event.videoScene.play": undefined;
+    "Event.videoScene.pause": undefined;
+    "Event.videoScene.complete": undefined;
+    "Event.videoScene.error": string;
   }
 }
 

+ 153 - 153
packages/krpano/src/utils.tsx

@@ -1,153 +1,153 @@
-import { ReactNode } from "react";
-import escapeHTML from "escape-html";
-import { XMLMeta } from "./types";
-import ReactDOMServer from "react-dom/server";
-
-/**
- * @see https://krpano.com/docu/actions
- */
-type FuncName =
-  | "addplugin"
-  | "removeplugin"
-  | "set"
-  | "loadxml"
-  | "loadscene"
-  | "loadpano"
-  | "tween"
-  | "addhotspot"
-  | "removehotspot"
-  | "includexml"
-  | "includexmlstring"
-  | "addlayer"
-  | "removelayer"
-  | "nexttick";
-
-/**
- * 执行单个函数
- * @param func 函数名
- * @param params 参数列表
- */
-export const buildKrpanoAction = (
-  func: FuncName,
-  ...params: Array<string | number | boolean>
-): string => `${func}(${params.map((p) => `${p}`).join(", ")});`;
-
-/**
- * 动态添加标签用
- * @see https://krpano.com/forum/wbb/index.php?page=Thread&threadID=15873
- */
-export const buildKrpanoTagSetterActions = (
-  name: string,
-  attrs: Record<string, string | boolean | number | undefined>
-): string =>
-  Object.keys(attrs)
-    .map((key) => {
-      const val = attrs[key];
-      key = key.toLowerCase();
-      if (val === undefined) {
-        return "";
-      }
-      // 如果属性值中有双引号,需要改用单引号
-      let quote = '"';
-      if (`${val}`.includes(quote)) {
-        quote = "'";
-      }
-      if (key === "style") {
-        return `assignstyle(${name}, ${val});`;
-      }
-      if (typeof val === "boolean" || typeof val === "number") {
-        return `set(${name}.${key}, ${val});`;
-      }
-      // content是XML文本,不能转义,因为不涉及用户输入也不需要
-      return `set(${name}.${key}, ${quote}${
-        ["content", "html"].includes(key) ? val : escapeHTML(val.toString())
-      }${quote});`;
-    })
-    .filter((str) => !!str)
-    .join("");
-
-/**
- * 根据元数据构建xml
- */
-export const buildXML = ({ tag, attrs, children }: XMLMeta): string => {
-  const attributes = Object.keys(attrs)
-    .map((key) => `${key.toLowerCase()}="${attrs[key]}"`)
-    .join(" ");
-
-  if (children && children.length) {
-    return `<${tag} ${attributes}>${children
-      .map((child) => buildXML(child))
-      .join("")}</${tag}>`;
-  }
-  return `<${tag} ${attributes} />`;
-};
-
-/**
- * 对Object进行map操作
- */
-export const mapObject = (
-  obj: Record<string, unknown>,
-  mapper: (key: string, value: unknown) => Record<string, unknown>
-): Record<string, unknown> => {
-  return Object.assign(
-    {},
-    ...Object.keys(obj).map((key) => {
-      const value = obj[key];
-      return mapper(key, value);
-    })
-  );
-};
-
-/**
- * 主要用于绑定Krpano事件和js调用。提取某个对象中的onXXX属性并替换为对应的调用字符串,丢弃其他属性
- */
-export const mapEventPropsToJSCall = (
-  obj: Record<string, unknown>,
-  getString: (key: string, value: unknown) => string
-): Record<string, string> =>
-  mapObject(obj, (key, value) => {
-    if (key.startsWith("on") && typeof value === "function") {
-      return { [key]: getString(key, value) };
-    }
-    return {};
-  }) as Record<string, string>;
-
-export const childrenToOuterHTML = (children: ReactNode) => {
-  const wrapper = document.createElement("div");
-  const childrenString = ReactDOMServer.renderToStaticMarkup(<>{children}</>);
-
-  wrapper.innerHTML = childrenString;
-
-  return wrapper.outerHTML;
-};
-
-export const compareVersions = (version1: string, version2: string) => {
-  const parts1: string[] = version1.split("-");
-  const parts2: string[] = version2.split("-");
-
-  const [major1, minor1] = parts1[0].split(".").map(Number);
-  const [major2, minor2] = parts2[0].split(".").map(Number);
-
-  if (major1 !== major2) {
-    return major1 - major2;
-  }
-
-  if (minor1 !== minor2) {
-    return minor1 - minor2;
-  }
-
-  if (parts1.length > 1 && parts2.length > 1) {
-    return parts1[1].localeCompare(parts2[1]);
-  }
-
-  if (parts1.length > 1) {
-    return 1;
-  } else if (parts2.length > 1) {
-    return -1;
-  }
-
-  return 0;
-};
-
-export const is121Version =
-  !!window.krpanoJS && compareVersions(window.krpanoJS.version, "1.21") > -1;
+import { ReactNode } from "react";
+import escapeHTML from "escape-html";
+import { XMLMeta } from "./types";
+import ReactDOMServer from "react-dom/server";
+
+/**
+ * @see https://krpano.com/docu/actions
+ */
+type FuncName =
+  | "addplugin"
+  | "removeplugin"
+  | "set"
+  | "loadxml"
+  | "loadscene"
+  | "loadpano"
+  | "tween"
+  | "addhotspot"
+  | "removehotspot"
+  | "includexml"
+  | "includexmlstring"
+  | "addlayer"
+  | "removelayer"
+  | "nexttick";
+
+/**
+ * 执行单个函数
+ * @param func 函数名
+ * @param params 参数列表
+ */
+export const buildKrpanoAction = (
+  func: FuncName,
+  ...params: Array<string | number | boolean>
+): string => `${func}(${params.map((p) => `${p}`).join(", ")});`;
+
+/**
+ * 动态添加标签用
+ * @see https://krpano.com/forum/wbb/index.php?page=Thread&threadID=15873
+ */
+export const buildKrpanoTagSetterActions = (
+  name: string,
+  attrs: Record<string, string | boolean | number | undefined>
+): string =>
+  Object.keys(attrs)
+    .map((key) => {
+      const val = attrs[key];
+      key = key.toLowerCase();
+      if (val === undefined) {
+        return "";
+      }
+      // 如果属性值中有双引号,需要改用单引号
+      let quote = '"';
+      if (`${val}`.includes(quote)) {
+        quote = "'";
+      }
+      if (key === "style") {
+        return `assignstyle(${name}, ${val});`;
+      }
+      if (typeof val === "boolean" || typeof val === "number") {
+        return `set(${name}.${key}, ${val});`;
+      }
+      // content是XML文本,不能转义,因为不涉及用户输入也不需要
+      return `set(${name}.${key}, ${quote}${
+        ["content", "html"].includes(key) ? val : escapeHTML(val.toString())
+      }${quote});`;
+    })
+    .filter((str) => !!str)
+    .join("");
+
+/**
+ * 根据元数据构建xml
+ */
+export const buildXML = ({ tag, attrs, children }: XMLMeta): string => {
+  const attributes = Object.keys(attrs)
+    .map((key) => `${key.toLowerCase()}="${attrs[key]}"`)
+    .join(" ");
+
+  if (children && children.length) {
+    return `<${tag} ${attributes}>${children
+      .map((child) => buildXML(child))
+      .join("")}</${tag}>`;
+  }
+  return `<${tag} ${attributes} />`;
+};
+
+/**
+ * 对Object进行map操作
+ */
+export const mapObject = (
+  obj: Record<string, unknown>,
+  mapper: (key: string, value: unknown) => Record<string, unknown>
+): Record<string, unknown> => {
+  return Object.assign(
+    {},
+    ...Object.keys(obj).map((key) => {
+      const value = obj[key];
+      return mapper(key, value);
+    })
+  );
+};
+
+/**
+ * 主要用于绑定Krpano事件和js调用。提取某个对象中的onXXX属性并替换为对应的调用字符串,丢弃其他属性
+ */
+export const mapEventPropsToJSCall = (
+  obj: Record<string, unknown>,
+  getString: (key: string, value: unknown) => string
+): Record<string, string> =>
+  mapObject(obj, (key, value) => {
+    if (key.startsWith("on") && typeof value === "function") {
+      return { [key]: getString(key, value) };
+    }
+    return {};
+  }) as Record<string, string>;
+
+export const childrenToOuterHTML = (children: ReactNode) => {
+  const wrapper = document.createElement("div");
+  const childrenString = ReactDOMServer.renderToStaticMarkup(<>{children}</>);
+
+  wrapper.innerHTML = childrenString;
+
+  return wrapper.outerHTML;
+};
+
+export const compareVersions = (version1: string, version2: string) => {
+  const parts1: string[] = version1.split("-");
+  const parts2: string[] = version2.split("-");
+
+  const [major1, minor1] = parts1[0].split(".").map(Number);
+  const [major2, minor2] = parts2[0].split(".").map(Number);
+
+  if (major1 !== major2) {
+    return major1 - major2;
+  }
+
+  if (minor1 !== minor2) {
+    return minor1 - minor2;
+  }
+
+  if (parts1.length > 1 && parts2.length > 1) {
+    return parts1[1].localeCompare(parts2[1]);
+  }
+
+  if (parts1.length > 1) {
+    return 1;
+  } else if (parts2.length > 1) {
+    return -1;
+  }
+
+  return 0;
+};
+
+export const is121Version =
+  !!window.krpanoJS && compareVersions(window.krpanoJS.version, "1.21") > -1;

+ 80 - 17
pnpm-lock.yaml

@@ -282,6 +282,9 @@ importers:
       react-dom:
         specifier: ^18.2.0
         version: 18.2.0(react@18.2.0)
+      react-transition-group:
+        specifier: ^4.4.5
+        version: 4.4.5(react-dom@18.2.0)(react@18.2.0)
     devDependencies:
       '@types/jest':
         specifier: ^27.0.3
@@ -305,7 +308,7 @@ importers:
         version: 4.17.21
       react:
         specifier: '>=17'
-        version: 16.14.0
+        version: 18.2.0
     devDependencies:
       '@babel/core':
         specifier: ^7.22.10
@@ -318,7 +321,7 @@ importers:
         version: 7.22.5(@babel/core@7.22.10)
       '@testing-library/react-hooks':
         specifier: ^8.0.1
-        version: 8.0.1(react@16.14.0)
+        version: 8.0.1(react@18.2.0)
       babel-jest:
         specifier: ^29.6.2
         version: 29.6.2(@babel/core@7.22.10)
@@ -331,6 +334,12 @@ importers:
       escape-html:
         specifier: ^1.0.3
         version: 1.0.3
+      mobx:
+        specifier: ^6.13.2
+        version: 6.13.2
+      mobx-react:
+        specifier: ^9.1.1
+        version: 9.1.1(mobx@6.13.2)(react-dom@18.2.0)(react@18.2.0)
       react:
         specifier: '>=18'
         version: 18.2.0
@@ -4762,7 +4771,7 @@ packages:
       redent: 3.0.0
     dev: false
 
-  /@testing-library/react-hooks@8.0.1(react@16.14.0):
+  /@testing-library/react-hooks@8.0.1(react@18.2.0):
     resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==}
     engines: {node: '>=12'}
     peerDependencies:
@@ -4779,8 +4788,8 @@ packages:
         optional: true
     dependencies:
       '@babel/runtime': 7.22.10
-      react: 16.14.0
-      react-error-boundary: 3.1.4(react@16.14.0)
+      react: 18.2.0
+      react-error-boundary: 3.1.4(react@18.2.0)
     dev: true
 
   /@testing-library/react@13.4.0(react-dom@18.2.0)(react@18.2.0):
@@ -9616,6 +9625,13 @@ packages:
       utila: 0.4.0
     dev: false
 
+  /dom-helpers@5.2.1:
+    resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
+    dependencies:
+      '@babel/runtime': 7.22.10
+      csstype: 3.1.2
+    dev: false
+
   /dom-serializer@0.1.1:
     resolution: {integrity: sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==}
     dependencies:
@@ -11862,6 +11878,7 @@ packages:
 
   /growly@1.3.0:
     resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==}
+    requiresBuild: true
     dev: true
     optional: true
 
@@ -16005,6 +16022,48 @@ packages:
     hasBin: true
     dev: true
 
+  /mobx-react-lite@4.0.7(mobx@6.13.2)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-RjwdseshK9Mg8On5tyJZHtGD+J78ZnCnRaxeQDSiciKVQDUbfZcXhmld0VMxAwvcTnPEHZySGGewm467Fcpreg==}
+    peerDependencies:
+      mobx: ^6.9.0
+      react: ^16.8.0 || ^17 || ^18
+      react-dom: '*'
+      react-native: '*'
+    peerDependenciesMeta:
+      react-dom:
+        optional: true
+      react-native:
+        optional: true
+    dependencies:
+      mobx: 6.13.2
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+      use-sync-external-store: 1.2.0(react@18.2.0)
+    dev: false
+
+  /mobx-react@9.1.1(mobx@6.13.2)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-gVV7AdSrAAxqXOJ2bAbGa5TkPqvITSzaPiiEkzpW4rRsMhSec7C2NBCJYILADHKp2tzOAIETGRsIY0UaCV5aEw==}
+    peerDependencies:
+      mobx: ^6.9.0
+      react: ^16.8.0 || ^17 || ^18
+      react-dom: '*'
+      react-native: '*'
+    peerDependenciesMeta:
+      react-dom:
+        optional: true
+      react-native:
+        optional: true
+    dependencies:
+      mobx: 6.13.2
+      mobx-react-lite: 4.0.7(mobx@6.13.2)(react-dom@18.2.0)(react@18.2.0)
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
+  /mobx@6.13.2:
+    resolution: {integrity: sha512-GIubI2qf+P6lG6rSEG0T2pg3jV9/0+O0ncF09+0umRe75+Cbnh1KNLM1GvbTY9RSc7QuU+LcPNZfxDY8B+3XRg==}
+    dev: false
+
   /move-concurrently@1.0.1:
     resolution: {integrity: sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==}
     dependencies:
@@ -19384,16 +19443,6 @@ packages:
       scheduler: 0.23.0
     dev: false
 
-  /react-error-boundary@3.1.4(react@16.14.0):
-    resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
-    engines: {node: '>=10', npm: '>=6'}
-    peerDependencies:
-      react: '>=16.13.1'
-    dependencies:
-      '@babel/runtime': 7.22.10
-      react: 16.14.0
-    dev: true
-
   /react-error-boundary@3.1.4(react@18.2.0):
     resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
     engines: {node: '>=10', npm: '>=6'}
@@ -19402,7 +19451,6 @@ packages:
     dependencies:
       '@babel/runtime': 7.22.10
       react: 18.2.0
-    dev: false
 
   /react-error-overlay@6.0.11:
     resolution: {integrity: sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==}
@@ -19583,6 +19631,20 @@ packages:
       react: 18.2.0
     dev: false
 
+  /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
+    peerDependencies:
+      react: '>=16.6.0'
+      react-dom: '>=16.6.0'
+    dependencies:
+      '@babel/runtime': 7.22.10
+      dom-helpers: 5.2.1
+      loose-envify: 1.4.0
+      prop-types: 15.8.1
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /react@16.14.0:
     resolution: {integrity: sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==}
     engines: {node: '>=0.10.0'}
@@ -19590,13 +19652,13 @@ packages:
       loose-envify: 1.4.0
       object-assign: 4.1.1
       prop-types: 15.8.1
+    dev: false
 
   /react@18.2.0:
     resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
     engines: {node: '>=0.10.0'}
     dependencies:
       loose-envify: 1.4.0
-    dev: false
 
   /read-pkg-up@4.0.0:
     resolution: {integrity: sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==}
@@ -20612,6 +20674,7 @@ packages:
 
   /shellwords@0.1.1:
     resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==}
+    requiresBuild: true
     dev: true
     optional: true