1import React, {useCallback, useEffect, useRef, useState} from 'react';2import useBaseUrl from '@docusaurus/useBaseUrl';3import {materializeSvg, supportedAppearances} from '../../../../../shared/svg/index.js';4import {canonicalizeHttpRendererSvg} from './httpSvg.mjs';5import {fitView, zoomView} from './viewMath.mjs';6import {httpRendererUrlFor} from './rendererRouting.mjs';7import styles from './styles.module.css';89function normalizeSvg(source) {10 const document = new DOMParser().parseFromString(source, 'image/svg+xml');11 const svg = document.documentElement;12 const viewBox = svg.getAttribute('viewBox')?.trim().split(/[\s,]+/).map(Number);13 if (svg.nodeName === 'svg' && viewBox?.length === 4 && viewBox.every(Number.isFinite)) {14 if (!svg.hasAttribute('width')) svg.setAttribute('width', String(viewBox[2]));15 if (!svg.hasAttribute('height')) svg.setAttribute('height', String(viewBox[3]));16 return new XMLSerializer().serializeToString(svg);17 }18 return source;19}2021async function responseError(response) {22 const body = await response.text();23 if (response.headers.get('Content-Type')?.includes('application/json')) {24 try {25 const payload = JSON.parse(body);26 if (typeof payload?.error?.message === 'string') return payload.error.message;27 } catch {28 // Use the plain response text.29 }30 }31 const text = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();32 return text || `The renderer returned HTTP ${response.status}.`;33}3435export default function DiagramExample({engine, label, sourceUrl}) {36 // sourceUrl is site-absolute (/examples/<type>.json); resolve it against37 // the site's base URL so the docs also work when served under a path.38 const exampleUrl = useBaseUrl(sourceUrl);39 const viewportRef = useRef(null);40 const imageRef = useRef(null);41 const dragRef = useRef(null);42 const objectUrlRef = useRef(null);43 const [imageUrl, setImageUrl] = useState('');44 const [status, setStatus] = useState('Loading the example…');45 const [view, setView] = useState({scale: 1, x: 0, y: 0});4647 const fit = useCallback(() => {48 const viewport = viewportRef.current;49 const image = imageRef.current;50 if (!viewport || !image?.naturalWidth || !image?.naturalHeight) return;51 setView(fitView(52 viewport.clientWidth,53 viewport.clientHeight,54 image.naturalWidth,55 image.naturalHeight,56 Math.min(48, viewport.clientWidth * 0.08),57 ));58 }, []);5960 const zoom = useCallback((factor, anchor) => {61 const viewport = viewportRef.current;62 if (!viewport) return;63 const x = anchor?.x ?? viewport.clientWidth / 2;64 const y = anchor?.y ?? viewport.clientHeight / 2;65 setView((current) => zoomView(current, factor, x, y));66 }, []);6768 useEffect(() => {69 const abortController = new AbortController();7071 async function render() {72 setStatus('Loading the example…');73 try {74 const exampleResponse = await fetch(exampleUrl, {signal: abortController.signal});75 if (!exampleResponse.ok) throw new Error('The example source is not available.');76 const example = await exampleResponse.json();77 let source;78 const renderResponse = await fetch(httpRendererUrlFor(engine), {79 method: 'POST',80 headers: {Accept: 'image/svg+xml', 'Content-Type': 'application/json'},81 body: JSON.stringify({82 source: example.source,83 format: 'svg',84 options: {},85 metadata: {},86 presentation: {background: '', padding: 0, frame: false},87 }),88 signal: abortController.signal,89 });90 if (!renderResponse.ok) throw new Error(await responseError(renderResponse));91 source = canonicalizeHttpRendererSvg(await renderResponse.text(), engine, renderResponse.headers);9293 const appearances = supportedAppearances(source);94 const appearance = appearances.includes('auto-transparent')95 ? 'auto-transparent'96 : appearances.includes('auto-framed') ? 'auto-framed' : 'raw';97 source = materializeSvg(source, appearance);98 source = normalizeSvg(source);99 const objectUrl = URL.createObjectURL(new Blob([source], {type: 'image/svg+xml'}));100 if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);101 objectUrlRef.current = objectUrl;102 setImageUrl(objectUrl);103 setStatus('Loading the rendered image…');104 } catch (error) {105 if (error.name !== 'AbortError') setStatus(error.message || 'The example could not render.');106 }107 }108109 render();110 return () => {111 abortController.abort();112 if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);113 objectUrlRef.current = null;114 };115 }, [engine, exampleUrl]);116117 useEffect(() => {118 const viewport = viewportRef.current;119 if (!viewport) return undefined;120 const observer = new ResizeObserver(fit);121 observer.observe(viewport);122 return () => observer.disconnect();123 }, [fit]);124125 useEffect(() => {126 const viewport = viewportRef.current;127 if (!viewport) return undefined;128 const wheelZoom = (event) => {129 if (!imageUrl) return;130 event.preventDefault();131 const bounds = viewport.getBoundingClientRect();132 const delta = Math.max(-240, Math.min(240, event.deltaY));133 zoom(Math.exp(-delta * 0.002), {x: event.clientX - bounds.left, y: event.clientY - bounds.top});134 };135 viewport.addEventListener('wheel', wheelZoom, {passive: false});136 return () => viewport.removeEventListener('wheel', wheelZoom);137 }, [imageUrl, zoom]);138139 function startPan(event) {140 if (!imageUrl || event.button !== 0) return;141 dragRef.current = {pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY, x: view.x, y: view.y};142 event.currentTarget.setPointerCapture(event.pointerId);143 event.currentTarget.dataset.dragging = 'true';144 }145146 function pan(event) {147 const drag = dragRef.current;148 if (!drag || drag.pointerId !== event.pointerId) return;149 setView((current) => ({...current, x: drag.x + event.clientX - drag.clientX, y: drag.y + event.clientY - drag.clientY}));150 }151152 function endPan(event) {153 if (dragRef.current?.pointerId !== event.pointerId) return;154 dragRef.current = null;155 delete event.currentTarget.dataset.dragging;156 }157158 return (159 <figure className={styles.example}>160 <div className={styles.heading}>161 <div>162 <strong>Rendered example</strong>163 <span>Drag to pan. Use the wheel or controls to zoom.</span>164 </div>165 <div className={styles.controls} aria-label="Diagram view controls">166 <button type="button" onClick={() => zoom(0.8)} aria-label="Zoom out">−</button>167 <output aria-label="Zoom level">{Math.round(view.scale * 100)}%</output>168 <button type="button" onClick={() => zoom(1.25)} aria-label="Zoom in">+</button>169 <button type="button" onClick={fit}>Fit</button>170 </div>171 </div>172 <div173 ref={viewportRef}174 className={styles.viewport}175 role="img"176 aria-label={`Rendered ${label} example. Drag to pan and use the controls to zoom.`}177 onPointerDown={startPan}178 onPointerMove={pan}179 onPointerUp={endPan}180 onPointerCancel={endPan}181 >182 {imageUrl && (183 <img184 ref={imageRef}185 src={imageUrl}186 alt=""187 draggable="false"188 onLoad={() => { setStatus('Rendered'); fit(); }}189 onError={() => setStatus('The rendered image could not load.')}190 style={{transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`}}191 />192 )}193 {status !== 'Rendered' && <p className={styles.status} role="status">{status}</p>}194 </div>195 <figcaption>This view uses the live diagram.zip renderer.</figcaption>196 </figure>197 );198}