import mermaid from 'mermaid'; import { action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc, DocListCast } from '../../../fields/Doc'; import { RichTextField } from '../../../fields/RichTextField'; import { Cast, DocCast, NumCast, RTFCast, StrCast } from '../../../fields/Types'; import { Gestures } from '../../../pen-gestures/GestureTypes'; import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; import { LinkManager } from '../../util/LinkManager'; import { undoable } from '../../util/UndoManager'; import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { InkingStroke } from '../InkingStroke'; import './DiagramBox.scss'; import { FieldView, FieldViewProps } from './FieldView'; import { FormattedTextBox } from './formattedText/FormattedTextBox'; import { Tooltip } from '@mui/material'; /** * this is a class for the diagram box doc type that can be found in the tools section of the side bar */ @observer export class DiagramBox extends ViewBoxAnnotatableComponent() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DiagramBox, fieldKey); } static isPointInBox = (box: Doc, pt: number[]): boolean => { if (typeof pt[0] === 'number' && typeof box.x === 'number' && typeof box.y === 'number' && typeof pt[1] === 'number') { return pt[0] < box.x + NumCast(box.width) && pt[0] > box.x && pt[1] > box.y && pt[1] < box.y + NumCast(box.height); } return false; }; _boxRef: HTMLDivElement | null = null; constructor(props: FieldViewProps) { super(props); makeObservable(this); } @observable _showCode = false; @observable _inputValue = ''; @observable _generating = false; @observable _errorMessage = ''; @computed get mermaidcode() { return StrCast(this.Document.$text, RTFCast(this.Document.$text)?.Text); } componentDidMount() { this._props.setContentViewBox?.(this); mermaid.initialize({ securityLevel: 'loose', startOnLoad: true, flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'cardinal' }, }); // when a new doc/text/ink/shape is created in the freeform view, this generates the corresponding mermaid diagram code reaction( () => DocListCast(this.Document.data), docArray => docArray.length && this.convertDrawingToMermaidCode(docArray), { fireImmediately: true } ); } /** * helper method for renderMermaidAsync * @param str string containing the mermaid code * @returns */ renderMermaid = (str: string) => { try { return mermaid.render('graph' + Date.now(), str); } catch { return { svg: '', bindFunctions: undefined }; } }; /** * will update the div containing the mermaid diagram to render the new mermaidCode */ renderMermaidAsync = async (mermaidCode: string, dashDiv: HTMLDivElement) => { try { const { svg, bindFunctions } = await this.renderMermaid(mermaidCode); dashDiv.innerHTML = svg; bindFunctions?.(dashDiv); } catch (error) { console.error('Error rendering Mermaid:', error); } }; setMermaidCode = undoable((res: string) => { this.Document.$text = new RichTextField( JSON.stringify({ doc: { type: 'doc', content: [ { type: 'code_block', content: [ { type: 'text', text: `^@mermaids\n` }, { type: 'text', text: this.removeWords(res) }, ], }, ], }, selection: { type: 'text', anchor: 1, head: 1 }, }), res ); }, 'set mermaid code'); /** * will generate mermaid code with GPT based on what the user requested */ generateMermaidCode = action(() => { this._generating = true; const prompt = 'Write this in mermaid code and only give me the mermaid code: ' + this._inputValue; gptAPICall(prompt, GPTCallType.MERMAID).then( action(res => { this._generating = false; if (res === 'Error connecting with API.') { this._errorMessage = 'GPT call failed; please try again.'; } // If GPT call succeeded, set mermaid code on Doc which will trigger a rendering if _showCode is false else if (res && this.isValidCode(res)) { this.setMermaidCode(res); this._errorMessage = ''; } else { this._errorMessage = 'GPT call succeeded but invalid html; please try again.'; } }) ); }); isValidCode = (html: string) => (html ? true : false); removeWords = (inputStrIn: string) => inputStrIn.replace('```mermaid', '').replace(`^@mermaids`, '').replace('```', '').replace(/^"/, '').replace(/"$/, ''); // method to convert the drawings on collection node side the mermaid code convertDrawingToMermaidCode = async (docArray: Doc[]) => { const rectangleArray = docArray.filter(doc => doc.title === Gestures.Rectangle || doc.title === Gestures.Circle); const lineArray = docArray.filter(doc => doc.title === Gestures.Line || doc.title === Gestures.Stroke); const textArray = docArray.filter(doc => doc.type === DocumentType.RTF); await new Promise(resolve => setTimeout(resolve)); const inkStrokeArray = lineArray.map(doc => DocumentManager.Instance.getDocumentView(doc, this.DocumentView?.())).filter(inkView => inkView?.ComponentView instanceof InkingStroke); if (inkStrokeArray[0] && inkStrokeArray.length === lineArray.length) { let mermaidCode = `graph TD \n`; const inkingStrokeArray = inkStrokeArray.map(stroke => stroke?.ComponentView as InkingStroke).filter(stroke => stroke); for (const rectangle of rectangleArray) { for (const inkStroke of inkingStrokeArray) { const inkData = inkStroke.inkScaledData(); const { inkScaleX, inkScaleY } = inkData; const inkStrokeXArray = inkData.inkData.map(coord => coord.X * inkScaleX); const inkStrokeYArray = inkData.inkData.map(coord => coord.Y * inkScaleY); // need to minX and minY to since the inkStroke.x and.y is not relative to the doc. so I have to do some calcluations const offX = Math.min(...inkStrokeXArray) - NumCast(inkStroke.Document.x); const offY = Math.min(...inkStrokeYArray) - NumCast(inkStroke.Document.y); const startX = inkStrokeXArray[0] - offX; const startY = inkStrokeYArray[0] - offY; const endX = inkStrokeXArray.lastElement() - offX; const endY = inkStrokeYArray.lastElement() - offY; if (DiagramBox.isPointInBox(rectangle, [startX, startY])) { for (const rectangle2 of rectangleArray) { if (DiagramBox.isPointInBox(rectangle2, [endX, endY])) { const linkedDocs = LinkManager.Instance.getAllRelatedLinks(inkStroke.Document).map(d => DocCast(LinkManager.getOppositeAnchor(d, inkStroke.Document))); const linkedDocText = Cast(linkedDocs[0]?.text, RichTextField, null)?.Text; const linkText = linkedDocText ? `|${linkedDocText}|` : ''; mermaidCode += ' ' + Math.abs(NumCast(rectangle.x)) + this.getTextInBox(rectangle, textArray) + '-->' + linkText + Math.abs(NumCast(rectangle2.x)) + this.getTextInBox(rectangle2, textArray) + `\n`; } } } } this.setMermaidCode(mermaidCode); } } }; getTextInBox = (box: Doc, richTextArray: Doc[]) => { for (const textDoc of richTextArray) { if (DiagramBox.isPointInBox(box, [NumCast(textDoc.x), NumCast(textDoc.y)])) { switch (box.title) { case Gestures.Rectangle: return '(' + ((textDoc.text as RichTextField)?.Text ?? '') + ')'; case Gestures.Circle: return '((' + ((textDoc.text as RichTextField)?.Text ?? '') + '))'; default: } // prettier-ignore } } return '( )'; }; setRef = (r: HTMLDivElement | null) => this.fixWheelEvents(r, this._props.isContentActive); setDiagramBoxRef = (r: HTMLDivElement | null) => r && this.renderMermaidAsync.call(this, this.removeWords(this.mermaidcode), r); render() { return (
e.key === 'Enter' && this.generateMermaidCode())} onChange={action(e => (this._inputValue = e.target.value))} /> (this._showCode = !this._showCode))} />
{this._showCode ? ( ) : this._generating ? (
) : (
{this._errorMessage || 'Type a prompt to generate a diagram'}
)}
); } } Docs.Prototypes.TemplateMap.set(DocumentType.DIAGRAM, { layout: { view: DiagramBox, dataField: 'data' }, options: { _height: 300, // _layout_fitWidth: true, _layout_nativeDimEditable: true, _layout_reflowVertical: true, _layout_reflowHorizontal: true, waitForDoubleClickToClick: 'never', systemIcon: 'BsGlobe', }, });