다이어그램 지원 추가

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

+17 -1
View File
@@ -15,13 +15,29 @@ const viewKey = computed(() => {
if (route.name === 'rhwp-editor') {
return 'rhwp-editor';
}
if (
route.name === 'excalidraw-editor' ||
route.name === 'mermaid-editor' ||
route.name === 'drawio-editor'
) {
return String(route.name);
}
return String(route.fullPath);
});
</script>
<template>
<RouterView v-slot="{ Component }">
<KeepAlive :include="['OnlyOfficeEditorView', 'RhwpEditorView']" :max="6">
<KeepAlive
:include="[
'OnlyOfficeEditorView',
'RhwpEditorView',
'ExcalidrawEditorView',
'MermaidEditorView',
'DrawioEditorView',
]"
:max="8"
>
<component :is="Component" :key="viewKey" />
</KeepAlive>
</RouterView>
@@ -1,11 +1,25 @@
<script setup lang="ts">
defineProps<{
filename: string;
}>();
withDefaults(
defineProps<{
filename: string;
saveLabel?: string;
showExportActions?: boolean;
showFilename?: boolean;
busy?: boolean;
}>(),
{
saveLabel: '저장',
showExportActions: false,
showFilename: true,
busy: false,
},
);
defineEmits<{
open: [];
save: [];
exportSvg: [];
exportPng: [];
close: [];
}>();
</script>
@@ -13,14 +27,48 @@ defineEmits<{
<template>
<header class="toolbar">
<div class="toolbar__left">
<button type="button" class="toolbar__btn" @click="$emit('open')">
<button
type="button"
class="toolbar__btn"
:disabled="busy"
@click="$emit('open')"
>
열기
</button>
<button type="button" class="toolbar__btn" @click="$emit('save')">
저장
<button
type="button"
class="toolbar__btn"
:disabled="busy"
@click="$emit('save')"
>
{{ saveLabel }}
</button>
<button
v-if="showExportActions"
type="button"
class="toolbar__btn"
:disabled="busy"
@click="$emit('exportSvg')"
>
SVG
</button>
<button
v-if="showExportActions"
type="button"
class="toolbar__btn"
:disabled="busy"
@click="$emit('exportPng')"
>
PNG
</button>
</div>
<p class="toolbar__title" :title="filename">{{ filename }}</p>
<p
class="toolbar__title"
:title="showFilename ? filename : undefined"
:aria-hidden="!showFilename"
>
{{ showFilename ? filename : '' }}
</p>
<div class="toolbar__right">
<RouterLink class="toolbar__link" to="/licenses">라이선스</RouterLink>
<button type="button" class="toolbar__btn" @click="$emit('close')">
@@ -48,6 +96,13 @@ defineEmits<{
display: flex;
gap: 0.4rem;
justify-self: start;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.toolbar__left::-webkit-scrollbar {
display: none;
}
.toolbar__right {
@@ -86,6 +141,11 @@ defineEmits<{
color: var(--accent);
}
.toolbar__btn:disabled {
opacity: 0.55;
cursor: wait;
}
.toolbar__link {
font-size: 0.8rem;
font-weight: 500;
@@ -96,4 +156,14 @@ defineEmits<{
.toolbar__link:hover {
color: var(--accent);
}
@media (max-width: 640px) {
.toolbar {
grid-template-columns: minmax(0, 1fr) auto;
}
.toolbar__title {
display: none;
}
}
</style>
@@ -0,0 +1,19 @@
import type { DiagramKind } from '@/config/app';
interface PendingDiagramFile {
kind: DiagramKind;
file: File;
}
let pending: PendingDiagramFile | null = null;
export function stageDiagramFile(kind: DiagramKind, file: File) {
pending = { kind, file };
}
export function consumeDiagramFile(kind: DiagramKind): File | null {
if (pending?.kind !== kind) return null;
const file = pending.file;
pending = null;
return file;
}
@@ -0,0 +1,194 @@
import {
onMounted,
onUnmounted,
ref,
type Ref,
} from 'vue';
const DRAWIO_ORIGIN = 'https://embed.diagrams.net';
const EXPORT_TIMEOUT_MS = 20_000;
export const DRAWIO_EMBED_URL =
`${DRAWIO_ORIGIN}/?embed=1&proto=json&spin=1&libraries=1` +
'&ui=min&noSaveBtn=1&noExitBtn=1&saveAndExit=0';
export const EMPTY_DRAWIO_XML =
'<mxfile host="embed.diagrams.net">' +
'<diagram id="page-1" name="Page-1">' +
'<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" ' +
'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" ' +
'pageWidth="827" pageHeight="1169" math="0" shadow="0">' +
'<root><mxCell id="0"/><mxCell id="1" parent="0"/></root>' +
'</mxGraphModel></diagram></mxfile>';
export interface DrawioExportResult {
format: string;
data?: string;
xml?: string;
filename?: string;
}
interface PendingExport {
format: string;
resolve: (result: DrawioExportResult) => void;
reject: (error: Error) => void;
timer: number;
}
export function useDrawioEmbed(
iframe: Ref<HTMLIFrameElement | null>,
onUiExport?: (result: DrawioExportResult) => void,
) {
const ready = ref(false);
const error = ref('');
let currentXml = EMPTY_DRAWIO_XML;
let currentTitle = '새다이어그램.drawio';
let pending: PendingExport | null = null;
let initTimer: number | null = null;
function post(message: Record<string, unknown>) {
const target = iframe.value?.contentWindow;
if (!target) throw new Error('draw.io 편집기가 준비되지 않았습니다.');
target.postMessage(JSON.stringify(message), DRAWIO_ORIGIN);
}
function sendLoad() {
post({
action: 'load',
xml: currentXml,
title: currentTitle,
autosave: 1,
exportProtocol: true,
noSaveBtn: 1,
noExitBtn: 1,
saveAndExit: 0,
});
}
function parseMessage(data: unknown): Record<string, unknown> | null {
if (typeof data === 'object' && data !== null) {
return data as Record<string, unknown>;
}
if (typeof data !== 'string') return null;
try {
return JSON.parse(data) as Record<string, unknown>;
} catch {
return null;
}
}
function onMessage(event: MessageEvent) {
if (
event.origin !== DRAWIO_ORIGIN ||
event.source !== iframe.value?.contentWindow
) {
return;
}
const message = parseMessage(event.data);
if (!message || typeof message.event !== 'string') return;
if (message.event === 'init') {
ready.value = true;
error.value = '';
if (initTimer !== null) {
window.clearTimeout(initTimer);
initTimer = null;
}
sendLoad();
return;
}
if (
(message.event === 'autosave' || message.event === 'save') &&
typeof message.xml === 'string'
) {
currentXml = message.xml;
return;
}
if (message.event === 'export') {
const result: DrawioExportResult = {
format:
typeof message.format === 'string' ? message.format : 'unknown',
data: typeof message.data === 'string' ? message.data : undefined,
xml: typeof message.xml === 'string' ? message.xml : undefined,
filename:
typeof message.filename === 'string' ? message.filename : undefined,
};
if (result.xml) currentXml = result.xml;
if (pending) {
const active = pending;
pending = null;
window.clearTimeout(active.timer);
active.resolve(result);
} else {
onUiExport?.(result);
}
}
}
function loadXml(xml: string, title: string) {
currentXml = xml;
currentTitle = title;
error.value = '';
if (ready.value) sendLoad();
}
function exportDiagram(format: 'xml' | 'svg' | 'png') {
return new Promise<DrawioExportResult>((resolve, reject) => {
if (!ready.value) {
reject(new Error('draw.io 편집기가 아직 준비되지 않았습니다.'));
return;
}
if (pending) {
reject(new Error('다른 내보내기 작업이 진행 중입니다.'));
return;
}
const timer = window.setTimeout(() => {
if (!pending) return;
pending = null;
reject(new Error('draw.io 내보내기 응답 시간이 초과되었습니다.'));
}, EXPORT_TIMEOUT_MS);
pending = { format, resolve, reject, timer };
try {
post({ action: 'export', format });
} catch (err) {
window.clearTimeout(timer);
pending = null;
reject(
err instanceof Error ? err : new Error('내보내기에 실패했습니다.'),
);
}
});
}
onMounted(() => {
window.addEventListener('message', onMessage);
initTimer = window.setTimeout(() => {
if (ready.value) return;
error.value =
'draw.io 편집기를 불러오지 못했습니다. 인터넷 연결을 확인해 주세요.';
}, EXPORT_TIMEOUT_MS);
});
onUnmounted(() => {
window.removeEventListener('message', onMessage);
if (initTimer !== null) window.clearTimeout(initTimer);
if (pending) {
window.clearTimeout(pending.timer);
pending.reject(new Error('편집기가 닫혔습니다.'));
pending = null;
}
});
return {
ready,
error,
loadXml,
exportDiagram,
};
}
@@ -1,6 +1,6 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { isHwp, isOffice } from '@/config/app';
import { diagramKindFor, isHwp, isOffice } from '@/config/app';
import {
createBlankDocument,
createSession,
@@ -12,6 +12,7 @@ import {
storeHwpBuffer,
storeHwpFile,
} from '@/composables/useSessionLifecycle';
import { stageDiagramFile } from '@/composables/diagramSession';
export function useOpenDocument() {
const router = useRouter();
@@ -24,6 +25,13 @@ export function useOpenDocument() {
busy.value = true;
try {
const diagramKind = diagramKindFor(file.name);
if (diagramKind) {
stageDiagramFile(diagramKind, file);
await router.push({ name: `${diagramKind}-editor` });
return true;
}
if (isHwp(file.name)) {
await storeHwpFile(file);
await createSession().catch(() => undefined);
@@ -33,7 +41,7 @@ export function useOpenDocument() {
if (!isOffice(file.name)) {
error.value =
'지원하지 않는 형식입니다. docx, xlsx, pptx, pdf, hwp, hwpx 등을 올려 주세요.';
'지원하지 않는 형식입니다. 오피스, HWP 또는 다이어그램 파일을 선택해 주세요.';
return false;
}
@@ -0,0 +1,25 @@
import { onMounted, onUnmounted, ref } from 'vue';
export type SystemTheme = 'light' | 'dark';
export function useSystemTheme() {
const theme = ref<SystemTheme>('light');
let media: MediaQueryList | null = null;
function sync(event?: MediaQueryListEvent) {
theme.value = (event?.matches ?? media?.matches) ? 'dark' : 'light';
}
onMounted(() => {
media = window.matchMedia('(prefers-color-scheme: dark)');
sync();
media.addEventListener('change', sync);
});
onUnmounted(() => {
media?.removeEventListener('change', sync);
media = null;
});
return theme;
}
+14
View File
@@ -5,6 +5,16 @@ export const APP_NAME = "sejong-office";
export const APP_DISPLAY_NAME = "Sejong Office";
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
excalidraw: "excalidraw",
mmd: "mermaid",
mermaid: "mermaid",
drawio: "drawio",
xml: "drawio",
};
/** HWP/HWPX — client-side rhwp */
export const HWP_EXTENSIONS = new Set(["hwp", "hwpx"]);
@@ -41,3 +51,7 @@ export function isHwp(filename: string): boolean {
export function isOffice(filename: string): boolean {
return OFFICE_EXTENSIONS.has(extensionOf(filename));
}
export function diagramKindFor(filename: string): DiagramKind | null {
return DIAGRAM_EXTENSION_KIND[extensionOf(filename)] ?? null;
}
+14
View File
@@ -12,6 +12,9 @@ export type FaviconKind =
| 'excel'
| 'powerpoint'
| 'hwp'
| 'sketch'
| 'code'
| 'diagram'
| 'text'
| 'csv'
| 'file';
@@ -25,6 +28,9 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
powerpoint:
'M9.8,13.4H12.3C13.8,13.4 14.46,13.12 15.1,12.58C15.74,12.03 16,11.25 16,10.23C16,9.26 15.75,8.5 15.1,7.88C14.45,7.29 13.83,7 12.3,7H8V17H9.8V13.4M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5C3,3.89 3.9,3 5,3H19M9.8,12V8.4H12.1C12.76,8.4 13.27,8.65 13.6,9C13.93,9.35 14.1,9.72 14.1,10.24C14.1,10.8 13.92,11.19 13.6,11.5C13.28,11.81 12.9,12 12.22,12H9.8Z',
hwp: 'M17,4V10L15,8L13,10V4H9V20H19V4H17M3,7V5H5V4C5,2.89 5.9,2 7,2H19C20.05,2 21,2.95 21,4V20C21,21.05 20.05,22 19,22H7C5.95,22 5,21.05 5,20V19H3V17H5V13H3V11H5V7H3M5,5V7H7V5H5M5,19H7V17H5V19M5,13H7V11H5V13Z',
sketch: 'M3 17.25V21H6.75L17.81 9.94L14.06 6.19L3 17.25M20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18 2.9 17.35 2.9 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04Z',
code: 'M8 3L6.59 4.41L10.17 8L6.59 11.59L8 13L13 8L8 3M14 11V13H22V11H14M5 19H19V17H5V19Z',
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
@@ -37,6 +43,9 @@ export const ICON_COLORS: Record<FaviconKind, string> = {
excel: '#43a047',
powerpoint: '#fb8c00',
hwp: '#00897b',
sketch: '#7b61a8',
code: '#d97706',
diagram: '#2e7d8a',
text: '#607d8b',
csv: '#00838f',
file: '#546e7a',
@@ -60,6 +69,11 @@ const EXT_KIND: Record<string, FaviconKind> = {
potx: 'powerpoint',
hwp: 'hwp',
hwpx: 'hwp',
excalidraw: 'sketch',
mmd: 'code',
mermaid: 'code',
drawio: 'diagram',
xml: 'diagram',
txt: 'text',
};
@@ -0,0 +1,148 @@
import { createRoot, type Root } from 'react-dom/client';
import {
Excalidraw,
MIME_TYPES,
exportToBlob,
exportToSvg,
loadFromBlob,
serializeAsJSON,
} from '@excalidraw/excalidraw';
import type {
ExcalidrawImperativeAPI,
} from '@excalidraw/excalidraw/types';
import type { Theme } from '@excalidraw/excalidraw/element/types';
import '@excalidraw/excalidraw/index.css';
window.EXCALIDRAW_ASSET_PATH = '/excalidraw-assets/';
export interface ExcalidrawController {
load(file: File): Promise<void>;
serialize(): Promise<string>;
exportSvg(): Promise<string>;
exportPng(): Promise<Blob>;
setTheme(theme: Theme): Promise<void>;
refresh(): Promise<void>;
destroy(): void;
}
function waitForApi(
getApi: () => ExcalidrawImperativeAPI | null,
): Promise<ExcalidrawImperativeAPI> {
return new Promise((resolve, reject) => {
let attempts = 0;
const timer = window.setInterval(() => {
const api = getApi();
if (api) {
window.clearInterval(timer);
resolve(api);
return;
}
attempts += 1;
if (attempts >= 100) {
window.clearInterval(timer);
reject(new Error('Excalidraw 편집기를 초기화하지 못했습니다.'));
}
}, 50);
});
}
export function mountExcalidraw(
host: HTMLElement,
initialTheme: Theme,
): ExcalidrawController {
let api: ExcalidrawImperativeAPI | null = null;
let root: Root | null = createRoot(host);
root.render(
<Excalidraw
excalidrawAPI={(instance) => {
api = instance;
}}
langCode="ko-KR"
theme={initialTheme}
autoFocus
/>,
);
const getApi = () => waitForApi(() => api);
return {
async load(file) {
const instance = await getApi();
const restored = await loadFromBlob(
file,
instance.getAppState(),
instance.getSceneElements(),
);
instance.updateScene({
elements: restored.elements ?? [],
appState: restored.appState ?? undefined,
});
if (restored.files) {
instance.addFiles(Object.values(restored.files));
}
instance.history.clear();
instance.scrollToContent(undefined, {
fitToContent: true,
animate: false,
});
},
async serialize() {
const instance = await getApi();
return serializeAsJSON(
instance.getSceneElements(),
instance.getAppState(),
instance.getFiles(),
'local',
);
},
async exportSvg() {
const instance = await getApi();
const svg = await exportToSvg({
elements: instance.getSceneElements(),
appState: {
...instance.getAppState(),
exportBackground: true,
},
files: instance.getFiles(),
});
return svg.outerHTML;
},
async exportPng() {
const instance = await getApi();
const blob = await exportToBlob({
elements: instance.getSceneElements(),
appState: {
...instance.getAppState(),
exportBackground: true,
exportScale: 2,
},
files: instance.getFiles(),
mimeType: MIME_TYPES.png,
});
if (!blob) {
throw new Error('PNG 이미지를 만들 수 없습니다.');
}
return blob;
},
async setTheme(theme) {
const instance = await getApi();
instance.updateScene({ appState: { theme } });
},
async refresh() {
const instance = await getApi();
instance.refresh();
},
destroy() {
root?.unmount();
root = null;
api = null;
},
};
}
+15
View File
@@ -19,6 +19,21 @@ const router = createRouter({
name: 'rhwp-editor',
component: () => import('@/views/RhwpEditorView.vue'),
},
{
path: '/editor/excalidraw',
name: 'excalidraw-editor',
component: () => import('@/views/ExcalidrawEditorView.vue'),
},
{
path: '/editor/mermaid',
name: 'mermaid-editor',
component: () => import('@/views/MermaidEditorView.vue'),
},
{
path: '/editor/drawio',
name: 'drawio-editor',
component: () => import('@/views/DrawioEditorView.vue'),
},
{
path: '/licenses',
name: 'licenses',
+89 -1
View File
@@ -14,5 +14,93 @@ export function downloadBlob(
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function downloadText(
text: string,
filename: string,
type = 'text/plain;charset=utf-8',
) {
downloadBlob(new Blob([text], { type }), filename);
}
export async function readTextFile(
file: File,
maxBytes = 20 * 1024 * 1024,
): Promise<string> {
if (file.size > maxBytes) {
throw new Error('파일이 너무 큽니다. 20MB 이하의 파일을 선택해 주세요.');
}
return file.text();
}
export function dataUriToBlob(dataUri: string): Blob {
const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUri);
if (!match) {
throw new Error('내보내기 데이터 형식이 올바르지 않습니다.');
}
const mime = match[1] || 'application/octet-stream';
const encoded = match[3];
if (match[2]) {
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: mime });
}
return new Blob([decodeURIComponent(encoded)], { type: mime });
}
function svgDimensions(svg: string): { width: number; height: number } {
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
const root = doc.documentElement;
const viewBox = root.getAttribute('viewBox')?.trim().split(/\s+/).map(Number);
if (viewBox?.length === 4 && viewBox.every(Number.isFinite)) {
return {
width: Math.max(1, viewBox[2]),
height: Math.max(1, viewBox[3]),
};
}
const width = Number.parseFloat(root.getAttribute('width') ?? '');
const height = Number.parseFloat(root.getAttribute('height') ?? '');
return {
width: Number.isFinite(width) && width > 0 ? width : 1200,
height: Number.isFinite(height) && height > 0 ? height : 800,
};
}
export async function svgToPngBlob(
svg: string,
scale = Math.max(2, window.devicePixelRatio || 1),
): Promise<Blob> {
const { width, height } = svgDimensions(svg);
const source = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(source);
try {
const image = new Image();
image.decoding = 'async';
image.src = url;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(width * scale);
canvas.height = Math.ceil(height * scale);
const context = canvas.getContext('2d');
if (!context) throw new Error('PNG 캔버스를 만들 수 없습니다.');
context.scale(scale, scale);
context.drawImage(image, 0, 0, width, height);
const png = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/png'),
);
if (!png) throw new Error('PNG 이미지를 만들 수 없습니다.');
return png;
} finally {
URL.revokeObjectURL(url);
}
}
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { consumeDiagramFile } from '@/composables/diagramSession';
import {
DRAWIO_EMBED_URL,
useDrawioEmbed,
type DrawioExportResult,
} from '@/composables/useDrawioEmbed';
import { usePageTitle } from '@/composables/usePageTitle';
import {
dataUriToBlob,
downloadBlob,
downloadText,
readTextFile,
} from '@/utils/downloadBlob';
defineOptions({ name: 'DrawioEditorView' });
const router = useRouter();
const iframe = ref<HTMLIFrameElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const filename = ref('새다이어그램.drawio');
const busy = ref(false);
const actionError = ref('');
usePageTitle(filename);
const { ready, error: embedError, loadXml, exportDiagram } = useDrawioEmbed(
iframe,
(result) => {
void downloadUiExport(result);
},
);
onMounted(() => {
const pending = consumeDiagramFile('drawio');
if (!pending) return;
void run(async () => {
loadXml(await readTextFile(pending), pending.name);
filename.value = pending.name;
});
});
function baseName(): string {
return filename.value.replace(/\.(?:drawio|xml)$/i, '') || '새다이어그램';
}
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 requireData(result: DrawioExportResult): Blob {
if (!result.data) throw new Error('draw.io 내보내기 데이터가 없습니다.');
return dataUriToBlob(result.data);
}
async function xmlFromResult(result: DrawioExportResult): Promise<string> {
if (result.xml) return result.xml;
if (result.data) return dataUriToBlob(result.data).text();
throw new Error('draw.io XML 데이터를 받지 못했습니다.');
}
async function downloadUiExport(result: DrawioExportResult) {
try {
if (result.format === 'xml') {
downloadText(
await xmlFromResult(result),
result.filename || filename.value,
'application/xml;charset=utf-8',
);
return;
}
if (result.data) {
const extension = result.format === 'svg' ? 'svg' : result.format;
downloadBlob(
dataUriToBlob(result.data),
result.filename || `${baseName()}.${extension}`,
);
}
} catch (error) {
actionError.value =
error instanceof Error ? error.message : '파일을 저장하지 못했습니다.';
}
}
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 (!/\.(?:drawio|xml)$/i.test(file.name)) {
actionError.value = '.drawio 또는 .xml 파일을 선택해 주세요.';
return;
}
void run(async () => {
loadXml(await readTextFile(file), file.name);
filename.value = file.name;
});
}
function onSave() {
void run(async () => {
const result = await exportDiagram('xml');
downloadText(
await xmlFromResult(result),
filename.value,
'application/xml;charset=utf-8',
);
});
}
function onExportSvg() {
void run(async () => {
downloadBlob(
requireData(await exportDiagram('svg')),
`${baseName()}.svg`,
);
});
}
function onExportPng() {
void run(async () => {
downloadBlob(
requireData(await exportDiagram('png')),
`${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"
:show-filename="false"
:busy="busy || !ready"
@open="onOpen"
@save="onSave"
@export-svg="onExportSvg"
@export-png="onExportPng"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".drawio,.xml,application/xml,text/xml"
@change="onFileSelected"
/>
<p v-if="actionError || embedError" class="editor-error" role="alert">
{{ actionError || embedError }}
</p>
<iframe
ref="iframe"
class="drawio-frame"
:src="DRAWIO_EMBED_URL"
title="draw.io 다이어그램 편집기"
allow="clipboard-read; clipboard-write"
/>
</div>
</AppShell>
</template>
<style scoped>
.editor-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
}
.drawio-frame {
flex: 1 1 auto;
min-height: 0;
width: 100%;
border: 0;
background: var(--surface);
}
.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;
}
</style>
@@ -0,0 +1,197 @@
<script setup lang="ts">
import {
nextTick,
onActivated,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { useRouter } from 'vue-router';
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 {
mountExcalidraw,
type ExcalidrawController,
} from '@/integrations/excalidraw/ExcalidrawHost';
import { downloadBlob, downloadText } from '@/utils/downloadBlob';
defineOptions({ name: 'ExcalidrawEditorView' });
const router = useRouter();
const host = ref<HTMLElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const filename = ref('새그림.excalidraw');
const busy = ref(false);
const error = ref('');
const theme = useSystemTheme();
let controller: ExcalidrawController | null = null;
usePageTitle(filename);
function baseName(): string {
return filename.value.replace(/\.excalidraw$/i, '') || '새그림';
}
async function run(task: () => Promise<void>) {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
await task();
} catch (err) {
error.value =
err instanceof Error ? err.message : '작업을 완료하지 못했습니다.';
} finally {
busy.value = false;
}
}
onMounted(() => {
if (!host.value) return;
controller = mountExcalidraw(host.value, theme.value);
const pending = consumeDiagramFile('excalidraw');
if (pending) {
void run(async () => {
if (!controller) throw new Error('편집기가 아직 준비되지 않았습니다.');
await controller.load(pending);
filename.value = pending.name;
});
}
});
onActivated(() => {
void nextTick(() => controller?.refresh());
});
onUnmounted(() => {
controller?.destroy();
controller = null;
});
watch(theme, (value) => {
void controller?.setTheme(value);
});
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 (!file.name.toLowerCase().endsWith('.excalidraw')) {
error.value = '.excalidraw 파일을 선택해 주세요.';
return;
}
void run(async () => {
if (!controller) throw new Error('편집기가 아직 준비되지 않았습니다.');
await controller.load(file);
filename.value = file.name;
});
}
function onSave() {
void run(async () => {
if (!controller) throw new Error('편집기가 아직 준비되지 않았습니다.');
downloadText(
await controller.serialize(),
filename.value,
'application/vnd.excalidraw+json',
);
});
}
function onExportSvg() {
void run(async () => {
if (!controller) throw new Error('편집기가 아직 준비되지 않았습니다.');
downloadText(
await controller.exportSvg(),
`${baseName()}.svg`,
'image/svg+xml;charset=utf-8',
);
});
}
function onExportPng() {
void run(async () => {
if (!controller) throw new Error('편집기가 아직 준비되지 않았습니다.');
downloadBlob(await controller.exportPng(), `${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=".excalidraw,application/vnd.excalidraw+json"
@change="onFileSelected"
/>
<p v-if="error" class="editor-error" role="alert">{{ error }}</p>
<div ref="host" class="excalidraw-host" />
</div>
</AppShell>
</template>
<style scoped>
.editor-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--canvas);
}
.excalidraw-host {
flex: 1 1 auto;
min-height: 0;
width: 100%;
}
.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;
}
</style>
+53 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import FileTypeIcon from '@/components/FileTypeIcon.vue';
import { APP_DISPLAY_NAME } from '@/config/app';
@@ -8,6 +9,7 @@ import { usePageTitle } from '@/composables/usePageTitle';
import type { BlankDocumentType } from '@/api/client';
const dragging = ref(false);
const router = useRouter();
const { busy, error, openDocument, createAndOpenBlank } = useOpenDocument();
usePageTitle();
@@ -24,6 +26,27 @@ const blankTypes: {
{ type: 'hwpx', title: '한글 문서 (HWPX)', subtitle: '새문서.hwpx' },
];
const diagramTypes = [
{
route: 'excalidraw-editor',
extension: 'excalidraw',
title: '스케치 (Excalidraw)',
subtitle: '새그림.excalidraw',
},
{
route: 'mermaid-editor',
extension: 'mmd',
title: '텍스트 다이어그램 (Mermaid)',
subtitle: '새다이어그램.mmd',
},
{
route: 'drawio-editor',
extension: 'drawio',
title: '다이어그램 (draw.io)',
subtitle: '새다이어그램.drawio',
},
] as const;
function onDrop(e: DragEvent) {
dragging.value = false;
void openDocument(e.dataTransfer?.files?.[0]);
@@ -38,6 +61,10 @@ function onInput(e: Event) {
function onCreate(type: BlankDocumentType) {
void createAndOpenBlank(type);
}
function onCreateDiagram(route: string) {
void router.push({ name: route });
}
</script>
<template>
@@ -65,7 +92,7 @@ function onCreate(type: BlankDocumentType) {
<input
class="drop__input"
type="file"
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv"
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml"
:disabled="busy"
@change="onInput"
/>
@@ -73,7 +100,7 @@ function onCreate(type: BlankDocumentType) {
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
</span>
<span class="drop__hint">
오피스·PDF · HWP/HWPX
오피스·PDF · HWP/HWPX · 다이어그램
</span>
</label>
@@ -101,6 +128,30 @@ function onCreate(type: BlankDocumentType) {
</button>
</div>
</section>
<section class="section">
<h2 class="section__title">다이어그램</h2>
<p class="section__lead">
브라우저에서 직접 편집하고 원본·SVG·PNG로 저장합니다.
</p>
<div class="grid">
<button
v-for="item in diagramTypes"
:key="item.route"
type="button"
class="tile"
:disabled="busy"
@click="onCreateDiagram(item.route)"
>
<FileTypeIcon :extension="item.extension" :size="36" />
<span class="tile__text">
<strong>{{ item.title }}</strong>
<span>{{ item.subtitle }}</span>
</span>
</button>
</div>
</section>
</main>
</AppShell>
</template>
@@ -9,6 +9,17 @@ const thirdParty = [
{ name: 'Vue', license: 'MIT', url: 'https://github.com/vuejs/core' },
{ name: 'Vue Router', license: 'MIT', url: 'https://github.com/vuejs/router' },
{ name: 'Vite', license: 'MIT', url: 'https://github.com/vitejs/vite' },
{ name: 'React', license: 'MIT', url: 'https://github.com/facebook/react' },
{
name: 'Excalidraw',
license: 'MIT',
url: 'https://github.com/excalidraw/excalidraw',
},
{
name: 'Mermaid',
license: 'MIT',
url: 'https://github.com/mermaid-js/mermaid',
},
{ name: 'Express', license: 'MIT', url: 'https://github.com/expressjs/express' },
{ name: 'jsonwebtoken', license: 'MIT', url: 'https://github.com/auth0/node-jsonwebtoken' },
{ name: 'multer', license: 'MIT', url: 'https://github.com/expressjs/multer' },
@@ -78,6 +89,24 @@ const thirdParty = [
</p>
</section>
<section class="card">
<h2>diagrams.net / draw.io</h2>
<p>
라이선스: <strong>Apache License 2.0</strong> —
<a
href="https://github.com/jgraph/drawio"
target="_blank"
rel="noopener noreferrer"
>github.com/jgraph/drawio</a
>
</p>
<p class="muted">
draw.io 편집 화면은 공식 <code>embed.diagrams.net</code> 정적 앱을
네트워크로 불러옵니다. 다이어그램 데이터는 브라우저의
<code>postMessage</code>를 통해 교환하며 본 앱 서버에 저장하지 않습니다.
</p>
</section>
<section class="card">
<h2>rhwp (@rhwp/editor, @rhwp/core)</h2>
<p>라이선스: <strong>MIT</strong></p>
@@ -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>
@@ -145,6 +145,7 @@ function onClose() {
<EditorToolbar
v-if="!error"
:filename="filename"
:show-filename="false"
@open="onOpen"
@save="onSave"
@close="onClose"
+1
View File
@@ -24,6 +24,7 @@ declare module '@rhwp/editor' {
}
interface Window {
EXCALIDRAW_ASSET_PATH?: string;
DocsAPI?: {
DocEditor: new (
id: string,