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:
1 parent
5ac6281304
commit
0800fc8571
27 files changed
+735
-394
No files matched your search
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.
@@ -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.
@@ -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>
|
||||||
|
|||||||
@@ -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,30 +26,27 @@ 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;
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = '';
|
|
||||||
const chunk = 0x8000;
|
|
||||||
for (let i = 0; i < bytes.length; i += chunk) {
|
|
||||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
||||||
}
|
|
||||||
sessionStorage.setItem(
|
|
||||||
HWP_FILE_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
name: file.name,
|
|
||||||
base64: btoa(binary),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(reader.error);
|
|
||||||
reader.readAsArrayBuffer(file);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
const chunk = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunk) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||||
|
}
|
||||||
|
sessionStorage.setItem(
|
||||||
|
HWP_FILE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
name,
|
||||||
|
base64: btoa(binary),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
|
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
|
||||||
const raw = sessionStorage.getItem(HWP_FILE_KEY);
|
const raw = sessionStorage.getItem(HWP_FILE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user