diff --git a/packages/backend/src/routes/documents.ts b/packages/backend/src/routes/documents.ts index 6259460..1a20933 100644 --- a/packages/backend/src/routes/documents.ts +++ b/packages/backend/src/routes/documents.ts @@ -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 }); diff --git a/packages/backend/src/services/onlyOffice.ts b/packages/backend/src/services/onlyOffice.ts index 43a55da..976e166 100644 --- a/packages/backend/src/services/onlyOffice.ts +++ b/packages/backend/src/services/onlyOffice.ts @@ -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 { + 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, diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index fc8b2dd..3fb73a2 100644 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -1,7 +1,10 @@ + + + + diff --git a/packages/frontend/src/components/EditorToolbar.vue b/packages/frontend/src/components/EditorToolbar.vue index 5abd9a1..ad0ca0f 100644 --- a/packages/frontend/src/components/EditorToolbar.vue +++ b/packages/frontend/src/components/EditorToolbar.vue @@ -9,7 +9,7 @@ withDefaults( busy?: boolean; }>(), { - saveLabel: '저장', + saveLabel: '다운로드', showSave: true, showExportActions: false, showFilename: true, diff --git a/packages/frontend/src/components/FileTypeIcon.vue b/packages/frontend/src/components/FileTypeIcon.vue index 57555ac..ac18dd0 100644 --- a/packages/frontend/src/components/FileTypeIcon.vue +++ b/packages/frontend/src/components/FileTypeIcon.vue @@ -1,7 +1,9 @@ diff --git a/packages/frontend/src/composables/usePageTitle.ts b/packages/frontend/src/composables/usePageTitle.ts index 723fb4a..ba81871 100644 --- a/packages/frontend/src/composables/usePageTitle.ts +++ b/packages/frontend/src/composables/usePageTitle.ts @@ -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; +}; + /** * 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 = 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); } diff --git a/packages/frontend/src/composables/useSessionLifecycle.ts b/packages/frontend/src/composables/useSessionLifecycle.ts index a63691f..801fc5e 100644 --- a/packages/frontend/src/composables/useSessionLifecycle.ts +++ b/packages/frontend/src/composables/useSessionLifecycle.ts @@ -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 { }); } +/** 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 }) { diff --git a/packages/frontend/src/config/favicons.ts b/packages/frontend/src/config/favicons.ts index 9f50b67..bfd8444 100644 --- a/packages/frontend/src/config/favicons.ts +++ b/packages/frontend/src/config/favicons.ts @@ -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 = { 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 = { 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> = { + 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 = { 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 = { txt: 'text', }; +export function iconSvgInner(kind: FaviconKind): string { + const color = ICON_COLORS[kind]; + const mark = ICON_MARK_PATHS[kind]; + if (mark) { + return ( + `` + + `` + ); + } + return ``; +} + function toDataUri(kind: FaviconKind): string { - const svg = ``; + const svg = `${iconSvgInner(kind)}`; 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; diff --git a/packages/frontend/src/views/CadViewerView.vue b/packages/frontend/src/views/CadViewerView.vue index 1181866..68e2c09 100644 --- a/packages/frontend/src/views/CadViewerView.vue +++ b/packages/frontend/src/views/CadViewerView.vue @@ -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); diff --git a/packages/frontend/src/views/DrawioEditorView.vue b/packages/frontend/src/views/DrawioEditorView.vue index 5c1e090..09ea784 100644 --- a/packages/frontend/src/views/DrawioEditorView.vue +++ b/packages/frontend/src/views/DrawioEditorView.vue @@ -154,7 +154,6 @@ function onClose() {
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() { > (null); const filename = ref('문서'); +const guideOpen = ref(false); const frameHost = ref(null); const fileInput = ref(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" />
+ +
diff --git a/packages/frontend/src/views/RhwpEditorView.vue b/packages/frontend/src/views/RhwpEditorView.vue index 0de46a1..6d945e1 100644 --- a/packages/frontend/src/views/RhwpEditorView.vue +++ b/packages/frontend/src/views/RhwpEditorView.vue @@ -1,37 +1,47 @@