first commit
This commit is contained in:
commit
a74fe7a388
42 files changed
+6058
No files matched your search
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { bindSessionPageHideOnce } from '@/composables/useSessionLifecycle';
|
||||
|
||||
bindSessionPageHideOnce();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
/** Keep editor instances alive when visiting /licenses etc. */
|
||||
const viewKey = computed(() => {
|
||||
if (route.name === 'onlyoffice-editor') {
|
||||
return `onlyoffice-${route.params.docId}`;
|
||||
}
|
||||
if (route.name === 'rhwp-editor') {
|
||||
return 'rhwp-editor';
|
||||
}
|
||||
return String(route.fullPath);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive :include="['OnlyOfficeEditorView', 'RhwpEditorView']" :max="6">
|
||||
<component :is="Component" :key="viewKey" />
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
export class ApiError extends Error {
|
||||
readonly code: string;
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, code: string, message?: string) {
|
||||
super(message ?? code);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJson<T>(res: Response): Promise<T> {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body.error ?? `http_${res.status}`,
|
||||
body.message ?? body.error ?? `HTTP ${res.status}`,
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function createSession(): Promise<{
|
||||
sessionId: string;
|
||||
reused?: boolean;
|
||||
}> {
|
||||
return parseJson(
|
||||
await fetch('/api/sessions', { method: 'POST', credentials: 'include' }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function heartbeat(): Promise<void> {
|
||||
await fetch('/api/sessions/heartbeat', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
|
||||
export async function endSession(): Promise<void> {
|
||||
await fetch('/api/sessions', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadDocument(file: File): Promise<{
|
||||
docId: string;
|
||||
filename: string;
|
||||
fileType: string;
|
||||
documentType: string;
|
||||
}> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('filename', file.name);
|
||||
return parseJson(
|
||||
await fetch('/api/documents', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export type EditorConfigResponse = {
|
||||
documentType: string;
|
||||
document: Record<string, unknown>;
|
||||
editorConfig: Record<string, unknown>;
|
||||
token: string;
|
||||
documentServerUrl: string;
|
||||
};
|
||||
|
||||
export async function fetchEditorConfig(
|
||||
docId: string,
|
||||
): Promise<EditorConfigResponse> {
|
||||
return parseJson(
|
||||
await fetch(`/api/documents/${docId}/editor-config`, {
|
||||
credentials: 'include',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadDocumentUrl(docId: string): string {
|
||||
return `/api/documents/${docId}/download`;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<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>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import AppFooter from './AppFooter.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Editor pages: fill viewport, compact footer */
|
||||
compactFooter?: boolean;
|
||||
}>(),
|
||||
{ compactFooter: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell" :class="{ 'shell--editor': compactFooter }">
|
||||
<div class="shell__main">
|
||||
<slot />
|
||||
</div>
|
||||
<AppFooter :compact="compactFooter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shell--editor {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell__main {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { ApiError } from '@/api/client';
|
||||
|
||||
const props = defineProps<{
|
||||
error: unknown;
|
||||
context?: 'onlyoffice' | 'rhwp';
|
||||
}>();
|
||||
|
||||
const code = computed(() => {
|
||||
if (props.error instanceof ApiError) return props.error.code;
|
||||
return 'unknown';
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
switch (code.value) {
|
||||
case 'not_found':
|
||||
return '문서를 찾을 수 없습니다';
|
||||
case 'session_required':
|
||||
return '세션이 만료되었습니다';
|
||||
case 'unsupported_type':
|
||||
return '지원하지 않는 파일 형식입니다';
|
||||
default:
|
||||
return '문서를 열 수 없습니다';
|
||||
}
|
||||
});
|
||||
|
||||
const detail = computed(() => {
|
||||
switch (code.value) {
|
||||
case 'not_found':
|
||||
return '임시 세션이 끝났거나, 다른 탭에서 세션이 바뀌었을 수 있습니다. 서버에는 파일을 영구 보관하지 않으므로 홈에서 다시 열어 주세요. 편집 중이라면 가능하면 에디터의 파일 → 저장으로 먼저 받아 두세요.';
|
||||
case 'session_required':
|
||||
return '브라우저를 오래 두었거나 세션이 초기화되었습니다. 홈으로 돌아가 파일을 다시 선택해 주세요.';
|
||||
default:
|
||||
if (props.error instanceof Error && props.error.message) {
|
||||
return props.error.message;
|
||||
}
|
||||
return '알 수 없는 오류가 발생했습니다. 홈에서 다시 시도해 주세요.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="err" role="alert">
|
||||
<p class="err__eyebrow">{{ context === 'rhwp' ? 'HWP 에디터' : '문서 에디터' }}</p>
|
||||
<h1 class="err__title">{{ title }}</h1>
|
||||
<p class="err__detail">{{ detail }}</p>
|
||||
<p v-if="code !== 'unknown'" class="err__code">오류 코드: {{ code }}</p>
|
||||
<div class="err__actions">
|
||||
<RouterLink class="err__btn err__btn--primary" to="/">홈으로 돌아가기</RouterLink>
|
||||
<RouterLink class="err__btn" to="/licenses">라이선스 보기</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.err {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.err__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.err__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.err__detail {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.err__code {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.err__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.err__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.err__btn--primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { createSession, endSession, heartbeat } from '@/api/client';
|
||||
|
||||
const HWP_FILE_KEY = 'sejong_hwp_file';
|
||||
|
||||
let pageHideBound = false;
|
||||
|
||||
function onPageHide() {
|
||||
navigator.sendBeacon(
|
||||
'/api/sessions/end',
|
||||
new Blob(['{}'], { type: 'application/json' }),
|
||||
);
|
||||
void fetch('/api/sessions', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
});
|
||||
clearHwpFile();
|
||||
}
|
||||
|
||||
/** Register once for the whole SPA (survives keep-alive editor switches). */
|
||||
export function bindSessionPageHideOnce() {
|
||||
if (pageHideBound || typeof window === 'undefined') return;
|
||||
pageHideBound = true;
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
}
|
||||
|
||||
export function storeHwpFile(file: File): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
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 loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
|
||||
const raw = sessionStorage.getItem(HWP_FILE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const { name, base64 } = JSON.parse(raw) as {
|
||||
name: string;
|
||||
base64: string;
|
||||
};
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return { name, buffer: bytes.buffer };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearHwpFile(): void {
|
||||
sessionStorage.removeItem(HWP_FILE_KEY);
|
||||
}
|
||||
|
||||
export function useSessionLifecycle(options?: { enableHeartbeat?: boolean }) {
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async function ensureSession() {
|
||||
// Reuses cookie session on the server — does not orphan open documents
|
||||
await createSession();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
bindSessionPageHideOnce();
|
||||
try {
|
||||
await ensureSession();
|
||||
} catch (err) {
|
||||
console.error('session create failed', err);
|
||||
}
|
||||
|
||||
if (options?.enableHeartbeat !== false) {
|
||||
timer = setInterval(() => {
|
||||
void heartbeat().catch(() => undefined);
|
||||
}, 30_000);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
return { ensureSession, endSession, clearHwpFile };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** 본 프로젝트 소스 저장소. 공개 후 URL 채우기. 비우면 푸터에서 링크 숨김 */
|
||||
export const APP_GIT_URL = "https://git.sejong.dev/ysj7705/sejong-office";
|
||||
|
||||
export const APP_NAME = "sejong-office";
|
||||
|
||||
export const APP_DISPLAY_NAME = "Sejong Office";
|
||||
|
||||
/** HWP/HWPX — client-side rhwp */
|
||||
export const HWP_EXTENSIONS = new Set(["hwp", "hwpx"]);
|
||||
|
||||
/** Office/PDF — OnlyOffice */
|
||||
export const OFFICE_EXTENSIONS = new Set([
|
||||
"docx",
|
||||
"xlsx",
|
||||
"pptx",
|
||||
"odt",
|
||||
"ods",
|
||||
"odp",
|
||||
"doc",
|
||||
"xls",
|
||||
"ppt",
|
||||
"rtf",
|
||||
"txt",
|
||||
"pdf",
|
||||
"csv",
|
||||
"dotx",
|
||||
"xltx",
|
||||
"potx",
|
||||
]);
|
||||
|
||||
export function extensionOf(filename: string): string {
|
||||
const idx = filename.lastIndexOf(".");
|
||||
if (idx < 0) return "";
|
||||
return filename.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function isHwp(filename: string): boolean {
|
||||
return HWP_EXTENSIONS.has(extensionOf(filename));
|
||||
}
|
||||
|
||||
export function isOffice(filename: string): boolean {
|
||||
return OFFICE_EXTENSIONS.has(extensionOf(filename));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './styles.css';
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import HomeView from '@/views/HomeView.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/editor/onlyoffice/:docId',
|
||||
name: 'onlyoffice-editor',
|
||||
component: () => import('@/views/OnlyOfficeEditorView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/editor/rhwp',
|
||||
name: 'rhwp-editor',
|
||||
component: () => import('@/views/RhwpEditorView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/licenses',
|
||||
name: 'licenses',
|
||||
component: () => import('@/views/LicensesView.vue'),
|
||||
},
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,43 @@
|
||||
:root {
|
||||
--ink: #1c2b24;
|
||||
--ink-soft: #2f4038;
|
||||
--muted: #5c6b63;
|
||||
--accent: #2f6b55;
|
||||
--accent-soft: #d7ebe2;
|
||||
--surface: #faf9f6;
|
||||
--canvas: #eef1ee;
|
||||
--border: #d0d7d2;
|
||||
--font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
|
||||
--font-display: 'Newsreader', 'IBM Plex Sans KR', serif;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import { APP_DISPLAY_NAME, isHwp, isOffice } from '@/config/app';
|
||||
import { createSession, uploadDocument } from '@/api/client';
|
||||
import { storeHwpFile } from '@/composables/useSessionLifecycle';
|
||||
|
||||
const router = useRouter();
|
||||
const dragging = ref(false);
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
async function handleFile(file: File | undefined) {
|
||||
if (!file || busy.value) return;
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
|
||||
try {
|
||||
if (isHwp(file.name)) {
|
||||
await storeHwpFile(file);
|
||||
await createSession().catch(() => undefined);
|
||||
await router.push({ name: 'rhwp-editor' });
|
||||
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) {
|
||||
dragging.value = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
void handleFile(file);
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
void handleFile(input.files?.[0]);
|
||||
input.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<main class="home">
|
||||
<div class="home__bg" aria-hidden="true" />
|
||||
<section class="hero">
|
||||
<p class="hero__brand">{{ APP_DISPLAY_NAME }}</p>
|
||||
<h1 class="hero__title">문서를 열고, 고치고, 가져가세요</h1>
|
||||
<p class="hero__lead">
|
||||
서버에 영구 저장하지 않습니다. 편집이 끝나면 다운로드하세요. 브라우저를
|
||||
닫으면 임시 파일은 삭제됩니다.
|
||||
</p>
|
||||
|
||||
<label
|
||||
class="drop"
|
||||
:class="{ 'drop--active': dragging, 'drop--busy': busy }"
|
||||
@dragenter.prevent="dragging = true"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input
|
||||
class="drop__input"
|
||||
type="file"
|
||||
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv"
|
||||
:disabled="busy"
|
||||
@change="onInput"
|
||||
/>
|
||||
<span class="drop__label">
|
||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||
</span>
|
||||
<span class="drop__hint">
|
||||
오피스·PDF → OnlyOffice · HWP/HWPX → rhwp
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="hero__error" role="alert">{{ error }}</p>
|
||||
</section>
|
||||
</main>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 3rem 1.25rem 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 10%, #c8e0d4 0%, transparent 55%),
|
||||
radial-gradient(ellipse 70% 50% at 90% 80%, #d9cfc0 0%, transparent 50%),
|
||||
linear-gradient(165deg, #f3f6f4 0%, #e8ebe6 45%, #f7f2ea 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.home__bg::after {
|
||||
content: '';
|
||||
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-size: clamp(2.4rem, 6vw, 3.4rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
margin: 0 0 1rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.65rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.hero__lead {
|
||||
margin: 0 0 1.75rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.drop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 2.25rem 1.5rem;
|
||||
border: 1.5px dashed color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease,
|
||||
transform 0.25s ease;
|
||||
}
|
||||
|
||||
.drop:hover,
|
||||
.drop--active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, var(--surface));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.drop--busy {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop__input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drop__label {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.drop__hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero__error {
|
||||
margin: 1rem 0 0;
|
||||
color: #9b2c2c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import { APP_DISPLAY_NAME, APP_GIT_URL, APP_NAME } from '@/config/app';
|
||||
|
||||
const thirdParty = [
|
||||
{ name: 'Vue', license: 'MIT', url: 'https://github.com/vuejs/core' },
|
||||
{ name: 'Vue Router', license: 'MIT', url: 'https://github.com/vuejs/router' },
|
||||
{ name: 'Vite', license: 'MIT', url: 'https://github.com/vitejs/vite' },
|
||||
{ name: 'Express', license: 'MIT', url: 'https://github.com/expressjs/express' },
|
||||
{ name: 'jsonwebtoken', license: 'MIT', url: 'https://github.com/auth0/node-jsonwebtoken' },
|
||||
{ name: 'multer', license: 'MIT', url: 'https://github.com/expressjs/multer' },
|
||||
{ name: 'uuid', license: 'MIT', url: 'https://github.com/uuidjs/uuid' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<main class="licenses">
|
||||
<header class="licenses__header">
|
||||
<RouterLink class="back" to="/">← 홈</RouterLink>
|
||||
<h1>오픈소스 라이선스 · 고지</h1>
|
||||
<p>
|
||||
{{ APP_DISPLAY_NAME }}({{ APP_NAME }})에 포함된 주요 소프트웨어의 라이선스와
|
||||
고지 사항입니다.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<h2>본 앱</h2>
|
||||
<p>라이선스: <strong>MIT</strong></p>
|
||||
<p v-if="APP_GIT_URL">
|
||||
소스 코드:
|
||||
<a :href="APP_GIT_URL" target="_blank" rel="noopener noreferrer">{{
|
||||
APP_GIT_URL
|
||||
}}</a>
|
||||
</p>
|
||||
<p v-else class="muted">
|
||||
소스 저장소 URL은 <code>APP_GIT_URL</code> 상수에 설정할 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>ONLYOFFICE Document Server</h2>
|
||||
<p>
|
||||
Community Edition은 일반적으로
|
||||
<strong>GNU AGPL v3</strong>로 배포됩니다. 서버에 Document Server를
|
||||
배포·수정하는 경우 AGPL 의무(소스 제공 등)를 확인하세요.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.onlyoffice.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>onlyoffice.com</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/ONLYOFFICE/DocumentServer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>DocumentServer (GitHub)</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">
|
||||
ONLYOFFICE는 Ascensio System SIA의 상표입니다. 본 프로젝트는 OnlyOffice와
|
||||
공식 제휴 관계가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>rhwp (@rhwp/editor, @rhwp/core)</h2>
|
||||
<p>라이선스: <strong>MIT</strong></p>
|
||||
<p>
|
||||
HWP/HWPX 뷰어·에디터 —
|
||||
<a
|
||||
href="https://github.com/edwardkim/rhwp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>github.com/edwardkim/rhwp</a
|
||||
>
|
||||
</p>
|
||||
<p class="muted">
|
||||
“한글”, “한컴”, “HWP”, “HWPX”는 주식회사 한글과컴퓨터의 등록 상표입니다.
|
||||
rhwp 및 본 앱은 한글과컴퓨터와 제휴·후원·승인 관계가 없는 독립 프로젝트입니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>기타 주요 의존성</h2>
|
||||
<ul class="deps">
|
||||
<li v-for="item in thirdParty" :key="item.name">
|
||||
<a :href="item.url" target="_blank" rel="noopener noreferrer">{{
|
||||
item.name
|
||||
}}</a>
|
||||
— {{ item.license }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.licenses {
|
||||
flex: 1;
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.licenses__header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.85rem;
|
||||
margin: 0.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.licenses__header p {
|
||||
color: var(--muted);
|
||||
margin: 0 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.25rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.card p,
|
||||
.card li {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.card a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted) !important;
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
.deps {
|
||||
padding-left: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.85em;
|
||||
background: var(--accent-soft);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorErrorState from '@/components/EditorErrorState.vue';
|
||||
import { fetchEditorConfig } from '@/api/client';
|
||||
import { useSessionLifecycle } from '@/composables/useSessionLifecycle';
|
||||
|
||||
defineOptions({ name: 'OnlyOfficeEditorView' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const error = ref<unknown>(null);
|
||||
const filename = ref('문서');
|
||||
const frameHost = ref<HTMLElement | null>(null);
|
||||
let editor: { destroyEditor?: () => void } | null = null;
|
||||
let scriptEl: HTMLScriptElement | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let initialized = false;
|
||||
|
||||
useSessionLifecycle({ enableHeartbeat: true });
|
||||
|
||||
function editorHeightPx(): number {
|
||||
const host = frameHost.value;
|
||||
if (host) {
|
||||
const h = host.clientHeight;
|
||||
if (h > 200) return h;
|
||||
}
|
||||
return Math.max(400, window.innerHeight - 120);
|
||||
}
|
||||
|
||||
function syncIframeSize() {
|
||||
const iframe = frameHost.value?.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.style.height = `${editorHeightPx()}px`;
|
||||
iframe.style.width = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.DocsAPI) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
scriptEl = document.createElement('script');
|
||||
scriptEl.src = src;
|
||||
scriptEl.async = true;
|
||||
scriptEl.onload = () => resolve();
|
||||
scriptEl.onerror = () =>
|
||||
reject(new Error('OnlyOffice 스크립트를 불러오지 못했습니다.'));
|
||||
document.head.appendChild(scriptEl);
|
||||
});
|
||||
}
|
||||
|
||||
async function initEditor() {
|
||||
if (initialized) return;
|
||||
const docId = route.params.docId as string;
|
||||
error.value = null;
|
||||
try {
|
||||
const cfg = await fetchEditorConfig(docId);
|
||||
filename.value = String(cfg.document.title ?? '문서');
|
||||
const apiUrl = `${cfg.documentServerUrl}/web-apps/apps/api/documents/api.js`;
|
||||
await loadScript(apiUrl);
|
||||
await nextTick();
|
||||
|
||||
if (!window.DocsAPI) {
|
||||
throw new Error('DocsAPI를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const height = `${editorHeightPx()}px`;
|
||||
|
||||
editor = new window.DocsAPI.DocEditor('onlyoffice-placeholder', {
|
||||
documentType: cfg.documentType,
|
||||
document: cfg.document,
|
||||
editorConfig: cfg.editorConfig,
|
||||
token: cfg.token,
|
||||
width: '100%',
|
||||
height,
|
||||
type: 'desktop',
|
||||
});
|
||||
initialized = true;
|
||||
|
||||
if (frameHost.value && typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => syncIframeSize());
|
||||
resizeObserver.observe(frameHost.value);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void initEditor();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
nextTick(() => syncIframeSize());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
editor?.destroyEditor?.();
|
||||
editor = null;
|
||||
initialized = false;
|
||||
});
|
||||
|
||||
function goHome() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :compact-footer="true">
|
||||
<div class="editor-page">
|
||||
<header v-if="!error" class="bar">
|
||||
<button type="button" class="bar__btn bar__btn--ghost" @click="goHome">
|
||||
← 홈
|
||||
</button>
|
||||
<div class="bar__meta">
|
||||
<strong>{{ filename }}</strong>
|
||||
<span>
|
||||
서버에 영구 저장되지 않습니다. 닫기 전에 다운로드하세요.
|
||||
<em>파일 → 저장</em>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<EditorErrorState v-if="error" :error="error" context="onlyoffice" />
|
||||
<div v-show="!error" ref="frameHost" class="editor-frame">
|
||||
<div id="onlyoffice-placeholder" class="editor-frame__inner" />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-frame__inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-frame__inner :deep(iframe),
|
||||
.editor-frame :deep(iframe) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { createEditor, type RhwpEditor } from '@rhwp/editor';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorErrorState from '@/components/EditorErrorState.vue';
|
||||
import { ApiError } from '@/api/client';
|
||||
import {
|
||||
clearHwpFile,
|
||||
loadHwpFile,
|
||||
useSessionLifecycle,
|
||||
} from '@/composables/useSessionLifecycle';
|
||||
|
||||
defineOptions({ name: 'RhwpEditorView' });
|
||||
|
||||
const router = useRouter();
|
||||
const error = ref<unknown>(null);
|
||||
const filename = ref('문서.hwp');
|
||||
let editor: RhwpEditor | null = null;
|
||||
let initialized = false;
|
||||
|
||||
useSessionLifecycle({ enableHeartbeat: true });
|
||||
|
||||
onMounted(async () => {
|
||||
if (initialized) return;
|
||||
|
||||
const stored = loadHwpFile();
|
||||
if (!stored) {
|
||||
error.value = new ApiError(
|
||||
404,
|
||||
'not_found',
|
||||
'열린 HWP 파일이 없습니다. 홈에서 다시 업로드해 주세요.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
filename.value = stored.name;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
editor = await createEditor('#rhwp-host', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
await editor.loadFile(stored.buffer, stored.name);
|
||||
initialized = true;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
initialized = false;
|
||||
});
|
||||
|
||||
function goHome() {
|
||||
clearHwpFile();
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :compact-footer="true">
|
||||
<div class="editor-page">
|
||||
<header v-if="!error" class="bar">
|
||||
<button type="button" class="bar__btn bar__btn--ghost" @click="goHome">
|
||||
← 홈
|
||||
</button>
|
||||
<div class="bar__meta">
|
||||
<strong>{{ filename }}</strong>
|
||||
<span>
|
||||
서버에 영구 저장되지 않습니다. 닫기 전에 다운로드하세요.
|
||||
<em>파일 → 저장</em>
|
||||
(조판은 한컴과 다를 수 있습니다)
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<EditorErrorState v-if="error" :error="error" context="rhwp" />
|
||||
<div v-show="!error" id="rhwp-host" class="editor-frame" />
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-frame :deep(iframe) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '@rhwp/editor' {
|
||||
export type RhwpEditor = {
|
||||
loadFile: (
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
fileName?: string,
|
||||
) => Promise<{ pageCount: number }>;
|
||||
pageCount: () => Promise<number>;
|
||||
getPageSvg: (page?: number) => Promise<string>;
|
||||
exportHwp: () => Promise<Uint8Array>;
|
||||
exportHwpx: () => Promise<Uint8Array>;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
export function createEditor(
|
||||
container: string | HTMLElement,
|
||||
options?: {
|
||||
studioUrl?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
},
|
||||
): Promise<RhwpEditor>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
DocsAPI?: {
|
||||
DocEditor: new (
|
||||
id: string,
|
||||
config: Record<string, unknown>,
|
||||
) => { destroyEditor?: () => void };
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user