Merge pull request #1888 from mermaid-js/release-promotion

Release live editor
This commit is contained in:
Sidharth Vinod
2026-02-16 21:05:24 +05:30
committed by GitHub
9 changed files with 418 additions and 59 deletions
+174
View File
@@ -0,0 +1,174 @@
<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 {
show: boolean;
input: string;
onClose: () => void;
onHeightChange?: (height: number) => void;
onTryFree: () => void;
}
let { show, input = $bindable(), onClose, onHeightChange, onTryFree }: Props = $props();
let textarea = $state<HTMLTextAreaElement>();
let container = $state<HTMLDivElement>();
$effect(() => {
if (!container || !onHeightChange) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target instanceof HTMLElement) {
onHeightChange(entry.target.offsetHeight);
}
}
});
observer.observe(container);
return () => observer.disconnect();
});
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 (input !== undefined) {
resizeTextarea();
}
});
function handleKeydown(e: KeyboardEvent) {
if (show && e.key === 'Escape') {
onClose();
}
}
function handleOutsideClick(e: MouseEvent) {
if (show && container && e.target instanceof Node && !container.contains(e.target)) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} onmousedown={handleOutsideClick} />
{#if show}
<div
bind:this={container}
class={cn(
'button-container-for-animation relative z-50 mr-6 flex w-auto flex-col gap-2 rounded-xl border-2 border-border bg-background p-2 shadow-xl dark:border-border-dark dark:bg-secondary',
!input.trim() && 'rainbow-border'
)}
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={input}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (input.trim()) {
onTryFree();
}
}
}}
placeholder="Describe what to add or change"
rows="1"
class="focus font-recursive min-h-0 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-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 dark:text-foreground"
>Sign Up at Mermaid.ai to try AI</span>
<Button
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>
+129 -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 { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
import { initEditor } from '$lib/util/monacoExtra';
import { errorDebug } from '$lib/util/util';
import { mode } from 'mode-watcher';
@@ -9,18 +10,28 @@
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 AIPromptPopup from './AIPromptPopup.svelte';
const { onUpdate }: EditorProps = $props();
let divElement: HTMLDivElement | undefined = $state();
let aiPromptPopupElement: HTMLDivElement | undefined = $state();
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
let editorOptions = {
minimap: {
enabled: false
},
overviewRulerLanes: 0
overviewRulerLanes: 0,
glyphMargin: true,
lineNumbersMinChars: 4
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = '';
let showPopup = $state(false);
let popupPosition = $state({ top: 0, lineNumber: 0 });
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
let input = $state('');
let lastMouseLine = 0;
const aiPromptManager = new AIPromptViewZoneManager();
const jsonModel = monaco.editor.createModel(
'',
@@ -33,6 +44,51 @@
monaco.Uri.parse('internal://mermaid.mmd')
);
const renderAIPromptGutterGlyphIcon = () => {
decorationsCollection?.clear();
if (!editor || showPopup) {
return;
}
const model = editor.getModel();
if (!model) {
return;
}
if (lastMouseLine > 0 && model.id === mermaidModel.id) {
decorationsCollection?.set([
{
range: new monaco.Range(lastMouseLine, 1, lastMouseLine, 1),
options: {
glyphMarginClassName: 'suggestion-icon'
}
}
]);
}
};
const closePopup = () => {
showPopup = false;
input = '';
aiPromptManager.hide();
renderAIPromptGutterGlyphIcon();
};
const toggleAIPopup = (lineNumber: number) => {
if (!divElement || !aiPromptPopupElement) return;
popupPosition = {
top: 0,
lineNumber
};
showPopup = !showPopup;
if (showPopup) {
aiPromptManager.show(popupPosition.lineNumber, aiPromptPopupElement, 100);
editor?.setSelection(new monaco.Range(0, 0, 0, 0));
} else {
aiPromptManager.hide();
}
renderAIPromptGutterGlyphIcon();
};
onMount(() => {
self.MonacoEnvironment = {
getWorker(_, label) {
@@ -61,6 +117,18 @@
initEditor(monaco);
errorDebug();
editor = monaco.editor.create(divElement, editorOptions);
aiPromptManager.setEditor(editor);
decorationsCollection = editor.createDecorationsCollection([]);
editor.onMouseDown((e) => {
const isGutter = e.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN;
if (isGutter && e.target.position?.lineNumber === lastMouseLine && lastMouseLine > 0) {
e.event.preventDefault();
e.event.stopPropagation();
toggleAIPopup(e.target.position.lineNumber);
}
});
editor.onDidChangeModelContent(({ isFlush }) => {
const newText = editor?.getValue();
if (!newText || currentText === newText || isFlush) {
@@ -79,6 +147,12 @@
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// 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,15 +161,31 @@
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
renderAIPromptGutterGlyphIcon();
}
// 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;
lastMouseLine = e.target.position?.lineNumber ?? 0;
renderAIPromptGutterGlyphIcon();
});
editor.onMouseLeave(() => {
lastMouseLine = 0;
renderAIPromptGutterGlyphIcon();
});
const unsubscribeMode = mode.subscribe((mode) => {
if (editor) {
monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', mode === 'dark');
}
});
const resizeObserver = new ResizeObserver((entries) => {
@@ -109,15 +199,51 @@
resizeObserver.observe(divElement);
}
renderAIPromptGutterGlyphIcon();
return () => {
unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
aiPromptManager.destroy();
editor?.dispose();
};
});
</script>
<div bind:this={divElement} id="editor" class="h-full flex-grow overflow-hidden"></div>
<div class="relative h-full grow overflow-hidden">
<div bind:this={divElement} id="editor" class="h-full w-full"></div>
<div bind:this={aiPromptPopupElement}>
<AIPromptPopup
show={showPopup}
bind:input
onHeightChange={(height) => aiPromptManager.updateHeight(height)}
onClose={closePopup}
onTryFree={() => {
window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
closePopup();
}} />
</div>
</div>
<style>
: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;
}
:global(#editor.mermaid-dark .suggestion-icon) {
background-color: #2e4d6b;
background-image: url('/icons/use-chat-dark.svg');
}
</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;
+71
View File
@@ -0,0 +1,71 @@
import type * as monaco from 'monaco-editor';
export class AIPromptViewZoneManager {
private editor: monaco.editor.IStandaloneCodeEditor | null = null;
private viewZoneId: string | null = null;
private viewZoneNode: HTMLElement | null = null;
private viewZone: monaco.editor.IViewZone | null = null;
private topPopupSpace = 8;
public setEditor(editor: monaco.editor.IStandaloneCodeEditor): void {
this.editor = editor;
}
public show(lineNumber: number, node: HTMLElement, height: number): void {
if (!this.editor) return;
this.viewZoneNode = node;
this.editor.changeViewZones((changeAccessor) => {
if (this.viewZoneId) {
changeAccessor.removeZone(this.viewZoneId);
}
const domNode = document.createElement('div');
domNode.style.top = '0px';
domNode.style.paddingTop = '8px';
domNode.style.paddingBottom = '0px';
domNode.appendChild(this.viewZoneNode as Node);
this.viewZone = {
afterLineNumber: lineNumber,
heightInPx: height + this.topPopupSpace,
domNode: domNode
};
this.viewZoneId = changeAccessor.addZone(this.viewZone);
});
}
public updateHeight(height: number): void {
if (!this.editor || !this.viewZoneId || !this.viewZone) return;
this.viewZone.heightInPx = height + this.topPopupSpace;
this.editor.changeViewZones((changeAccessor) => {
if (this.viewZoneId) {
changeAccessor.layoutZone(this.viewZoneId);
}
});
}
public hide(): void {
if (!this.editor || !this.viewZoneId) return;
this.editor.changeViewZones((changeAccessor) => {
if (this.viewZoneId) {
changeAccessor.removeZone(this.viewZoneId);
this.viewZoneId = null;
this.viewZone = null;
}
});
if (this.viewZoneNode) {
this.viewZoneNode = null;
}
}
public destroy(): void {
this.hide();
this.editor = null;
}
}
+13 -52
View File
@@ -9,68 +9,29 @@
let { closeBanner }: Props = $props();
interface VariantConfig {
bannerCopy: string;
buttonCopy: string;
campaign: 'variant_a' | 'variant_b' | 'variant_c';
}
const utmSource = env.domain === 'mermaid.ai' ? 'mermaid_ai_live' : 'mermaid_live_editor';
const variants: VariantConfig[] = [
{
bannerCopy: 'Use code NEWYEAR for 10% off Mermaid Advanced Editor (limited time)',
buttonCopy: 'Try now',
campaign: 'variant_a'
},
{
bannerCopy: 'Limited time: 10% off Mermaid Advanced Editor with code NEWYEAR',
buttonCopy: 'Claim discount',
campaign: 'variant_b'
},
{
bannerCopy: 'Try Mermaid Advanced Editor — get 10% off with code NEWYEAR',
buttonCopy: 'Get started',
campaign: 'variant_c'
}
];
// Determine UTM source based on deployment domain
const getUtmSource = (): string => {
const domain = env.domain;
if (domain === 'mermaid.ai') {
return 'mermaid_ai_live';
}
return 'mermaid_live_editor';
};
// Randomly select a variant on component mount
const getRandomVariant = (): VariantConfig => {
const index = Math.floor(Math.random() * variants.length);
return variants[index];
};
const selectedVariant = getRandomVariant();
const utmSource = getUtmSource();
const buildUrl = (variant: VariantConfig): string => {
const params = new URLSearchParams({
utm_medium: 'banner_ad',
utm_campaign: variant.campaign,
utm_source: utmSource
});
return `https://mermaid.ai/?${params.toString()}`;
};
const url = `https://mermaid.ai/app/user/billing/checkout?${new URLSearchParams({
coupon: 'dmIuNbqx',
tier: 'plus',
utm_campaign: 'newyear',
utm_medium: 'banner_ad',
utm_source: utmSource
}).toString()}`;
</script>
<div class="flex w-full items-center bg-[#E0095F] p-1.5" role="banner">
<div class="grid grow">
<a
href={buildUrl(selectedVariant)}
href={url}
target="_blank"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline">
<span class="text-base tracking-wider text-white">{selectedVariant.bannerCopy}</span>
<span class="text-base tracking-wider text-white">
Limited time: 10% off Mermaid Advanced Editor with code NEWYEAR
</span>
<Button
class="shrink-0 rounded-md bg-[#1E1A2E] px-3 py-1.5 text-base font-semibold tracking-wide text-white hover:bg-[#261A56]">
{selectedVariant.buttonCopy}
Claim discount
</Button>
</a>
</div>
+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, getUTMSource, 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 = getUTMSource();
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';
@@ -88,3 +89,10 @@ function fallbackCopyToClipboard(text: string) {
textArea.remove();
}
}
export const getUTMSource = (): string => {
if (typeof window !== 'undefined' && window.location.host.includes('mermaid.ai')) {
return C.aiLiveEditor;
}
return C.utmSource;
};
+9
View File
@@ -0,0 +1,9 @@
<svg id="mySvg" width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" role="img">
<path class="p1"
d="M19 4C19 6.50909 19.8727 8.63636 21.6182 10.3818C23.3636 12.1273 25.4909 13 28 13C25.4909 13 23.3636 13.8727 21.6182 15.6182C19.8727 17.3636 19 19.4909 19 22C19 19.4909 18.1273 17.3636 16.3818 15.6182C14.6364 13.8727 12.5091 13 10 13C12.5091 13 14.6364 12.1273 16.3818 10.3818C18.1273 8.63636 19 6.50909 19 4Z"
fill="white" />
<path class="p2"
d="M8.5 16C8.5 17.2545 8.93636 18.3182 9.80909 19.1909C10.6818 20.0636 11.7455 20.5 13 20.5C11.7455 20.5 10.6818 20.9364 9.80909 21.8091C8.93636 22.6818 8.5 23.7455 8.5 25C8.5 23.7455 8.06364 22.6818 7.19091 21.8091C6.31818 20.9364 5.25455 20.5 4 20.5C5.25455 20.5 6.31818 20.0636 7.19091 19.1909C8.06364 18.3182 8.5 17.2545 8.5 16Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 879 B

+9
View File
@@ -0,0 +1,9 @@
<svg id="mySvg" width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" role="img">
<path class="p1"
d="M19 4C19 6.50909 19.8727 8.63636 21.6182 10.3818C23.3636 12.1273 25.4909 13 28 13C25.4909 13 23.3636 13.8727 21.6182 15.6182C19.8727 17.3636 19 19.4909 19 22C19 19.4909 18.1273 17.3636 16.3818 15.6182C14.6364 13.8727 12.5091 13 10 13C12.5091 13 14.6364 12.1273 16.3818 10.3818C18.1273 8.63636 19 6.50909 19 4Z"
fill="black" />
<path class="p2"
d="M8.5 16C8.5 17.2545 8.93636 18.3182 9.80909 19.1909C10.6818 20.0636 11.7455 20.5 13 20.5C11.7455 20.5 10.6818 20.9364 9.80909 21.8091C8.93636 22.6818 8.5 23.7455 8.5 25C8.5 23.7455 8.06364 22.6818 7.19091 21.8091C6.31818 20.9364 5.25455 20.5 4 20.5C5.25455 20.5 6.31818 20.0636 7.19091 19.1909C8.06364 18.3182 8.5 17.2545 8.5 16Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 879 B