Merge pull request #1896 from mermaid-js/sidv/popup
Editor Chooser popup
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event, {});
|
||||
return response;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<ModeWatcher />
|
||||
<Toaster />
|
||||
|
||||
<main class="h-[100dvh]">
|
||||
<main class="h-dvh">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user