download 기능 수정

This commit is contained in:
ysj7705 committed 2026-07-23 17:17:24 +09:00
1 parent 80eae206b6
commit f5d880caf5
21 files changed
+461 -122

No files matched your search

+2 -29
View File
@@ -7,11 +7,10 @@ import {
addDocument,
deleteDocument,
getDocument,
overwriteDocument,
} from '../services/sessionStore.js';
import {
buildEditorConfig,
convertToOriginalFormat,
persistDocumentFromUrl,
} from '../services/onlyOffice.js';
import {
createBlankDocument,
@@ -187,33 +186,7 @@ documentsRouter.post('/:docId/callback', async (req, res) => {
}
try {
const downloaded = await fetch(fileUrl);
if (!downloaded.ok) {
console.error('[callback] download failed', downloaded.status);
res.json({ error: 1 });
return;
}
let buffer: Buffer = Buffer.from(await downloaded.arrayBuffer());
const returnedType = (filetype ?? doc.fileType).toLowerCase();
if (returnedType !== doc.fileType) {
const converted = await convertToOriginalFormat(
fileUrl,
returnedType,
doc.fileType,
);
if (!converted) {
console.error(
`[callback] refused save: got ${returnedType}, need ${doc.fileType}`,
);
res.json({ error: 1 });
return;
}
buffer = Buffer.from(converted);
}
await overwriteDocument(doc, buffer, doc.fileType);
await persistDocumentFromUrl(doc, fileUrl, filetype);
} catch (err) {
console.error('[callback] save error', err);
res.json({ error: 1 });
@@ -1,5 +1,6 @@
import { config } from '../config.js';
import type { SessionDoc } from '../types/session.js';
import { overwriteDocument } from './sessionStore.js';
import { signOnlyOfficePayload } from '../utils/jwt.js';
export function buildEditorConfig(doc: SessionDoc, userId: string) {
@@ -49,6 +50,59 @@ export function buildEditorConfig(doc: SessionDoc, userId: string) {
};
}
/** Prefer the internal Document Server base when the backend fetches the file. */
export function toInternalOnlyOfficeUrl(raw: string): string {
try {
const target = new URL(raw);
const publicBase = new URL(config.onlyOfficeUrl);
const internalBase = new URL(config.onlyOfficeInternalUrl);
if (
target.host === publicBase.host ||
target.host === internalBase.host
) {
target.protocol = internalBase.protocol;
target.host = internalBase.host;
}
return target.toString();
} catch {
return raw;
}
}
/**
* Download an OnlyOffice-produced file URL and overwrite the session document
* (converting back to the original extension when needed).
*/
export async function persistDocumentFromUrl(
doc: SessionDoc,
fileUrl: string,
filetype?: string,
): Promise<Buffer> {
const fetchUrl = toInternalOnlyOfficeUrl(fileUrl);
const downloaded = await fetch(fetchUrl);
if (!downloaded.ok) {
throw new Error(`download_failed_${downloaded.status}`);
}
let buffer: Buffer = Buffer.from(await downloaded.arrayBuffer());
const returnedType = (filetype ?? doc.fileType).toLowerCase();
if (returnedType !== doc.fileType) {
const converted = await convertToOriginalFormat(
fetchUrl,
returnedType,
doc.fileType,
);
if (!converted) {
throw new Error(`convert_failed:${returnedType}->${doc.fileType}`);
}
buffer = Buffer.from(converted);
}
await overwriteDocument(doc, buffer, doc.fileType);
return buffer;
}
export async function convertToOriginalFormat(
sourceUrl: string,
fromExt: string,
+6 -3
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { bindSessionPageHideOnce } from '@/composables/useSessionLifecycle';
import {
bindSessionPageHideOnce,
hwpOpenToken,
} from '@/composables/useSessionLifecycle';
bindSessionPageHideOnce();
@@ -12,8 +15,9 @@ const viewKey = computed(() => {
if (route.name === 'onlyoffice-editor') {
return `onlyoffice-${route.params.docId}`;
}
// Token changes on every staged HWP open so the view remounts with the new file.
if (route.name === 'rhwp-editor') {
return 'rhwp-editor';
return `rhwp-${hwpOpenToken()}`;
}
if (
route.name === 'excalidraw-editor' ||
@@ -35,7 +39,6 @@ const viewKey = computed(() => {
<KeepAlive
:include="[
'OnlyOfficeEditorView',
'RhwpEditorView',
'ExcalidrawEditorView',
'MermaidEditorView',
'DrawioEditorView',
+9 -5
View File
@@ -35,12 +35,20 @@ export async function createSession(): Promise<{
}
export async function heartbeat(): Promise<void> {
await fetch('/api/sessions/heartbeat', {
const res = await fetch('/api/sessions/heartbeat', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) {
throw new ApiError(
res.status,
'session_required',
'세션이 만료되었거나 없습니다.',
);
}
}
export async function endSession(): Promise<void> {
await fetch('/api/sessions', {
method: 'DELETE',
@@ -116,7 +124,3 @@ export async function fetchEditorConfig(
}),
);
}
export function downloadDocumentUrl(docId: string): string {
return `/api/documents/${docId}/download`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue';
const props = withDefaults(
defineProps<{
open?: boolean;
title?: string;
lead?: string;
steps?: string[];
pathHint?: string;
}>(),
{
open: false,
title: '다운로드 방법',
lead: '',
steps: () => [],
pathHint: '',
},
);
const emit = defineEmits<{
close: [];
}>();
function onKeydown(e: KeyboardEvent) {
if (!props.open) return;
if (e.key === 'Escape') emit('close');
}
onMounted(() => {
window.addEventListener('keydown', onKeydown);
});
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
if (typeof document !== 'undefined') {
document.body.style.overflow = '';
}
});
watch(
() => props.open,
(open) => {
if (typeof document === 'undefined') return;
document.body.style.overflow = open ? 'hidden' : '';
},
);
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="guide"
role="dialog"
aria-modal="true"
aria-labelledby="download-guide-title"
@click.self="emit('close')"
>
<div class="guide__panel">
<h2 id="download-guide-title" class="guide__title">{{ title }}</h2>
<p v-if="lead" class="guide__lead">{{ lead }}</p>
<ol v-if="steps.length" class="guide__steps">
<li v-for="(step, i) in steps" :key="i">{{ step }}</li>
</ol>
<p v-if="pathHint" class="guide__path" aria-hidden="true">
{{ pathHint }}
</p>
<div class="guide__actions">
<button type="button" class="guide__btn" @click="emit('close')">
확인
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.guide {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 1.25rem;
background: rgb(0 0 0 / 45%);
}
.guide__panel {
width: min(420px, 100%);
padding: 1.25rem 1.35rem 1.1rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
box-shadow: 0 12px 40px rgb(0 0 0 / 18%);
}
.guide__title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 650;
}
.guide__lead {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--ink-soft);
line-height: 1.45;
}
.guide__steps {
margin: 0 0 0.85rem;
padding-left: 1.2rem;
font-size: 0.9rem;
line-height: 1.55;
color: var(--ink);
}
.guide__steps li + li {
margin-top: 0.35rem;
}
.guide__path {
margin: 0 0 1.1rem;
padding: 0.55rem 0.7rem;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.guide__actions {
display: flex;
justify-content: flex-end;
}
.guide__btn {
font: inherit;
font-size: 0.88rem;
font-weight: 600;
padding: 0.45rem 1rem;
border-radius: 6px;
border: none;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.guide__btn:hover {
filter: brightness(1.05);
}
</style>
@@ -9,7 +9,7 @@ withDefaults(
busy?: boolean;
}>(),
{
saveLabel: '저장',
saveLabel: '다운로드',
showSave: true,
showExportActions: false,
showFilename: true,
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
HWP_ICON_URL,
ICON_COLORS,
ICON_MARK_PATHS,
ICON_PATHS,
iconKindForExtension,
} from '@/config/favicons';
@@ -11,28 +13,65 @@ const props = withDefaults(
extension: string;
size?: number;
}>(),
{ size: 40 },
{ size: 36 },
);
const kind = computed(() => iconKindForExtension(props.extension.toLowerCase()));
const mark = computed(() => ICON_MARK_PATHS[kind.value]);
const boxStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}));
</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>
<span class="ft-icon" :style="boxStyle" aria-hidden="true">
<img
v-if="kind === 'hwp'"
class="ft-icon__img"
:src="HWP_ICON_URL"
alt=""
draggable="false"
/>
<svg
v-else
class="ft-icon__svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path :fill="ICON_COLORS[kind]" :d="ICON_PATHS[kind]" />
<path
v-if="mark"
fill="#ffffff"
fill-rule="evenodd"
:d="mark"
/>
</svg>
</span>
</template>
<style scoped>
.ft-icon {
flex-shrink: 0;
display: inline-grid;
place-items: center;
box-sizing: border-box;
overflow: hidden;
border-radius: 4px;
line-height: 0;
}
.ft-icon__svg,
.ft-icon__img {
display: block;
width: 100%;
height: 100%;
}
/* Raster HWP logo is full-bleed; match SVG document inset (~3/24). */
.ft-icon__img {
object-fit: contain;
padding: 12.5%;
box-sizing: border-box;
}
</style>
@@ -6,27 +6,44 @@ import {
type MaybeRefOrGetter,
} from 'vue';
import { APP_DISPLAY_NAME } from '@/config/app';
import { faviconHrefFor, setFavicon } from '@/config/favicons';
import {
faviconHrefFor,
faviconHrefForKind,
setFavicon,
type FaviconKind,
} from '@/config/favicons';
export function formatPageTitle(filename?: string | null): string {
const name = filename?.trim();
return name ? `${name} - ${APP_DISPLAY_NAME}` : APP_DISPLAY_NAME;
}
export type UsePageTitleOptions = {
/** Force favicon kind (tools whose file extensions collide with other tools). */
faviconKind?: MaybeRefOrGetter<FaviconKind | null | undefined>;
};
/**
* Sets `document.title` to `filename - Sejong Office` (or just the app name)
* and swaps the favicon by file extension (home icon when no file).
* Pass `faviconKind` when the route should keep a fixed tool/viewer icon.
*/
export function usePageTitle(
filename: MaybeRefOrGetter<string | null | undefined> = null,
options?: UsePageTitleOptions,
) {
function apply() {
const name = toValue(filename);
document.title = formatPageTitle(name);
setFavicon(faviconHrefFor(name));
const kind = options?.faviconKind != null ? toValue(options.faviconKind) : null;
setFavicon(kind ? faviconHrefForKind(kind) : faviconHrefFor(name));
}
watch(() => toValue(filename), apply, { immediate: true });
watch(
() => [toValue(filename), toValue(options?.faviconKind)] as const,
apply,
{ immediate: true },
);
onMounted(apply);
onActivated(apply);
}
@@ -2,6 +2,7 @@ import { onMounted, onUnmounted } from 'vue';
import { createSession, endSession, heartbeat } from '@/api/client';
const HWP_FILE_KEY = 'sejong_hwp_file';
const HWP_OPEN_TOKEN_KEY = 'sejong_hwp_open_token';
let pageHideBound = false;
@@ -31,6 +32,16 @@ export function storeHwpFile(file: File): Promise<void> {
});
}
/** Bumps whenever a HWP/HWPX is staged for the editor (forces a fresh view). */
export function hwpOpenToken(): string {
if (typeof sessionStorage === 'undefined') return '0';
return sessionStorage.getItem(HWP_OPEN_TOKEN_KEY) ?? '0';
}
function bumpHwpOpenToken(): void {
sessionStorage.setItem(HWP_OPEN_TOKEN_KEY, `${Date.now()}`);
}
export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
const bytes = new Uint8Array(buffer);
let binary = '';
@@ -45,6 +56,7 @@ export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
base64: btoa(binary),
}),
);
bumpHwpOpenToken();
}
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
@@ -68,6 +80,7 @@ export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
export function clearHwpFile(): void {
sessionStorage.removeItem(HWP_FILE_KEY);
sessionStorage.removeItem(HWP_OPEN_TOKEN_KEY);
}
export function useSessionLifecycle(options?: { enableHeartbeat?: boolean }) {
+58 -16
View File
@@ -4,6 +4,7 @@
*/
import { extensionOf } from '@/config/app';
import hwpIconUrl from '@/assets/icons/hwp.png';
export type FaviconKind =
| 'home'
@@ -23,15 +24,17 @@ export type FaviconKind =
| 'csv'
| 'file';
/** Shared filled document plate for letter-mark office icons. */
export const DOC_SHELL_PATH =
'M19,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';
export const ICON_PATHS: Record<FaviconKind, string> = {
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',
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',
excel:
'M16.2,17H14.2L12,13.2L9.8,17H7.8L11,12L7.8,7H9.8L12,10.8L14.2,7H16.2L13,12M19,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',
powerpoint:
'M9.8,13.4H12.3C13.8,13.4 14.46,13.12 15.1,12.58C15.74,12.03 16,11.25 16,10.23C16,9.26 15.75,8.5 15.1,7.88C14.45,7.29 13.83,7 12.3,7H8V17H9.8V13.4M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5C3,3.89 3.9,3 5,3H19M9.8,12V8.4H12.1C12.76,8.4 13.27,8.65 13.6,9C13.93,9.35 14.1,9.72 14.1,10.24C14.1,10.8 13.92,11.19 13.6,11.5C13.28,11.81 12.9,12 12.22,12H9.8Z',
hwp: 'M17,4V10L15,8L13,10V4H9V20H19V4H17M3,7V5H5V4C5,2.89 5.9,2 7,2H19C20.05,2 21,2.95 21,4V20C21,21.05 20.05,22 19,22H7C5.95,22 5,21.05 5,20V19H3V17H5V13H3V11H5V7H3M5,5V7H7V5H5M5,19H7V17H5V19M5,13H7V11H5V13Z',
pdf: DOC_SHELL_PATH,
word: DOC_SHELL_PATH,
excel: DOC_SHELL_PATH,
powerpoint: DOC_SHELL_PATH,
hwp: DOC_SHELL_PATH,
sketch: 'M3 17.25V21H6.75L17.81 9.94L14.06 6.19L3 17.25M20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18 2.9 17.35 2.9 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04Z',
code: 'M8 3L6.59 4.41L10.17 8L6.59 11.59L8 13L13 8L8 3M14 11V13H22V11H14M5 19H19V17H5V19Z',
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
@@ -45,13 +48,32 @@ export const ICON_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',
};
/**
* White glyphs drawn on `DOC_SHELL_PATH` (evenodd for letter counters).
* Paths adapted from Material Design Icons (@mdi/svg), Apache-2.0.
*/
export const ICON_MARK_PATHS: Partial<Record<FaviconKind, string>> = {
pdf:
'M9.5,11.5C9.5,12.3 8.8,13 8,13H7V15H5.5V9H8C8.8,9 9.5,9.7 9.5,10.5V11.5M7,10.5H8V11.5H7V10.5Z' +
'M14.5,13.5C14.5,14.3 13.8,15 13,15H10.5V9H13C13.8,9 14.5,9.7 14.5,10.5V13.5M12,10.5H13V13.5H12V10.5Z' +
'M18.5,10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5Z',
word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9Z',
excel:
'M16.2,17H14.2L12,13.2L9.8,17H7.8L11,12L7.8,7H9.8L12,10.8L14.2,7H16.2L13,12Z',
powerpoint:
'M9.8,13.4H12.3C13.8,13.4 14.46,13.12 15.1,12.58C15.74,12.03 16,11.25 16,10.23C16,9.26 15.75,8.5 15.1,7.88C14.45,7.29 13.83,7 12.3,7H8V17H9.8V13.4M9.8,12V8.4H12.1C12.76,8.4 13.27,8.65 13.6,9C13.93,9.35 14.1,9.72 14.1,10.24C14.1,10.8 13.92,11.19 13.6,11.5C13.28,11.81 12.9,12 12.22,12H9.8Z',
};
/** Raster logo for Hangul (HWP) documents. */
export const HWP_ICON_URL = hwpIconUrl;
export const ICON_COLORS: Record<FaviconKind, string> = {
home: '#0f6cbd',
pdf: '#e53935',
word: '#1e88e5',
excel: '#43a047',
powerpoint: '#fb8c00',
hwp: '#00897b',
hwp: '#34aee7',
sketch: '#7b61a8',
code: '#d97706',
diagram: '#2e7d8a',
@@ -122,8 +144,20 @@ const EXT_KIND: Record<string, FaviconKind> = {
txt: 'text',
};
export function iconSvgInner(kind: FaviconKind): string {
const color = ICON_COLORS[kind];
const mark = ICON_MARK_PATHS[kind];
if (mark) {
return (
`<path fill="${color}" d="${ICON_PATHS[kind]}"/>` +
`<path fill="#ffffff" fill-rule="evenodd" d="${mark}"/>`
);
}
return `<path fill="${color}" d="${ICON_PATHS[kind]}"/>`;
}
function toDataUri(kind: FaviconKind): string {
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>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${iconSvgInner(kind)}</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
@@ -133,6 +167,16 @@ export function iconKindForExtension(ext: string): FaviconKind {
return EXT_KIND[ext] ?? 'file';
}
export function faviconHrefForKind(kind: FaviconKind): string {
if (kind === 'hwp') return HWP_ICON_URL;
let href = CACHE.get(kind);
if (!href) {
href = toDataUri(kind);
CACHE.set(kind, href);
}
return href;
}
export function faviconHrefFor(filename?: string | null): string {
const name = filename?.trim();
let kind: FaviconKind = 'home';
@@ -140,12 +184,7 @@ export function faviconHrefFor(filename?: string | null): string {
const ext = extensionOf(name);
kind = ext ? (EXT_KIND[ext] ?? 'file') : 'home';
}
let href = CACHE.get(kind);
if (!href) {
href = toDataUri(kind);
CACHE.set(kind, href);
}
return href;
return faviconHrefForKind(kind);
}
export function setFavicon(href: string) {
@@ -155,7 +194,10 @@ export function setFavicon(href: string) {
link.rel = 'icon';
document.head.appendChild(link);
}
link.type = 'image/svg+xml';
link.type =
/\.png(?:\?|$)/i.test(href) || href.startsWith('data:image/png')
? 'image/png'
: 'image/svg+xml';
// Bust stubborn tab caches when switching between data URIs
if (link.href !== href) {
link.href = href;
@@ -39,7 +39,7 @@ const error = ref('');
const notice = ref('');
const theme = useSystemTheme();
usePageTitle(filename);
usePageTitle(filename, { faviconKind: 'cad' });
const settings = AcApSettingManager.instance;
settings.set('isShowCommandLine', false);
@@ -154,7 +154,6 @@ function onClose() {
<div class="editor-page">
<EditorToolbar
:filename="filename"
save-label="원본 저장"
:show-export-actions="true"
:show-filename="false"
:busy="busy || !ready"
@@ -135,7 +135,6 @@ function onClose() {
<div class="editor-page">
<EditorToolbar
:filename="filename"
save-label="원본 저장"
:show-export-actions="true"
:busy="busy"
@open="onOpen"
+5
View File
@@ -364,6 +364,11 @@ function onOpenTool(route: string) {
border-color 0.15s ease;
}
.tile :deep(.ft-icon) {
width: 36px;
height: 36px;
}
.tile:hover:not(:disabled) {
background: var(--card-hover);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
@@ -176,7 +176,6 @@ function onClose() {
<div class="editor-page">
<EditorToolbar
:filename="filename"
save-label="원본 저장"
:show-export-actions="true"
:busy="busy"
@open="onOpen"
@@ -85,7 +85,7 @@ const propertyGroups = computed(() =>
selectedKey.value ? selectionGroups.value : modelGroups.value,
);
usePageTitle(filename);
usePageTitle(filename, { faviconKind: 'model3d' });
function applyFiles(nextFiles: File[]) {
const primary = findPrimaryModelFile(nextFiles);
@@ -257,7 +257,6 @@ function onClose() {
>
<EditorToolbar
:filename="filename"
save-label="내보내기"
:show-save="Boolean(stats)"
:busy="loading || exportBusy"
@open="onOpen"
@@ -2,9 +2,10 @@
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import DownloadGuideModal from '@/components/DownloadGuideModal.vue';
import EditorErrorState from '@/components/EditorErrorState.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { downloadDocumentUrl, fetchEditorConfig } from '@/api/client';
import { fetchEditorConfig } from '@/api/client';
import { useOpenDocument } from '@/composables/useOpenDocument';
import { usePageTitle } from '@/composables/usePageTitle';
import { useSessionLifecycle } from '@/composables/useSessionLifecycle';
@@ -15,6 +16,7 @@ const route = useRoute();
const router = useRouter();
const error = ref<unknown>(null);
const filename = ref('문서');
const guideOpen = ref(false);
const frameHost = ref<HTMLElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
let editor: { destroyEditor?: () => void } | null = null;
@@ -27,6 +29,12 @@ const { openDocument } = useOpenDocument();
usePageTitle(filename);
useSessionLifecycle({ enableHeartbeat: true });
const downloadGuideSteps = [
'편집기 좌측 상단의 파일을 누릅니다.',
'다운로드 방법을 선택합니다.',
'원하는 확장자를 고르면 파일이 저장됩니다.',
];
function editorHeightPx(): number {
const host = frameHost.value;
if (host) {
@@ -123,15 +131,8 @@ function onFileSelected(e: Event) {
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 onDownload() {
guideOpen.value = true;
}
function onClose() {
@@ -147,7 +148,7 @@ function onClose() {
:filename="filename"
:show-filename="false"
@open="onOpen"
@save="onSave"
@save="onDownload"
@close="onClose"
/>
<input
@@ -162,6 +163,14 @@ function onClose() {
<div v-show="!error" ref="frameHost" class="editor-frame">
<div id="onlyoffice-placeholder" class="editor-frame__inner" />
</div>
<DownloadGuideModal
:open="guideOpen"
lead="수정한 파일은 EuroOffice 편집기 메뉴에서 받을 수 있습니다."
:steps="downloadGuideSteps"
path-hint="파일 → 다운로드 방법 → 확장자 선택"
@close="guideOpen = false"
/>
</div>
</AppShell>
</template>
+63 -33
View File
@@ -1,37 +1,47 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { createEditor, type RhwpEditor } from '@rhwp/editor';
import AppShell from '@/components/AppShell.vue';
import DownloadGuideModal from '@/components/DownloadGuideModal.vue';
import EditorErrorState from '@/components/EditorErrorState.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { ApiError } from '@/api/client';
import { extensionOf, isHwp } from '@/config/app';
import { isHwp } from '@/config/app';
import { usePageTitle } from '@/composables/usePageTitle';
import {
clearHwpFile,
hwpOpenToken,
loadHwpFile,
storeHwpFile,
useSessionLifecycle,
} from '@/composables/useSessionLifecycle';
import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'RhwpEditorView' });
const router = useRouter();
const error = ref<unknown>(null);
const filename = ref('문서.hwp');
const guideOpen = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
let editor: RhwpEditor | null = null;
let initialized = false;
let loadedToken = '';
let loadSeq = 0;
usePageTitle(filename);
useSessionLifecycle({ enableHeartbeat: true });
onMounted(async () => {
if (initialized) return;
const downloadGuideSteps = [
'편집기 메뉴에서 파일을 누릅니다.',
'저장 또는 다른 이름으로 저장을 선택합니다.',
'원하는 위치와 이름으로 저장합니다.',
];
async function bootstrapFromStore() {
const seq = ++loadSeq;
const token = hwpOpenToken();
const stored = loadHwpFile();
if (!stored) {
error.value = new ApiError(
404,
@@ -41,25 +51,51 @@ onMounted(async () => {
return;
}
// Same staged file already shown (e.g. mounted + activated).
if (editor && loadedToken === token && loadedToken !== '0') {
filename.value = stored.name;
error.value = null;
return;
}
filename.value = stored.name;
error.value = null;
try {
editor = await createEditor('#rhwp-host', {
width: '100%',
height: '100%',
});
await nextTick();
if (seq !== loadSeq) return;
if (!editor) {
editor = await createEditor('#rhwp-host', {
width: '100%',
height: '100%',
});
}
if (seq !== loadSeq) return;
await editor.loadFile(stored.buffer, stored.name);
initialized = true;
if (seq !== loadSeq) return;
loadedToken = token;
} catch (err) {
if (seq !== loadSeq) return;
error.value = err;
}
}
onMounted(() => {
void bootstrapFromStore();
});
onActivated(() => {
void bootstrapFromStore();
});
onUnmounted(() => {
loadSeq += 1;
editor?.destroy();
editor = null;
initialized = false;
loadedToken = '';
});
function onOpen() {
@@ -70,7 +106,7 @@ async function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || !editor) return;
if (!file) return;
if (!isHwp(file.name)) {
error.value = new ApiError(
@@ -83,30 +119,16 @@ async function onFileSelected(e: Event) {
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;
// Token bump remounts via App viewKey; also reload in place if not remounted.
loadedToken = '';
await bootstrapFromStore();
} 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;
}
async function onDownload() {
guideOpen.value = true;
}
function onClose() {
@@ -122,7 +144,7 @@ function onClose() {
v-if="!error"
:filename="filename"
@open="onOpen"
@save="onSave"
@save="onDownload"
@close="onClose"
/>
<input
@@ -135,6 +157,14 @@ function onClose() {
<EditorErrorState v-if="error" :error="error" context="rhwp" />
<div v-show="!error" id="rhwp-host" class="editor-frame" />
<DownloadGuideModal
:open="guideOpen"
lead="수정한 한글 문서는 rhwp 편집기 메뉴에서 받을 수 있습니다."
:steps="downloadGuideSteps"
path-hint="파일 → 저장(다른 이름으로 저장)"
@close="guideOpen = false"
/>
</div>
</AppShell>
</template>
@@ -29,7 +29,7 @@ const feather = ref(DEFAULT_BG_REMOVE_OPTIONS.feather);
const mode = ref<BgRemoveMode>(DEFAULT_BG_REMOVE_OPTIONS.mode);
const imageUrlInput = ref('');
usePageTitle(filename);
usePageTitle(filename, { faviconKind: 'bgremove' });
const hasImage = computed(() => Boolean(sourceUrl.value));
const bgCss = computed(() => {
@@ -197,7 +197,6 @@ onBeforeUnmount(() => {
>
<EditorToolbar
:filename="filename"
save-label="PNG 저장"
:show-save="Boolean(resultUrl)"
:busy="busy"
@open="onOpen"
@@ -75,7 +75,7 @@ function syncBodyScrollLock() {
queueOpen.value || settingsOpen.value ? 'hidden' : '';
}
usePageTitle(filename);
usePageTitle(filename, { faviconKind: 'scanner' });
const activePage = computed(
() => pages.value.find((page) => page.id === activeId.value) ?? null,
@@ -615,7 +615,6 @@ const progressPercent = computed(() =>
<div class="scan-page">
<EditorToolbar
:filename="filename"
save-label="PDF 저장"
:show-save="pages.length > 0"
:busy="busy"
@open="onOpen"