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