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));
|
||||
};
|
||||
|
||||
@@ -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="currentColor" />
|
||||
<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="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 893 B |
Reference in New Issue
Block a user