download 기능 수정
This commit is contained in:
1 parent
80eae206b6
commit
f5d880caf5
21 files changed
+461
-122
No files matched your search
@@ -7,11 +7,10 @@ import {
|
|||||||
addDocument,
|
addDocument,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
getDocument,
|
getDocument,
|
||||||
overwriteDocument,
|
|
||||||
} from '../services/sessionStore.js';
|
} from '../services/sessionStore.js';
|
||||||
import {
|
import {
|
||||||
buildEditorConfig,
|
buildEditorConfig,
|
||||||
convertToOriginalFormat,
|
persistDocumentFromUrl,
|
||||||
} from '../services/onlyOffice.js';
|
} from '../services/onlyOffice.js';
|
||||||
import {
|
import {
|
||||||
createBlankDocument,
|
createBlankDocument,
|
||||||
@@ -187,33 +186,7 @@ documentsRouter.post('/:docId/callback', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const downloaded = await fetch(fileUrl);
|
await persistDocumentFromUrl(doc, fileUrl, filetype);
|
||||||
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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[callback] save error', err);
|
console.error('[callback] save error', err);
|
||||||
res.json({ error: 1 });
|
res.json({ error: 1 });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import type { SessionDoc } from '../types/session.js';
|
import type { SessionDoc } from '../types/session.js';
|
||||||
|
import { overwriteDocument } from './sessionStore.js';
|
||||||
import { signOnlyOfficePayload } from '../utils/jwt.js';
|
import { signOnlyOfficePayload } from '../utils/jwt.js';
|
||||||
|
|
||||||
export function buildEditorConfig(doc: SessionDoc, userId: string) {
|
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(
|
export async function convertToOriginalFormat(
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
fromExt: string,
|
fromExt: string,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { bindSessionPageHideOnce } from '@/composables/useSessionLifecycle';
|
import {
|
||||||
|
bindSessionPageHideOnce,
|
||||||
|
hwpOpenToken,
|
||||||
|
} from '@/composables/useSessionLifecycle';
|
||||||
|
|
||||||
bindSessionPageHideOnce();
|
bindSessionPageHideOnce();
|
||||||
|
|
||||||
@@ -12,8 +15,9 @@ const viewKey = computed(() => {
|
|||||||
if (route.name === 'onlyoffice-editor') {
|
if (route.name === 'onlyoffice-editor') {
|
||||||
return `onlyoffice-${route.params.docId}`;
|
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') {
|
if (route.name === 'rhwp-editor') {
|
||||||
return 'rhwp-editor';
|
return `rhwp-${hwpOpenToken()}`;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
route.name === 'excalidraw-editor' ||
|
route.name === 'excalidraw-editor' ||
|
||||||
@@ -35,7 +39,6 @@ const viewKey = computed(() => {
|
|||||||
<KeepAlive
|
<KeepAlive
|
||||||
:include="[
|
:include="[
|
||||||
'OnlyOfficeEditorView',
|
'OnlyOfficeEditorView',
|
||||||
'RhwpEditorView',
|
|
||||||
'ExcalidrawEditorView',
|
'ExcalidrawEditorView',
|
||||||
'MermaidEditorView',
|
'MermaidEditorView',
|
||||||
'DrawioEditorView',
|
'DrawioEditorView',
|
||||||
|
|||||||
@@ -35,12 +35,20 @@ export async function createSession(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function heartbeat(): Promise<void> {
|
export async function heartbeat(): Promise<void> {
|
||||||
await fetch('/api/sessions/heartbeat', {
|
const res = await fetch('/api/sessions/heartbeat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
'session_required',
|
||||||
|
'세션이 만료되었거나 없습니다.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function endSession(): Promise<void> {
|
export async function endSession(): Promise<void> {
|
||||||
await fetch('/api/sessions', {
|
await fetch('/api/sessions', {
|
||||||
method: 'DELETE',
|
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;
|
busy?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
saveLabel: '저장',
|
saveLabel: '다운로드',
|
||||||
showSave: true,
|
showSave: true,
|
||||||
showExportActions: false,
|
showExportActions: false,
|
||||||
showFilename: true,
|
showFilename: true,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import {
|
import {
|
||||||
|
HWP_ICON_URL,
|
||||||
ICON_COLORS,
|
ICON_COLORS,
|
||||||
|
ICON_MARK_PATHS,
|
||||||
ICON_PATHS,
|
ICON_PATHS,
|
||||||
iconKindForExtension,
|
iconKindForExtension,
|
||||||
} from '@/config/favicons';
|
} from '@/config/favicons';
|
||||||
@@ -11,28 +13,65 @@ const props = withDefaults(
|
|||||||
extension: string;
|
extension: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}>(),
|
}>(),
|
||||||
{ size: 40 },
|
{ size: 36 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const kind = computed(() => iconKindForExtension(props.extension.toLowerCase()));
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg
|
<span class="ft-icon" :style="boxStyle" aria-hidden="true">
|
||||||
class="ft-icon"
|
<img
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
v-if="kind === 'hwp'"
|
||||||
viewBox="0 0 24 24"
|
class="ft-icon__img"
|
||||||
:width="size"
|
:src="HWP_ICON_URL"
|
||||||
:height="size"
|
alt=""
|
||||||
aria-hidden="true"
|
draggable="false"
|
||||||
>
|
/>
|
||||||
<path :fill="ICON_COLORS[kind]" :d="ICON_PATHS[kind]" />
|
<svg
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ft-icon {
|
.ft-icon {
|
||||||
flex-shrink: 0;
|
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;
|
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>
|
</style>
|
||||||
@@ -6,27 +6,44 @@ import {
|
|||||||
type MaybeRefOrGetter,
|
type MaybeRefOrGetter,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { APP_DISPLAY_NAME } from '@/config/app';
|
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 {
|
export function formatPageTitle(filename?: string | null): string {
|
||||||
const name = filename?.trim();
|
const name = filename?.trim();
|
||||||
return name ? `${name} - ${APP_DISPLAY_NAME}` : APP_DISPLAY_NAME;
|
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)
|
* Sets `document.title` to `filename - Sejong Office` (or just the app name)
|
||||||
* and swaps the favicon by file extension (home icon when no file).
|
* 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(
|
export function usePageTitle(
|
||||||
filename: MaybeRefOrGetter<string | null | undefined> = null,
|
filename: MaybeRefOrGetter<string | null | undefined> = null,
|
||||||
|
options?: UsePageTitleOptions,
|
||||||
) {
|
) {
|
||||||
function apply() {
|
function apply() {
|
||||||
const name = toValue(filename);
|
const name = toValue(filename);
|
||||||
document.title = formatPageTitle(name);
|
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);
|
onMounted(apply);
|
||||||
onActivated(apply);
|
onActivated(apply);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { onMounted, onUnmounted } from 'vue';
|
|||||||
import { createSession, endSession, heartbeat } from '@/api/client';
|
import { createSession, endSession, heartbeat } from '@/api/client';
|
||||||
|
|
||||||
const HWP_FILE_KEY = 'sejong_hwp_file';
|
const HWP_FILE_KEY = 'sejong_hwp_file';
|
||||||
|
const HWP_OPEN_TOKEN_KEY = 'sejong_hwp_open_token';
|
||||||
|
|
||||||
let pageHideBound = false;
|
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 {
|
export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
|
||||||
const bytes = new Uint8Array(buffer);
|
const bytes = new Uint8Array(buffer);
|
||||||
let binary = '';
|
let binary = '';
|
||||||
@@ -45,6 +56,7 @@ export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void {
|
|||||||
base64: btoa(binary),
|
base64: btoa(binary),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
bumpHwpOpenToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
|
export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
|
||||||
@@ -68,6 +80,7 @@ export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null {
|
|||||||
|
|
||||||
export function clearHwpFile(): void {
|
export function clearHwpFile(): void {
|
||||||
sessionStorage.removeItem(HWP_FILE_KEY);
|
sessionStorage.removeItem(HWP_FILE_KEY);
|
||||||
|
sessionStorage.removeItem(HWP_OPEN_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessionLifecycle(options?: { enableHeartbeat?: boolean }) {
|
export function useSessionLifecycle(options?: { enableHeartbeat?: boolean }) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { extensionOf } from '@/config/app';
|
import { extensionOf } from '@/config/app';
|
||||||
|
import hwpIconUrl from '@/assets/icons/hwp.png';
|
||||||
|
|
||||||
export type FaviconKind =
|
export type FaviconKind =
|
||||||
| 'home'
|
| 'home'
|
||||||
@@ -23,15 +24,17 @@ export type FaviconKind =
|
|||||||
| 'csv'
|
| 'csv'
|
||||||
| 'file';
|
| '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> = {
|
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: DOC_SHELL_PATH,
|
||||||
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: DOC_SHELL_PATH,
|
||||||
excel:
|
excel: DOC_SHELL_PATH,
|
||||||
'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: DOC_SHELL_PATH,
|
||||||
powerpoint:
|
hwp: DOC_SHELL_PATH,
|
||||||
'M9.8,13.4H12.3C13.8,13.4 14.46,13.12 15.1,12.58C15.74,12.03 16,11.25 16,10.23C16,9.26 15.75,8.5 15.1,7.88C14.45,7.29 13.83,7 12.3,7H8V17H9.8V13.4M19,3A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5C3,3.89 3.9,3 5,3H19M9.8,12V8.4H12.1C12.76,8.4 13.27,8.65 13.6,9C13.93,9.35 14.1,9.72 14.1,10.24C14.1,10.8 13.92,11.19 13.6,11.5C13.28,11.81 12.9,12 12.22,12H9.8Z',
|
|
||||||
hwp: 'M17,4V10L15,8L13,10V4H9V20H19V4H17M3,7V5H5V4C5,2.89 5.9,2 7,2H19C20.05,2 21,2.95 21,4V20C21,21.05 20.05,22 19,22H7C5.95,22 5,21.05 5,20V19H3V17H5V13H3V11H5V7H3M5,5V7H7V5H5M5,19H7V17H5V19M5,13H7V11H5V13Z',
|
|
||||||
sketch: 'M3 17.25V21H6.75L17.81 9.94L14.06 6.19L3 17.25M20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18 2.9 17.35 2.9 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04Z',
|
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',
|
code: 'M8 3L6.59 4.41L10.17 8L6.59 11.59L8 13L13 8L8 3M14 11V13H22V11H14M5 19H19V17H5V19Z',
|
||||||
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
|
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',
|
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> = {
|
export const ICON_COLORS: Record<FaviconKind, string> = {
|
||||||
home: '#0f6cbd',
|
home: '#0f6cbd',
|
||||||
pdf: '#e53935',
|
pdf: '#e53935',
|
||||||
word: '#1e88e5',
|
word: '#1e88e5',
|
||||||
excel: '#43a047',
|
excel: '#43a047',
|
||||||
powerpoint: '#fb8c00',
|
powerpoint: '#fb8c00',
|
||||||
hwp: '#00897b',
|
hwp: '#34aee7',
|
||||||
sketch: '#7b61a8',
|
sketch: '#7b61a8',
|
||||||
code: '#d97706',
|
code: '#d97706',
|
||||||
diagram: '#2e7d8a',
|
diagram: '#2e7d8a',
|
||||||
@@ -122,8 +144,20 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
|||||||
txt: 'text',
|
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 {
|
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)}`;
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +167,16 @@ export function iconKindForExtension(ext: string): FaviconKind {
|
|||||||
return EXT_KIND[ext] ?? 'file';
|
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 {
|
export function faviconHrefFor(filename?: string | null): string {
|
||||||
const name = filename?.trim();
|
const name = filename?.trim();
|
||||||
let kind: FaviconKind = 'home';
|
let kind: FaviconKind = 'home';
|
||||||
@@ -140,12 +184,7 @@ export function faviconHrefFor(filename?: string | null): string {
|
|||||||
const ext = extensionOf(name);
|
const ext = extensionOf(name);
|
||||||
kind = ext ? (EXT_KIND[ext] ?? 'file') : 'home';
|
kind = ext ? (EXT_KIND[ext] ?? 'file') : 'home';
|
||||||
}
|
}
|
||||||
let href = CACHE.get(kind);
|
return faviconHrefForKind(kind);
|
||||||
if (!href) {
|
|
||||||
href = toDataUri(kind);
|
|
||||||
CACHE.set(kind, href);
|
|
||||||
}
|
|
||||||
return href;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setFavicon(href: string) {
|
export function setFavicon(href: string) {
|
||||||
@@ -155,7 +194,10 @@ export function setFavicon(href: string) {
|
|||||||
link.rel = 'icon';
|
link.rel = 'icon';
|
||||||
document.head.appendChild(link);
|
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
|
// Bust stubborn tab caches when switching between data URIs
|
||||||
if (link.href !== href) {
|
if (link.href !== href) {
|
||||||
link.href = href;
|
link.href = href;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const error = ref('');
|
|||||||
const notice = ref('');
|
const notice = ref('');
|
||||||
const theme = useSystemTheme();
|
const theme = useSystemTheme();
|
||||||
|
|
||||||
usePageTitle(filename);
|
usePageTitle(filename, { faviconKind: 'cad' });
|
||||||
|
|
||||||
const settings = AcApSettingManager.instance;
|
const settings = AcApSettingManager.instance;
|
||||||
settings.set('isShowCommandLine', false);
|
settings.set('isShowCommandLine', false);
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ function onClose() {
|
|||||||
<div class="editor-page">
|
<div class="editor-page">
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
save-label="원본 저장"
|
|
||||||
:show-export-actions="true"
|
:show-export-actions="true"
|
||||||
:show-filename="false"
|
:show-filename="false"
|
||||||
:busy="busy || !ready"
|
:busy="busy || !ready"
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ function onClose() {
|
|||||||
<div class="editor-page">
|
<div class="editor-page">
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
save-label="원본 저장"
|
|
||||||
:show-export-actions="true"
|
:show-export-actions="true"
|
||||||
:busy="busy"
|
:busy="busy"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
|
|||||||
@@ -364,6 +364,11 @@ function onOpenTool(route: string) {
|
|||||||
border-color 0.15s ease;
|
border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile :deep(.ft-icon) {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
.tile:hover:not(:disabled) {
|
.tile:hover:not(:disabled) {
|
||||||
background: var(--card-hover);
|
background: var(--card-hover);
|
||||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ function onClose() {
|
|||||||
<div class="editor-page">
|
<div class="editor-page">
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
save-label="원본 저장"
|
|
||||||
:show-export-actions="true"
|
:show-export-actions="true"
|
||||||
:busy="busy"
|
:busy="busy"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const propertyGroups = computed(() =>
|
|||||||
selectedKey.value ? selectionGroups.value : modelGroups.value,
|
selectedKey.value ? selectionGroups.value : modelGroups.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
usePageTitle(filename);
|
usePageTitle(filename, { faviconKind: 'model3d' });
|
||||||
|
|
||||||
function applyFiles(nextFiles: File[]) {
|
function applyFiles(nextFiles: File[]) {
|
||||||
const primary = findPrimaryModelFile(nextFiles);
|
const primary = findPrimaryModelFile(nextFiles);
|
||||||
@@ -257,7 +257,6 @@ function onClose() {
|
|||||||
>
|
>
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
save-label="내보내기"
|
|
||||||
:show-save="Boolean(stats)"
|
:show-save="Boolean(stats)"
|
||||||
:busy="loading || exportBusy"
|
:busy="loading || exportBusy"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
|
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 DownloadGuideModal from '@/components/DownloadGuideModal.vue';
|
||||||
import EditorErrorState from '@/components/EditorErrorState.vue';
|
import EditorErrorState from '@/components/EditorErrorState.vue';
|
||||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||||
import { downloadDocumentUrl, fetchEditorConfig } from '@/api/client';
|
import { fetchEditorConfig } from '@/api/client';
|
||||||
import { useOpenDocument } from '@/composables/useOpenDocument';
|
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';
|
||||||
@@ -15,6 +16,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const error = ref<unknown>(null);
|
const error = ref<unknown>(null);
|
||||||
const filename = ref('문서');
|
const filename = ref('문서');
|
||||||
|
const guideOpen = ref(false);
|
||||||
const frameHost = ref<HTMLElement | null>(null);
|
const frameHost = ref<HTMLElement | null>(null);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
let editor: { destroyEditor?: () => void } | null = null;
|
let editor: { destroyEditor?: () => void } | null = null;
|
||||||
@@ -27,6 +29,12 @@ const { openDocument } = useOpenDocument();
|
|||||||
usePageTitle(filename);
|
usePageTitle(filename);
|
||||||
useSessionLifecycle({ enableHeartbeat: true });
|
useSessionLifecycle({ enableHeartbeat: true });
|
||||||
|
|
||||||
|
const downloadGuideSteps = [
|
||||||
|
'편집기 좌측 상단의 파일을 누릅니다.',
|
||||||
|
'다운로드 방법을 선택합니다.',
|
||||||
|
'원하는 확장자를 고르면 파일이 저장됩니다.',
|
||||||
|
];
|
||||||
|
|
||||||
function editorHeightPx(): number {
|
function editorHeightPx(): number {
|
||||||
const host = frameHost.value;
|
const host = frameHost.value;
|
||||||
if (host) {
|
if (host) {
|
||||||
@@ -123,15 +131,8 @@ function onFileSelected(e: Event) {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSave() {
|
function onDownload() {
|
||||||
const docId = route.params.docId as string;
|
guideOpen.value = true;
|
||||||
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() {
|
function onClose() {
|
||||||
@@ -147,7 +148,7 @@ function onClose() {
|
|||||||
:filename="filename"
|
:filename="filename"
|
||||||
:show-filename="false"
|
:show-filename="false"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
@save="onSave"
|
@save="onDownload"
|
||||||
@close="onClose"
|
@close="onClose"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -162,6 +163,14 @@ function onClose() {
|
|||||||
<div v-show="!error" ref="frameHost" class="editor-frame">
|
<div v-show="!error" ref="frameHost" class="editor-frame">
|
||||||
<div id="onlyoffice-placeholder" class="editor-frame__inner" />
|
<div id="onlyoffice-placeholder" class="editor-frame__inner" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DownloadGuideModal
|
||||||
|
:open="guideOpen"
|
||||||
|
lead="수정한 파일은 EuroOffice 편집기 메뉴에서 받을 수 있습니다."
|
||||||
|
:steps="downloadGuideSteps"
|
||||||
|
path-hint="파일 → 다운로드 방법 → 확장자 선택"
|
||||||
|
@close="guideOpen = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,37 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
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 DownloadGuideModal from '@/components/DownloadGuideModal.vue';
|
||||||
import EditorErrorState from '@/components/EditorErrorState.vue';
|
import EditorErrorState from '@/components/EditorErrorState.vue';
|
||||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import { extensionOf, isHwp } from '@/config/app';
|
import { isHwp } from '@/config/app';
|
||||||
import { usePageTitle } from '@/composables/usePageTitle';
|
import { usePageTitle } from '@/composables/usePageTitle';
|
||||||
import {
|
import {
|
||||||
clearHwpFile,
|
clearHwpFile,
|
||||||
|
hwpOpenToken,
|
||||||
loadHwpFile,
|
loadHwpFile,
|
||||||
storeHwpFile,
|
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 guideOpen = ref(false);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
let editor: RhwpEditor | null = null;
|
let editor: RhwpEditor | null = null;
|
||||||
let initialized = false;
|
let loadedToken = '';
|
||||||
|
let loadSeq = 0;
|
||||||
|
|
||||||
usePageTitle(filename);
|
usePageTitle(filename);
|
||||||
useSessionLifecycle({ enableHeartbeat: true });
|
useSessionLifecycle({ enableHeartbeat: true });
|
||||||
|
|
||||||
onMounted(async () => {
|
const downloadGuideSteps = [
|
||||||
if (initialized) return;
|
'편집기 메뉴에서 파일을 누릅니다.',
|
||||||
|
'저장 또는 다른 이름으로 저장을 선택합니다.',
|
||||||
|
'원하는 위치와 이름으로 저장합니다.',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function bootstrapFromStore() {
|
||||||
|
const seq = ++loadSeq;
|
||||||
|
const token = hwpOpenToken();
|
||||||
const stored = loadHwpFile();
|
const stored = loadHwpFile();
|
||||||
|
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
error.value = new ApiError(
|
error.value = new ApiError(
|
||||||
404,
|
404,
|
||||||
@@ -41,25 +51,51 @@ onMounted(async () => {
|
|||||||
return;
|
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;
|
filename.value = stored.name;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
editor = await createEditor('#rhwp-host', {
|
await nextTick();
|
||||||
width: '100%',
|
if (seq !== loadSeq) return;
|
||||||
height: '100%',
|
|
||||||
});
|
if (!editor) {
|
||||||
|
editor = await createEditor('#rhwp-host', {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (seq !== loadSeq) return;
|
||||||
|
|
||||||
await editor.loadFile(stored.buffer, stored.name);
|
await editor.loadFile(stored.buffer, stored.name);
|
||||||
initialized = true;
|
if (seq !== loadSeq) return;
|
||||||
|
|
||||||
|
loadedToken = token;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (seq !== loadSeq) return;
|
||||||
error.value = err;
|
error.value = err;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void bootstrapFromStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
void bootstrapFromStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
loadSeq += 1;
|
||||||
editor?.destroy();
|
editor?.destroy();
|
||||||
editor = null;
|
editor = null;
|
||||||
initialized = false;
|
loadedToken = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
function onOpen() {
|
function onOpen() {
|
||||||
@@ -70,7 +106,7 @@ async function onFileSelected(e: Event) {
|
|||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (!file || !editor) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (!isHwp(file.name)) {
|
if (!isHwp(file.name)) {
|
||||||
error.value = new ApiError(
|
error.value = new ApiError(
|
||||||
@@ -83,30 +119,16 @@ async function onFileSelected(e: Event) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await storeHwpFile(file);
|
await storeHwpFile(file);
|
||||||
const stored = loadHwpFile();
|
// Token bump remounts via App viewKey; also reload in place if not remounted.
|
||||||
if (!stored) throw new Error('파일을 읽을 수 없습니다.');
|
loadedToken = '';
|
||||||
await editor.loadFile(stored.buffer, stored.name);
|
await bootstrapFromStore();
|
||||||
filename.value = stored.name;
|
|
||||||
error.value = null;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err;
|
error.value = err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSave() {
|
async function onDownload() {
|
||||||
if (!editor) return;
|
guideOpen.value = true;
|
||||||
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() {
|
function onClose() {
|
||||||
@@ -122,7 +144,7 @@ function onClose() {
|
|||||||
v-if="!error"
|
v-if="!error"
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
@save="onSave"
|
@save="onDownload"
|
||||||
@close="onClose"
|
@close="onClose"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -135,6 +157,14 @@ function onClose() {
|
|||||||
|
|
||||||
<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" />
|
||||||
|
|
||||||
|
<DownloadGuideModal
|
||||||
|
:open="guideOpen"
|
||||||
|
lead="수정한 한글 문서는 rhwp 편집기 메뉴에서 받을 수 있습니다."
|
||||||
|
:steps="downloadGuideSteps"
|
||||||
|
path-hint="파일 → 저장(다른 이름으로 저장)"
|
||||||
|
@close="guideOpen = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const feather = ref(DEFAULT_BG_REMOVE_OPTIONS.feather);
|
|||||||
const mode = ref<BgRemoveMode>(DEFAULT_BG_REMOVE_OPTIONS.mode);
|
const mode = ref<BgRemoveMode>(DEFAULT_BG_REMOVE_OPTIONS.mode);
|
||||||
const imageUrlInput = ref('');
|
const imageUrlInput = ref('');
|
||||||
|
|
||||||
usePageTitle(filename);
|
usePageTitle(filename, { faviconKind: 'bgremove' });
|
||||||
|
|
||||||
const hasImage = computed(() => Boolean(sourceUrl.value));
|
const hasImage = computed(() => Boolean(sourceUrl.value));
|
||||||
const bgCss = computed(() => {
|
const bgCss = computed(() => {
|
||||||
@@ -197,7 +197,6 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
save-label="PNG 저장"
|
|
||||||
:show-save="Boolean(resultUrl)"
|
:show-save="Boolean(resultUrl)"
|
||||||
:busy="busy"
|
:busy="busy"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function syncBodyScrollLock() {
|
|||||||
queueOpen.value || settingsOpen.value ? 'hidden' : '';
|
queueOpen.value || settingsOpen.value ? 'hidden' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
usePageTitle(filename);
|
usePageTitle(filename, { faviconKind: 'scanner' });
|
||||||
|
|
||||||
const activePage = computed(
|
const activePage = computed(
|
||||||
() => pages.value.find((page) => page.id === activeId.value) ?? null,
|
() => pages.value.find((page) => page.id === activeId.value) ?? null,
|
||||||
@@ -615,7 +615,6 @@ const progressPercent = computed(() =>
|
|||||||
<div class="scan-page">
|
<div class="scan-page">
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
save-label="PDF 저장"
|
|
||||||
:show-save="pages.length > 0"
|
:show-save="pages.length > 0"
|
||||||
:busy="busy"
|
:busy="busy"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
|
|||||||
Reference in new issue
Block a user