Migrate to Euro-Office and refresh the editor home experience.

Swap Document Server to Euro-Office, add blank-document creation, and restyle the app with an Office-like light/dark theme and compact editor toolbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ysj7705andCursor committed 2026-07-16 17:47:06 +09:00
1 parent 5ac6281304
commit 0800fc8571
27 files changed
+724 -383

No files matched your search

+2 -2
View File
@@ -1,11 +1,11 @@
# Shared JWT secret (must match OnlyOffice Document Server) # Shared JWT secret (must match Euro-Office Document Server)
JWT_SECRET=change-me-to-a-long-random-secret JWT_SECRET=change-me-to-a-long-random-secret
# URLs # URLs
PUBLIC_URL=http://localhost:3000 PUBLIC_URL=http://localhost:3000
INTERNAL_URL=http://backend:3000 INTERNAL_URL=http://backend:3000
ONLYOFFICE_URL=http://localhost:8080 ONLYOFFICE_URL=http://localhost:8080
ONLYOFFICE_INTERNAL_URL=http://onlyoffice ONLYOFFICE_INTERNAL_URL=http://documentserver
# Session # Session
SESSION_TTL_MS=900000 SESSION_TTL_MS=900000
+1
View File
@@ -25,6 +25,7 @@ RUN npm install --omit=dev -w @sejong-office/backend && npm cache clean --force
COPY --from=build /app/packages/backend/dist ./packages/backend/dist COPY --from=build /app/packages/backend/dist ./packages/backend/dist
COPY --from=build /app/packages/backend/public ./packages/backend/public COPY --from=build /app/packages/backend/public ./packages/backend/public
COPY --from=build /app/packages/backend/templates ./packages/backend/templates
WORKDIR /app/packages/backend WORKDIR /app/packages/backend
ENV TEMP_DIR=/tmp/sejong ENV TEMP_DIR=/tmp/sejong
+6 -6
View File
@@ -2,7 +2,7 @@
임시 세션 기반 문서 편집 웹앱입니다. 임시 세션 기반 문서 편집 웹앱입니다.
- **오피스 / PDF** → [ONLYOFFICE Document Server](https://www.onlyoffice.com/) - **오피스 / PDF** → [Euro-Office Document Server](https://github.com/Euro-Office/DocumentServer)
- **HWP / HWPX** → [rhwp](https://github.com/edwardkim/rhwp) (`@rhwp/editor`) - **HWP / HWPX** → [rhwp](https://github.com/edwardkim/rhwp) (`@rhwp/editor`)
서버에 문서를 **영구 저장하지 않습니다**. 오피스·PDF는 세션 동안만 tmpfs에 두고, 브라우저 종료·TTL로 삭제합니다. HWP/HWPX는 기본적으로 브라우저 메모리에서만 다룹니다. 편집 결과는 **다운로드**로만 가져가세요. 서버에 문서를 **영구 저장하지 않습니다**. 오피스·PDF는 세션 동안만 tmpfs에 두고, 브라우저 종료·TTL로 삭제합니다. HWP/HWPX는 기본적으로 브라우저 메모리에서만 다룹니다. 편집 결과는 **다운로드**로만 가져가세요.
@@ -40,10 +40,10 @@ npm run dev:backend
npm run dev:frontend npm run dev:frontend
``` ```
OnlyOffice Document Server는 Docker로만 띄워도 됩니다. Euro-Office Document Server는 Docker로만 띄워도 됩니다.
```bash ```bash
docker compose up onlyoffice docker compose up documentserver
``` ```
## 소스 저장소 링크 ## 소스 저장소 링크
@@ -52,10 +52,10 @@ docker compose up onlyoffice
## 라이선스 고지 ## 라이선스 고지
앱 내 [/licenses](http://localhost:3000/licenses) 페이지에서 OnlyOffice(AGPL), rhwp(MIT), 주요 OSS 고지를 볼 수 있습니다. 앱 내 [/licenses](http://localhost:3000/licenses) 페이지에서 Euro-Office(AGPL), rhwp(MIT), 주요 OSS 고지를 볼 수 있습니다.
- 본 앱: MIT - 본 앱: MIT
- ONLYOFFICE Document Server Community: AGPL-3.0 (배포 시 의무 확인) - Euro-Office Document Server: AGPL-3.0 (배포 시 의무 확인)
- rhwp: MIT — Hancom 상표와 무관한 독립 프로젝트 - rhwp: MIT — Hancom 상표와 무관한 독립 프로젝트
## 포맷 안내 ## 포맷 안내
@@ -63,6 +63,6 @@ docker compose up onlyoffice
| 확장자 | 엔진 | 저장 | | 확장자 | 엔진 | 저장 |
|--------|------|------| |--------|------|------|
| hwp, hwpx | rhwp | 원본 확장자로 export | | hwp, hwpx | rhwp | 원본 확장자로 export |
| pdf, docx, xlsx, pptx 등 | OnlyOffice | 원본 확장자 유지 시도 (`assemblyFormatAsOrigin`) | | pdf, docx, xlsx, pptx 등 | Euro-Office | 원본 확장자 유지 시도 (`forcesave` + ConvertService) |
rhwp는 v0.7.x 기준이며, 복잡한 공공문서에서 한컴과 조판이 다를 수 있습니다. rhwp는 v0.7.x 기준이며, 복잡한 공공문서에서 한컴과 조판이 다를 수 있습니다.
+6 -6
View File
@@ -8,7 +8,7 @@ services:
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000} PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
INTERNAL_URL: ${INTERNAL_URL:-http://backend:3000} INTERNAL_URL: ${INTERNAL_URL:-http://backend:3000}
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-http://localhost:8080} ONLYOFFICE_URL: ${ONLYOFFICE_URL:-http://localhost:8080}
ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice} ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://documentserver}
SESSION_TTL_MS: ${SESSION_TTL_MS:-900000} SESSION_TTL_MS: ${SESSION_TTL_MS:-900000}
TEMP_DIR: /tmp/sejong TEMP_DIR: /tmp/sejong
PORT: 3000 PORT: 3000
@@ -16,11 +16,11 @@ services:
tmpfs: tmpfs:
- /tmp/sejong:size=512m,mode=1777 - /tmp/sejong:size=512m,mode=1777
depends_on: depends_on:
- onlyoffice - documentserver
restart: unless-stopped restart: unless-stopped
onlyoffice: documentserver:
image: onlyoffice/documentserver:8.3 image: ghcr.io/euro-office/documentserver:v9.3.2
ports: ports:
- '8080:80' - '8080:80'
environment: environment:
@@ -30,8 +30,8 @@ services:
JWT_IN_BODY: 'true' JWT_IN_BODY: 'true'
volumes: volumes:
# Persist DS license/cache lightly; document content still lives in backend tmpfs # Persist DS license/cache lightly; document content still lives in backend tmpfs
- onlyoffice_data:/var/www/onlyoffice/Data - documentserver_data:/var/www/onlyoffice/Data
restart: unless-stopped restart: unless-stopped
volumes: volumes:
onlyoffice_data: documentserver_data:
+32
View File
@@ -13,6 +13,10 @@ import {
buildEditorConfig, buildEditorConfig,
convertToOriginalFormat, convertToOriginalFormat,
} from '../services/onlyOffice.js'; } from '../services/onlyOffice.js';
import {
createBlankDocument,
isBlankType,
} from '../services/blankDocument.js';
import { extensionOf } from '../utils/formats.js'; import { extensionOf } from '../utils/formats.js';
import { decodeUploadFilename } from '../utils/filename.js'; import { decodeUploadFilename } from '../utils/filename.js';
import { extractToken, verifyOnlyOfficeToken } from '../utils/jwt.js'; import { extractToken, verifyOnlyOfficeToken } from '../utils/jwt.js';
@@ -24,6 +28,34 @@ const upload = multer({
export const documentsRouter = Router(); export const documentsRouter = Router();
documentsRouter.post('/new', requireSession, async (req, res) => {
const type = typeof req.body?.type === 'string' ? req.body.type.trim() : '';
if (!isBlankType(type)) {
res.status(400).json({
error: 'unsupported_type',
message: '지원 형식: docx, xlsx, pptx, pdf, hwpx',
});
return;
}
try {
const doc = await createBlankDocument(
res.locals.sessionId as string,
type,
);
res.json({
docId: doc.docId,
filename: doc.filename,
fileType: doc.fileType,
documentType: doc.documentType,
token: doc.token,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'create_failed' });
}
});
documentsRouter.post( documentsRouter.post(
'/', '/',
requireSession, requireSession,
@@ -0,0 +1,42 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { addDocument } from './sessionStore.js';
import type { SessionDoc } from '../types/session.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const BLANK_TYPES = ['docx', 'xlsx', 'pptx', 'pdf', 'hwpx'] as const;
export type BlankType = (typeof BLANK_TYPES)[number];
export function isBlankType(value: string): value is BlankType {
return (BLANK_TYPES as readonly string[]).includes(value);
}
/** Resolve templates dir for both tsx (src) and compiled dist. */
async function templatesDir(): Promise<string> {
const candidates = [
path.resolve(__dirname, '../../templates'),
path.resolve(__dirname, '../templates'),
];
for (const dir of candidates) {
try {
await fs.access(dir);
return dir;
} catch {
/* try next */
}
}
return candidates[0];
}
export async function createBlankDocument(
sessionId: string,
type: BlankType,
): Promise<SessionDoc> {
const dir = await templatesDir();
const templatePath = path.join(dir, `blank.${type}`);
const buffer = await fs.readFile(templatePath);
const filename = `새문서.${type}`;
return addDocument(sessionId, filename, type, buffer);
}
+2
View File
@@ -78,6 +78,8 @@ export function mimeFor(ext: string): string {
rtf: 'application/rtf', rtf: 'application/rtf',
txt: 'text/plain', txt: 'text/plain',
csv: 'text/csv', csv: 'text/csv',
hwp: 'application/x-hwp',
hwpx: 'application/hwp+zip',
}; };
return map[ext] ?? 'application/octet-stream'; return map[ext] ?? 'application/octet-stream';
} }
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
%PDF-1.4
1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>endobj
4 0 obj<< /Length 0 >>stream
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000229 00000 n
trailer<< /Size 5 /Root 1 0 R >>
startxref
279
%%EOF
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,12 +7,12 @@
<link <link
rel="icon" rel="icon"
type="image/svg+xml" type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%232f6b55' d='M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z'/%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%230f6cbd' d='M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z'/%3E%3C/svg%3E"
/> />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,600&display=swap" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
</head> </head>
+33
View File
@@ -66,6 +66,39 @@ export async function uploadDocument(file: File): Promise<{
); );
} }
export type BlankDocumentType = 'docx' | 'xlsx' | 'pptx' | 'pdf' | 'hwpx';
export async function createBlankDocument(type: BlankDocumentType): Promise<{
docId: string;
filename: string;
fileType: string;
documentType: string;
token: string;
}> {
return parseJson(
await fetch('/api/documents/new', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type }),
}),
);
}
export async function fetchDocumentContent(
docId: string,
token: string,
): Promise<ArrayBuffer> {
const res = await fetch(
`/api/documents/${docId}/content?token=${encodeURIComponent(token)}`,
{ credentials: 'include' },
);
if (!res.ok) {
throw new ApiError(res.status, 'content_failed', '문서를 불러오지 못했습니다.');
}
return res.arrayBuffer();
}
export type EditorConfigResponse = { export type EditorConfigResponse = {
documentType: string; documentType: string;
document: Record<string, unknown>; document: Record<string, unknown>;
@@ -1,92 +0,0 @@
<script setup lang="ts">
import { APP_GIT_URL, APP_DISPLAY_NAME } from '@/config/app';
withDefaults(
defineProps<{
compact?: boolean;
}>(),
{ compact: false },
);
</script>
<template>
<footer class="footer" :class="{ 'footer--compact': compact }">
<div class="footer__inner">
<span class="footer__brand">{{ APP_DISPLAY_NAME }}</span>
<nav class="footer__nav" aria-label="부가 링크">
<a
v-if="APP_GIT_URL"
class="footer__link"
:href="APP_GIT_URL"
target="_blank"
rel="noopener noreferrer"
>
소스 코드
</a>
<RouterLink class="footer__link" to="/licenses">
오픈소스 라이선스
</RouterLink>
</nav>
</div>
</footer>
</template>
<style scoped>
.footer {
flex: 0 0 auto;
margin-top: auto;
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 85%, transparent);
backdrop-filter: blur(8px);
}
.footer--compact .footer__inner {
padding: 0.4rem 1rem;
}
.footer__inner {
max-width: 960px;
margin: 0 auto;
padding: 1rem 1.25rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
align-items: center;
justify-content: space-between;
}
.footer--compact .footer__inner {
max-width: none;
}
.footer__brand {
font-size: 0.85rem;
color: var(--muted);
font-weight: 500;
}
.footer--compact .footer__brand,
.footer--compact .footer__link {
font-size: 0.75rem;
}
.footer__nav {
display: flex;
gap: 1.25rem;
}
.footer__link {
font-size: 0.85rem;
color: var(--ink);
text-decoration: none;
border-bottom: 1px solid transparent;
transition:
border-color 0.2s ease,
color 0.2s ease;
}
.footer__link:hover {
border-bottom-color: var(--accent);
color: var(--accent);
}
</style>
@@ -1,21 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import AppFooter from './AppFooter.vue';
withDefaults( withDefaults(
defineProps<{ defineProps<{
/** Editor pages: fill viewport, compact footer */ /** Editor pages: fill viewport, no scroll */
compactFooter?: boolean; editor?: boolean;
}>(), }>(),
{ compactFooter: false }, { editor: false },
); );
</script> </script>
<template> <template>
<div class="shell" :class="{ 'shell--editor': compactFooter }"> <div class="shell" :class="{ 'shell--editor': editor }">
<div class="shell__main"> <div class="shell__main">
<slot /> <slot />
</div> </div>
<AppFooter :compact="compactFooter" />
</div> </div>
</template> </template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
defineProps<{
filename: string;
}>();
defineEmits<{
open: [];
save: [];
close: [];
}>();
</script>
<template>
<header class="toolbar">
<div class="toolbar__left">
<button type="button" class="toolbar__btn" @click="$emit('open')">
열기
</button>
<button type="button" class="toolbar__btn" @click="$emit('save')">
저장
</button>
</div>
<p class="toolbar__title" :title="filename">{{ filename }}</p>
<div class="toolbar__right">
<RouterLink class="toolbar__link" to="/licenses">라이선스</RouterLink>
<button type="button" class="toolbar__btn" @click="$emit('close')">
닫기
</button>
</div>
</header>
</template>
<style scoped>
.toolbar {
flex: 0 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 0.5rem;
min-height: 40px;
padding: 0.35rem 0.75rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
z-index: 2;
}
.toolbar__left {
display: flex;
gap: 0.4rem;
justify-self: start;
}
.toolbar__right {
display: flex;
gap: 0.65rem;
align-items: center;
justify-self: end;
}
.toolbar__title {
margin: 0;
max-width: min(40vw, 28rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-size: 0.875rem;
font-weight: 600;
color: var(--ink);
}
.toolbar__btn {
border: 1px solid var(--border);
padding: 0.3rem 0.7rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
background: var(--surface);
color: var(--ink);
}
.toolbar__btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.toolbar__link {
font-size: 0.8rem;
font-weight: 500;
color: var(--muted);
text-decoration: none;
}
.toolbar__link:hover {
color: var(--accent);
}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
ICON_COLORS,
ICON_PATHS,
iconKindForExtension,
} from '@/config/favicons';
const props = withDefaults(
defineProps<{
extension: string;
size?: number;
}>(),
{ size: 40 },
);
const kind = computed(() => iconKindForExtension(props.extension.toLowerCase()));
</script>
<template>
<svg
class="ft-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
:width="size"
:height="size"
aria-hidden="true"
>
<path :fill="ICON_COLORS[kind]" :d="ICON_PATHS[kind]" />
</svg>
</template>
<style scoped>
.ft-icon {
flex-shrink: 0;
display: block;
}
</style>
@@ -0,0 +1,84 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { isHwp, isOffice } from '@/config/app';
import {
createBlankDocument,
createSession,
fetchDocumentContent,
uploadDocument,
type BlankDocumentType,
} from '@/api/client';
import {
storeHwpBuffer,
storeHwpFile,
} from '@/composables/useSessionLifecycle';
export function useOpenDocument() {
const router = useRouter();
const busy = ref(false);
const error = ref('');
async function openDocument(file: File | undefined): Promise<boolean> {
if (!file || busy.value) return false;
error.value = '';
busy.value = true;
try {
if (isHwp(file.name)) {
await storeHwpFile(file);
await createSession().catch(() => undefined);
await router.push({ name: 'rhwp-editor' });
return true;
}
if (!isOffice(file.name)) {
error.value =
'지원하지 않는 형식입니다. docx, xlsx, pptx, pdf, hwp, hwpx 등을 올려 주세요.';
return false;
}
await createSession();
const { docId } = await uploadDocument(file);
await router.push({ name: 'onlyoffice-editor', params: { docId } });
return true;
} catch (err) {
error.value =
err instanceof Error ? err.message : '파일을 열 수 없습니다.';
return false;
} finally {
busy.value = false;
}
}
async function createAndOpenBlank(type: BlankDocumentType): Promise<boolean> {
if (busy.value) return false;
error.value = '';
busy.value = true;
try {
await createSession();
const doc = await createBlankDocument(type);
if (type === 'hwpx') {
const buffer = await fetchDocumentContent(doc.docId, doc.token);
storeHwpBuffer(doc.filename, buffer);
await router.push({ name: 'rhwp-editor' });
return true;
}
await router.push({
name: 'onlyoffice-editor',
params: { docId: doc.docId },
});
return true;
} catch (err) {
error.value =
err instanceof Error ? err.message : '새 문서를 만들 수 없습니다.';
return false;
} finally {
busy.value = false;
}
}
return { busy, error, openDocument, createAndOpenBlank };
}
@@ -26,10 +26,12 @@ export function bindSessionPageHideOnce() {
} }
export function storeHwpFile(file: File): Promise<void> { export function storeHwpFile(file: File): Promise<void> {
return new Promise((resolve, reject) => { return file.arrayBuffer().then((buffer) => {
const reader = new FileReader(); storeHwpBuffer(file.name, buffer);
reader.onload = () => { });
const buffer = reader.result as ArrayBuffer; }
export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
let binary = ''; let binary = '';
const chunk = 0x8000; const chunk = 0x8000;
@@ -39,15 +41,10 @@ export function storeHwpFile(file: File): Promise<void> {
sessionStorage.setItem( sessionStorage.setItem(
HWP_FILE_KEY, HWP_FILE_KEY,
JSON.stringify({ JSON.stringify({
name: file.name, name,
base64: btoa(binary), base64: btoa(binary),
}), }),
); );
resolve();
};
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
} }
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null { export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
+9 -5
View File
@@ -5,7 +5,7 @@
import { extensionOf } from '@/config/app'; import { extensionOf } from '@/config/app';
type FaviconKind = export type FaviconKind =
| 'home' | 'home'
| 'pdf' | 'pdf'
| 'word' | 'word'
@@ -16,7 +16,7 @@ type FaviconKind =
| 'csv' | 'csv'
| 'file'; | 'file';
const PATHS: Record<FaviconKind, string> = { export const ICON_PATHS: Record<FaviconKind, string> = {
home: 'M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z', home: 'M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z',
pdf: 'M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z', pdf: 'M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z',
word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z', word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z',
@@ -30,8 +30,8 @@ const PATHS: Record<FaviconKind, string> = {
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', 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',
}; };
const COLORS: Record<FaviconKind, string> = { export const ICON_COLORS: Record<FaviconKind, string> = {
home: '#2f6b55', home: '#0f6cbd',
pdf: '#e53935', pdf: '#e53935',
word: '#1e88e5', word: '#1e88e5',
excel: '#43a047', excel: '#43a047',
@@ -64,12 +64,16 @@ const EXT_KIND: Record<string, FaviconKind> = {
}; };
function toDataUri(kind: FaviconKind): string { function toDataUri(kind: FaviconKind): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${COLORS[kind]}" d="${PATHS[kind]}"/></svg>`; const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${ICON_COLORS[kind]}" d="${ICON_PATHS[kind]}"/></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`; return `data:image/svg+xml,${encodeURIComponent(svg)}`;
} }
const CACHE = new Map<FaviconKind, string>(); const CACHE = new Map<FaviconKind, string>();
export function iconKindForExtension(ext: string): FaviconKind {
return EXT_KIND[ext] ?? 'file';
}
export function faviconHrefFor(filename?: string | null): string { export function faviconHrefFor(filename?: string | null): string {
const name = filename?.trim(); const name = filename?.trim();
let kind: FaviconKind = 'home'; let kind: FaviconKind = 'home';
+30 -9
View File
@@ -1,14 +1,35 @@
:root { :root {
--ink: #1c2b24; --ink: #242424;
--ink-soft: #2f4038; --ink-soft: #424242;
--muted: #5c6b63; --muted: #616161;
--accent: #2f6b55; --accent: #0f6cbd;
--accent-soft: #d7ebe2; --accent-soft: #e8f3fc;
--surface: #faf9f6; --surface: #ffffff;
--canvas: #eef1ee; --canvas: #f3f3f3;
--border: #d0d7d2; --border: #e0e0e0;
--card: #ffffff;
--card-hover: #f5f5f5;
--danger: #c62828;
--font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif; --font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
--font-display: 'Newsreader', 'IBM Plex Sans KR', serif; --font-display: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--ink: #f5f5f5;
--ink-soft: #e0e0e0;
--muted: #a0a0a0;
--accent: #4da3ff;
--accent-soft: #1a2f42;
--surface: #2b2b2b;
--canvas: #1f1f1f;
--border: #3a3a3a;
--card: #2b2b2b;
--card-hover: #333333;
--danger: #ef9a9a;
color-scheme: dark;
}
} }
*, *,
@@ -0,0 +1,18 @@
export function downloadBlob(
data: Blob | ArrayBuffer | Uint8Array,
filename: string,
) {
const blob =
data instanceof Blob
? data
: new Blob([data instanceof Uint8Array ? data.slice() : data]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
+149 -111
View File
@@ -1,72 +1,57 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import { APP_DISPLAY_NAME, isHwp, isOffice } from '@/config/app'; import FileTypeIcon from '@/components/FileTypeIcon.vue';
import { createSession, uploadDocument } from '@/api/client'; import { APP_DISPLAY_NAME } from '@/config/app';
import { useOpenDocument } from '@/composables/useOpenDocument';
import { usePageTitle } from '@/composables/usePageTitle'; import { usePageTitle } from '@/composables/usePageTitle';
import { storeHwpFile } from '@/composables/useSessionLifecycle'; import type { BlankDocumentType } from '@/api/client';
const router = useRouter();
const dragging = ref(false); const dragging = ref(false);
const error = ref(''); const { busy, error, openDocument, createAndOpenBlank } = useOpenDocument();
const busy = ref(false);
usePageTitle(); usePageTitle();
async function handleFile(file: File | undefined) { const blankTypes: {
if (!file || busy.value) return; type: BlankDocumentType;
error.value = ''; title: string;
busy.value = true; subtitle: string;
}[] = [
try { { type: 'docx', title: '문서 (DOCX)', subtitle: '새문서.docx' },
if (isHwp(file.name)) { { type: 'xlsx', title: '스프레드시트 (XLSX)', subtitle: '새문서.xlsx' },
await storeHwpFile(file); { type: 'pptx', title: '프레젠테이션 (PPTX)', subtitle: '새문서.pptx' },
await createSession().catch(() => undefined); { type: 'pdf', title: 'PDF', subtitle: '새문서.pdf' },
await router.push({ name: 'rhwp-editor' }); { type: 'hwpx', title: '한글 문서 (HWPX)', subtitle: '새문서.hwpx' },
return; ];
}
if (!isOffice(file.name)) {
error.value =
'지원하지 않는 형식입니다. docx, xlsx, pptx, pdf, hwp, hwpx 등을 올려 주세요.';
return;
}
await createSession();
const { docId } = await uploadDocument(file);
await router.push({ name: 'onlyoffice-editor', params: { docId } });
} catch (err) {
error.value =
err instanceof Error ? err.message : '파일을 열 수 없습니다.';
} finally {
busy.value = false;
}
}
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
dragging.value = false; dragging.value = false;
const file = e.dataTransfer?.files?.[0]; void openDocument(e.dataTransfer?.files?.[0]);
void handleFile(file);
} }
function onInput(e: Event) { function onInput(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
void handleFile(input.files?.[0]); void openDocument(input.files?.[0]);
input.value = ''; input.value = '';
} }
function onCreate(type: BlankDocumentType) {
void createAndOpenBlank(type);
}
</script> </script>
<template> <template>
<AppShell> <AppShell>
<main class="home"> <main class="home">
<div class="home__bg" aria-hidden="true" /> <header class="home__top">
<section class="hero"> <h1 class="home__brand">{{ APP_DISPLAY_NAME }}</h1>
<p class="hero__brand">{{ APP_DISPLAY_NAME }}</p> <RouterLink class="home__licenses" to="/licenses">라이선스</RouterLink>
<h1 class="hero__title">문서를 열고, 고치고, 가져가세요</h1> </header>
<p class="hero__lead">
서버에 영구 저장하지 않습니다. 편집이 끝나면 다운로드하세요. 브라우저를 <section class="section">
닫으면 임시 파일은 삭제됩니다. <h2 class="section__title">파일 열기</h2>
<p class="section__lead">
서버에 영구 저장하지 않습니다. 편집 후 저장(다운로드)하세요.
</p> </p>
<label <label
@@ -88,11 +73,33 @@ function onInput(e: Event) {
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }} {{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
</span> </span>
<span class="drop__hint"> <span class="drop__hint">
오피스·PDF → OnlyOffice · HWP/HWPX → rhwp 오피스·PDF · HWP/HWPX
</span> </span>
</label> </label>
<p v-if="error" class="hero__error" role="alert">{{ error }}</p> <p v-if="error" class="home__error" role="alert">{{ error }}</p>
</section>
<section class="section">
<h2 class="section__title">새 문서</h2>
<p class="section__lead">빈 문서를 만들어 바로 편집합니다.</p>
<div class="grid">
<button
v-for="item in blankTypes"
:key="item.type"
type="button"
class="tile"
:disabled="busy"
@click="onCreate(item.type)"
>
<FileTypeIcon :extension="item.type" :size="36" />
<span class="tile__text">
<strong>{{ item.title }}</strong>
<span>{{ item.subtitle }}</span>
</span>
</button>
</div>
</section> </section>
</main> </main>
</AppShell> </AppShell>
@@ -100,86 +107,77 @@ function onInput(e: Event) {
<style scoped> <style scoped>
.home { .home {
position: relative;
flex: 1; flex: 1;
display: grid; width: min(920px, 100%);
place-items: center; margin: 0 auto;
padding: 3rem 1.25rem 2rem; padding: 1.5rem 1.25rem 3rem;
overflow: hidden;
} }
.home__bg { .home__top {
position: absolute; display: flex;
inset: 0; align-items: center;
background: justify-content: space-between;
radial-gradient(ellipse 80% 60% at 20% 10%, #c8e0d4 0%, transparent 55%), gap: 1rem;
radial-gradient(ellipse 70% 50% at 90% 80%, #d9cfc0 0%, transparent 50%), margin-bottom: 1.75rem;
linear-gradient(165deg, #f3f6f4 0%, #e8ebe6 45%, #f7f2ea 100%);
z-index: 0;
} }
.home__bg::after { .home__brand {
content: ''; margin: 0;
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%232d4a3e' fill-opacity='0.04'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
opacity: 0.9;
}
.hero {
position: relative;
z-index: 1;
width: min(560px, 100%);
text-align: center;
animation: rise 0.7s ease both;
}
.hero__brand {
font-family: var(--font-display); font-family: var(--font-display);
font-size: clamp(2.4rem, 6vw, 3.4rem); font-size: 1.5rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--ink); color: var(--ink);
margin: 0 0 1rem;
line-height: 1.1;
} }
.hero__title { .home__licenses {
font-family: var(--font-sans); font-size: 0.85rem;
color: var(--muted);
text-decoration: none;
}
.home__licenses:hover {
color: var(--accent);
}
.section {
margin-bottom: 2rem;
}
.section__title {
margin: 0 0 0.35rem;
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 600; font-weight: 600;
margin: 0 0 0.65rem; color: var(--ink);
color: var(--ink-soft);
} }
.hero__lead { .section__lead {
margin: 0 0 1.75rem; margin: 0 0 1rem;
font-size: 0.9rem;
color: var(--muted); color: var(--muted);
font-size: 0.95rem;
line-height: 1.55;
} }
.drop { .drop {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.45rem; justify-content: center;
padding: 2.25rem 1.5rem; gap: 0.35rem;
border: 1.5px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); min-height: 7.5rem;
border-radius: 4px; padding: 1.5rem 1.25rem;
background: color-mix(in srgb, var(--surface) 70%, transparent); border: 1.5px dashed var(--border);
border-radius: 8px;
background: var(--card);
cursor: pointer; cursor: pointer;
transition: transition:
border-color 0.25s ease, border-color 0.2s ease,
background 0.25s ease, background 0.2s ease;
transform 0.25s ease;
} }
.drop:hover, .drop:hover,
.drop--active { .drop--active {
border-color: var(--accent); border-color: var(--accent);
background: color-mix(in srgb, var(--accent-soft) 55%, var(--surface)); background: var(--accent-soft);
transform: translateY(-2px);
} }
.drop--busy { .drop--busy {
@@ -197,6 +195,7 @@ function onInput(e: Event) {
.drop__label { .drop__label {
font-weight: 600; font-weight: 600;
font-size: 0.95rem;
color: var(--ink); color: var(--ink);
} }
@@ -205,20 +204,59 @@ function onInput(e: Event) {
color: var(--muted); color: var(--muted);
} }
.hero__error { .home__error {
margin: 1rem 0 0; margin: 0.85rem 0 0;
color: #9b2c2c; color: var(--danger);
font-size: 0.9rem; font-size: 0.9rem;
} }
@keyframes rise { .grid {
from { display: grid;
opacity: 0; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
transform: translateY(12px); gap: 0.75rem;
} }
to {
opacity: 1; .tile {
transform: translateY(0); display: flex;
} align-items: center;
gap: 0.85rem;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
text-align: left;
cursor: pointer;
font-family: inherit;
color: var(--ink);
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.tile:hover:not(:disabled) {
background: var(--card-hover);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
.tile:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tile__text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.tile__text strong {
font-size: 0.92rem;
font-weight: 600;
}
.tile__text span {
font-size: 0.78rem;
color: var(--muted);
} }
</style> </style>
+12 -7
View File
@@ -40,22 +40,26 @@ const thirdParty = [
<p v-else class="muted"> <p v-else class="muted">
소스 저장소 URL은 <code>APP_GIT_URL</code> 상수에 설정할 수 있습니다. 소스 저장소 URL은 <code>APP_GIT_URL</code> 상수에 설정할 수 있습니다.
</p> </p>
<p class="muted">
본 앱의 UI는 Microsoft Office와 유사한 레이아웃을 참고했으나,
Microsoft·Office·Word·Excel·PowerPoint 등 Microsoft 상표와 공식 제휴
관계가 없습니다.
</p>
</section> </section>
<section class="card"> <section class="card">
<h2>ONLYOFFICE Document Server</h2> <h2>Euro-Office Document Server</h2>
<p> <p>
Community Edition은 일반적으로
<strong>GNU AGPL v3</strong>로 배포됩니다. 서버에 Document Server를 <strong>GNU AGPL v3</strong>로 배포됩니다. 서버에 Document Server를
배포·수정하는 경우 AGPL 의무(소스 제공 등)를 확인하세요. 배포·수정하는 경우 AGPL 의무(소스 제공 등)를 확인하세요.
</p> </p>
<ul> <ul>
<li> <li>
<a <a
href="https://www.onlyoffice.com/" href="https://github.com/Euro-Office/DocumentServer"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>onlyoffice.com</a >Euro-Office DocumentServer (GitHub)</a
> >
</li> </li>
<li> <li>
@@ -63,13 +67,14 @@ const thirdParty = [
href="https://github.com/ONLYOFFICE/DocumentServer" href="https://github.com/ONLYOFFICE/DocumentServer"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>DocumentServer (GitHub)</a >ONLYOFFICE DocumentServer (upstream)</a
> >
</li> </li>
</ul> </ul>
<p class="muted"> <p class="muted">
ONLYOFFICE는 Ascensio System SIA의 상표입니다. 본 프로젝트는 OnlyOffice와 Euro-Office Document Server는 ONLYOFFICE Document Server를 기반으로
공식 제휴 관계가 없습니다. 합니다. ONLYOFFICE는 Ascensio System SIA의 상표입니다. 본 프로젝트는
Euro-Office·OnlyOffice와 공식 제휴 관계가 없습니다.
</p> </p>
</section> </section>
@@ -3,7 +3,9 @@ import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import EditorErrorState from '@/components/EditorErrorState.vue'; import EditorErrorState from '@/components/EditorErrorState.vue';
import { fetchEditorConfig } from '@/api/client'; import EditorToolbar from '@/components/EditorToolbar.vue';
import { downloadDocumentUrl, fetchEditorConfig } from '@/api/client';
import { useOpenDocument } from '@/composables/useOpenDocument';
import { usePageTitle } from '@/composables/usePageTitle'; import { usePageTitle } from '@/composables/usePageTitle';
import { useSessionLifecycle } from '@/composables/useSessionLifecycle'; import { useSessionLifecycle } from '@/composables/useSessionLifecycle';
@@ -14,11 +16,14 @@ const router = useRouter();
const error = ref<unknown>(null); const error = ref<unknown>(null);
const filename = ref('문서'); const filename = ref('문서');
const frameHost = ref<HTMLElement | null>(null); const frameHost = ref<HTMLElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
let editor: { destroyEditor?: () => void } | null = null; let editor: { destroyEditor?: () => void } | null = null;
let scriptEl: HTMLScriptElement | null = null; let scriptEl: HTMLScriptElement | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
let initialized = false; let initialized = false;
const { openDocument } = useOpenDocument();
usePageTitle(filename); usePageTitle(filename);
useSessionLifecycle({ enableHeartbeat: true }); useSessionLifecycle({ enableHeartbeat: true });
@@ -28,7 +33,7 @@ function editorHeightPx(): number {
const h = host.clientHeight; const h = host.clientHeight;
if (h > 200) return h; if (h > 200) return h;
} }
return Math.max(400, window.innerHeight - 120); return Math.max(400, window.innerHeight - 48);
} }
function syncIframeSize() { function syncIframeSize() {
@@ -108,26 +113,49 @@ onUnmounted(() => {
initialized = false; initialized = false;
}); });
function goHome() { function onOpen() {
fileInput.value?.click();
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
void openDocument(input.files?.[0]);
input.value = '';
}
function onSave() {
const docId = route.params.docId as string;
const a = document.createElement('a');
a.href = downloadDocumentUrl(docId);
a.download = filename.value;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function onClose() {
void router.push({ name: 'home' }); void router.push({ name: 'home' });
} }
</script> </script>
<template> <template>
<AppShell :compact-footer="true"> <AppShell :editor="true">
<div class="editor-page"> <div class="editor-page">
<header v-if="!error" class="bar"> <EditorToolbar
<button type="button" class="bar__btn bar__btn--ghost" @click="goHome"> v-if="!error"
← 홈 :filename="filename"
</button> @open="onOpen"
<div class="bar__meta"> @save="onSave"
<strong>{{ filename }}</strong> @close="onClose"
<span> />
서버에 영구 저장되지 않습니다. 닫기 전에 다운로드하세요. <input
<em>파일 → 저장</em> ref="fileInput"
</span> class="sr-only"
</div> type="file"
</header> accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv"
@change="onFileSelected"
/>
<EditorErrorState v-if="error" :error="error" context="onlyoffice" /> <EditorErrorState v-if="error" :error="error" context="onlyoffice" />
<div v-show="!error" ref="frameHost" class="editor-frame"> <div v-show="!error" ref="frameHost" class="editor-frame">
@@ -147,55 +175,16 @@ function goHome() {
background: var(--canvas); background: var(--canvas);
} }
.bar { .sr-only {
flex: 0 0 auto; position: absolute;
display: flex; width: 1px;
flex-wrap: wrap; height: 1px;
gap: 0.75rem; padding: 0;
align-items: center; margin: -1px;
padding: 0.65rem 1rem; overflow: hidden;
border-bottom: 1px solid var(--border); clip: rect(0, 0, 0, 0);
background: var(--surface);
z-index: 2;
}
.bar__meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 12rem;
}
.bar__meta strong {
font-size: 0.95rem;
}
.bar__meta span {
font-size: 0.75rem;
color: var(--muted);
}
.bar__meta em {
font-style: normal;
font-weight: 600;
color: var(--accent);
white-space: nowrap; white-space: nowrap;
} border: 0;
.bar__btn {
border: none;
padding: 0.5rem 0.95rem;
border-radius: 3px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.bar__btn--ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--border);
} }
.editor-frame { .editor-frame {
+77 -62
View File
@@ -4,19 +4,24 @@ import { useRouter } from 'vue-router';
import { createEditor, type RhwpEditor } from '@rhwp/editor'; import { createEditor, type RhwpEditor } from '@rhwp/editor';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import EditorErrorState from '@/components/EditorErrorState.vue'; import EditorErrorState from '@/components/EditorErrorState.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import { extensionOf, isHwp } from '@/config/app';
import { usePageTitle } from '@/composables/usePageTitle'; import { usePageTitle } from '@/composables/usePageTitle';
import { import {
clearHwpFile, clearHwpFile,
loadHwpFile, loadHwpFile,
storeHwpFile,
useSessionLifecycle, useSessionLifecycle,
} from '@/composables/useSessionLifecycle'; } from '@/composables/useSessionLifecycle';
import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'RhwpEditorView' }); defineOptions({ name: 'RhwpEditorView' });
const router = useRouter(); const router = useRouter();
const error = ref<unknown>(null); const error = ref<unknown>(null);
const filename = ref('문서.hwp'); const filename = ref('문서.hwp');
const fileInput = ref<HTMLInputElement | null>(null);
let editor: RhwpEditor | null = null; let editor: RhwpEditor | null = null;
let initialized = false; let initialized = false;
@@ -57,28 +62,76 @@ onUnmounted(() => {
initialized = false; initialized = false;
}); });
function goHome() { function onOpen() {
fileInput.value?.click();
}
async function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || !editor) return;
if (!isHwp(file.name)) {
error.value = new ApiError(
400,
'unsupported_type',
'HWP/HWPX 파일만 열 수 있습니다.',
);
return;
}
try {
await storeHwpFile(file);
const stored = loadHwpFile();
if (!stored) throw new Error('파일을 읽을 수 없습니다.');
await editor.loadFile(stored.buffer, stored.name);
filename.value = stored.name;
error.value = null;
} catch (err) {
error.value = err;
}
}
async function onSave() {
if (!editor) return;
try {
const ext = extensionOf(filename.value);
const bytes =
ext === 'hwpx' ? await editor.exportHwpx() : await editor.exportHwp();
const name =
ext === 'hwpx' || ext === 'hwp'
? filename.value
: `${filename.value}.hwp`;
downloadBlob(bytes, name);
} catch (err) {
error.value = err;
}
}
function onClose() {
clearHwpFile(); clearHwpFile();
void router.push({ name: 'home' }); void router.push({ name: 'home' });
} }
</script> </script>
<template> <template>
<AppShell :compact-footer="true"> <AppShell :editor="true">
<div class="editor-page"> <div class="editor-page">
<header v-if="!error" class="bar"> <EditorToolbar
<button type="button" class="bar__btn bar__btn--ghost" @click="goHome"> v-if="!error"
← 홈 :filename="filename"
</button> @open="onOpen"
<div class="bar__meta"> @save="onSave"
<strong>{{ filename }}</strong> @close="onClose"
<span> />
서버에 영구 저장되지 않습니다. 닫기 전에 다운로드하세요. <input
<em>파일 → 저장</em> ref="fileInput"
(조판은 한컴과 다를 수 있습니다) class="sr-only"
</span> type="file"
</div> accept=".hwp,.hwpx"
</header> @change="onFileSelected"
/>
<EditorErrorState v-if="error" :error="error" context="rhwp" /> <EditorErrorState v-if="error" :error="error" context="rhwp" />
<div v-show="!error" id="rhwp-host" class="editor-frame" /> <div v-show="!error" id="rhwp-host" class="editor-frame" />
@@ -96,54 +149,16 @@ function goHome() {
background: var(--canvas); background: var(--canvas);
} }
.bar { .sr-only {
flex: 0 0 auto; position: absolute;
display: flex; width: 1px;
flex-wrap: wrap; height: 1px;
gap: 0.75rem; padding: 0;
align-items: center; margin: -1px;
padding: 0.65rem 1rem; overflow: hidden;
border-bottom: 1px solid var(--border); clip: rect(0, 0, 0, 0);
background: var(--surface);
}
.bar__meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 12rem;
}
.bar__meta strong {
font-size: 0.95rem;
}
.bar__meta span {
font-size: 0.75rem;
color: var(--muted);
}
.bar__meta em {
font-style: normal;
font-weight: 600;
color: var(--accent);
white-space: nowrap; white-space: nowrap;
} border: 0;
.bar__btn {
border: none;
padding: 0.5rem 0.95rem;
border-radius: 3px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.bar__btn--ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--border);
} }
.editor-frame { .editor-frame {