add mermaid inline AI
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -10,5 +10,6 @@ export const TID = {
|
||||
} as const;
|
||||
|
||||
export const C = {
|
||||
aiLiveEditor: 'ai_live_editor',
|
||||
utmSource: 'mermaid_live_editor'
|
||||
} as const;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user