download 기능 수정
This commit is contained in:
1 parent
80eae206b6
commit
f5d880caf5
21 files changed
+461
-122
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user