DrawioViewer.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import {
  2. type JSX,
  3. memo,
  4. type ReactNode,
  5. useCallback,
  6. useEffect,
  7. useMemo,
  8. useRef,
  9. useState,
  10. } from 'react';
  11. import { GROWI_RENDERING_ATTR } from '@growi/core/dist/consts';
  12. import { debounce } from 'throttle-debounce';
  13. import type { IGraphViewerGlobal } from '..';
  14. import { generateMxgraphData } from '../utils/embed';
  15. import { isGraphViewerGlobal } from '../utils/global';
  16. import styles from './DrawioViewer.module.scss';
  17. declare global {
  18. var GraphViewer: IGraphViewerGlobal;
  19. }
  20. export type DrawioViewerProps = {
  21. isDarkMode: 'true' | 'false';
  22. diagramIndex: number;
  23. bol: number;
  24. eol: number;
  25. children?: ReactNode;
  26. onRenderingStart?: () => void;
  27. onRenderingUpdated?: (mxfile: string | null) => void;
  28. };
  29. export type DrawioEditByViewerProps = {
  30. bol: number;
  31. eol: number;
  32. drawioMxFile: string;
  33. };
  34. export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
  35. const {
  36. isDarkMode,
  37. diagramIndex,
  38. bol,
  39. eol,
  40. children,
  41. onRenderingStart,
  42. onRenderingUpdated,
  43. } = props;
  44. const drawioContainerRef = useRef<HTMLDivElement>(null);
  45. const [error, setError] = useState<Error>();
  46. const renderDrawio = useCallback(() => {
  47. if (drawioContainerRef.current == null) {
  48. return;
  49. }
  50. if (!('GraphViewer' in window && isGraphViewerGlobal(GraphViewer))) {
  51. // Do nothing if loading has not been terminated.
  52. // Alternatively, GraphViewer.processElements() will be called in Script.onLoad.
  53. // see DrawioViewerScript.tsx
  54. return;
  55. }
  56. const mxgraphs = drawioContainerRef.current.getElementsByClassName(
  57. 'mxgraph',
  58. ) as HTMLCollectionOf<HTMLElement>;
  59. if (mxgraphs.length > 0) {
  60. // This component should have only one '.mxgraph' element
  61. const div = mxgraphs[0];
  62. if (div != null) {
  63. div.innerHTML = '';
  64. div.style.width = '';
  65. div.style.height = '';
  66. // render diagram with createViewerForElement
  67. try {
  68. GraphViewer.useResizeSensor = false;
  69. GraphViewer.prototype.checkVisibleState = false;
  70. GraphViewer.prototype.lightboxZIndex = 1055; // set $zindex-modal
  71. GraphViewer.prototype.toolbarZIndex = 1055; // set $zindex-modal
  72. GraphViewer.createViewerForElement(div);
  73. } catch (err) {
  74. setError(err);
  75. }
  76. }
  77. }
  78. }, []);
  79. const renderDrawioWithDebounce = useMemo(
  80. () => debounce(200, renderDrawio),
  81. [renderDrawio],
  82. );
  83. const mxgraphHtml = useMemo(() => {
  84. setError(undefined);
  85. if (children == null) {
  86. return '';
  87. }
  88. const code = Array.isArray(children)
  89. ? children
  90. .filter((elem) => typeof elem === 'string') // omit non-string elements (e.g. br element generated by line-breaks option)
  91. .join('')
  92. : children.toString();
  93. let mxgraphData: string | undefined;
  94. try {
  95. mxgraphData = generateMxgraphData(code, isDarkMode === 'true');
  96. } catch (err) {
  97. setError(err);
  98. }
  99. return `<div class="mxgraph" data-mxgraph="${mxgraphData}"></div>`;
  100. }, [children, isDarkMode]);
  101. useEffect(() => {
  102. if (mxgraphHtml.length > 0) {
  103. onRenderingStart?.();
  104. renderDrawioWithDebounce();
  105. }
  106. }, [mxgraphHtml, onRenderingStart, renderDrawioWithDebounce]);
  107. useEffect(() => {
  108. if (error != null) {
  109. onRenderingUpdated?.(null);
  110. drawioContainerRef.current?.removeAttribute(GROWI_RENDERING_ATTR);
  111. }
  112. }, [error, onRenderingUpdated]);
  113. // **************** detect data-mxgraph has rendered ****************
  114. useEffect(() => {
  115. const container = drawioContainerRef.current;
  116. if (container == null) return;
  117. const observerCallback = (mutationRecords: MutationRecord[]) => {
  118. for (const record of mutationRecords) {
  119. const target = record.target as HTMLElement;
  120. const mxgraphData = target.dataset.mxgraph;
  121. if (mxgraphData != null) {
  122. const mxgraph = JSON.parse(mxgraphData);
  123. onRenderingUpdated?.(mxgraph.xml);
  124. drawioContainerRef.current?.removeAttribute(GROWI_RENDERING_ATTR);
  125. }
  126. }
  127. };
  128. const observer = new MutationObserver(observerCallback);
  129. observer.observe(container, { childList: true, subtree: true });
  130. return () => {
  131. observer.disconnect();
  132. };
  133. }, [onRenderingUpdated]);
  134. // ******************************* end *******************************
  135. // ******************* detect container is resized *******************
  136. useEffect(() => {
  137. if (drawioContainerRef.current == null) {
  138. return;
  139. }
  140. const observer = new ResizeObserver((entries) => {
  141. for (const _entry of entries) {
  142. // setElementWidth(entry.contentRect.width);
  143. onRenderingStart?.();
  144. renderDrawioWithDebounce();
  145. }
  146. });
  147. observer.observe(drawioContainerRef.current);
  148. return () => {
  149. observer.disconnect();
  150. };
  151. }, [onRenderingStart, renderDrawioWithDebounce]);
  152. // ******************************* end *******************************
  153. return (
  154. <div
  155. key={`drawio-viewer-${diagramIndex}`}
  156. ref={drawioContainerRef}
  157. className={`drawio-viewer ${styles['drawio-viewer']} p-2`}
  158. data-begin-line-number-of-markdown={bol}
  159. data-end-line-number-of-markdown={eol}
  160. {...{ [GROWI_RENDERING_ATTR]: 'true' }}
  161. >
  162. {/* show error */}
  163. {error != null && (
  164. <span className="text-muted">
  165. <span className="material-symbols-outlined me-1">error</span>
  166. {error.name && <strong>{error.name}: </strong>}
  167. {error.message}
  168. </span>
  169. )}
  170. {error == null && (
  171. // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
  172. <div dangerouslySetInnerHTML={{ __html: mxgraphHtml }} />
  173. )}
  174. </div>
  175. );
  176. });
  177. DrawioViewer.displayName = 'DrawioViewer';