DrawioViewer.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  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_IS_CONTENT_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. // finish rendering to allow auto-scroll system to detect the upcoming layout shift
  111. drawioContainerRef.current?.setAttribute(
  112. GROWI_IS_CONTENT_RENDERING_ATTR,
  113. 'false',
  114. );
  115. }
  116. }, [error, onRenderingUpdated]);
  117. // **************** detect data-mxgraph has rendered ****************
  118. useEffect(() => {
  119. const container = drawioContainerRef.current;
  120. if (container == null) return;
  121. const observerCallback = (mutationRecords: MutationRecord[]) => {
  122. for (const record of mutationRecords) {
  123. const target = record.target as HTMLElement;
  124. const mxgraphData = target.dataset.mxgraph;
  125. if (mxgraphData != null) {
  126. onRenderingUpdated?.(JSON.parse(mxgraphData).xml);
  127. drawioContainerRef.current?.setAttribute(
  128. GROWI_IS_CONTENT_RENDERING_ATTR,
  129. 'false',
  130. );
  131. }
  132. }
  133. };
  134. const observer = new MutationObserver(observerCallback);
  135. observer.observe(container, { childList: true, subtree: true });
  136. return () => {
  137. observer.disconnect();
  138. };
  139. }, [onRenderingUpdated]);
  140. // ******************************* end *******************************
  141. // ******************* detect container is resized *******************
  142. useEffect(() => {
  143. if (drawioContainerRef.current == null) {
  144. return;
  145. }
  146. const observer = new ResizeObserver((entries) => {
  147. for (const _entry of entries) {
  148. onRenderingStart?.();
  149. // Signal re-rendering in progress so the auto-scroll system can detect the upcoming layout shift
  150. drawioContainerRef.current?.setAttribute(
  151. GROWI_IS_CONTENT_RENDERING_ATTR,
  152. 'true',
  153. );
  154. renderDrawioWithDebounce();
  155. }
  156. });
  157. observer.observe(drawioContainerRef.current);
  158. return () => {
  159. observer.disconnect();
  160. };
  161. }, [onRenderingStart, renderDrawioWithDebounce]);
  162. // ******************************* end *******************************
  163. return (
  164. <div
  165. key={`drawio-viewer-${diagramIndex}`}
  166. ref={drawioContainerRef}
  167. className={`drawio-viewer ${styles['drawio-viewer']} p-2`}
  168. data-begin-line-number-of-markdown={bol}
  169. data-end-line-number-of-markdown={eol}
  170. {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true' }}
  171. >
  172. {/* show error */}
  173. {error != null && (
  174. <span className="text-muted">
  175. <span className="material-symbols-outlined me-1">error</span>
  176. {error.name && <strong>{error.name}: </strong>}
  177. {error.message}
  178. </span>
  179. )}
  180. {error == null && (
  181. // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
  182. <div dangerouslySetInnerHTML={{ __html: mxgraphHtml }} />
  183. )}
  184. </div>
  185. );
  186. });
  187. DrawioViewer.displayName = 'DrawioViewer';