LabelStudio.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { configure } from "mobx";
  2. import { destroy } from "mobx-state-tree";
  3. import { render, unmountComponentAtNode } from "react-dom";
  4. import { createRoot } from "react-dom/client";
  5. import camelCase from "lodash/camelCase";
  6. import { LabelStudio as LabelStudioReact } from "./Component";
  7. import App from "./components/App/App";
  8. import { configureStore } from "./configureStore";
  9. import legacyEvents from "./core/External";
  10. import { Hotkey } from "./core/Hotkey";
  11. import defaultOptions from "./defaultOptions";
  12. import { destroy as destroySharedStore } from "./mixins/SharedChoiceStore/mixin";
  13. import { EventInvoker } from "./utils/events";
  14. import { FF_LSDV_4620_3_ML, isFF } from "./utils/feature-flags";
  15. import { cleanDomAfterReact, findReactKey } from "./utils/reactCleaner";
  16. import { isDefined } from "./utils/utilities";
  17. // Extend window interface for TypeScript
  18. declare global {
  19. interface Window {
  20. Htx: any;
  21. }
  22. }
  23. configure({
  24. isolateGlobalState: true,
  25. });
  26. type Callback = (...args: any[]) => any;
  27. type LSFUser = any;
  28. type LSFTask = any;
  29. // @todo type LSFOptions = SnapshotIn<typeof AppStore>;
  30. // because those options will go as initial values for AppStore
  31. // but it's not types yet, so here is some excerpt of its parameters
  32. type LSFOptions = Record<string, any> & {
  33. interfaces: string[];
  34. keymap?: any;
  35. user?: LSFUser;
  36. users?: LSFUser[];
  37. task?: LSFTask;
  38. settings?: {
  39. forceBottomPanel?: boolean;
  40. };
  41. instanceOptions?: {
  42. reactVersion?: "v18" | "v17";
  43. };
  44. };
  45. export class LabelStudio {
  46. static Component = LabelStudioReact;
  47. static instances = new Set<LabelStudio>();
  48. static destroyAll() {
  49. LabelStudio.instances.forEach((inst) => inst.destroy?.());
  50. LabelStudio.instances.clear();
  51. }
  52. options: Partial<LSFOptions>;
  53. root: Element | string;
  54. store: any;
  55. reactRoot: any;
  56. destroy: (() => void) | null = () => {};
  57. events = new EventInvoker();
  58. getRootElement(root: Element | string) {
  59. let element: Element | null = null;
  60. if (typeof root === "string") {
  61. element = document.getElementById(root);
  62. } else {
  63. element = root;
  64. }
  65. if (!element) {
  66. throw new Error(`Root element not found (selector: ${root})`);
  67. }
  68. return element;
  69. }
  70. constructor(root: Element | string, userOptions: Partial<LSFOptions> = {}) {
  71. const options = { ...defaultOptions, ...userOptions };
  72. if (options.keymap) {
  73. Hotkey.setKeymap(options.keymap);
  74. }
  75. this.root = root;
  76. this.options = options;
  77. if (options.instanceOptions?.reactVersion === "v18") {
  78. this.createAppV18();
  79. } else {
  80. this.createAppV17();
  81. }
  82. // @todo whole approach to hotkeys should be rewritten,
  83. // @todo but for now we need a way to export Hotkey to different app
  84. if (window.Htx) window.Htx.Hotkey = Hotkey;
  85. this.supportLegacyEvents();
  86. if (options.instanceOptions?.reactVersion !== "v18") {
  87. LabelStudio.instances.add(this);
  88. }
  89. }
  90. on(eventName: string, callback: Callback) {
  91. this.events.on(eventName, callback);
  92. }
  93. off(eventName: string, callback: Callback) {
  94. if (isDefined(callback)) {
  95. this.events.off(eventName, callback);
  96. } else {
  97. this.events.removeAll(eventName);
  98. }
  99. }
  100. // This is a temporary solution that allows React 17 to work in the meantime.
  101. // and we can update our other usages of LabelStudio to use createRoot, namely tests will likely be affected.
  102. async createAppV17() {
  103. const { store } = await configureStore(this.options, this.events);
  104. const rootElement = this.getRootElement(this.root);
  105. this.store = store;
  106. window.Htx = this.store;
  107. const isRendered = false;
  108. const renderApp = () => {
  109. if (isRendered) {
  110. clearRenderedApp();
  111. }
  112. render(<App store={this.store} />, rootElement);
  113. };
  114. const clearRenderedApp = () => {
  115. if (!rootElement.childNodes?.length) return;
  116. const childNodes = [...rootElement.childNodes];
  117. // cleanDomAfterReact needs this key to be sure that cleaning affects only current react subtree
  118. const reactKey = findReactKey(childNodes[0]);
  119. unmountComponentAtNode(rootElement);
  120. /*
  121. Unmounting doesn't help with clearing React's fibers
  122. but removing the manually helps
  123. @see https://github.com/facebook/react/pull/20290 (similar problem)
  124. That's maybe not relevant in version 18
  125. */
  126. cleanDomAfterReact(childNodes, reactKey);
  127. cleanDomAfterReact([rootElement], reactKey);
  128. };
  129. renderApp();
  130. store.setAppControls({
  131. isRendered() {
  132. return isRendered;
  133. },
  134. render: renderApp,
  135. clear: clearRenderedApp,
  136. });
  137. this.destroy = () => {
  138. if (isFF(FF_LSDV_4620_3_ML)) {
  139. clearRenderedApp();
  140. }
  141. destroySharedStore();
  142. if (isFF(FF_LSDV_4620_3_ML)) {
  143. /*
  144. It seems that destroying children separately helps GC to collect garbage
  145. ...
  146. */
  147. this.store.selfDestroy();
  148. }
  149. destroy(this.store);
  150. Hotkey.unbindAll();
  151. if (isFF(FF_LSDV_4620_3_ML)) {
  152. /*
  153. ...
  154. as well as nulling all these this.store
  155. */
  156. this.store = null;
  157. this.destroy = null;
  158. LabelStudio.instances.delete(this);
  159. }
  160. };
  161. }
  162. // To support React 18 properly, we need to use createRoot
  163. // and render the app with it, and properly unmount it and cleanup all references
  164. async createAppV18() {
  165. const { store } = await configureStore(this.options, this.events);
  166. const rootElement = this.getRootElement(this.root);
  167. this.store = store;
  168. window.Htx = this.store;
  169. let isRendered = false;
  170. const renderApp = () => {
  171. if (isRendered) {
  172. clearRenderedApp();
  173. }
  174. this.reactRoot = createRoot(rootElement);
  175. const AppComponent = App as any;
  176. this.reactRoot.render(<AppComponent store={this.store} />);
  177. isRendered = true;
  178. };
  179. const clearRenderedApp = () => {
  180. if (this.reactRoot && isRendered) {
  181. this.reactRoot.unmount();
  182. this.reactRoot = null;
  183. isRendered = false;
  184. }
  185. };
  186. renderApp();
  187. store.setAppControls({
  188. isRendered() {
  189. return isRendered;
  190. },
  191. render: renderApp,
  192. clear: clearRenderedApp,
  193. });
  194. this.destroy = () => {
  195. // Clear rendered app
  196. clearRenderedApp();
  197. // Destroy shared store
  198. destroySharedStore();
  199. // Destroy store
  200. destroy(this.store);
  201. // Unbind all hotkeys
  202. Hotkey.unbindAll();
  203. // Clear references
  204. this.store = null;
  205. window.Htx = null;
  206. this.destroy = null;
  207. };
  208. }
  209. supportLegacyEvents() {
  210. const keys = Object.keys(legacyEvents);
  211. keys.forEach((key) => {
  212. const callback = this.options[key];
  213. if (isDefined(callback)) {
  214. const eventName = camelCase(key.replace(/^on/, ""));
  215. this.events.on(eventName, callback);
  216. }
  217. });
  218. }
  219. }