다이어그램 지원 추가

This commit is contained in:
ysj7705 committed 2026-07-21 17:36:42 +09:00
1 parent 338397e016
commit c1ceec3fb1
255 files changed
+4379 -15

No files matched your search

@@ -0,0 +1,314 @@
<script setup lang="ts">
import {
nextTick,
onActivated,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { useRouter } from 'vue-router';
import mermaid from 'mermaid';
import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { consumeDiagramFile } from '@/composables/diagramSession';
import { usePageTitle } from '@/composables/usePageTitle';
import { useSystemTheme } from '@/composables/useSystemTheme';
import {
downloadBlob,
downloadText,
readTextFile,
svgToPngBlob,
} from '@/utils/downloadBlob';
defineOptions({ name: 'MermaidEditorView' });
const DEFAULT_CODE = `flowchart TD
Start[시작] --> Decision{결정}
Decision -->|예| Done[완료]
Decision -->|아니요| Start`;
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const filename = ref('새다이어그램.mmd');
const code = ref(DEFAULT_CODE);
const renderedSvg = ref('');
const renderError = ref('');
const actionError = ref('');
const busy = ref(false);
const theme = useSystemTheme();
let renderTimer: number | null = null;
let renderVersion = 0;
usePageTitle(filename);
function baseName(): string {
return filename.value.replace(/\.(?:mmd|mermaid)$/i, '') || '새다이어그램';
}
function errorMessage(error: unknown): string {
if (!(error instanceof Error)) return '다이어그램을 렌더링하지 못했습니다.';
return error.message.split('\n').slice(0, 4).join('\n');
}
async function renderDiagram() {
const version = ++renderVersion;
try {
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: theme.value === 'dark' ? 'dark' : 'default',
fontFamily: 'IBM Plex Sans KR, sans-serif',
htmlLabels: false,
});
await mermaid.parse(code.value);
const id = `mermaid-preview-${version}`;
const result = await mermaid.render(id, code.value);
if (version !== renderVersion) return;
renderedSvg.value = result.svg;
renderError.value = '';
} catch (error) {
if (version !== renderVersion) return;
renderError.value = errorMessage(error);
}
}
function scheduleRender() {
if (renderTimer !== null) window.clearTimeout(renderTimer);
renderTimer = window.setTimeout(() => {
renderTimer = null;
void renderDiagram();
}, 250);
}
watch([code, theme], scheduleRender, { immediate: true });
onMounted(() => {
const pending = consumeDiagramFile('mermaid');
if (!pending) return;
void run(async () => {
code.value = await readTextFile(pending);
filename.value = pending.name.replace(/\.mermaid$/i, '.mmd');
await renderDiagram();
});
});
onActivated(() => {
void nextTick(renderDiagram);
});
onUnmounted(() => {
if (renderTimer !== null) window.clearTimeout(renderTimer);
renderVersion += 1;
});
async function run(task: () => Promise<void>) {
if (busy.value) return;
busy.value = true;
actionError.value = '';
try {
await task();
} catch (error) {
actionError.value =
error instanceof Error ? error.message : '작업을 완료하지 못했습니다.';
} finally {
busy.value = false;
}
}
function onOpen() {
fileInput.value?.click();
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!/\.(?:mmd|mermaid)$/i.test(file.name)) {
actionError.value = '.mmd 또는 .mermaid 파일을 선택해 주세요.';
return;
}
void run(async () => {
code.value = await readTextFile(file);
filename.value = file.name.replace(/\.mermaid$/i, '.mmd');
await renderDiagram();
});
}
function onSave() {
downloadText(code.value, filename.value, 'text/plain;charset=utf-8');
}
function ensureSvg(): string {
if (renderError.value || !renderedSvg.value) {
throw new Error('구문 오류를 수정한 후 내보내 주세요.');
}
return renderedSvg.value;
}
function onExportSvg() {
void run(async () => {
downloadText(
ensureSvg(),
`${baseName()}.svg`,
'image/svg+xml;charset=utf-8',
);
});
}
function onExportPng() {
void run(async () => {
downloadBlob(
await svgToPngBlob(ensureSvg()),
`${baseName()}.png`,
);
});
}
function onClose() {
void router.push({ name: 'home' });
}
</script>
<template>
<AppShell :editor="true">
<div class="editor-page">
<EditorToolbar
:filename="filename"
save-label="원본 저장"
:show-export-actions="true"
:busy="busy"
@open="onOpen"
@save="onSave"
@export-svg="onExportSvg"
@export-png="onExportPng"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".mmd,.mermaid,text/plain"
@change="onFileSelected"
/>
<p v-if="actionError" class="editor-error" role="alert">
{{ actionError }}
</p>
<div class="mermaid-editor">
<section class="source-pane" aria-label="Mermaid 코드 편집">
<textarea
v-model="code"
class="source"
spellcheck="false"
aria-label="Mermaid 코드"
/>
</section>
<section class="preview-pane" aria-label="Mermaid 미리보기">
<pre v-if="renderError" class="render-error">{{ renderError }}</pre>
<div v-else class="preview" v-html="renderedSvg" />
</section>
</div>
</div>
</AppShell>
</template>
<style scoped>
.editor-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
}
.mermaid-editor {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: minmax(18rem, 0.85fr) minmax(0, 1.4fr);
gap: 1px;
background: var(--border);
}
.source-pane,
.preview-pane {
min-width: 0;
min-height: 0;
background: var(--surface);
}
.source {
display: block;
width: 100%;
height: 100%;
resize: none;
border: 0;
outline: 0;
padding: 1rem;
background: var(--surface);
color: var(--ink);
font: 0.9rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
tab-size: 2;
}
.preview-pane {
overflow: auto;
padding: 1.25rem;
}
.preview {
min-width: min-content;
min-height: 100%;
display: grid;
place-items: center;
}
.preview :deep(svg) {
display: block;
max-width: 100%;
height: auto;
}
.render-error {
margin: 0;
padding: 1rem;
border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border));
border-radius: 6px;
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
color: var(--danger);
white-space: pre-wrap;
font: 0.82rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.editor-error {
flex: 0 0 auto;
margin: 0;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border));
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
color: var(--danger);
font-size: 0.8rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 760px) {
.mermaid-editor {
grid-template-columns: 1fr;
grid-template-rows: minmax(14rem, 0.8fr) minmax(14rem, 1fr);
}
}
</style>