rspace-online/lib/folk-mermaid-gen.ts

694 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const MERMAID_ANIMATOR_URL = "https://mermaid.rspace.online";
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 380px;
min-height: 460px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #7c3aed, #2563eb);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
.input-area {
padding: 12px;
border-bottom: 1px solid var(--rs-border, #e2e8f0);
}
.prompt-input, .code-input {
width: 100%;
padding: 10px 12px;
border: 2px solid var(--rs-input-border, #e2e8f0);
border-radius: 8px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
background: var(--rs-input-bg, #fff);
color: var(--rs-input-text, inherit);
}
.code-input {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
}
.prompt-input:focus, .code-input:focus {
border-color: #7c3aed;
}
.controls {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.generate-btn, .animate-btn {
flex: 1;
padding: 8px 12px;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
min-width: 100px;
}
.generate-btn {
background: linear-gradient(135deg, #7c3aed, #2563eb);
}
.animate-btn {
background: linear-gradient(135deg, #059669, #0d9488);
}
.generate-btn:hover, .animate-btn:hover {
opacity: 0.9;
}
.generate-btn:disabled, .animate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-btn {
padding: 6px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 11px;
background: white;
cursor: pointer;
}
.toggle-btn.active {
background: #7c3aed;
color: white;
border-color: #7c3aed;
}
.controls-row {
display: flex;
gap: 6px;
margin-top: 6px;
align-items: center;
}
.controls-row select {
padding: 5px 8px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 11px;
background: white;
cursor: pointer;
}
.controls-row label {
font-size: 11px;
color: #64748b;
}
.controls-row input[type="range"] {
width: 80px;
accent-color: #7c3aed;
}
.preview-area {
flex: 1;
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
text-align: center;
gap: 8px;
}
.placeholder-icon {
font-size: 48px;
opacity: 0.5;
}
.svg-preview {
width: 100%;
border-radius: 8px;
background: white;
padding: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
overflow: auto;
}
.svg-preview svg {
max-width: 100%;
height: auto;
}
.gif-preview {
width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.history-item {
position: relative;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px;
}
.history-prompt {
font-size: 11px;
color: #64748b;
margin-top: 4px;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 4px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
}
.download-btn {
display: inline-block;
margin-top: 4px;
padding: 4px 10px;
font-size: 11px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
color: inherit;
}
.download-btn:hover {
background: #e2e8f0;
}
`;
interface MermaidDiagram {
id: string;
prompt: string;
source: string;
svgHtml: string;
gifBase64?: string;
timestamp: Date;
}
declare global {
interface HTMLElementTagNameMap {
"folk-mermaid-gen": FolkMermaidGen;
}
}
export class FolkMermaidGen extends FolkShape {
static override tagName = "folk-mermaid-gen";
static override portDescriptors = [
{ name: "prompt", type: "text" as const, direction: "input" as const },
{ name: "diagram", type: "text" as const, direction: "output" as const },
];
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n");
const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#diagrams: MermaidDiagram[] = [];
#isLoading = false;
#error: string | null = null;
#showCode = false;
#currentSource = "";
#animationMode: "progressive" | "template" | "sequence" = "progressive";
#animationDelay = 800;
#theme: "default" | "dark" | "forest" | "neutral" = "default";
// DOM refs
#promptInput: HTMLTextAreaElement | null = null;
#codeInput: HTMLTextAreaElement | null = null;
#previewArea: HTMLElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#animateBtn: HTMLButtonElement | null = null;
#toggleBtn: HTMLButtonElement | null = null;
#modeSelect: HTMLSelectElement | null = null;
#themeSelect: HTMLSelectElement | null = null;
#delayInput: HTMLInputElement | null = null;
#delayLabel: HTMLElement | null = null;
#promptArea: HTMLElement | null = null;
#codeArea: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🔀</span>
<span>Mermaid Diagrams</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="input-area">
<div class="prompt-section">
<textarea class="prompt-input" placeholder="Describe the diagram you want (e.g. 'CI/CD pipeline with build, test, deploy')..." rows="2"></textarea>
</div>
<div class="code-section" style="display:none">
<textarea class="code-input" placeholder="graph TD\n A[Start] --> B[End]" rows="4"></textarea>
</div>
<div class="controls">
<button class="generate-btn">Generate</button>
<button class="animate-btn" disabled>Animate GIF</button>
<button class="toggle-btn">Code</button>
</div>
<div class="controls-row">
<label>Mode:</label>
<select class="mode-select">
<option value="progressive">Progressive</option>
<option value="template">Template</option>
<option value="sequence">Sequence</option>
</select>
<label>Theme:</label>
<select class="theme-select">
<option value="default">Default</option>
<option value="dark">Dark</option>
<option value="forest">Forest</option>
<option value="neutral">Neutral</option>
</select>
<label>Delay:</label>
<input type="range" class="delay-input" min="100" max="2000" step="100" value="800" />
<span class="delay-label">800ms</span>
</div>
</div>
<div class="preview-area">
<div class="placeholder">
<span class="placeholder-icon">🔀</span>
<span>Describe a diagram or write mermaid code</span>
</div>
</div>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
// Cache DOM refs
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#codeInput = wrapper.querySelector(".code-input");
this.#previewArea = wrapper.querySelector(".preview-area");
this.#generateBtn = wrapper.querySelector(".generate-btn");
this.#animateBtn = wrapper.querySelector(".animate-btn");
this.#toggleBtn = wrapper.querySelector(".toggle-btn");
this.#modeSelect = wrapper.querySelector(".mode-select");
this.#themeSelect = wrapper.querySelector(".theme-select");
this.#delayInput = wrapper.querySelector(".delay-input");
this.#delayLabel = wrapper.querySelector(".delay-label");
this.#promptArea = wrapper.querySelector(".prompt-section");
this.#codeArea = wrapper.querySelector(".code-section");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Event listeners
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#showCode) {
this.#renderFromCode();
} else {
this.#generateFromPrompt();
}
});
this.#animateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#animateGif();
});
this.#toggleBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#showCode = !this.#showCode;
this.#updateToggle();
});
this.#modeSelect?.addEventListener("change", () => {
this.#animationMode = (this.#modeSelect?.value as any) || "progressive";
});
this.#themeSelect?.addEventListener("change", () => {
this.#theme = (this.#themeSelect?.value as any) || "default";
});
this.#delayInput?.addEventListener("input", () => {
this.#animationDelay = parseInt(this.#delayInput?.value || "800");
if (this.#delayLabel) this.#delayLabel.textContent = `${this.#animationDelay}ms`;
});
this.#promptInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.#generateFromPrompt();
}
});
this.#codeInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.ctrlKey) {
e.preventDefault();
this.#renderFromCode();
}
});
// Prevent canvas drag
for (const el of [this.#previewArea, this.#promptInput, this.#codeInput, this.#modeSelect, this.#themeSelect, this.#delayInput]) {
el?.addEventListener("pointerdown", (e) => e.stopPropagation());
}
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
#updateToggle() {
if (this.#promptArea) this.#promptArea.style.display = this.#showCode ? "none" : "";
if (this.#codeArea) this.#codeArea.style.display = this.#showCode ? "" : "none";
if (this.#toggleBtn) {
this.#toggleBtn.textContent = this.#showCode ? "Prompt" : "Code";
this.#toggleBtn.classList.toggle("active", this.#showCode);
}
if (this.#generateBtn) {
this.#generateBtn.textContent = this.#showCode ? "Render" : "Generate";
}
// Sync code input with current source
if (this.#showCode && this.#codeInput && this.#currentSource) {
this.#codeInput.value = this.#currentSource;
}
}
async #generateFromPrompt() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || this.#isLoading) return;
this.#isLoading = true;
this.#error = null;
this.#setButtonsDisabled(true);
this.#renderLoading("Generating diagram...");
try {
const response = await fetch("/api/prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "llama3.1",
systemPrompt: "You are a Mermaid diagram generator. Output ONLY valid mermaid diagram code, no explanation, no markdown code fences. Start directly with the diagram type (graph, flowchart, sequenceDiagram, etc).",
messages: [{ role: "user", content: `Generate a Mermaid diagram for: ${prompt}` }],
}),
});
if (!response.ok) throw new Error(`AI generation failed: ${response.statusText}`);
const data = await response.json() as { content?: string };
let source = (data.content || "").trim();
// Strip markdown fences if present
source = source.replace(/^```(?:mermaid)?\n?/i, "").replace(/\n?```$/i, "").trim();
if (!source) throw new Error("Empty response from AI");
this.#currentSource = source;
if (this.#codeInput) this.#codeInput.value = source;
await this.#renderMermaidSvg(source, prompt);
} catch (error) {
this.#error = error instanceof Error ? error.message : "Generation failed";
this.#renderError();
} finally {
this.#isLoading = false;
this.#setButtonsDisabled(false);
}
}
async #renderFromCode() {
const source = this.#codeInput?.value.trim();
if (!source || this.#isLoading) return;
this.#currentSource = source;
this.#isLoading = true;
this.#error = null;
this.#setButtonsDisabled(true);
this.#renderLoading("Rendering diagram...");
try {
await this.#renderMermaidSvg(source, "(code edit)");
} catch (error) {
this.#error = error instanceof Error ? error.message : "Render failed";
this.#renderError();
} finally {
this.#isLoading = false;
this.#setButtonsDisabled(false);
}
}
async #renderMermaidSvg(source: string, prompt: string) {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({ startOnLoad: false, theme: this.#theme });
const id = `mermaid-${Date.now()}`;
const { svg } = await mermaid.render(id, source);
const diagram: MermaidDiagram = {
id: crypto.randomUUID(),
prompt,
source,
svgHtml: svg,
timestamp: new Date(),
};
this.#diagrams.unshift(diagram);
this.#renderPreview();
if (this.#animateBtn) this.#animateBtn.disabled = false;
if (this.#promptInput) this.#promptInput.value = "";
}
async #animateGif() {
if (!this.#currentSource || this.#isLoading) return;
this.#isLoading = true;
this.#setButtonsDisabled(true);
this.#renderLoading("Animating GIF...");
try {
const response = await fetch(`${MERMAID_ANIMATOR_URL}/api/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: this.#currentSource,
mode: this.#animationMode,
delay: this.#animationDelay,
theme: this.#theme,
}),
});
if (!response.ok) throw new Error(`Animation failed: ${response.statusText}`);
const data = await response.json() as { gif: string; frames: number; width: number; height: number };
// Update latest history entry with GIF
if (this.#diagrams.length > 0) {
this.#diagrams[0].gifBase64 = data.gif;
}
this.#renderPreview();
} catch (error) {
this.#error = error instanceof Error ? error.message : "Animation failed";
this.#renderError();
} finally {
this.#isLoading = false;
this.#setButtonsDisabled(false);
}
}
#setButtonsDisabled(disabled: boolean) {
if (this.#generateBtn) this.#generateBtn.disabled = disabled;
if (this.#animateBtn) this.#animateBtn.disabled = disabled || !this.#currentSource;
}
#renderLoading(message: string) {
if (!this.#previewArea) return;
this.#previewArea.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<span>${this.#escapeHtml(message)}</span>
</div>
`;
}
#renderError() {
if (!this.#previewArea) return;
this.#previewArea.innerHTML = `
<div class="error">${this.#escapeHtml(this.#error || "Unknown error")}</div>
${this.#diagrams.length > 0 ? this.#renderDiagramList() : '<div class="placeholder"><span class="placeholder-icon">🔀</span><span>Try again</span></div>'}
`;
}
#renderPreview() {
if (!this.#previewArea) return;
if (this.#diagrams.length === 0) {
this.#previewArea.innerHTML = `
<div class="placeholder">
<span class="placeholder-icon">🔀</span>
<span>Describe a diagram or write mermaid code</span>
</div>
`;
return;
}
this.#previewArea.innerHTML = this.#renderDiagramList();
// Wire up download buttons
this.#previewArea.querySelectorAll(".download-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
});
});
}
#renderDiagramList(): string {
return this.#diagrams.map((d) => {
const preview = d.gifBase64
? `<img class="gif-preview" src="data:image/gif;base64,${d.gifBase64}" alt="Animated diagram" />`
: `<div class="svg-preview">${d.svgHtml}</div>`;
const downloadLink = d.gifBase64
? `<a class="download-btn" href="data:image/gif;base64,${d.gifBase64}" download="mermaid-diagram.gif">Download GIF</a>`
: "";
return `
<div class="history-item">
${preview}
<div class="history-prompt">${this.#escapeHtml(d.prompt)}</div>
${downloadLink}
</div>
`;
}).join("");
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkMermaidGen {
const shape = FolkShape.fromData(data) as FolkMermaidGen;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-mermaid-gen",
diagrams: this.#diagrams.map((d) => ({
...d,
timestamp: d.timestamp.toISOString(),
})),
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}