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

Release live editor
This commit is contained in:
Sidharth Vinod
2026-03-03 10:34:27 -08:00
committed by GitHub
12 changed files with 230 additions and 21 deletions
-6
View File
@@ -1,6 +0,0 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event, {});
return response;
};
+2 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { env } from '$/util/env';
import { isOnMermaidAI } from '$/util/migration/domainMigration';
import type { ComponentProps } from 'svelte';
import ExternalLinkWrapper from './ExternalLinkWrapper.svelte';
@@ -12,13 +13,12 @@
> = $props();
const mermaidChartDomain = 'mermaid.ai';
const isMermaidAiDomain = env.domain === mermaidChartDomain;
</script>
<ExternalLinkWrapper
{...props}
domain={mermaidChartDomain}
isVisible={env.isEnabledMermaidChartLinks}
showPopup={!isMermaidAiDomain}>
showPopup={!isOnMermaidAI()}>
{@render children()}
</ExternalLinkWrapper>
+4 -3
View File
@@ -21,9 +21,10 @@
interface Props {
mobileToggle?: Snippet;
children: Snippet;
hidePromotion?: boolean;
}
let { children, mobileToggle }: Props = $props();
let { children, mobileToggle, hidePromotion = false }: Props = $props();
type Links = ComponentProps<typeof DropdownNavMenu>['links'];
@@ -39,7 +40,7 @@
}
];
let activePromotion = $state(getActivePromotion());
let activePromotion = $state(hidePromotion ? undefined : getActivePromotion());
const trackBannerClick = () => {
if (!plausible || !activePromotion) {
@@ -54,7 +55,7 @@
{#if activePromotion}
<div class="top-bar z-10 flex h-fit w-full bg-primary">
<div
class="flex flex-grow"
class="flex grow"
role="button"
tabindex="0"
onclick={trackBannerClick}
@@ -0,0 +1,140 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser, isOnMermaidAI } from '$/util/migration/domainMigration';
import { logEvent } from '$/util/stats';
import CodeIcon from '~icons/custom/code';
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
import ChatIcon from '~icons/material-symbols/chat-outline-rounded';
import EditIcon from '~icons/material-symbols/edit-outline-rounded';
import HistoryIcon from '~icons/material-symbols/history';
import HomeIcon from '~icons/material-symbols/home-storage-outline-rounded';
import SparklesIcon from '~icons/material-symbols/kid-star-outline';
import LanguageIcon from '~icons/material-symbols/language';
import WidthIcon from '~icons/material-symbols/width-rounded';
interface Props {
open: boolean;
}
let { open = $bindable() }: Props = $props();
const close = () => {
dismissEditorChooser();
open = false;
};
const handleStartTrial = () => {
logEvent('editorChooserPlus');
close();
const utmSource = isOnMermaidAI() ? 'mermaid_ai_live' : 'mermaid_live_editor';
window.open(
`https://mermaid.ai/app/user/billing/checkout?${new URLSearchParams({
coupon: 'dmIuNbqx',
tier: 'plus',
utm_campaign: 'start_plus',
utm_medium: '2_editor_selection',
utm_source: utmSource
}).toString()}`,
'_blank'
);
};
const handleStartFree = () => {
logEvent('editorChooserOpenSource');
close();
};
</script>
<Dialog.Root
bind:open
onOpenChange={(v) => {
if (!v) handleStartFree();
}}>
<Dialog.Content class="max-w-2xl bg-pink-50 p-0 dark:bg-background">
<Dialog.Header class="px-8 pt-8 pb-0">
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
<Dialog.Description class="-mt-2 text-center text-sm font-light">
You'll never see this again
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 px-6 pt-4 pb-8 sm:grid-cols-2">
<!-- Mermaid Plus Card -->
<div
class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
<div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
Recommended
</div>
<div class="flex flex-col p-6">
<h3 class="text-xl font-bold">Mermaid Plus</h3>
<p class="mb-4 text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
<div class="mb-2 flex justify-center">
<span
class="rounded-full bg-pink-100 px-3 py-0.5 text-xs font-semibold text-pink-700 dark:bg-pink-950 dark:text-pink-300">
Limited time: 10% off
</span>
</div>
<Button variant="accent" class="mb-6 w-full" onclick={handleStartTrial}>
Start free trial
</Button>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<EditIcon class="size-4 shrink-0 text-muted-foreground" />
Visual editor
</li>
<li class="flex items-center gap-2">
<SparklesIcon class="size-4 shrink-0 text-muted-foreground" />
300 AI credits
</li>
<li class="flex items-center gap-2">
<HomeIcon class="size-4 shrink-0 text-muted-foreground" />
Unlimited diagram storage
</li>
<li class="flex items-center gap-2">
<WidthIcon class="size-4 shrink-0 text-muted-foreground" />
Limitless diagram size
</li>
<li class="flex items-center gap-2">
<ChatIcon class="size-4 shrink-0 text-muted-foreground" />
View & comment collaboration
</li>
</ul>
</div>
</div>
<!-- Open Source Card -->
<div class="flex flex-col rounded-xl border bg-white p-6 shadow dark:bg-card">
<h3 class="mt-10 text-xl font-bold">Open Source</h3>
<p class="mb-4 text-sm text-muted-foreground">Code only, no login, always free</p>
<Button variant="outline" class="mb-6 w-full border-accent" onclick={handleStartFree}>
Start free
</Button>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<LanguageIcon class="size-4 shrink-0 text-muted-foreground" />
Diagram stored in URL
</li>
<li class="flex items-center gap-2">
<CodeIcon class="size-4 shrink-0 text-muted-foreground" />
Code editor
</li>
<li class="flex items-center gap-2">
<OpenSourceIcon class="size-4 shrink-0 text-muted-foreground" />
Open source
</li>
<li class="flex items-center gap-2">
<HistoryIcon class="size-4 shrink-0 text-muted-foreground" />
Version history
</li>
</ul>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
+1
View File
@@ -11,5 +11,6 @@ export const TID = {
export const C = {
aiLiveEditor: 'ai_live_editor',
editorChooserDismissedKey: 'mermaid-editor-chooser-dismissed',
utmSource: 'mermaid_live_editor'
} as const;
+62
View File
@@ -0,0 +1,62 @@
import { C } from '$/constants';
import { env } from '$/util/env';
const mermaidAiDomain = 'mermaid.ai';
/**
* Check if we're on mermaid.ai
*/
export const isOnMermaidAI = (): boolean => {
const domain = window.location.hostname;
return domain === mermaidAiDomain || domain.endsWith(`.${mermaidAiDomain}`);
};
// localStorage keys that indicate a returning user.
// Note: codeStore is excluded because it's always populated with the default state on first load.
const userDataStorageKeys = [
'manualHistoryStore', // Manual history entries
'autoHistoryStore' // Auto history entries
];
/**
* Check if user has any stored data in localStorage.
* This includes saved diagrams, history entries, etc.
*/
const hasStoredUserData = (): boolean => {
for (const key of userDataStorageKeys) {
const value = window.localStorage.getItem(key);
if (value && value !== '[]' && value !== 'null' && value !== '{}') {
return true;
}
}
return false;
};
/**
* Check if the current URL has pako data (diagram content from a shared link).
*/
const hasPakoData = (): boolean => {
const hash = window.location.hash;
return (
hash.includes('pako:') || hash.includes('base64:') || (hash.length > 10 && hash.startsWith('#'))
);
};
/**
* Check if the editor chooser modal should be shown.
* Shows for new users who haven't dismissed it and aren't viewing a shared link.
*/
export const shouldShowEditorChooser = (): boolean => {
if (!env.isEnabledMermaidChartLinks) return false;
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
if (hasStoredUserData()) return false;
if (hasPakoData()) return false;
return true;
};
/**
* Dismiss the editor chooser modal permanently
*/
export const dismissEditorChooser = (): void => {
window.localStorage.setItem(C.editorChooserDismissedKey, 'true');
};
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import { env } from '$/util/env';
import { isOnMermaidAI } from '$/util/migration/domainMigration';
import type { Snippet } from 'svelte';
interface Props {
@@ -9,7 +9,7 @@
let { closeBanner }: Props = $props();
const utmSource = env.domain === 'mermaid.ai' ? 'mermaid_ai_live' : 'mermaid_live_editor';
const utmSource = isOnMermaidAI() ? 'mermaid_ai_live' : 'mermaid_live_editor';
const url = `https://mermaid.ai/app/user/billing/checkout?${new URLSearchParams({
coupon: 'dmIuNbqx',
+4 -1
View File
@@ -77,12 +77,15 @@ const minutesToMilliSeconds = (minutes: number): number => {
return minutes * 60_000;
};
const noDelay = 0;
const defaultDelay = minutesToMilliSeconds(1);
const delaysPerEvent = {
bannerClick: defaultDelay,
bannerClick: noDelay,
copyClipboard: defaultDelay,
copyMarkdown: defaultDelay,
download: defaultDelay,
editorChooserOpenSource: noDelay,
editorChooserPlus: noDelay,
history: defaultDelay,
loadGist: defaultDelay,
loadSampleDiagram: defaultDelay,
+2 -1
View File
@@ -2,6 +2,7 @@ import { C } from '$/constants';
import { env } from './env';
import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading';
import { isOnMermaidAI } from './migration/domainMigration';
import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state';
import { initAnalytics, plausible } from './stats';
@@ -91,7 +92,7 @@ function fallbackCopyToClipboard(text: string) {
}
export const getUTMSource = (): string => {
if (typeof window !== 'undefined' && window.location.host.includes('mermaid.ai')) {
if (typeof window !== 'undefined' && isOnMermaidAI()) {
return C.aiLiveEditor;
}
return C.utmSource;
+1 -1
View File
@@ -41,7 +41,7 @@
<ModeWatcher />
<Toaster />
<main class="h-[100dvh]">
<main class="h-dvh">
{@render children()}
</main>
+7 -4
View File
@@ -6,6 +6,7 @@
import History from '$/components/History/History.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte';
import Navbar from '$/components/Navbar.svelte';
import PanZoomToolbar from '$/components/PanZoomToolbar.svelte';
import Preset from '$/components/Preset.svelte';
@@ -18,6 +19,7 @@
import VersionSecurityToolbar from '$/components/VersionSecurityToolbar.svelte';
import View from '$/components/View.svelte';
import type { EditorMode, Tab } from '$/types';
import { shouldShowEditorChooser } from '$/util/migration/domainMigration';
import { PanZoomState } from '$/util/panZoom';
import { stateStore, updateCodeStore, urlsStore } from '$/util/state';
import { logEvent } from '$/util/stats';
@@ -50,8 +52,10 @@
let width = $state(0);
let isMobile = $derived(width < 640);
let isViewMode = $state(true);
let showEditorChooser = $state(false);
onMount(async () => {
showEditorChooser = shouldShowEditorChooser();
await initHandler();
window.addEventListener('appinstalled', () => {
logEvent('pwaInstalled', { isMobile });
@@ -137,10 +141,7 @@
</Resizable.Pane>
{#if isHistoryOpen}
<Resizable.Handle class="ml-1 hidden opacity-0 sm:block" />
<Resizable.Pane
minSize={15}
defaultSize={30}
class="hidden h-full flex-grow flex-col sm:flex">
<Resizable.Pane minSize={15} defaultSize={30} class="hidden h-full grow flex-col sm:flex">
<History />
</Resizable.Pane>
{/if}
@@ -148,3 +149,5 @@
</div>
</div>
</div>
<EditorChooserModal bind:open={showEditorChooser} />
+5 -1
View File
@@ -1,4 +1,4 @@
import { TID } from '$/constants';
import { C, TID } from '$/constants';
import { test as base, expect, type Locator, type Page } from '@playwright/test';
import { verifyFileSizeGreaterThan, type EditorOptions } from './utils';
@@ -115,6 +115,10 @@ export class EditorPage {
export const test = base.extend<{ editPage: EditorPage }>({
editPage: async ({ page }, use) => {
// Dismiss the editor chooser modal so it doesn't block interactions
await page.addInitScript((key) => {
window.localStorage.setItem(key, 'true');
}, C.editorChooserDismissedKey);
const editorPage = new EditorPage(page);
await editorPage.start();
await editorPage.toggleSampleDiagrams();