diff --git a/rhwp-studio/src/command/commands/file.ts b/rhwp-studio/src/command/commands/file.ts index 65732ec..90417d5 100644 --- a/rhwp-studio/src/command/commands/file.ts +++ b/rhwp-studio/src/command/commands/file.ts @@ -3,16 +3,12 @@ import { PageSetupDialog } from '@/ui/page-setup-dialog'; import { AboutDialog } from '@/ui/about-dialog'; import { showConfirm } from '@/ui/confirm-dialog'; import { showSaveAs } from '@/ui/save-as-dialog'; - -// File System Access API (Chrome/Edge) -declare global { - interface Window { - showSaveFilePicker?: (options?: { - suggestedName?: string; - types?: { description: string; accept: Record }[]; - }) => Promise; - } -} +import { + pickOpenFileHandle, + readFileFromHandle, + saveDocumentToFileSystem, + type FileSystemWindowLike, +} from '@/command/file-system-access'; export const fileCommands: CommandDef[] = [ { @@ -36,8 +32,25 @@ export const fileCommands: CommandDef[] = [ { id: 'file:open', label: '열기', - execute() { - document.getElementById('file-input')?.click(); + async execute(services) { + try { + const handle = await pickOpenFileHandle(window as FileSystemWindowLike); + if (!handle) { + document.getElementById('file-input')?.click(); + return; + } + + const { bytes, name } = await readFileFromHandle(handle); + services.eventBus.emit('open-document-bytes', { + bytes, + fileName: name, + fileHandle: handle, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error('[file:open] 열기 실패:', msg); + alert(`파일 열기에 실패했습니다:\n${msg}`); + } }, }, { @@ -53,34 +66,29 @@ export const fileCommands: CommandDef[] = [ const isHwpx = sourceFormat === 'hwpx'; const bytes = isHwpx ? services.wasm.exportHwpx() : services.wasm.exportHwp(); const mimeType = isHwpx ? 'application/hwp+zip' : 'application/x-hwp'; - const ext = isHwpx ? '.hwpx' : '.hwp'; const blob = new Blob([bytes as unknown as BlobPart], { type: mimeType }); console.log(`[file:save] format=${sourceFormat}, isHwpx=${isHwpx}, ${bytes.length} bytes`); - // 1) File System Access API 지원 시 네이티브 저장 대화상자 사용 - if ('showSaveFilePicker' in window) { - try { - const handle = await window.showSaveFilePicker!({ - suggestedName: saveName, - types: [{ - description: isHwpx ? 'HWPX 문서' : 'HWP 문서', - accept: isHwpx - ? { 'application/hwp+zip': ['.hwpx'] } - : { 'application/x-hwp': ['.hwp'] }, - }], - }); - const writable = await handle.createWritable(); - await writable.write(blob); - await writable.close(); - services.wasm.fileName = handle.name; - console.log(`[file:save] ${handle.name} (${(bytes.length / 1024).toFixed(1)}KB)`); + // 1) 기존 파일 handle이 있으면 같은 파일에 저장, 없으면 save picker 시도 + try { + const saveResult = await saveDocumentToFileSystem({ + blob, + suggestedName: saveName, + currentHandle: services.wasm.currentFileHandle, + windowLike: window as FileSystemWindowLike, + }); + + if (saveResult.method !== 'fallback') { + services.wasm.currentFileHandle = saveResult.handle; + services.wasm.fileName = saveResult.fileName; + console.log(`[file:save] ${saveResult.fileName} (${(bytes.length / 1024).toFixed(1)}KB)`); return; - } catch (e) { - // 사용자가 취소하면 AbortError 발생 — 무시 - if (e instanceof DOMException && e.name === 'AbortError') return; - // 그 외 오류는 폴백으로 진행 - console.warn('[file:save] File System Access API 실패, 폴백:', e); } + } catch (e) { + // 사용자가 취소하면 AbortError 발생 — 무시 + if (e instanceof DOMException && e.name === 'AbortError') return; + // 그 외 오류는 폴백으로 진행 + console.warn('[file:save] File System Access API 실패, 폴백:', e); } // 2) 폴백: 새 문서인 경우 자체 파일이름 대화상자 표시 diff --git a/rhwp-studio/src/command/file-system-access.ts b/rhwp-studio/src/command/file-system-access.ts new file mode 100644 index 0000000..0fccbfa --- /dev/null +++ b/rhwp-studio/src/command/file-system-access.ts @@ -0,0 +1,112 @@ +export interface FileSystemWritableFileStreamLike { + write(data: Blob): Promise; + close(): Promise; +} + +export interface FileSystemFileHandleLike { + kind?: 'file'; + name: string; + getFile(): Promise; + createWritable(): Promise; +} + +export interface FileSystemWindowLike { + showOpenFilePicker?: (options?: { + excludeAcceptAllOption?: boolean; + multiple?: boolean; + types?: { description: string; accept: Record }[]; + }) => Promise; + showSaveFilePicker?: (options?: { + suggestedName?: string; + types?: { description: string; accept: Record }[]; + }) => Promise; +} + +export interface FileHandleReadResult { + name: string; + bytes: Uint8Array; +} + +export interface SaveDocumentOptions { + blob: Blob; + suggestedName: string; + currentHandle: FileSystemFileHandleLike | null; + windowLike: FileSystemWindowLike; +} + +export interface SaveDocumentResult { + method: 'current-handle' | 'save-picker' | 'fallback'; + handle: FileSystemFileHandleLike | null; + fileName: string; +} + +const HWP_PICKER_TYPES = [{ + description: 'HWP 문서', + accept: { 'application/x-hwp': ['.hwp', '.hwpx'] }, +}]; + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError'; +} + +async function writeBlobToHandle(handle: FileSystemFileHandleLike, blob: Blob): Promise { + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); +} + +export async function pickOpenFileHandle(windowLike: FileSystemWindowLike): Promise { + if (!windowLike.showOpenFilePicker) return null; + + try { + const handles = await windowLike.showOpenFilePicker({ + excludeAcceptAllOption: true, + multiple: false, + types: HWP_PICKER_TYPES, + }); + return handles[0] ?? null; + } catch (error) { + if (isAbortError(error)) return null; + throw error; + } +} + +export async function readFileFromHandle(handle: FileSystemFileHandleLike): Promise { + const file = await handle.getFile(); + return { + name: file.name, + bytes: new Uint8Array(await file.arrayBuffer()), + }; +} + +export async function saveDocumentToFileSystem(options: SaveDocumentOptions): Promise { + const { blob, suggestedName, currentHandle, windowLike } = options; + + if (currentHandle) { + await writeBlobToHandle(currentHandle, blob); + return { + method: 'current-handle', + handle: currentHandle, + fileName: currentHandle.name, + }; + } + + if (windowLike.showSaveFilePicker) { + const handle = await windowLike.showSaveFilePicker({ + suggestedName, + types: HWP_PICKER_TYPES, + }); + await writeBlobToHandle(handle, blob); + return { + method: 'save-picker', + handle, + fileName: handle.name, + }; + } + + return { + method: 'fallback', + handle: null, + fileName: suggestedName, + }; +} diff --git a/rhwp-studio/src/core/wasm-bridge.ts b/rhwp-studio/src/core/wasm-bridge.ts index e46adcf..7bf8ef8 100644 --- a/rhwp-studio/src/core/wasm-bridge.ts +++ b/rhwp-studio/src/core/wasm-bridge.ts @@ -2,6 +2,7 @@ import init, { HwpDocument, version } from '@wasm/rhwp.js'; import type { DocumentInfo, PageInfo, PageDef, SectionDef, CursorRect, HitTestResult, LineInfo, TableDimensions, CellInfo, CellBbox, CellProperties, TableProperties, DocumentPosition, MoveVerticalResult, SelectionRect, CharProperties, ParaProperties, CellPathEntry, NavContextEntry, FieldInfoResult, BookmarkInfo } from './types'; import { resolveFont, fontFamilyWithFallback } from './font-substitution'; import { REGISTERED_FONTS } from './font-loader'; +import type { FileSystemFileHandleLike } from '@/command/file-system-access'; /** * CSS font 문자열에서 font-family를 추출하여 폰트 치환을 적용한다. @@ -32,6 +33,7 @@ export class WasmBridge { private doc: HwpDocument | null = null; private initialized = false; private _fileName = 'document.hwp'; + private _currentFileHandle: FileSystemFileHandleLike | null = null; async initialize(): Promise { if (this.initialized) return; @@ -64,6 +66,7 @@ export class WasmBridge { this.doc.free(); } this._fileName = fileName ?? 'document.hwp'; + this._currentFileHandle = null; this.doc = new HwpDocument(data); this.doc.convertToEditable(); this.doc.setFileName(this._fileName); @@ -79,6 +82,7 @@ export class WasmBridge { } const info: DocumentInfo = JSON.parse(this.doc.createBlankDocument()); this._fileName = '새 문서.hwp'; + this._currentFileHandle = null; this.doc.setFileName(this._fileName); console.log(`[WasmBridge] 새 문서 생성: ${info.pageCount}페이지`); return info; @@ -92,6 +96,14 @@ export class WasmBridge { this._fileName = name; } + get currentFileHandle(): FileSystemFileHandleLike | null { + return this._currentFileHandle; + } + + set currentFileHandle(handle: FileSystemFileHandleLike | null) { + this._currentFileHandle = handle; + } + get isNewDocument(): boolean { return this._fileName === '새 문서.hwp'; } diff --git a/rhwp-studio/src/main.ts b/rhwp-studio/src/main.ts index 8d5c11e..8a3f31c 100644 --- a/rhwp-studio/src/main.ts +++ b/rhwp-studio/src/main.ts @@ -435,9 +435,7 @@ async function loadFile(file: File): Promise { msg.textContent = '파일 로딩 중...'; const startTime = performance.now(); const data = new Uint8Array(await file.arrayBuffer()); - const docInfo = wasm.loadDocument(data, file.name); - const elapsed = performance.now() - startTime; - await initializeDocument(docInfo, `${file.name} — ${docInfo.pageCount}페이지 (${elapsed.toFixed(1)}ms)`); + await loadBytes(data, file.name, null, startTime); } catch (error) { const errMsg = `파일 로드 실패: ${error}`; msg.textContent = errMsg; @@ -447,6 +445,18 @@ async function loadFile(file: File): Promise { } } +async function loadBytes( + data: Uint8Array, + fileName: string, + fileHandle: typeof wasm.currentFileHandle, + startTime = performance.now(), +): Promise { + const docInfo = wasm.loadDocument(data, fileName); + wasm.currentFileHandle = fileHandle; + const elapsed = performance.now() - startTime; + await initializeDocument(docInfo, `${fileName} — ${docInfo.pageCount}페이지 (${elapsed.toFixed(1)}ms)`); +} + async function createNewDocument(): Promise { const msg = sbMessage(); try { @@ -461,6 +471,14 @@ async function createNewDocument(): Promise { // 커맨드에서 새 문서 생성 호출 eventBus.on('create-new-document', () => { createNewDocument(); }); +eventBus.on('open-document-bytes', async (payload) => { + const data = payload as { + bytes: Uint8Array; + fileName: string; + fileHandle: typeof wasm.currentFileHandle; + }; + await loadBytes(data.bytes, data.fileName, data.fileHandle); +}); // 수식 더블클릭 → 수식 편집 대화상자 eventBus.on('equation-edit-request', () => { diff --git a/rhwp-studio/tests/file-system-access.test.ts b/rhwp-studio/tests/file-system-access.test.ts new file mode 100644 index 0000000..b1400ff --- /dev/null +++ b/rhwp-studio/tests/file-system-access.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + pickOpenFileHandle, + readFileFromHandle, + saveDocumentToFileSystem, +} from '../src/command/file-system-access.ts'; + +type FakeWriteCall = Blob; + +interface FakeWritable { + writes: FakeWriteCall[]; + closed: boolean; + write(data: Blob): Promise; + close(): Promise; +} + +function createWritable(): FakeWritable { + return { + writes: [], + closed: false, + async write(data: Blob) { + this.writes.push(data); + }, + async close() { + this.closed = true; + }, + }; +} + +function createHandle(name: string, fileContent = 'fixture') { + const writable = createWritable(); + return { + kind: 'file' as const, + name, + writable, + async getFile() { + return new File([fileContent], name, { type: 'application/x-hwp' }); + }, + async createWritable() { + return writable; + }, + }; +} + +test('pickOpenFileHandle는 showOpenFilePicker가 있으면 첫 handle을 반환한다', async () => { + const handle = createHandle('opened.hwp'); + let receivedOptions: Record | undefined; + + const result = await pickOpenFileHandle({ + showOpenFilePicker: async (options) => { + receivedOptions = options as Record; + return [handle]; + }, + }); + + assert.equal(result, handle); + assert.ok(receivedOptions); +}); + +test('readFileFromHandle은 handle 파일 내용을 Uint8Array로 읽는다', async () => { + const handle = createHandle('opened.hwp', 'abc'); + + const result = await readFileFromHandle(handle); + + assert.equal(result.name, 'opened.hwp'); + assert.deepEqual(Array.from(result.bytes), [97, 98, 99]); +}); + +test('saveDocumentToFileSystem은 current handle이 있으면 picker 없이 같은 파일에 저장한다', async () => { + const currentHandle = createHandle('opened.hwp'); + let pickerCalled = false; + const blob = new Blob(['saved'], { type: 'application/x-hwp' }); + + const result = await saveDocumentToFileSystem({ + blob, + suggestedName: 'opened.hwp', + currentHandle, + windowLike: { + showSaveFilePicker: async () => { + pickerCalled = true; + return createHandle('picker.hwp'); + }, + }, + }); + + assert.equal(result.method, 'current-handle'); + assert.equal(result.handle, currentHandle); + assert.equal(result.fileName, 'opened.hwp'); + assert.equal(pickerCalled, false); + assert.equal(currentHandle.writable.writes.length, 1); + assert.equal(currentHandle.writable.closed, true); +}); + +test('saveDocumentToFileSystem은 current handle이 없으면 save picker를 사용한다', async () => { + const pickerHandle = createHandle('picked.hwp'); + const blob = new Blob(['saved'], { type: 'application/x-hwp' }); + + const result = await saveDocumentToFileSystem({ + blob, + suggestedName: 'new-doc.hwp', + currentHandle: null, + windowLike: { + showSaveFilePicker: async (options) => { + assert.equal(options?.suggestedName, 'new-doc.hwp'); + return pickerHandle; + }, + }, + }); + + assert.equal(result.method, 'save-picker'); + assert.equal(result.handle, pickerHandle); + assert.equal(result.fileName, 'picked.hwp'); + assert.equal(pickerHandle.writable.writes.length, 1); + assert.equal(pickerHandle.writable.closed, true); +});