add mermaid inline AI

This commit is contained in:
saurabhg772244
2026-02-12 13:13:16 +05:30
parent 629d9639d2
commit 830df8c44b
6 changed files with 316 additions and 7 deletions
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import { cn } from '$lib/utils.js';
import CloseIcon from '~icons/material-symbols/close-rounded';
interface Props {
top: number;
show: boolean;
suggestion: string;
onclose: () => void;
ontryFree: () => void;
}
let { top, show, suggestion = $bindable(), onclose, ontryFree }: Props = $props();
let textarea = $state<HTMLTextAreaElement>();
function resizeTextarea() {
if (!textarea) return;
const computed = globalThis.getComputedStyle(textarea);
const lineHeight =
Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.5 || 0;
const paddingTop = Number.parseFloat(computed.paddingTop) || 0;
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
const minHeight = lineHeight + paddingTop + paddingBottom;
const maxLines = 8;
const maxHeight = lineHeight * maxLines + paddingTop + paddingBottom;
textarea.style.height = 'auto';
const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight));
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
}
$effect(() => {
if (suggestion !== undefined) {
resizeTextarea();
}
});
</script>
{#if show}
<div
class={cn(
'button-container-for-animation absolute right-4 left-12 z-50 flex flex-col gap-2 rounded-xl border-2 bg-white p-2 shadow-xl',
'border-primary-200 dark:border-secondary-700 dark:bg-secondary-900',
!suggestion.trim() && 'rainbow-border'
)}
style="top: {top}px;"
role="dialog"
aria-modal="true"
tabindex="-1">
<div class="relative flex min-h-2 items-start gap-1 px-1">
<textarea
bind:this={textarea}
bind:value={suggestion}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (suggestion.trim()) {
ontryFree();
}
}
}}
placeholder="Describe what to add or change"
rows="1"
class="focus font-recursive flex-1 resize-none border-none bg-transparent px-1 text-sm font-normal text-foreground placeholder:text-muted-foreground focus:ring-0 focus:outline-none disabled:opacity-50 dark:text-muted-foreground dark:placeholder:text-muted-foreground"
style="height: 20px; overflow-y: hidden;"></textarea>
<button onclick={onclose} class="text-muted-foreground hover:text-foreground">
<CloseIcon class="size-4" />
</button>
</div>
<div class="flex items-center justify-between">
<span class="font-recursive text-xs font-normal text-foreground"
>Signup to Mermaid.ai to try AI</span>
<Button
disabled={!suggestion.trim()}
class="font-recursive h-6 w-16 gap-1.5 rounded-sm bg-accent p-1 text-xs font-medium text-white no-underline hover:bg-accent/90 hover:text-white hover:no-underline active:bg-accent/80 dark:bg-accent dark:text-white! dark:hover:bg-accent/90 dark:active:bg-accent/80"
onclick={ontryFree}>
Try free
</Button>
</div>
</div>
{/if}
<style>
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes gradient-angle-shift {
0% {
--gradient-angle: 0deg;
}
100% {
--gradient-angle: 360deg;
}
}
.button-container-for-animation {
border-radius: 12px;
}
.button-container-for-animation::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 14px;
padding: 2px;
background: conic-gradient(
from var(--gradient-angle, 0deg),
color-mix(in srgb, var(--color-accent) 0%, transparent) 0%,
color-mix(in srgb, var(--color-accent) 0%, transparent) 12%,
color-mix(in srgb, var(--color-accent) 12%, transparent) 18%,
color-mix(in srgb, var(--color-accent) 42%, transparent) 24%,
color-mix(in srgb, var(--color-accent) 77%, transparent) 32%,
rgba(93, 85, 212, 0.923) 41%,
rgba(93, 85, 212, 0.5) 52%,
color-mix(in srgb, var(--color-accent) 58%, transparent) 72%,
color-mix(in srgb, var(--color-accent) 56%, transparent) 82%,
color-mix(in srgb, var(--color-accent) 36%, transparent) 87%,
color-mix(in srgb, var(--color-accent) 19%, transparent) 92%,
color-mix(in srgb, var(--color-accent) 10%, transparent) 96%,
color-mix(in srgb, var(--color-accent) 3%, transparent) 98%,
color-mix(in srgb, var(--color-accent) 0%, transparent) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
z-index: 0;
opacity: 0;
transition: opacity 0.3s ease-out;
}
.button-container-for-animation.rainbow-border::before {
opacity: 1;
animation: gradient-angle-shift 2s linear infinite;
}
</style>
+146 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { env } from '$/util/env';
import { stateStore } from '$/util/state';
import { stateStore, urlsStore } from '$/util/state';
import { isMac } from '$/util/util';
import { initEditor } from '$lib/util/monacoExtra';
import { errorDebug } from '$lib/util/util';
import { mode } from 'mode-watcher';
@@ -9,6 +10,7 @@
import monacoEditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import monacoJsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import { onMount } from 'svelte';
import AIGlyphPopup from './AIGlyphPopup.svelte';
const { onUpdate }: EditorProps = $props();
@@ -18,9 +20,17 @@
minimap: {
enabled: false
},
overviewRulerLanes: 0
overviewRulerLanes: 0,
glyphMargin: true
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = '';
let showPopup = $state(false);
let popupPosition = $state({ top: 0 });
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
let hintCollection: monaco.editor.IEditorDecorationsCollection | undefined;
let suggestion = $state('');
const quickEditHintStyle = `--quick-edit-hint-text: '${isMac ? 'Quick edit (⌘⏎)' : 'Quick edit (Ctrl+⏎)'}'`;
const jsonModel = monaco.editor.createModel(
'',
@@ -33,6 +43,31 @@
monaco.Uri.parse('internal://mermaid.mmd')
);
const renderQuickEditHint = () => {
hintCollection?.clear();
if (!editor || showPopup) {
return;
}
const model = editor.getModel();
const position = editor.getPosition();
if (!model || !position) {
return;
}
const { lineNumber } = position;
if (model.getLineContent(lineNumber).trim() === '') {
const column = model.getLineMaxColumn(lineNumber);
hintCollection?.set([
{
range: new monaco.Range(lineNumber, column, lineNumber, column),
options: {
afterContentClassName: 'quick-edit-hint'
}
}
]);
}
};
onMount(() => {
self.MonacoEnvironment = {
getWorker(_, label) {
@@ -61,7 +96,31 @@
initEditor(monaco);
errorDebug();
editor = monaco.editor.create(divElement, editorOptions);
decorationsCollection = editor.createDecorationsCollection([]);
hintCollection = editor.createDecorationsCollection([]);
editor.onMouseDown((e) => {
if (
e.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN &&
e.target.position?.lineNumber
) {
const mouseEvent = e.event;
if (!divElement) return;
const rect = divElement.getBoundingClientRect();
popupPosition = {
top: mouseEvent.posy - rect.top
};
showPopup = !showPopup;
renderQuickEditHint();
}
});
editor.onDidChangeCursorPosition(() => {
renderQuickEditHint();
});
editor.onDidChangeModelContent(({ isFlush }) => {
renderQuickEditHint();
const newText = editor?.getValue();
if (!newText || currentText === newText || isFlush) {
return;
@@ -79,6 +138,12 @@
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderQuickEditHint();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
@@ -87,12 +152,38 @@
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
renderQuickEditHint();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
editor.onMouseMove((e) => {
if (!editor) return;
if (showPopup) return;
if (editor.getModel()?.id !== mermaidModel.id) return;
if (e.target.position?.lineNumber) {
const line = e.target.position.lineNumber;
decorationsCollection?.set([
{
range: new monaco.Range(line, 1, line, 1),
options: {
isWholeLine: true,
glyphMarginClassName: 'suggestion-icon'
}
}
]);
} else {
decorationsCollection?.clear();
}
});
editor.onMouseLeave(() => {
decorationsCollection?.clear();
});
const unsubscribeMode = mode.subscribe((mode) => {
if (editor) {
monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
@@ -109,6 +200,8 @@
resizeObserver.observe(divElement);
}
renderQuickEditHint();
return () => {
unsubscribeState();
unsubscribeMode();
@@ -120,4 +213,54 @@
});
</script>
<div bind:this={divElement} id="editor" class="h-full flex-grow overflow-hidden"></div>
<div class="relative h-full grow overflow-hidden" style={quickEditHintStyle}>
<div bind:this={divElement} id="editor" class="h-full w-full"></div>
<AIGlyphPopup
top={popupPosition.top}
show={showPopup}
bind:suggestion
onclose={() => {
showPopup = false;
suggestion = '';
renderQuickEditHint();
}}
ontryFree={() => {
window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
showPopup = false;
suggestion = '';
renderQuickEditHint();
}} />
</div>
<style>
:global(.quick-edit-hint::after) {
content: var(--quick-edit-hint-text);
color: #a1a1aa;
font-size: small;
pointer-events: none;
user-select: none;
margin-left: 4px;
opacity: 0.6;
}
:global(.ai-glyph-margin) {
background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="24" height="24" viewBox="0 0 24 24"%3E%3Cpath fill="%23a855f7" d="M9 21.035l-1.185-5.917L2 14.154l5.815-1.139L9 7.098l1.328 5.917l5.672 1.139l-5.672 1.157L9 21.035zM15.42 11.22l-1.614-2.822l-2.86-.948l2.86-.928l1.614-2.86l1.248 2.86l2.902.928l-2.902.948l-1.248 2.822zm2.083 10.39l-1.04-2.583l-2.607-.647l2.607-.625l1.04-2.606l.855 2.606l2.585.625l-2.585.647l-.855 2.583z"%2F%3E%3C%2Fsvg%3E');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
cursor: pointer;
}
:global(.suggestion-icon) {
background-color: #e8eaf9;
width: 20px !important;
height: 20px !important;
margin-left: 4px;
background-image: url('/icons/use-chat.svg');
background-size: 16px 16px;
background-repeat: no-repeat;
background-position: center;
border-radius: 4px;
cursor: pointer;
}
</style>
+1
View File
@@ -10,5 +10,6 @@ export const TID = {
} as const;
export const C = {
aiLiveEditor: 'ai_live_editor',
utmSource: 'mermaid_live_editor'
} as const;
+4 -4
View File
@@ -1,4 +1,3 @@
import { C } from '$/constants';
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { debounce } from 'lodash-es';
import type { MermaidConfig } from 'mermaid';
@@ -12,7 +11,7 @@ import {
import { parse } from './mermaid';
import { localStorage, persist } from './persist';
import { deserializeState, pakoSerde, serializeState } from './serde';
import { errorDebug, formatJSON, MCBaseURL } from './util';
import { errorDebug, formatJSON, getSource, MCBaseURL } from './util';
export const defaultState: State = {
code: `flowchart TD
@@ -140,10 +139,11 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
mermaidChart: ({
medium
}: {
medium: 'ai_repair' | 'main_menu' | 'save_diagram' | 'share';
medium: 'ai_repair' | 'main_menu' | 'save_diagram' | 'share' | 'vibe_diagramming';
}) => {
const utmSource = getSource();
const params = new URLSearchParams({
utm_source: C.utmSource,
utm_source: utmSource,
utm_medium: medium
}).toString();
return {
+8
View File
@@ -1,3 +1,4 @@
import { C } from '../constants';
import { env } from './env';
import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading';
@@ -11,6 +12,13 @@ export const getDomain = (url?: string): string => {
return domain;
};
export const getSource = (): string => {
if (typeof window !== 'undefined' && window.location.host.includes('mermaid.ai')) {
return C.aiLiveEditor;
}
return C.utmSource;
};
export const loadStateFromURL = (): void => {
loadState(window.location.hash.slice(1));
};