다이어그램 지원 추가
This commit is contained in:
1 parent
338397e016
commit
c1ceec3fb1
255 files changed
+4379
-15
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
@@ -24,6 +24,7 @@ declare module '@rhwp/editor' {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
EXCALIDRAW_ASSET_PATH?: string;
|
||||
DocsAPI?: {
|
||||
DocEditor: new (
|
||||
id: string,
|
||||
|
||||
Reference in new issue
Block a user